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