1use crate::commit::parse_git_diff_name_status;
2use crate::stash::GitStash;
3use crate::status::{GitStatus, StatusCode};
4use crate::{Oid, SHORT_SHA_LENGTH};
5use anyhow::{Context as _, Result, anyhow, bail};
6use collections::HashMap;
7use futures::future::BoxFuture;
8use futures::{AsyncWriteExt, FutureExt as _, select_biased};
9use git2::BranchType;
10use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
11use parking_lot::Mutex;
12use rope::Rope;
13use schemars::JsonSchema;
14use serde::Deserialize;
15use std::borrow::{Borrow, Cow};
16use std::ffi::{OsStr, OsString};
17use std::io::prelude::*;
18use std::path::Component;
19use std::process::{ExitStatus, Stdio};
20use std::sync::LazyLock;
21use std::{
22 cmp::Ordering,
23 future,
24 io::{BufRead, BufReader, BufWriter, Read},
25 path::{Path, PathBuf},
26 sync::Arc,
27};
28use sum_tree::MapSeekTarget;
29use thiserror::Error;
30use util::command::{new_smol_command, new_std_command};
31use util::{ResultExt, paths};
32use uuid::Uuid;
33
34pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
35
36pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
37
38#[derive(Clone, Debug, Hash, PartialEq, Eq)]
39pub struct Branch {
40 pub is_head: bool,
41 pub ref_name: SharedString,
42 pub upstream: Option<Upstream>,
43 pub most_recent_commit: Option<CommitSummary>,
44}
45
46impl Branch {
47 pub fn name(&self) -> &str {
48 self.ref_name
49 .as_ref()
50 .strip_prefix("refs/heads/")
51 .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
52 .unwrap_or(self.ref_name.as_ref())
53 }
54
55 pub fn is_remote(&self) -> bool {
56 self.ref_name.starts_with("refs/remotes/")
57 }
58
59 pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
60 self.upstream
61 .as_ref()
62 .and_then(|upstream| upstream.tracking.status())
63 }
64
65 pub fn priority_key(&self) -> (bool, Option<i64>) {
66 (
67 self.is_head,
68 self.most_recent_commit
69 .as_ref()
70 .map(|commit| commit.commit_timestamp),
71 )
72 }
73}
74
75#[derive(Clone, Debug, Hash, PartialEq, Eq)]
76pub struct Upstream {
77 pub ref_name: SharedString,
78 pub tracking: UpstreamTracking,
79}
80
81impl Upstream {
82 pub fn is_remote(&self) -> bool {
83 self.remote_name().is_some()
84 }
85
86 pub fn remote_name(&self) -> Option<&str> {
87 self.ref_name
88 .strip_prefix("refs/remotes/")
89 .and_then(|stripped| stripped.split("/").next())
90 }
91
92 pub fn stripped_ref_name(&self) -> Option<&str> {
93 self.ref_name.strip_prefix("refs/remotes/")
94 }
95}
96
97#[derive(Clone, Copy, Default)]
98pub struct CommitOptions {
99 pub amend: bool,
100 pub signoff: bool,
101}
102
103#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
104pub enum UpstreamTracking {
105 /// Remote ref not present in local repository.
106 Gone,
107 /// Remote ref present in local repository (fetched from remote).
108 Tracked(UpstreamTrackingStatus),
109}
110
111impl From<UpstreamTrackingStatus> for UpstreamTracking {
112 fn from(status: UpstreamTrackingStatus) -> Self {
113 UpstreamTracking::Tracked(status)
114 }
115}
116
117impl UpstreamTracking {
118 pub fn is_gone(&self) -> bool {
119 matches!(self, UpstreamTracking::Gone)
120 }
121
122 pub fn status(&self) -> Option<UpstreamTrackingStatus> {
123 match self {
124 UpstreamTracking::Gone => None,
125 UpstreamTracking::Tracked(status) => Some(*status),
126 }
127 }
128}
129
130#[derive(Debug, Clone)]
131pub struct RemoteCommandOutput {
132 pub stdout: String,
133 pub stderr: String,
134}
135
136impl RemoteCommandOutput {
137 pub fn is_empty(&self) -> bool {
138 self.stdout.is_empty() && self.stderr.is_empty()
139 }
140}
141
142#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
143pub struct UpstreamTrackingStatus {
144 pub ahead: u32,
145 pub behind: u32,
146}
147
148#[derive(Clone, Debug, Hash, PartialEq, Eq)]
149pub struct CommitSummary {
150 pub sha: SharedString,
151 pub subject: SharedString,
152 /// This is a unix timestamp
153 pub commit_timestamp: i64,
154 pub author_name: SharedString,
155 pub has_parent: bool,
156}
157
158#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
159pub struct CommitDetails {
160 pub sha: SharedString,
161 pub message: SharedString,
162 pub commit_timestamp: i64,
163 pub author_email: SharedString,
164 pub author_name: SharedString,
165}
166
167#[derive(Debug)]
168pub struct CommitDiff {
169 pub files: Vec<CommitFile>,
170}
171
172#[derive(Debug)]
173pub struct CommitFile {
174 pub path: RepoPath,
175 pub old_text: Option<String>,
176 pub new_text: Option<String>,
177}
178
179impl CommitDetails {
180 pub fn short_sha(&self) -> SharedString {
181 self.sha[..SHORT_SHA_LENGTH].to_string().into()
182 }
183}
184
185#[derive(Debug, Clone, Hash, PartialEq, Eq)]
186pub struct Remote {
187 pub name: SharedString,
188}
189
190pub enum ResetMode {
191 /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
192 /// committed are now staged).
193 Soft,
194 /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
195 /// committed are now unstaged).
196 Mixed,
197}
198
199#[derive(Debug, Clone, Hash, PartialEq, Eq)]
200pub enum FetchOptions {
201 All,
202 Remote(Remote),
203}
204
205impl FetchOptions {
206 pub fn to_proto(&self) -> Option<String> {
207 match self {
208 FetchOptions::All => None,
209 FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
210 }
211 }
212
213 pub fn from_proto(remote_name: Option<String>) -> Self {
214 match remote_name {
215 Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
216 None => FetchOptions::All,
217 }
218 }
219
220 pub fn name(&self) -> SharedString {
221 match self {
222 Self::All => "Fetch all remotes".into(),
223 Self::Remote(remote) => remote.name.clone(),
224 }
225 }
226}
227
228impl std::fmt::Display for FetchOptions {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 match self {
231 FetchOptions::All => write!(f, "--all"),
232 FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
233 }
234 }
235}
236
237/// Modifies .git/info/exclude temporarily
238pub struct GitExcludeOverride {
239 git_exclude_path: PathBuf,
240 original_excludes: Option<String>,
241 added_excludes: Option<String>,
242}
243
244impl GitExcludeOverride {
245 pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
246 let original_excludes = smol::fs::read_to_string(&git_exclude_path).await.ok();
247
248 Ok(GitExcludeOverride {
249 git_exclude_path,
250 original_excludes,
251 added_excludes: None,
252 })
253 }
254
255 pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
256 self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
257 format!("{already_added}\n{excludes}")
258 } else {
259 excludes.to_string()
260 });
261
262 let mut content = self.original_excludes.clone().unwrap_or_default();
263 content.push_str("\n\n# ====== Auto-added by Zed: =======\n");
264 content.push_str(self.added_excludes.as_ref().unwrap());
265 content.push('\n');
266
267 smol::fs::write(&self.git_exclude_path, content).await?;
268 Ok(())
269 }
270
271 pub async fn restore_original(&mut self) -> Result<()> {
272 if let Some(ref original) = self.original_excludes {
273 smol::fs::write(&self.git_exclude_path, original).await?;
274 } else if self.git_exclude_path.exists() {
275 smol::fs::remove_file(&self.git_exclude_path).await?;
276 }
277
278 self.added_excludes = None;
279
280 Ok(())
281 }
282}
283
284impl Drop for GitExcludeOverride {
285 fn drop(&mut self) {
286 if self.added_excludes.is_some() {
287 let git_exclude_path = self.git_exclude_path.clone();
288 let original_excludes = self.original_excludes.clone();
289 smol::spawn(async move {
290 if let Some(original) = original_excludes {
291 smol::fs::write(&git_exclude_path, original).await
292 } else {
293 smol::fs::remove_file(&git_exclude_path).await
294 }
295 })
296 .detach();
297 }
298 }
299}
300
301pub trait GitRepository: Send + Sync {
302 fn reload_index(&self);
303
304 /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
305 ///
306 /// Also returns `None` for symlinks.
307 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
308
309 /// Returns the contents of an entry in the repository's HEAD, or None if HEAD does not exist or has no entry for the given path.
310 ///
311 /// Also returns `None` for symlinks.
312 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
313
314 fn set_index_text(
315 &self,
316 path: RepoPath,
317 content: Option<String>,
318 env: Arc<HashMap<String, String>>,
319 ) -> BoxFuture<'_, anyhow::Result<()>>;
320
321 /// Returns the URL of the remote with the given name.
322 fn remote_url(&self, name: &str) -> Option<String>;
323
324 /// Resolve a list of refs to SHAs.
325 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
326
327 fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
328 async move {
329 self.revparse_batch(vec!["HEAD".into()])
330 .await
331 .unwrap_or_default()
332 .into_iter()
333 .next()
334 .flatten()
335 }
336 .boxed()
337 }
338
339 fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
340
341 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
342
343 fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
344
345 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
346
347 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
348 fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
349
350 fn reset(
351 &self,
352 commit: String,
353 mode: ResetMode,
354 env: Arc<HashMap<String, String>>,
355 ) -> BoxFuture<'_, Result<()>>;
356
357 fn checkout_files(
358 &self,
359 commit: String,
360 paths: Vec<RepoPath>,
361 env: Arc<HashMap<String, String>>,
362 ) -> BoxFuture<'_, Result<()>>;
363
364 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
365
366 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
367 fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
368
369 /// Returns the absolute path to the repository. For worktrees, this will be the path to the
370 /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
371 fn path(&self) -> PathBuf;
372
373 fn main_repository_path(&self) -> PathBuf;
374
375 /// Updates the index to match the worktree at the given paths.
376 ///
377 /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
378 fn stage_paths(
379 &self,
380 paths: Vec<RepoPath>,
381 env: Arc<HashMap<String, String>>,
382 ) -> BoxFuture<'_, Result<()>>;
383 /// Updates the index to match HEAD at the given paths.
384 ///
385 /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
386 fn unstage_paths(
387 &self,
388 paths: Vec<RepoPath>,
389 env: Arc<HashMap<String, String>>,
390 ) -> BoxFuture<'_, Result<()>>;
391
392 fn commit(
393 &self,
394 message: SharedString,
395 name_and_email: Option<(SharedString, SharedString)>,
396 options: CommitOptions,
397 env: Arc<HashMap<String, String>>,
398 ) -> BoxFuture<'_, Result<()>>;
399
400 fn stash_paths(
401 &self,
402 paths: Vec<RepoPath>,
403 env: Arc<HashMap<String, String>>,
404 ) -> BoxFuture<'_, Result<()>>;
405
406 fn stash_pop(
407 &self,
408 index: Option<usize>,
409 env: Arc<HashMap<String, String>>,
410 ) -> BoxFuture<'_, Result<()>>;
411
412 fn stash_apply(
413 &self,
414 index: Option<usize>,
415 env: Arc<HashMap<String, String>>,
416 ) -> BoxFuture<'_, Result<()>>;
417
418 fn stash_drop(
419 &self,
420 index: Option<usize>,
421 env: Arc<HashMap<String, String>>,
422 ) -> BoxFuture<'_, Result<()>>;
423
424 fn push(
425 &self,
426 branch_name: String,
427 upstream_name: String,
428 options: Option<PushOptions>,
429 askpass: AskPassDelegate,
430 env: Arc<HashMap<String, String>>,
431 // This method takes an AsyncApp to ensure it's invoked on the main thread,
432 // otherwise git-credentials-manager won't work.
433 cx: AsyncApp,
434 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
435
436 fn pull(
437 &self,
438 branch_name: String,
439 upstream_name: String,
440 askpass: AskPassDelegate,
441 env: Arc<HashMap<String, String>>,
442 // This method takes an AsyncApp to ensure it's invoked on the main thread,
443 // otherwise git-credentials-manager won't work.
444 cx: AsyncApp,
445 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
446
447 fn fetch(
448 &self,
449 fetch_options: FetchOptions,
450 askpass: AskPassDelegate,
451 env: Arc<HashMap<String, String>>,
452 // This method takes an AsyncApp to ensure it's invoked on the main thread,
453 // otherwise git-credentials-manager won't work.
454 cx: AsyncApp,
455 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
456
457 fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>>;
458
459 /// returns a list of remote branches that contain HEAD
460 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
461
462 /// Run git diff
463 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
464
465 /// Creates a checkpoint for the repository.
466 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
467
468 /// Resets to a previously-created checkpoint.
469 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
470
471 /// Compares two checkpoints, returning true if they are equal
472 fn compare_checkpoints(
473 &self,
474 left: GitRepositoryCheckpoint,
475 right: GitRepositoryCheckpoint,
476 ) -> BoxFuture<'_, Result<bool>>;
477
478 /// Computes a diff between two checkpoints.
479 fn diff_checkpoints(
480 &self,
481 base_checkpoint: GitRepositoryCheckpoint,
482 target_checkpoint: GitRepositoryCheckpoint,
483 ) -> BoxFuture<'_, Result<String>>;
484
485 fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>>;
486}
487
488pub enum DiffType {
489 HeadToIndex,
490 HeadToWorktree,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
494pub enum PushOptions {
495 SetUpstream,
496 Force,
497}
498
499impl std::fmt::Debug for dyn GitRepository {
500 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
501 f.debug_struct("dyn GitRepository<...>").finish()
502 }
503}
504
505pub struct RealGitRepository {
506 pub repository: Arc<Mutex<git2::Repository>>,
507 pub git_binary_path: PathBuf,
508 executor: BackgroundExecutor,
509}
510
511impl RealGitRepository {
512 pub fn new(
513 dotgit_path: &Path,
514 git_binary_path: Option<PathBuf>,
515 executor: BackgroundExecutor,
516 ) -> Option<Self> {
517 let workdir_root = dotgit_path.parent()?;
518 let repository = git2::Repository::open(workdir_root).log_err()?;
519 Some(Self {
520 repository: Arc::new(Mutex::new(repository)),
521 git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
522 executor,
523 })
524 }
525
526 fn working_directory(&self) -> Result<PathBuf> {
527 self.repository
528 .lock()
529 .workdir()
530 .context("failed to read git work directory")
531 .map(Path::to_path_buf)
532 }
533}
534
535#[derive(Clone, Debug)]
536pub struct GitRepositoryCheckpoint {
537 pub commit_sha: Oid,
538}
539
540#[derive(Debug)]
541pub struct GitCommitter {
542 pub name: Option<String>,
543 pub email: Option<String>,
544}
545
546pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
547 if cfg!(any(feature = "test-support", test)) {
548 return GitCommitter {
549 name: None,
550 email: None,
551 };
552 }
553
554 let git_binary_path =
555 if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
556 cx.update(|cx| {
557 cx.path_for_auxiliary_executable("git")
558 .context("could not find git binary path")
559 .log_err()
560 })
561 .ok()
562 .flatten()
563 } else {
564 None
565 };
566
567 let git = GitBinary::new(
568 git_binary_path.unwrap_or(PathBuf::from("git")),
569 paths::home_dir().clone(),
570 cx.background_executor().clone(),
571 );
572
573 cx.background_spawn(async move {
574 let name = git.run(["config", "--global", "user.name"]).await.log_err();
575 let email = git
576 .run(["config", "--global", "user.email"])
577 .await
578 .log_err();
579 GitCommitter { name, email }
580 })
581 .await
582}
583
584impl GitRepository for RealGitRepository {
585 fn reload_index(&self) {
586 if let Ok(mut index) = self.repository.lock().index() {
587 _ = index.read(false);
588 }
589 }
590
591 fn path(&self) -> PathBuf {
592 let repo = self.repository.lock();
593 repo.path().into()
594 }
595
596 fn main_repository_path(&self) -> PathBuf {
597 let repo = self.repository.lock();
598 repo.commondir().into()
599 }
600
601 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
602 let working_directory = self.working_directory();
603 self.executor
604 .spawn(async move {
605 let working_directory = working_directory?;
606 let output = new_std_command("git")
607 .current_dir(&working_directory)
608 .args([
609 "--no-optional-locks",
610 "show",
611 "--no-patch",
612 "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
613 &commit,
614 ])
615 .output()?;
616 let output = std::str::from_utf8(&output.stdout)?;
617 let fields = output.split('\0').collect::<Vec<_>>();
618 if fields.len() != 6 {
619 bail!("unexpected git-show output for {commit:?}: {output:?}")
620 }
621 let sha = fields[0].to_string().into();
622 let message = fields[1].to_string().into();
623 let commit_timestamp = fields[2].parse()?;
624 let author_email = fields[3].to_string().into();
625 let author_name = fields[4].to_string().into();
626 Ok(CommitDetails {
627 sha,
628 message,
629 commit_timestamp,
630 author_email,
631 author_name,
632 })
633 })
634 .boxed()
635 }
636
637 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
638 let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
639 else {
640 return future::ready(Err(anyhow!("no working directory"))).boxed();
641 };
642 cx.background_spawn(async move {
643 let show_output = util::command::new_std_command("git")
644 .current_dir(&working_directory)
645 .args([
646 "--no-optional-locks",
647 "show",
648 "--format=%P",
649 "-z",
650 "--no-renames",
651 "--name-status",
652 ])
653 .arg(&commit)
654 .stdin(Stdio::null())
655 .stdout(Stdio::piped())
656 .stderr(Stdio::piped())
657 .output()
658 .context("starting git show process")?;
659
660 let show_stdout = String::from_utf8_lossy(&show_output.stdout);
661 let mut lines = show_stdout.split('\n');
662 let parent_sha = lines.next().unwrap().trim().trim_end_matches('\0');
663 let changes = parse_git_diff_name_status(lines.next().unwrap_or(""));
664
665 let mut cat_file_process = util::command::new_std_command("git")
666 .current_dir(&working_directory)
667 .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
668 .stdin(Stdio::piped())
669 .stdout(Stdio::piped())
670 .stderr(Stdio::piped())
671 .spawn()
672 .context("starting git cat-file process")?;
673
674 use std::io::Write as _;
675 let mut files = Vec::<CommitFile>::new();
676 let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
677 let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
678 let mut info_line = String::new();
679 let mut newline = [b'\0'];
680 for (path, status_code) in changes {
681 match status_code {
682 StatusCode::Modified => {
683 writeln!(&mut stdin, "{commit}:{}", path.display())?;
684 writeln!(&mut stdin, "{parent_sha}:{}", path.display())?;
685 }
686 StatusCode::Added => {
687 writeln!(&mut stdin, "{commit}:{}", path.display())?;
688 }
689 StatusCode::Deleted => {
690 writeln!(&mut stdin, "{parent_sha}:{}", path.display())?;
691 }
692 _ => continue,
693 }
694 stdin.flush()?;
695
696 info_line.clear();
697 stdout.read_line(&mut info_line)?;
698
699 let len = info_line.trim_end().parse().with_context(|| {
700 format!("invalid object size output from cat-file {info_line}")
701 })?;
702 let mut text = vec![0; len];
703 stdout.read_exact(&mut text)?;
704 stdout.read_exact(&mut newline)?;
705 let text = String::from_utf8_lossy(&text).to_string();
706
707 let mut old_text = None;
708 let mut new_text = None;
709 match status_code {
710 StatusCode::Modified => {
711 info_line.clear();
712 stdout.read_line(&mut info_line)?;
713 let len = info_line.trim_end().parse().with_context(|| {
714 format!("invalid object size output from cat-file {}", info_line)
715 })?;
716 let mut parent_text = vec![0; len];
717 stdout.read_exact(&mut parent_text)?;
718 stdout.read_exact(&mut newline)?;
719 old_text = Some(String::from_utf8_lossy(&parent_text).to_string());
720 new_text = Some(text);
721 }
722 StatusCode::Added => new_text = Some(text),
723 StatusCode::Deleted => old_text = Some(text),
724 _ => continue,
725 }
726
727 files.push(CommitFile {
728 path: path.into(),
729 old_text,
730 new_text,
731 })
732 }
733
734 Ok(CommitDiff { files })
735 })
736 .boxed()
737 }
738
739 fn reset(
740 &self,
741 commit: String,
742 mode: ResetMode,
743 env: Arc<HashMap<String, String>>,
744 ) -> BoxFuture<'_, Result<()>> {
745 async move {
746 let working_directory = self.working_directory();
747
748 let mode_flag = match mode {
749 ResetMode::Mixed => "--mixed",
750 ResetMode::Soft => "--soft",
751 };
752
753 let output = new_smol_command(&self.git_binary_path)
754 .envs(env.iter())
755 .current_dir(&working_directory?)
756 .args(["reset", mode_flag, &commit])
757 .output()
758 .await?;
759 anyhow::ensure!(
760 output.status.success(),
761 "Failed to reset:\n{}",
762 String::from_utf8_lossy(&output.stderr),
763 );
764 Ok(())
765 }
766 .boxed()
767 }
768
769 fn checkout_files(
770 &self,
771 commit: String,
772 paths: Vec<RepoPath>,
773 env: Arc<HashMap<String, String>>,
774 ) -> BoxFuture<'_, Result<()>> {
775 let working_directory = self.working_directory();
776 let git_binary_path = self.git_binary_path.clone();
777 async move {
778 if paths.is_empty() {
779 return Ok(());
780 }
781
782 let output = new_smol_command(&git_binary_path)
783 .current_dir(&working_directory?)
784 .envs(env.iter())
785 .args(["checkout", &commit, "--"])
786 .args(paths.iter().map(|path| path.as_ref()))
787 .output()
788 .await?;
789 anyhow::ensure!(
790 output.status.success(),
791 "Failed to checkout files:\n{}",
792 String::from_utf8_lossy(&output.stderr),
793 );
794 Ok(())
795 }
796 .boxed()
797 }
798
799 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
800 // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
801 const GIT_MODE_SYMLINK: u32 = 0o120000;
802
803 let repo = self.repository.clone();
804 self.executor
805 .spawn(async move {
806 fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
807 // This check is required because index.get_path() unwraps internally :(
808 check_path_to_repo_path_errors(path)?;
809
810 let mut index = repo.index()?;
811 index.read(false)?;
812
813 const STAGE_NORMAL: i32 = 0;
814 let oid = match index.get_path(path, STAGE_NORMAL) {
815 Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
816 _ => return Ok(None),
817 };
818
819 let content = repo.find_blob(oid)?.content().to_owned();
820 Ok(String::from_utf8(content).ok())
821 }
822
823 match logic(&repo.lock(), &path) {
824 Ok(value) => return value,
825 Err(err) => log::error!("Error loading index text: {:?}", err),
826 }
827 None
828 })
829 .boxed()
830 }
831
832 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
833 let repo = self.repository.clone();
834 self.executor
835 .spawn(async move {
836 let repo = repo.lock();
837 let head = repo.head().ok()?.peel_to_tree().log_err()?;
838 let entry = head.get_path(&path).ok()?;
839 if entry.filemode() == i32::from(git2::FileMode::Link) {
840 return None;
841 }
842 let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
843 String::from_utf8(content).ok()
844 })
845 .boxed()
846 }
847
848 fn set_index_text(
849 &self,
850 path: RepoPath,
851 content: Option<String>,
852 env: Arc<HashMap<String, String>>,
853 ) -> BoxFuture<'_, anyhow::Result<()>> {
854 let working_directory = self.working_directory();
855 let git_binary_path = self.git_binary_path.clone();
856 self.executor
857 .spawn(async move {
858 let working_directory = working_directory?;
859 if let Some(content) = content {
860 let mut child = new_smol_command(&git_binary_path)
861 .current_dir(&working_directory)
862 .envs(env.iter())
863 .args(["hash-object", "-w", "--stdin"])
864 .stdin(Stdio::piped())
865 .stdout(Stdio::piped())
866 .spawn()?;
867 let mut stdin = child.stdin.take().unwrap();
868 stdin.write_all(content.as_bytes()).await?;
869 stdin.flush().await?;
870 drop(stdin);
871 let output = child.output().await?.stdout;
872 let sha = str::from_utf8(&output)?.trim();
873
874 log::debug!("indexing SHA: {sha}, path {path:?}");
875
876 let output = new_smol_command(&git_binary_path)
877 .current_dir(&working_directory)
878 .envs(env.iter())
879 .args(["update-index", "--add", "--cacheinfo", "100644", sha])
880 .arg(path.to_unix_style())
881 .output()
882 .await?;
883
884 anyhow::ensure!(
885 output.status.success(),
886 "Failed to stage:\n{}",
887 String::from_utf8_lossy(&output.stderr)
888 );
889 } else {
890 log::debug!("removing path {path:?} from the index");
891 let output = new_smol_command(&git_binary_path)
892 .current_dir(&working_directory)
893 .envs(env.iter())
894 .args(["update-index", "--force-remove"])
895 .arg(path.to_unix_style())
896 .output()
897 .await?;
898 anyhow::ensure!(
899 output.status.success(),
900 "Failed to unstage:\n{}",
901 String::from_utf8_lossy(&output.stderr)
902 );
903 }
904
905 Ok(())
906 })
907 .boxed()
908 }
909
910 fn remote_url(&self, name: &str) -> Option<String> {
911 let repo = self.repository.lock();
912 let remote = repo.find_remote(name).ok()?;
913 remote.url().map(|url| url.to_string())
914 }
915
916 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
917 let working_directory = self.working_directory();
918 self.executor
919 .spawn(async move {
920 let working_directory = working_directory?;
921 let mut process = new_std_command("git")
922 .current_dir(&working_directory)
923 .args([
924 "--no-optional-locks",
925 "cat-file",
926 "--batch-check=%(objectname)",
927 ])
928 .stdin(Stdio::piped())
929 .stdout(Stdio::piped())
930 .stderr(Stdio::piped())
931 .spawn()?;
932
933 let stdin = process
934 .stdin
935 .take()
936 .context("no stdin for git cat-file subprocess")?;
937 let mut stdin = BufWriter::new(stdin);
938 for rev in &revs {
939 writeln!(&mut stdin, "{rev}")?;
940 }
941 stdin.flush()?;
942 drop(stdin);
943
944 let output = process.wait_with_output()?;
945 let output = std::str::from_utf8(&output.stdout)?;
946 let shas = output
947 .lines()
948 .map(|line| {
949 if line.ends_with("missing") {
950 None
951 } else {
952 Some(line.to_string())
953 }
954 })
955 .collect::<Vec<_>>();
956
957 if shas.len() != revs.len() {
958 // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
959 bail!("unexpected number of shas")
960 }
961
962 Ok(shas)
963 })
964 .boxed()
965 }
966
967 fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
968 let path = self.path().join("MERGE_MSG");
969 self.executor
970 .spawn(async move { std::fs::read_to_string(&path).ok() })
971 .boxed()
972 }
973
974 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
975 let git_binary_path = self.git_binary_path.clone();
976 let working_directory = match self.working_directory() {
977 Ok(working_directory) => working_directory,
978 Err(e) => return Task::ready(Err(e)),
979 };
980 let args = git_status_args(path_prefixes);
981 log::debug!("Checking for git status in {path_prefixes:?}");
982 self.executor.spawn(async move {
983 let output = new_std_command(&git_binary_path)
984 .current_dir(working_directory)
985 .args(args)
986 .output()?;
987 if output.status.success() {
988 let stdout = String::from_utf8_lossy(&output.stdout);
989 stdout.parse()
990 } else {
991 let stderr = String::from_utf8_lossy(&output.stderr);
992 anyhow::bail!("git status failed: {stderr}");
993 }
994 })
995 }
996
997 fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
998 let git_binary_path = self.git_binary_path.clone();
999 let working_directory = self.working_directory();
1000 self.executor
1001 .spawn(async move {
1002 let output = new_std_command(&git_binary_path)
1003 .current_dir(working_directory?)
1004 .args(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
1005 .output()?;
1006 if output.status.success() {
1007 let stdout = String::from_utf8_lossy(&output.stdout);
1008 stdout.parse()
1009 } else {
1010 let stderr = String::from_utf8_lossy(&output.stderr);
1011 anyhow::bail!("git status failed: {stderr}");
1012 }
1013 })
1014 .boxed()
1015 }
1016
1017 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
1018 let working_directory = self.working_directory();
1019 let git_binary_path = self.git_binary_path.clone();
1020 self.executor
1021 .spawn(async move {
1022 let fields = [
1023 "%(HEAD)",
1024 "%(objectname)",
1025 "%(parent)",
1026 "%(refname)",
1027 "%(upstream)",
1028 "%(upstream:track)",
1029 "%(committerdate:unix)",
1030 "%(authorname)",
1031 "%(contents:subject)",
1032 ]
1033 .join("%00");
1034 let args = vec![
1035 "for-each-ref",
1036 "refs/heads/**/*",
1037 "refs/remotes/**/*",
1038 "--format",
1039 &fields,
1040 ];
1041 let working_directory = working_directory?;
1042 let output = new_smol_command(&git_binary_path)
1043 .current_dir(&working_directory)
1044 .args(args)
1045 .output()
1046 .await?;
1047
1048 anyhow::ensure!(
1049 output.status.success(),
1050 "Failed to git git branches:\n{}",
1051 String::from_utf8_lossy(&output.stderr)
1052 );
1053
1054 let input = String::from_utf8_lossy(&output.stdout);
1055
1056 let mut branches = parse_branch_input(&input)?;
1057 if branches.is_empty() {
1058 let args = vec!["symbolic-ref", "--quiet", "HEAD"];
1059
1060 let output = new_smol_command(&git_binary_path)
1061 .current_dir(&working_directory)
1062 .args(args)
1063 .output()
1064 .await?;
1065
1066 // git symbolic-ref returns a non-0 exit code if HEAD points
1067 // to something other than a branch
1068 if output.status.success() {
1069 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
1070
1071 branches.push(Branch {
1072 ref_name: name.into(),
1073 is_head: true,
1074 upstream: None,
1075 most_recent_commit: None,
1076 });
1077 }
1078 }
1079
1080 Ok(branches)
1081 })
1082 .boxed()
1083 }
1084
1085 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1086 let repo = self.repository.clone();
1087 let working_directory = self.working_directory();
1088 let git_binary_path = self.git_binary_path.clone();
1089 let executor = self.executor.clone();
1090 let branch = self.executor.spawn(async move {
1091 let repo = repo.lock();
1092 let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
1093 branch
1094 } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
1095 let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
1096 let revision = revision.get();
1097 let branch_commit = revision.peel_to_commit()?;
1098 let mut branch = repo.branch(branch_name, &branch_commit, false)?;
1099 branch.set_upstream(Some(&name))?;
1100 branch
1101 } else {
1102 anyhow::bail!("Branch not found");
1103 };
1104
1105 Ok(branch
1106 .name()?
1107 .context("cannot checkout anonymous branch")?
1108 .to_string())
1109 });
1110
1111 self.executor
1112 .spawn(async move {
1113 let branch = branch.await?;
1114
1115 GitBinary::new(git_binary_path, working_directory?, executor)
1116 .run(&["checkout", &branch])
1117 .await?;
1118
1119 anyhow::Ok(())
1120 })
1121 .boxed()
1122 }
1123
1124 fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1125 let repo = self.repository.clone();
1126 self.executor
1127 .spawn(async move {
1128 let repo = repo.lock();
1129 let current_commit = repo.head()?.peel_to_commit()?;
1130 repo.branch(&name, ¤t_commit, false)?;
1131 Ok(())
1132 })
1133 .boxed()
1134 }
1135
1136 fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>> {
1137 let working_directory = self.working_directory();
1138 let git_binary_path = self.git_binary_path.clone();
1139
1140 let remote_url = self
1141 .remote_url("upstream")
1142 .or_else(|| self.remote_url("origin"));
1143
1144 self.executor
1145 .spawn(async move {
1146 crate::blame::Blame::for_path(
1147 &git_binary_path,
1148 &working_directory?,
1149 &path,
1150 &content,
1151 remote_url,
1152 )
1153 .await
1154 })
1155 .boxed()
1156 }
1157
1158 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
1159 let working_directory = self.working_directory();
1160 let git_binary_path = self.git_binary_path.clone();
1161 self.executor
1162 .spawn(async move {
1163 let args = match diff {
1164 DiffType::HeadToIndex => Some("--staged"),
1165 DiffType::HeadToWorktree => None,
1166 };
1167
1168 let output = new_smol_command(&git_binary_path)
1169 .current_dir(&working_directory?)
1170 .args(["diff"])
1171 .args(args)
1172 .output()
1173 .await?;
1174
1175 anyhow::ensure!(
1176 output.status.success(),
1177 "Failed to run git diff:\n{}",
1178 String::from_utf8_lossy(&output.stderr)
1179 );
1180 Ok(String::from_utf8_lossy(&output.stdout).to_string())
1181 })
1182 .boxed()
1183 }
1184
1185 fn stage_paths(
1186 &self,
1187 paths: Vec<RepoPath>,
1188 env: Arc<HashMap<String, String>>,
1189 ) -> BoxFuture<'_, Result<()>> {
1190 let working_directory = self.working_directory();
1191 let git_binary_path = self.git_binary_path.clone();
1192 self.executor
1193 .spawn(async move {
1194 if !paths.is_empty() {
1195 let output = new_smol_command(&git_binary_path)
1196 .current_dir(&working_directory?)
1197 .envs(env.iter())
1198 .args(["update-index", "--add", "--remove", "--"])
1199 .args(paths.iter().map(|p| p.to_unix_style()))
1200 .output()
1201 .await?;
1202 anyhow::ensure!(
1203 output.status.success(),
1204 "Failed to stage paths:\n{}",
1205 String::from_utf8_lossy(&output.stderr),
1206 );
1207 }
1208 Ok(())
1209 })
1210 .boxed()
1211 }
1212
1213 fn unstage_paths(
1214 &self,
1215 paths: Vec<RepoPath>,
1216 env: Arc<HashMap<String, String>>,
1217 ) -> BoxFuture<'_, Result<()>> {
1218 let working_directory = self.working_directory();
1219 let git_binary_path = self.git_binary_path.clone();
1220
1221 self.executor
1222 .spawn(async move {
1223 if !paths.is_empty() {
1224 let output = new_smol_command(&git_binary_path)
1225 .current_dir(&working_directory?)
1226 .envs(env.iter())
1227 .args(["reset", "--quiet", "--"])
1228 .args(paths.iter().map(|p| p.as_ref()))
1229 .output()
1230 .await?;
1231
1232 anyhow::ensure!(
1233 output.status.success(),
1234 "Failed to unstage:\n{}",
1235 String::from_utf8_lossy(&output.stderr),
1236 );
1237 }
1238 Ok(())
1239 })
1240 .boxed()
1241 }
1242
1243 fn stash_paths(
1244 &self,
1245 paths: Vec<RepoPath>,
1246 env: Arc<HashMap<String, String>>,
1247 ) -> BoxFuture<'_, Result<()>> {
1248 let working_directory = self.working_directory();
1249 self.executor
1250 .spawn(async move {
1251 let mut cmd = new_smol_command("git");
1252 cmd.current_dir(&working_directory?)
1253 .envs(env.iter())
1254 .args(["stash", "push", "--quiet"])
1255 .arg("--include-untracked");
1256
1257 cmd.args(paths.iter().map(|p| p.as_ref()));
1258
1259 let output = cmd.output().await?;
1260
1261 anyhow::ensure!(
1262 output.status.success(),
1263 "Failed to stash:\n{}",
1264 String::from_utf8_lossy(&output.stderr)
1265 );
1266 Ok(())
1267 })
1268 .boxed()
1269 }
1270
1271 fn stash_pop(
1272 &self,
1273 index: Option<usize>,
1274 env: Arc<HashMap<String, String>>,
1275 ) -> BoxFuture<'_, Result<()>> {
1276 let working_directory = self.working_directory();
1277 self.executor
1278 .spawn(async move {
1279 let mut cmd = new_smol_command("git");
1280 let mut args = vec!["stash".to_string(), "pop".to_string()];
1281 if let Some(index) = index {
1282 args.push(format!("stash@{{{}}}", index));
1283 }
1284 cmd.current_dir(&working_directory?)
1285 .envs(env.iter())
1286 .args(args);
1287
1288 let output = cmd.output().await?;
1289
1290 anyhow::ensure!(
1291 output.status.success(),
1292 "Failed to stash pop:\n{}",
1293 String::from_utf8_lossy(&output.stderr)
1294 );
1295 Ok(())
1296 })
1297 .boxed()
1298 }
1299
1300 fn stash_apply(
1301 &self,
1302 index: Option<usize>,
1303 env: Arc<HashMap<String, String>>,
1304 ) -> BoxFuture<'_, Result<()>> {
1305 let working_directory = self.working_directory();
1306 self.executor
1307 .spawn(async move {
1308 let mut cmd = new_smol_command("git");
1309 let mut args = vec!["stash".to_string(), "apply".to_string()];
1310 if let Some(index) = index {
1311 args.push(format!("stash@{{{}}}", index));
1312 }
1313 cmd.current_dir(&working_directory?)
1314 .envs(env.iter())
1315 .args(args);
1316
1317 let output = cmd.output().await?;
1318
1319 anyhow::ensure!(
1320 output.status.success(),
1321 "Failed to apply stash:\n{}",
1322 String::from_utf8_lossy(&output.stderr)
1323 );
1324 Ok(())
1325 })
1326 .boxed()
1327 }
1328
1329 fn stash_drop(
1330 &self,
1331 index: Option<usize>,
1332 env: Arc<HashMap<String, String>>,
1333 ) -> BoxFuture<'_, Result<()>> {
1334 let working_directory = self.working_directory();
1335 self.executor
1336 .spawn(async move {
1337 let mut cmd = new_smol_command("git");
1338 let mut args = vec!["stash".to_string(), "drop".to_string()];
1339 if let Some(index) = index {
1340 args.push(format!("stash@{{{}}}", index));
1341 }
1342 cmd.current_dir(&working_directory?)
1343 .envs(env.iter())
1344 .args(args);
1345
1346 let output = cmd.output().await?;
1347
1348 anyhow::ensure!(
1349 output.status.success(),
1350 "Failed to stash drop:\n{}",
1351 String::from_utf8_lossy(&output.stderr)
1352 );
1353 Ok(())
1354 })
1355 .boxed()
1356 }
1357
1358 fn commit(
1359 &self,
1360 message: SharedString,
1361 name_and_email: Option<(SharedString, SharedString)>,
1362 options: CommitOptions,
1363 env: Arc<HashMap<String, String>>,
1364 ) -> BoxFuture<'_, Result<()>> {
1365 let working_directory = self.working_directory();
1366 self.executor
1367 .spawn(async move {
1368 let mut cmd = new_smol_command("git");
1369 cmd.current_dir(&working_directory?)
1370 .envs(env.iter())
1371 .args(["commit", "--quiet", "-m"])
1372 .arg(&message.to_string())
1373 .arg("--cleanup=strip");
1374
1375 if options.amend {
1376 cmd.arg("--amend");
1377 }
1378
1379 if options.signoff {
1380 cmd.arg("--signoff");
1381 }
1382
1383 if let Some((name, email)) = name_and_email {
1384 cmd.arg("--author").arg(&format!("{name} <{email}>"));
1385 }
1386
1387 let output = cmd.output().await?;
1388
1389 anyhow::ensure!(
1390 output.status.success(),
1391 "Failed to commit:\n{}",
1392 String::from_utf8_lossy(&output.stderr)
1393 );
1394 Ok(())
1395 })
1396 .boxed()
1397 }
1398
1399 fn push(
1400 &self,
1401 branch_name: String,
1402 remote_name: String,
1403 options: Option<PushOptions>,
1404 ask_pass: AskPassDelegate,
1405 env: Arc<HashMap<String, String>>,
1406 cx: AsyncApp,
1407 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1408 let working_directory = self.working_directory();
1409 let executor = cx.background_executor().clone();
1410 async move {
1411 let working_directory = working_directory?;
1412 let mut command = new_smol_command("git");
1413 command
1414 .envs(env.iter())
1415 .current_dir(&working_directory)
1416 .args(["push"])
1417 .args(options.map(|option| match option {
1418 PushOptions::SetUpstream => "--set-upstream",
1419 PushOptions::Force => "--force-with-lease",
1420 }))
1421 .arg(remote_name)
1422 .arg(format!("{}:{}", branch_name, branch_name))
1423 .stdin(smol::process::Stdio::null())
1424 .stdout(smol::process::Stdio::piped())
1425 .stderr(smol::process::Stdio::piped());
1426
1427 run_git_command(env, ask_pass, command, &executor).await
1428 }
1429 .boxed()
1430 }
1431
1432 fn pull(
1433 &self,
1434 branch_name: String,
1435 remote_name: String,
1436 ask_pass: AskPassDelegate,
1437 env: Arc<HashMap<String, String>>,
1438 cx: AsyncApp,
1439 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1440 let working_directory = self.working_directory();
1441 let executor = cx.background_executor().clone();
1442 async move {
1443 let mut command = new_smol_command("git");
1444 command
1445 .envs(env.iter())
1446 .current_dir(&working_directory?)
1447 .args(["pull"])
1448 .arg(remote_name)
1449 .arg(branch_name)
1450 .stdout(smol::process::Stdio::piped())
1451 .stderr(smol::process::Stdio::piped());
1452
1453 run_git_command(env, ask_pass, command, &executor).await
1454 }
1455 .boxed()
1456 }
1457
1458 fn fetch(
1459 &self,
1460 fetch_options: FetchOptions,
1461 ask_pass: AskPassDelegate,
1462 env: Arc<HashMap<String, String>>,
1463 cx: AsyncApp,
1464 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1465 let working_directory = self.working_directory();
1466 let remote_name = format!("{}", fetch_options);
1467 let executor = cx.background_executor().clone();
1468 async move {
1469 let mut command = new_smol_command("git");
1470 command
1471 .envs(env.iter())
1472 .current_dir(&working_directory?)
1473 .args(["fetch", &remote_name])
1474 .stdout(smol::process::Stdio::piped())
1475 .stderr(smol::process::Stdio::piped());
1476
1477 run_git_command(env, ask_pass, command, &executor).await
1478 }
1479 .boxed()
1480 }
1481
1482 fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
1483 let working_directory = self.working_directory();
1484 let git_binary_path = self.git_binary_path.clone();
1485 self.executor
1486 .spawn(async move {
1487 let working_directory = working_directory?;
1488 if let Some(branch_name) = branch_name {
1489 let output = new_smol_command(&git_binary_path)
1490 .current_dir(&working_directory)
1491 .args(["config", "--get"])
1492 .arg(format!("branch.{}.remote", branch_name))
1493 .output()
1494 .await?;
1495
1496 if output.status.success() {
1497 let remote_name = String::from_utf8_lossy(&output.stdout);
1498
1499 return Ok(vec![Remote {
1500 name: remote_name.trim().to_string().into(),
1501 }]);
1502 }
1503 }
1504
1505 let output = new_smol_command(&git_binary_path)
1506 .current_dir(&working_directory)
1507 .args(["remote"])
1508 .output()
1509 .await?;
1510
1511 anyhow::ensure!(
1512 output.status.success(),
1513 "Failed to get remotes:\n{}",
1514 String::from_utf8_lossy(&output.stderr)
1515 );
1516 let remote_names = String::from_utf8_lossy(&output.stdout)
1517 .split('\n')
1518 .filter(|name| !name.is_empty())
1519 .map(|name| Remote {
1520 name: name.trim().to_string().into(),
1521 })
1522 .collect();
1523 Ok(remote_names)
1524 })
1525 .boxed()
1526 }
1527
1528 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
1529 let working_directory = self.working_directory();
1530 let git_binary_path = self.git_binary_path.clone();
1531 self.executor
1532 .spawn(async move {
1533 let working_directory = working_directory?;
1534 let git_cmd = async |args: &[&str]| -> Result<String> {
1535 let output = new_smol_command(&git_binary_path)
1536 .current_dir(&working_directory)
1537 .args(args)
1538 .output()
1539 .await?;
1540 anyhow::ensure!(
1541 output.status.success(),
1542 String::from_utf8_lossy(&output.stderr).to_string()
1543 );
1544 Ok(String::from_utf8(output.stdout)?)
1545 };
1546
1547 let head = git_cmd(&["rev-parse", "HEAD"])
1548 .await
1549 .context("Failed to get HEAD")?
1550 .trim()
1551 .to_owned();
1552
1553 let mut remote_branches = vec![];
1554 let mut add_if_matching = async |remote_head: &str| {
1555 if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
1556 && merge_base.trim() == head
1557 && let Some(s) = remote_head.strip_prefix("refs/remotes/")
1558 {
1559 remote_branches.push(s.to_owned().into());
1560 }
1561 };
1562
1563 // check the main branch of each remote
1564 let remotes = git_cmd(&["remote"])
1565 .await
1566 .context("Failed to get remotes")?;
1567 for remote in remotes.lines() {
1568 if let Ok(remote_head) =
1569 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
1570 {
1571 add_if_matching(remote_head.trim()).await;
1572 }
1573 }
1574
1575 // ... and the remote branch that the checked-out one is tracking
1576 if let Ok(remote_head) =
1577 git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
1578 {
1579 add_if_matching(remote_head.trim()).await;
1580 }
1581
1582 Ok(remote_branches)
1583 })
1584 .boxed()
1585 }
1586
1587 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
1588 let working_directory = self.working_directory();
1589 let git_binary_path = self.git_binary_path.clone();
1590 let executor = self.executor.clone();
1591 self.executor
1592 .spawn(async move {
1593 let working_directory = working_directory?;
1594 let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
1595 .envs(checkpoint_author_envs());
1596 git.with_temp_index(async |git| {
1597 let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
1598 let mut excludes = exclude_files(git).await?;
1599
1600 git.run(&["add", "--all"]).await?;
1601 let tree = git.run(&["write-tree"]).await?;
1602 let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
1603 git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
1604 .await?
1605 } else {
1606 git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
1607 };
1608
1609 excludes.restore_original().await?;
1610
1611 Ok(GitRepositoryCheckpoint {
1612 commit_sha: checkpoint_sha.parse()?,
1613 })
1614 })
1615 .await
1616 })
1617 .boxed()
1618 }
1619
1620 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
1621 let working_directory = self.working_directory();
1622 let git_binary_path = self.git_binary_path.clone();
1623
1624 let executor = self.executor.clone();
1625 self.executor
1626 .spawn(async move {
1627 let working_directory = working_directory?;
1628
1629 let git = GitBinary::new(git_binary_path, working_directory, executor);
1630 git.run(&[
1631 "restore",
1632 "--source",
1633 &checkpoint.commit_sha.to_string(),
1634 "--worktree",
1635 ".",
1636 ])
1637 .await?;
1638
1639 // TODO: We don't track binary and large files anymore,
1640 // so the following call would delete them.
1641 // Implement an alternative way to track files added by agent.
1642 //
1643 // git.with_temp_index(async move |git| {
1644 // git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
1645 // .await?;
1646 // git.run(&["clean", "-d", "--force"]).await
1647 // })
1648 // .await?;
1649
1650 Ok(())
1651 })
1652 .boxed()
1653 }
1654
1655 fn compare_checkpoints(
1656 &self,
1657 left: GitRepositoryCheckpoint,
1658 right: GitRepositoryCheckpoint,
1659 ) -> BoxFuture<'_, Result<bool>> {
1660 let working_directory = self.working_directory();
1661 let git_binary_path = self.git_binary_path.clone();
1662
1663 let executor = self.executor.clone();
1664 self.executor
1665 .spawn(async move {
1666 let working_directory = working_directory?;
1667 let git = GitBinary::new(git_binary_path, working_directory, executor);
1668 let result = git
1669 .run(&[
1670 "diff-tree",
1671 "--quiet",
1672 &left.commit_sha.to_string(),
1673 &right.commit_sha.to_string(),
1674 ])
1675 .await;
1676 match result {
1677 Ok(_) => Ok(true),
1678 Err(error) => {
1679 if let Some(GitBinaryCommandError { status, .. }) =
1680 error.downcast_ref::<GitBinaryCommandError>()
1681 && status.code() == Some(1)
1682 {
1683 return Ok(false);
1684 }
1685
1686 Err(error)
1687 }
1688 }
1689 })
1690 .boxed()
1691 }
1692
1693 fn diff_checkpoints(
1694 &self,
1695 base_checkpoint: GitRepositoryCheckpoint,
1696 target_checkpoint: GitRepositoryCheckpoint,
1697 ) -> BoxFuture<'_, Result<String>> {
1698 let working_directory = self.working_directory();
1699 let git_binary_path = self.git_binary_path.clone();
1700
1701 let executor = self.executor.clone();
1702 self.executor
1703 .spawn(async move {
1704 let working_directory = working_directory?;
1705 let git = GitBinary::new(git_binary_path, working_directory, executor);
1706 git.run(&[
1707 "diff",
1708 "--find-renames",
1709 "--patch",
1710 &base_checkpoint.commit_sha.to_string(),
1711 &target_checkpoint.commit_sha.to_string(),
1712 ])
1713 .await
1714 })
1715 .boxed()
1716 }
1717
1718 fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
1719 let working_directory = self.working_directory();
1720 let git_binary_path = self.git_binary_path.clone();
1721
1722 let executor = self.executor.clone();
1723 self.executor
1724 .spawn(async move {
1725 let working_directory = working_directory?;
1726 let git = GitBinary::new(git_binary_path, working_directory, executor);
1727
1728 if let Ok(output) = git
1729 .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
1730 .await
1731 {
1732 let output = output
1733 .strip_prefix("refs/remotes/upstream/")
1734 .map(|s| SharedString::from(s.to_owned()));
1735 return Ok(output);
1736 }
1737
1738 let output = git
1739 .run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
1740 .await?;
1741
1742 Ok(output
1743 .strip_prefix("refs/remotes/origin/")
1744 .map(|s| SharedString::from(s.to_owned())))
1745 })
1746 .boxed()
1747 }
1748}
1749
1750fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
1751 let mut args = vec![
1752 OsString::from("--no-optional-locks"),
1753 OsString::from("status"),
1754 OsString::from("--porcelain=v1"),
1755 OsString::from("--untracked-files=all"),
1756 OsString::from("--no-renames"),
1757 OsString::from("-z"),
1758 ];
1759 args.extend(path_prefixes.iter().map(|path_prefix| {
1760 if path_prefix.0.as_ref() == Path::new("") {
1761 Path::new(".").into()
1762 } else {
1763 path_prefix.as_os_str().into()
1764 }
1765 }));
1766 args
1767}
1768
1769/// Temporarily git-ignore commonly ignored files and files over 2MB
1770async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
1771 const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
1772 let mut excludes = git.with_exclude_overrides().await?;
1773 excludes
1774 .add_excludes(include_str!("./checkpoint.gitignore"))
1775 .await?;
1776
1777 let working_directory = git.working_directory.clone();
1778 let untracked_files = git.list_untracked_files().await?;
1779 let excluded_paths = untracked_files.into_iter().map(|path| {
1780 let working_directory = working_directory.clone();
1781 smol::spawn(async move {
1782 let full_path = working_directory.join(path.clone());
1783 match smol::fs::metadata(&full_path).await {
1784 Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
1785 Some(PathBuf::from("/").join(path.clone()))
1786 }
1787 _ => None,
1788 }
1789 })
1790 });
1791
1792 let excluded_paths = futures::future::join_all(excluded_paths).await;
1793 let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
1794
1795 if !excluded_paths.is_empty() {
1796 let exclude_patterns = excluded_paths
1797 .into_iter()
1798 .map(|path| path.to_string_lossy().to_string())
1799 .collect::<Vec<_>>()
1800 .join("\n");
1801 excludes.add_excludes(&exclude_patterns).await?;
1802 }
1803
1804 Ok(excludes)
1805}
1806
1807struct GitBinary {
1808 git_binary_path: PathBuf,
1809 working_directory: PathBuf,
1810 executor: BackgroundExecutor,
1811 index_file_path: Option<PathBuf>,
1812 envs: HashMap<String, String>,
1813}
1814
1815impl GitBinary {
1816 fn new(
1817 git_binary_path: PathBuf,
1818 working_directory: PathBuf,
1819 executor: BackgroundExecutor,
1820 ) -> Self {
1821 Self {
1822 git_binary_path,
1823 working_directory,
1824 executor,
1825 index_file_path: None,
1826 envs: HashMap::default(),
1827 }
1828 }
1829
1830 async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
1831 let status_output = self
1832 .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
1833 .await?;
1834
1835 let paths = status_output
1836 .split('\0')
1837 .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
1838 .map(|entry| PathBuf::from(&entry[3..]))
1839 .collect::<Vec<_>>();
1840 Ok(paths)
1841 }
1842
1843 fn envs(mut self, envs: HashMap<String, String>) -> Self {
1844 self.envs = envs;
1845 self
1846 }
1847
1848 pub async fn with_temp_index<R>(
1849 &mut self,
1850 f: impl AsyncFnOnce(&Self) -> Result<R>,
1851 ) -> Result<R> {
1852 let index_file_path = self.path_for_index_id(Uuid::new_v4());
1853
1854 let delete_temp_index = util::defer({
1855 let index_file_path = index_file_path.clone();
1856 let executor = self.executor.clone();
1857 move || {
1858 executor
1859 .spawn(async move {
1860 smol::fs::remove_file(index_file_path).await.log_err();
1861 })
1862 .detach();
1863 }
1864 });
1865
1866 // Copy the default index file so that Git doesn't have to rebuild the
1867 // whole index from scratch. This might fail if this is an empty repository.
1868 smol::fs::copy(
1869 self.working_directory.join(".git").join("index"),
1870 &index_file_path,
1871 )
1872 .await
1873 .ok();
1874
1875 self.index_file_path = Some(index_file_path.clone());
1876 let result = f(self).await;
1877 self.index_file_path = None;
1878 let result = result?;
1879
1880 smol::fs::remove_file(index_file_path).await.ok();
1881 delete_temp_index.abort();
1882
1883 Ok(result)
1884 }
1885
1886 pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
1887 let path = self
1888 .working_directory
1889 .join(".git")
1890 .join("info")
1891 .join("exclude");
1892
1893 GitExcludeOverride::new(path).await
1894 }
1895
1896 fn path_for_index_id(&self, id: Uuid) -> PathBuf {
1897 self.working_directory
1898 .join(".git")
1899 .join(format!("index-{}.tmp", id))
1900 }
1901
1902 pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1903 where
1904 S: AsRef<OsStr>,
1905 {
1906 let mut stdout = self.run_raw(args).await?;
1907 if stdout.chars().last() == Some('\n') {
1908 stdout.pop();
1909 }
1910 Ok(stdout)
1911 }
1912
1913 /// Returns the result of the command without trimming the trailing newline.
1914 pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1915 where
1916 S: AsRef<OsStr>,
1917 {
1918 let mut command = self.build_command(args);
1919 let output = command.output().await?;
1920 anyhow::ensure!(
1921 output.status.success(),
1922 GitBinaryCommandError {
1923 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1924 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1925 status: output.status,
1926 }
1927 );
1928 Ok(String::from_utf8(output.stdout)?)
1929 }
1930
1931 fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
1932 where
1933 S: AsRef<OsStr>,
1934 {
1935 let mut command = new_smol_command(&self.git_binary_path);
1936 command.current_dir(&self.working_directory);
1937 command.args(args);
1938 if let Some(index_file_path) = self.index_file_path.as_ref() {
1939 command.env("GIT_INDEX_FILE", index_file_path);
1940 }
1941 command.envs(&self.envs);
1942 command
1943 }
1944}
1945
1946#[derive(Error, Debug)]
1947#[error("Git command failed:\n{stdout}{stderr}\n")]
1948struct GitBinaryCommandError {
1949 stdout: String,
1950 stderr: String,
1951 status: ExitStatus,
1952}
1953
1954async fn run_git_command(
1955 env: Arc<HashMap<String, String>>,
1956 ask_pass: AskPassDelegate,
1957 mut command: smol::process::Command,
1958 executor: &BackgroundExecutor,
1959) -> Result<RemoteCommandOutput> {
1960 if env.contains_key("GIT_ASKPASS") {
1961 let git_process = command.spawn()?;
1962 let output = git_process.output().await?;
1963 anyhow::ensure!(
1964 output.status.success(),
1965 "{}",
1966 String::from_utf8_lossy(&output.stderr)
1967 );
1968 Ok(RemoteCommandOutput {
1969 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1970 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1971 })
1972 } else {
1973 let ask_pass = AskPassSession::new(executor, ask_pass).await?;
1974 command
1975 .env("GIT_ASKPASS", ask_pass.script_path())
1976 .env("SSH_ASKPASS", ask_pass.script_path())
1977 .env("SSH_ASKPASS_REQUIRE", "force");
1978 let git_process = command.spawn()?;
1979
1980 run_askpass_command(ask_pass, git_process).await
1981 }
1982}
1983
1984async fn run_askpass_command(
1985 mut ask_pass: AskPassSession,
1986 git_process: smol::process::Child,
1987) -> anyhow::Result<RemoteCommandOutput> {
1988 select_biased! {
1989 result = ask_pass.run().fuse() => {
1990 match result {
1991 AskPassResult::CancelledByUser => {
1992 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
1993 }
1994 AskPassResult::Timedout => {
1995 Err(anyhow!("Connecting to host timed out"))?
1996 }
1997 }
1998 }
1999 output = git_process.output().fuse() => {
2000 let output = output?;
2001 anyhow::ensure!(
2002 output.status.success(),
2003 "{}",
2004 String::from_utf8_lossy(&output.stderr)
2005 );
2006 Ok(RemoteCommandOutput {
2007 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
2008 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
2009 })
2010 }
2011 }
2012}
2013
2014pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
2015 LazyLock::new(|| RepoPath(Path::new("").into()));
2016
2017#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
2018pub struct RepoPath(pub Arc<Path>);
2019
2020impl RepoPath {
2021 pub fn new(path: PathBuf) -> Self {
2022 debug_assert!(path.is_relative(), "Repo paths must be relative");
2023
2024 RepoPath(path.into())
2025 }
2026
2027 pub fn from_str(path: &str) -> Self {
2028 let path = Path::new(path);
2029 debug_assert!(path.is_relative(), "Repo paths must be relative");
2030
2031 RepoPath(path.into())
2032 }
2033
2034 pub fn to_unix_style(&self) -> Cow<'_, OsStr> {
2035 #[cfg(target_os = "windows")]
2036 {
2037 use std::ffi::OsString;
2038
2039 let path = self.0.as_os_str().to_string_lossy().replace("\\", "/");
2040 Cow::Owned(OsString::from(path))
2041 }
2042 #[cfg(not(target_os = "windows"))]
2043 {
2044 Cow::Borrowed(self.0.as_os_str())
2045 }
2046 }
2047}
2048
2049impl std::fmt::Display for RepoPath {
2050 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2051 self.0.to_string_lossy().fmt(f)
2052 }
2053}
2054
2055impl From<&Path> for RepoPath {
2056 fn from(value: &Path) -> Self {
2057 RepoPath::new(value.into())
2058 }
2059}
2060
2061impl From<Arc<Path>> for RepoPath {
2062 fn from(value: Arc<Path>) -> Self {
2063 RepoPath(value)
2064 }
2065}
2066
2067impl From<PathBuf> for RepoPath {
2068 fn from(value: PathBuf) -> Self {
2069 RepoPath::new(value)
2070 }
2071}
2072
2073impl From<&str> for RepoPath {
2074 fn from(value: &str) -> Self {
2075 Self::from_str(value)
2076 }
2077}
2078
2079impl Default for RepoPath {
2080 fn default() -> Self {
2081 RepoPath(Path::new("").into())
2082 }
2083}
2084
2085impl AsRef<Path> for RepoPath {
2086 fn as_ref(&self) -> &Path {
2087 self.0.as_ref()
2088 }
2089}
2090
2091impl std::ops::Deref for RepoPath {
2092 type Target = Path;
2093
2094 fn deref(&self) -> &Self::Target {
2095 &self.0
2096 }
2097}
2098
2099impl Borrow<Path> for RepoPath {
2100 fn borrow(&self) -> &Path {
2101 self.0.as_ref()
2102 }
2103}
2104
2105#[derive(Debug)]
2106pub struct RepoPathDescendants<'a>(pub &'a Path);
2107
2108impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
2109 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
2110 if key.starts_with(self.0) {
2111 Ordering::Greater
2112 } else {
2113 self.0.cmp(key)
2114 }
2115 }
2116}
2117
2118fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
2119 let mut branches = Vec::new();
2120 for line in input.split('\n') {
2121 if line.is_empty() {
2122 continue;
2123 }
2124 let mut fields = line.split('\x00');
2125 let is_current_branch = fields.next().context("no HEAD")? == "*";
2126 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
2127 let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
2128 let ref_name = fields.next().context("no refname")?.to_string().into();
2129 let upstream_name = fields.next().context("no upstream")?.to_string();
2130 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
2131 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
2132 let author_name = fields.next().context("no authorname")?.to_string().into();
2133 let subject: SharedString = fields
2134 .next()
2135 .context("no contents:subject")?
2136 .to_string()
2137 .into();
2138
2139 branches.push(Branch {
2140 is_head: is_current_branch,
2141 ref_name,
2142 most_recent_commit: Some(CommitSummary {
2143 sha: head_sha,
2144 subject,
2145 commit_timestamp: commiterdate,
2146 author_name: author_name,
2147 has_parent: !parent_sha.is_empty(),
2148 }),
2149 upstream: if upstream_name.is_empty() {
2150 None
2151 } else {
2152 Some(Upstream {
2153 ref_name: upstream_name.into(),
2154 tracking: upstream_tracking,
2155 })
2156 },
2157 })
2158 }
2159
2160 Ok(branches)
2161}
2162
2163fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
2164 if upstream_track.is_empty() {
2165 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2166 ahead: 0,
2167 behind: 0,
2168 }));
2169 }
2170
2171 let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
2172 let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
2173 let mut ahead: u32 = 0;
2174 let mut behind: u32 = 0;
2175 for component in upstream_track.split(", ") {
2176 if component == "gone" {
2177 return Ok(UpstreamTracking::Gone);
2178 }
2179 if let Some(ahead_num) = component.strip_prefix("ahead ") {
2180 ahead = ahead_num.parse::<u32>()?;
2181 }
2182 if let Some(behind_num) = component.strip_prefix("behind ") {
2183 behind = behind_num.parse::<u32>()?;
2184 }
2185 }
2186 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2187 ahead,
2188 behind,
2189 }))
2190}
2191
2192fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
2193 match relative_file_path.components().next() {
2194 None => anyhow::bail!("repo path should not be empty"),
2195 Some(Component::Prefix(_)) => anyhow::bail!(
2196 "repo path `{}` should be relative, not a windows prefix",
2197 relative_file_path.to_string_lossy()
2198 ),
2199 Some(Component::RootDir) => {
2200 anyhow::bail!(
2201 "repo path `{}` should be relative",
2202 relative_file_path.to_string_lossy()
2203 )
2204 }
2205 Some(Component::CurDir) => {
2206 anyhow::bail!(
2207 "repo path `{}` should not start with `.`",
2208 relative_file_path.to_string_lossy()
2209 )
2210 }
2211 Some(Component::ParentDir) => {
2212 anyhow::bail!(
2213 "repo path `{}` should not start with `..`",
2214 relative_file_path.to_string_lossy()
2215 )
2216 }
2217 _ => Ok(()),
2218 }
2219}
2220
2221fn checkpoint_author_envs() -> HashMap<String, String> {
2222 HashMap::from_iter([
2223 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
2224 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
2225 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
2226 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
2227 ])
2228}
2229
2230#[cfg(test)]
2231mod tests {
2232 use super::*;
2233 use gpui::TestAppContext;
2234
2235 #[gpui::test]
2236 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
2237 cx.executor().allow_parking();
2238
2239 let repo_dir = tempfile::tempdir().unwrap();
2240
2241 git2::Repository::init(repo_dir.path()).unwrap();
2242 let file_path = repo_dir.path().join("file");
2243 smol::fs::write(&file_path, "initial").await.unwrap();
2244
2245 let repo =
2246 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2247 repo.stage_paths(
2248 vec![RepoPath::from_str("file")],
2249 Arc::new(HashMap::default()),
2250 )
2251 .await
2252 .unwrap();
2253 repo.commit(
2254 "Initial commit".into(),
2255 None,
2256 CommitOptions::default(),
2257 Arc::new(checkpoint_author_envs()),
2258 )
2259 .await
2260 .unwrap();
2261
2262 smol::fs::write(&file_path, "modified before checkpoint")
2263 .await
2264 .unwrap();
2265 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
2266 .await
2267 .unwrap();
2268 let checkpoint = repo.checkpoint().await.unwrap();
2269
2270 // Ensure the user can't see any branches after creating a checkpoint.
2271 assert_eq!(repo.branches().await.unwrap().len(), 1);
2272
2273 smol::fs::write(&file_path, "modified after checkpoint")
2274 .await
2275 .unwrap();
2276 repo.stage_paths(
2277 vec![RepoPath::from_str("file")],
2278 Arc::new(HashMap::default()),
2279 )
2280 .await
2281 .unwrap();
2282 repo.commit(
2283 "Commit after checkpoint".into(),
2284 None,
2285 CommitOptions::default(),
2286 Arc::new(checkpoint_author_envs()),
2287 )
2288 .await
2289 .unwrap();
2290
2291 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
2292 .await
2293 .unwrap();
2294 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
2295 .await
2296 .unwrap();
2297
2298 // Ensure checkpoint stays alive even after a Git GC.
2299 repo.gc().await.unwrap();
2300 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
2301
2302 assert_eq!(
2303 smol::fs::read_to_string(&file_path).await.unwrap(),
2304 "modified before checkpoint"
2305 );
2306 assert_eq!(
2307 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
2308 .await
2309 .unwrap(),
2310 "1"
2311 );
2312 // See TODO above
2313 // assert_eq!(
2314 // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
2315 // .await
2316 // .ok(),
2317 // None
2318 // );
2319 }
2320
2321 #[gpui::test]
2322 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
2323 cx.executor().allow_parking();
2324
2325 let repo_dir = tempfile::tempdir().unwrap();
2326 git2::Repository::init(repo_dir.path()).unwrap();
2327 let repo =
2328 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2329
2330 smol::fs::write(repo_dir.path().join("foo"), "foo")
2331 .await
2332 .unwrap();
2333 let checkpoint_sha = repo.checkpoint().await.unwrap();
2334
2335 // Ensure the user can't see any branches after creating a checkpoint.
2336 assert_eq!(repo.branches().await.unwrap().len(), 1);
2337
2338 smol::fs::write(repo_dir.path().join("foo"), "bar")
2339 .await
2340 .unwrap();
2341 smol::fs::write(repo_dir.path().join("baz"), "qux")
2342 .await
2343 .unwrap();
2344 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
2345 assert_eq!(
2346 smol::fs::read_to_string(repo_dir.path().join("foo"))
2347 .await
2348 .unwrap(),
2349 "foo"
2350 );
2351 // See TODOs above
2352 // assert_eq!(
2353 // smol::fs::read_to_string(repo_dir.path().join("baz"))
2354 // .await
2355 // .ok(),
2356 // None
2357 // );
2358 }
2359
2360 #[gpui::test]
2361 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
2362 cx.executor().allow_parking();
2363
2364 let repo_dir = tempfile::tempdir().unwrap();
2365 git2::Repository::init(repo_dir.path()).unwrap();
2366 let repo =
2367 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2368
2369 smol::fs::write(repo_dir.path().join("file1"), "content1")
2370 .await
2371 .unwrap();
2372 let checkpoint1 = repo.checkpoint().await.unwrap();
2373
2374 smol::fs::write(repo_dir.path().join("file2"), "content2")
2375 .await
2376 .unwrap();
2377 let checkpoint2 = repo.checkpoint().await.unwrap();
2378
2379 assert!(
2380 !repo
2381 .compare_checkpoints(checkpoint1, checkpoint2.clone())
2382 .await
2383 .unwrap()
2384 );
2385
2386 let checkpoint3 = repo.checkpoint().await.unwrap();
2387 assert!(
2388 repo.compare_checkpoints(checkpoint2, checkpoint3)
2389 .await
2390 .unwrap()
2391 );
2392 }
2393
2394 #[gpui::test]
2395 async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
2396 cx.executor().allow_parking();
2397
2398 let repo_dir = tempfile::tempdir().unwrap();
2399 let text_path = repo_dir.path().join("main.rs");
2400 let bin_path = repo_dir.path().join("binary.o");
2401
2402 git2::Repository::init(repo_dir.path()).unwrap();
2403
2404 smol::fs::write(&text_path, "fn main() {}").await.unwrap();
2405
2406 smol::fs::write(&bin_path, "some binary file here")
2407 .await
2408 .unwrap();
2409
2410 let repo =
2411 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2412
2413 // initial commit
2414 repo.stage_paths(
2415 vec![RepoPath::from_str("main.rs")],
2416 Arc::new(HashMap::default()),
2417 )
2418 .await
2419 .unwrap();
2420 repo.commit(
2421 "Initial commit".into(),
2422 None,
2423 CommitOptions::default(),
2424 Arc::new(checkpoint_author_envs()),
2425 )
2426 .await
2427 .unwrap();
2428
2429 let checkpoint = repo.checkpoint().await.unwrap();
2430
2431 smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
2432 .await
2433 .unwrap();
2434 smol::fs::write(&bin_path, "Modified binary file")
2435 .await
2436 .unwrap();
2437
2438 repo.restore_checkpoint(checkpoint).await.unwrap();
2439
2440 // Text files should be restored to checkpoint state,
2441 // but binaries should not (they aren't tracked)
2442 assert_eq!(
2443 smol::fs::read_to_string(&text_path).await.unwrap(),
2444 "fn main() {}"
2445 );
2446
2447 assert_eq!(
2448 smol::fs::read_to_string(&bin_path).await.unwrap(),
2449 "Modified binary file"
2450 );
2451 }
2452
2453 #[test]
2454 fn test_branches_parsing() {
2455 // suppress "help: octal escapes are not supported, `\0` is always null"
2456 #[allow(clippy::octal_escapes)]
2457 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
2458 assert_eq!(
2459 parse_branch_input(input).unwrap(),
2460 vec![Branch {
2461 is_head: true,
2462 ref_name: "refs/heads/zed-patches".into(),
2463 upstream: Some(Upstream {
2464 ref_name: "refs/remotes/origin/zed-patches".into(),
2465 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
2466 ahead: 0,
2467 behind: 0
2468 })
2469 }),
2470 most_recent_commit: Some(CommitSummary {
2471 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
2472 subject: "generated protobuf".into(),
2473 commit_timestamp: 1733187470,
2474 author_name: SharedString::new("John Doe"),
2475 has_parent: false,
2476 })
2477 }]
2478 )
2479 }
2480
2481 impl RealGitRepository {
2482 /// Force a Git garbage collection on the repository.
2483 fn gc(&self) -> BoxFuture<'_, Result<()>> {
2484 let working_directory = self.working_directory();
2485 let git_binary_path = self.git_binary_path.clone();
2486 let executor = self.executor.clone();
2487 self.executor
2488 .spawn(async move {
2489 let git_binary_path = git_binary_path.clone();
2490 let working_directory = working_directory?;
2491 let git = GitBinary::new(git_binary_path, working_directory, executor);
2492 git.run(&["gc", "--prune"]).await?;
2493 Ok(())
2494 })
2495 .boxed()
2496 }
2497 }
2498}