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 if dot_git.is_dir() {
1726 // Regular repository - .git is a directory
1727 Ok(dot_git)
1728 } else if dot_git.is_file() {
1729 // Worktree - .git is a file containing the path to the actual git directory
1730 let contents = smol::fs::read_to_string(&dot_git).await?;
1731
1732 // The file contains a line like: "gitdir: /path/to/actual/.git/worktrees/name"
1733 if let Some(gitdir_line) = contents.lines().find(|line| line.starts_with("gitdir:")) {
1734 let gitdir_path = gitdir_line.trim_start_matches("gitdir:").trim();
1735
1736 // The path may be relative or absolute
1737 let git_dir = if Path::new(gitdir_path).is_absolute() {
1738 PathBuf::from(gitdir_path)
1739 } else {
1740 // Relative path - resolve it relative to the .git file's location
1741 // (not the working directory)
1742 dot_git
1743 .parent()
1744 .ok_or_else(|| anyhow!(".git file has no parent directory"))?
1745 .join(gitdir_path)
1746 };
1747
1748 // Canonicalize the path to resolve any .. or . components
1749 let git_dir = smol::fs::canonicalize(&git_dir).await.map_err(|e| {
1750 anyhow!(
1751 "Failed to canonicalize git directory path {:?}: {}",
1752 git_dir,
1753 e
1754 )
1755 })?;
1756
1757 if git_dir.exists() {
1758 Ok(git_dir)
1759 } else {
1760 Err(anyhow!(
1761 "Git directory specified in .git file does not exist: {:?}",
1762 git_dir
1763 ))
1764 }
1765 } else {
1766 Err(anyhow!(
1767 ".git file does not contain a valid gitdir reference"
1768 ))
1769 }
1770 } else {
1771 Err(anyhow!(
1772 ".git path does not exist or is neither a file nor directory"
1773 ))
1774 }
1775 }
1776
1777 fn new(
1778 git_binary_path: PathBuf,
1779 working_directory: PathBuf,
1780 executor: BackgroundExecutor,
1781 ) -> Self {
1782 Self {
1783 git_binary_path,
1784 working_directory,
1785 executor,
1786 index_file_path: None,
1787 envs: HashMap::default(),
1788 }
1789 }
1790
1791 async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
1792 let status_output = self
1793 .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
1794 .await?;
1795
1796 let paths = status_output
1797 .split('\0')
1798 .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
1799 .map(|entry| PathBuf::from(&entry[3..]))
1800 .collect::<Vec<_>>();
1801 Ok(paths)
1802 }
1803
1804 fn envs(mut self, envs: HashMap<String, String>) -> Self {
1805 self.envs = envs;
1806 self
1807 }
1808
1809 pub async fn with_temp_index<R, F>(&mut self, f: F) -> Result<R>
1810 where
1811 F: for<'a> FnOnce(&'a Self) -> BoxFuture<'a, Result<R>>,
1812 {
1813 let index_file_path = self.path_for_index_id(Uuid::new_v4()).await?;
1814
1815 let delete_temp_index = util::defer({
1816 let index_file_path = index_file_path.clone();
1817 let executor = self.executor.clone();
1818 move || {
1819 executor
1820 .spawn(async move {
1821 smol::fs::remove_file(index_file_path).await.log_err();
1822 })
1823 .detach();
1824 }
1825 });
1826
1827 // Copy the default index file so that Git doesn't have to rebuild the
1828 // whole index from scratch. This might fail if this is an empty repository.
1829 let git_dir = self.resolve_git_dir().await?;
1830 smol::fs::copy(git_dir.join("index"), &index_file_path)
1831 .await
1832 .ok();
1833
1834 self.index_file_path = Some(index_file_path.clone());
1835 let result = f(self).await;
1836 self.index_file_path = None;
1837 let result = result?;
1838
1839 smol::fs::remove_file(index_file_path).await.ok();
1840 delete_temp_index.abort();
1841
1842 Ok(result)
1843 }
1844
1845 pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
1846 let git_dir = self.resolve_git_dir().await?;
1847 let path = git_dir.join("info").join("exclude");
1848 GitExcludeOverride::new(path).await
1849 }
1850
1851 async fn path_for_index_id(&self, id: Uuid) -> Result<PathBuf> {
1852 let git_dir = self.resolve_git_dir().await?;
1853 Ok(git_dir.join(format!("index-{}.tmp", id)))
1854 }
1855
1856 pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1857 where
1858 S: AsRef<OsStr>,
1859 {
1860 let mut stdout = self.run_raw(args).await?;
1861 if stdout.chars().last() == Some('\n') {
1862 stdout.pop();
1863 }
1864 Ok(stdout)
1865 }
1866
1867 /// Returns the result of the command without trimming the trailing newline.
1868 pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1869 where
1870 S: AsRef<OsStr>,
1871 {
1872 let mut command = self.build_command(args);
1873 let output = command.output().await?;
1874 anyhow::ensure!(
1875 output.status.success(),
1876 GitBinaryCommandError {
1877 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1878 status: output.status,
1879 }
1880 );
1881 Ok(String::from_utf8(output.stdout)?)
1882 }
1883
1884 fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
1885 where
1886 S: AsRef<OsStr>,
1887 {
1888 let mut command = new_smol_command(&self.git_binary_path);
1889 command.current_dir(&self.working_directory);
1890 command.args(args);
1891 if let Some(index_file_path) = self.index_file_path.as_ref() {
1892 command.env("GIT_INDEX_FILE", index_file_path);
1893 }
1894 command.envs(&self.envs);
1895 command
1896 }
1897}
1898
1899#[derive(Error, Debug)]
1900#[error("Git command failed: {stdout}")]
1901struct GitBinaryCommandError {
1902 stdout: String,
1903 status: ExitStatus,
1904}
1905
1906async fn run_git_command(
1907 env: Arc<HashMap<String, String>>,
1908 ask_pass: AskPassDelegate,
1909 mut command: smol::process::Command,
1910 executor: &BackgroundExecutor,
1911) -> Result<RemoteCommandOutput> {
1912 if env.contains_key("GIT_ASKPASS") {
1913 let git_process = command.spawn()?;
1914 let output = git_process.output().await?;
1915 anyhow::ensure!(
1916 output.status.success(),
1917 "{}",
1918 String::from_utf8_lossy(&output.stderr)
1919 );
1920 Ok(RemoteCommandOutput {
1921 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1922 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1923 })
1924 } else {
1925 let ask_pass = AskPassSession::new(executor, ask_pass).await?;
1926 command
1927 .env("GIT_ASKPASS", ask_pass.script_path())
1928 .env("SSH_ASKPASS", ask_pass.script_path())
1929 .env("SSH_ASKPASS_REQUIRE", "force");
1930 let git_process = command.spawn()?;
1931
1932 run_askpass_command(ask_pass, git_process).await
1933 }
1934}
1935
1936async fn run_askpass_command(
1937 mut ask_pass: AskPassSession,
1938 git_process: smol::process::Child,
1939) -> anyhow::Result<RemoteCommandOutput> {
1940 select_biased! {
1941 result = ask_pass.run().fuse() => {
1942 match result {
1943 AskPassResult::CancelledByUser => {
1944 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
1945 }
1946 AskPassResult::Timedout => {
1947 Err(anyhow!("Connecting to host timed out"))?
1948 }
1949 }
1950 }
1951 output = git_process.output().fuse() => {
1952 let output = output?;
1953 anyhow::ensure!(
1954 output.status.success(),
1955 "{}",
1956 String::from_utf8_lossy(&output.stderr)
1957 );
1958 Ok(RemoteCommandOutput {
1959 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1960 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1961 })
1962 }
1963 }
1964}
1965
1966pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1967 LazyLock::new(|| RepoPath(Path::new("").into()));
1968
1969#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1970pub struct RepoPath(pub Arc<Path>);
1971
1972impl RepoPath {
1973 pub fn new(path: PathBuf) -> Self {
1974 debug_assert!(path.is_relative(), "Repo paths must be relative");
1975
1976 RepoPath(path.into())
1977 }
1978
1979 pub fn from_str(path: &str) -> Self {
1980 let path = Path::new(path);
1981 debug_assert!(path.is_relative(), "Repo paths must be relative");
1982
1983 RepoPath(path.into())
1984 }
1985
1986 pub fn to_unix_style(&self) -> Cow<'_, OsStr> {
1987 #[cfg(target_os = "windows")]
1988 {
1989 use std::ffi::OsString;
1990
1991 let path = self.0.as_os_str().to_string_lossy().replace("\\", "/");
1992 Cow::Owned(OsString::from(path))
1993 }
1994 #[cfg(not(target_os = "windows"))]
1995 {
1996 Cow::Borrowed(self.0.as_os_str())
1997 }
1998 }
1999}
2000
2001impl std::fmt::Display for RepoPath {
2002 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2003 self.0.to_string_lossy().fmt(f)
2004 }
2005}
2006
2007impl From<&Path> for RepoPath {
2008 fn from(value: &Path) -> Self {
2009 RepoPath::new(value.into())
2010 }
2011}
2012
2013impl From<Arc<Path>> for RepoPath {
2014 fn from(value: Arc<Path>) -> Self {
2015 RepoPath(value)
2016 }
2017}
2018
2019impl From<PathBuf> for RepoPath {
2020 fn from(value: PathBuf) -> Self {
2021 RepoPath::new(value)
2022 }
2023}
2024
2025impl From<&str> for RepoPath {
2026 fn from(value: &str) -> Self {
2027 Self::from_str(value)
2028 }
2029}
2030
2031impl Default for RepoPath {
2032 fn default() -> Self {
2033 RepoPath(Path::new("").into())
2034 }
2035}
2036
2037impl AsRef<Path> for RepoPath {
2038 fn as_ref(&self) -> &Path {
2039 self.0.as_ref()
2040 }
2041}
2042
2043impl std::ops::Deref for RepoPath {
2044 type Target = Path;
2045
2046 fn deref(&self) -> &Self::Target {
2047 &self.0
2048 }
2049}
2050
2051impl Borrow<Path> for RepoPath {
2052 fn borrow(&self) -> &Path {
2053 self.0.as_ref()
2054 }
2055}
2056
2057#[derive(Debug)]
2058pub struct RepoPathDescendants<'a>(pub &'a Path);
2059
2060impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
2061 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
2062 if key.starts_with(self.0) {
2063 Ordering::Greater
2064 } else {
2065 self.0.cmp(key)
2066 }
2067 }
2068}
2069
2070fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
2071 let mut branches = Vec::new();
2072 for line in input.split('\n') {
2073 if line.is_empty() {
2074 continue;
2075 }
2076 let mut fields = line.split('\x00');
2077 let is_current_branch = fields.next().context("no HEAD")? == "*";
2078 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
2079 let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
2080 let ref_name = fields.next().context("no refname")?.to_string().into();
2081 let upstream_name = fields.next().context("no upstream")?.to_string();
2082 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
2083 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
2084 let subject: SharedString = fields
2085 .next()
2086 .context("no contents:subject")?
2087 .to_string()
2088 .into();
2089
2090 branches.push(Branch {
2091 is_head: is_current_branch,
2092 ref_name,
2093 most_recent_commit: Some(CommitSummary {
2094 sha: head_sha,
2095 subject,
2096 commit_timestamp: commiterdate,
2097 has_parent: !parent_sha.is_empty(),
2098 }),
2099 upstream: if upstream_name.is_empty() {
2100 None
2101 } else {
2102 Some(Upstream {
2103 ref_name: upstream_name.into(),
2104 tracking: upstream_tracking,
2105 })
2106 },
2107 })
2108 }
2109
2110 Ok(branches)
2111}
2112
2113fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
2114 if upstream_track.is_empty() {
2115 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2116 ahead: 0,
2117 behind: 0,
2118 }));
2119 }
2120
2121 let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
2122 let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
2123 let mut ahead: u32 = 0;
2124 let mut behind: u32 = 0;
2125 for component in upstream_track.split(", ") {
2126 if component == "gone" {
2127 return Ok(UpstreamTracking::Gone);
2128 }
2129 if let Some(ahead_num) = component.strip_prefix("ahead ") {
2130 ahead = ahead_num.parse::<u32>()?;
2131 }
2132 if let Some(behind_num) = component.strip_prefix("behind ") {
2133 behind = behind_num.parse::<u32>()?;
2134 }
2135 }
2136 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2137 ahead,
2138 behind,
2139 }))
2140}
2141
2142fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
2143 match relative_file_path.components().next() {
2144 None => anyhow::bail!("repo path should not be empty"),
2145 Some(Component::Prefix(_)) => anyhow::bail!(
2146 "repo path `{}` should be relative, not a windows prefix",
2147 relative_file_path.to_string_lossy()
2148 ),
2149 Some(Component::RootDir) => {
2150 anyhow::bail!(
2151 "repo path `{}` should be relative",
2152 relative_file_path.to_string_lossy()
2153 )
2154 }
2155 Some(Component::CurDir) => {
2156 anyhow::bail!(
2157 "repo path `{}` should not start with `.`",
2158 relative_file_path.to_string_lossy()
2159 )
2160 }
2161 Some(Component::ParentDir) => {
2162 anyhow::bail!(
2163 "repo path `{}` should not start with `..`",
2164 relative_file_path.to_string_lossy()
2165 )
2166 }
2167 _ => Ok(()),
2168 }
2169}
2170
2171fn checkpoint_author_envs() -> HashMap<String, String> {
2172 HashMap::from_iter([
2173 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
2174 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
2175 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
2176 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
2177 ])
2178}
2179
2180#[cfg(test)]
2181mod tests {
2182 use super::*;
2183 use gpui::TestAppContext;
2184
2185 #[gpui::test]
2186 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
2187 cx.executor().allow_parking();
2188
2189 let repo_dir = tempfile::tempdir().unwrap();
2190
2191 git2::Repository::init(repo_dir.path()).unwrap();
2192 let file_path = repo_dir.path().join("file");
2193 smol::fs::write(&file_path, "initial").await.unwrap();
2194
2195 let repo =
2196 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2197 repo.stage_paths(
2198 vec![RepoPath::from_str("file")],
2199 Arc::new(HashMap::default()),
2200 )
2201 .await
2202 .unwrap();
2203 repo.commit(
2204 "Initial commit".into(),
2205 None,
2206 CommitOptions::default(),
2207 Arc::new(checkpoint_author_envs()),
2208 )
2209 .await
2210 .unwrap();
2211
2212 smol::fs::write(&file_path, "modified before checkpoint")
2213 .await
2214 .unwrap();
2215 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
2216 .await
2217 .unwrap();
2218 let checkpoint = repo.checkpoint().await.unwrap();
2219
2220 // Ensure the user can't see any branches after creating a checkpoint.
2221 assert_eq!(repo.branches().await.unwrap().len(), 1);
2222
2223 smol::fs::write(&file_path, "modified after checkpoint")
2224 .await
2225 .unwrap();
2226 repo.stage_paths(
2227 vec![RepoPath::from_str("file")],
2228 Arc::new(HashMap::default()),
2229 )
2230 .await
2231 .unwrap();
2232 repo.commit(
2233 "Commit after checkpoint".into(),
2234 None,
2235 CommitOptions::default(),
2236 Arc::new(checkpoint_author_envs()),
2237 )
2238 .await
2239 .unwrap();
2240
2241 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
2242 .await
2243 .unwrap();
2244 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
2245 .await
2246 .unwrap();
2247
2248 // Ensure checkpoint stays alive even after a Git GC.
2249 repo.gc().await.unwrap();
2250 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
2251
2252 assert_eq!(
2253 smol::fs::read_to_string(&file_path).await.unwrap(),
2254 "modified before checkpoint"
2255 );
2256 assert_eq!(
2257 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
2258 .await
2259 .unwrap(),
2260 "1"
2261 );
2262 // See TODO above
2263 // assert_eq!(
2264 // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
2265 // .await
2266 // .ok(),
2267 // None
2268 // );
2269 }
2270
2271 #[gpui::test]
2272 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
2273 cx.executor().allow_parking();
2274
2275 let repo_dir = tempfile::tempdir().unwrap();
2276 git2::Repository::init(repo_dir.path()).unwrap();
2277 let repo =
2278 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2279
2280 smol::fs::write(repo_dir.path().join("foo"), "foo")
2281 .await
2282 .unwrap();
2283 let checkpoint_sha = repo.checkpoint().await.unwrap();
2284
2285 // Ensure the user can't see any branches after creating a checkpoint.
2286 assert_eq!(repo.branches().await.unwrap().len(), 1);
2287
2288 smol::fs::write(repo_dir.path().join("foo"), "bar")
2289 .await
2290 .unwrap();
2291 smol::fs::write(repo_dir.path().join("baz"), "qux")
2292 .await
2293 .unwrap();
2294 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
2295 assert_eq!(
2296 smol::fs::read_to_string(repo_dir.path().join("foo"))
2297 .await
2298 .unwrap(),
2299 "foo"
2300 );
2301 // See TODOs above
2302 // assert_eq!(
2303 // smol::fs::read_to_string(repo_dir.path().join("baz"))
2304 // .await
2305 // .ok(),
2306 // None
2307 // );
2308 }
2309
2310 #[gpui::test]
2311 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
2312 cx.executor().allow_parking();
2313
2314 let repo_dir = tempfile::tempdir().unwrap();
2315 git2::Repository::init(repo_dir.path()).unwrap();
2316 let repo =
2317 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2318
2319 smol::fs::write(repo_dir.path().join("file1"), "content1")
2320 .await
2321 .unwrap();
2322 let checkpoint1 = repo.checkpoint().await.unwrap();
2323
2324 smol::fs::write(repo_dir.path().join("file2"), "content2")
2325 .await
2326 .unwrap();
2327 let checkpoint2 = repo.checkpoint().await.unwrap();
2328
2329 assert!(
2330 !repo
2331 .compare_checkpoints(checkpoint1, checkpoint2.clone())
2332 .await
2333 .unwrap()
2334 );
2335
2336 let checkpoint3 = repo.checkpoint().await.unwrap();
2337 assert!(
2338 repo.compare_checkpoints(checkpoint2, checkpoint3)
2339 .await
2340 .unwrap()
2341 );
2342 }
2343
2344 #[gpui::test]
2345 async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
2346 cx.executor().allow_parking();
2347
2348 let repo_dir = tempfile::tempdir().unwrap();
2349 let text_path = repo_dir.path().join("main.rs");
2350 let bin_path = repo_dir.path().join("binary.o");
2351
2352 git2::Repository::init(repo_dir.path()).unwrap();
2353
2354 smol::fs::write(&text_path, "fn main() {}").await.unwrap();
2355
2356 smol::fs::write(&bin_path, "some binary file here")
2357 .await
2358 .unwrap();
2359
2360 let repo =
2361 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2362
2363 // initial commit
2364 repo.stage_paths(
2365 vec![RepoPath::from_str("main.rs")],
2366 Arc::new(HashMap::default()),
2367 )
2368 .await
2369 .unwrap();
2370 repo.commit(
2371 "Initial commit".into(),
2372 None,
2373 CommitOptions::default(),
2374 Arc::new(checkpoint_author_envs()),
2375 )
2376 .await
2377 .unwrap();
2378
2379 let checkpoint = repo.checkpoint().await.unwrap();
2380
2381 smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
2382 .await
2383 .unwrap();
2384 smol::fs::write(&bin_path, "Modified binary file")
2385 .await
2386 .unwrap();
2387
2388 repo.restore_checkpoint(checkpoint).await.unwrap();
2389
2390 // Text files should be restored to checkpoint state,
2391 // but binaries should not (they aren't tracked)
2392 assert_eq!(
2393 smol::fs::read_to_string(&text_path).await.unwrap(),
2394 "fn main() {}"
2395 );
2396
2397 assert_eq!(
2398 smol::fs::read_to_string(&bin_path).await.unwrap(),
2399 "Modified binary file"
2400 );
2401 }
2402
2403 #[test]
2404 fn test_branches_parsing() {
2405 // suppress "help: octal escapes are not supported, `\0` is always null"
2406 #[allow(clippy::octal_escapes)]
2407 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
2408 assert_eq!(
2409 parse_branch_input(input).unwrap(),
2410 vec![Branch {
2411 is_head: true,
2412 ref_name: "refs/heads/zed-patches".into(),
2413 upstream: Some(Upstream {
2414 ref_name: "refs/remotes/origin/zed-patches".into(),
2415 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
2416 ahead: 0,
2417 behind: 0
2418 })
2419 }),
2420 most_recent_commit: Some(CommitSummary {
2421 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
2422 subject: "generated protobuf".into(),
2423 commit_timestamp: 1733187470,
2424 has_parent: false,
2425 })
2426 }]
2427 )
2428 }
2429
2430 #[gpui::test]
2431 async fn test_checkpoint_with_worktree(cx: &mut TestAppContext) {
2432 cx.executor().allow_parking();
2433
2434 // Create main repository
2435 let main_repo_dir = tempfile::tempdir().unwrap();
2436 let main_repo_path = main_repo_dir.path();
2437
2438 git2::Repository::init(main_repo_path).unwrap();
2439
2440 // Create initial commit in main repo
2441 let file_path = main_repo_path.join("test.txt");
2442 smol::fs::write(&file_path, "initial content")
2443 .await
2444 .unwrap();
2445
2446 let git_repo = git2::Repository::open(main_repo_path).unwrap();
2447 let mut index = git_repo.index().unwrap();
2448 index.add_path(std::path::Path::new("test.txt")).unwrap();
2449 index.write().unwrap();
2450
2451 let tree_id = index.write_tree().unwrap();
2452 let tree = git_repo.find_tree(tree_id).unwrap();
2453 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2454 let commit = git_repo
2455 .commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
2456 .unwrap();
2457
2458 // Create a branch for the worktree
2459 let commit_obj = git_repo.find_commit(commit).unwrap();
2460 git_repo
2461 .branch("worktree-branch", &commit_obj, false)
2462 .unwrap();
2463
2464 // Create a worktree subdirectory in the main repo's parent
2465 let worktree_name = format!("worktree-{}", uuid::Uuid::new_v4());
2466 let worktree_path = main_repo_dir.path().parent().unwrap().join(&worktree_name);
2467 std::fs::create_dir_all(&worktree_path).unwrap();
2468
2469 // Use git command line to create the worktree (more reliable than git2 API)
2470 let output = std::process::Command::new("git")
2471 .arg("-C")
2472 .arg(main_repo_path)
2473 .arg("worktree")
2474 .arg("add")
2475 .arg(&worktree_path)
2476 .arg("worktree-branch")
2477 .output()
2478 .expect("Failed to execute git worktree add");
2479
2480 if !output.status.success() {
2481 panic!(
2482 "Failed to create worktree: {}",
2483 String::from_utf8_lossy(&output.stderr)
2484 );
2485 }
2486
2487 // Verify that .git is a file in the worktree (not a directory)
2488 let git_file = worktree_path.join(".git");
2489 assert!(git_file.exists(), ".git should exist in worktree");
2490 assert!(
2491 git_file.is_file(),
2492 ".git should be a file in worktree, not a directory"
2493 );
2494
2495 // Try to create a RealGitRepository with the worktree
2496 // This should work but currently fails because .git is a file
2497 let repo = RealGitRepository::new(&git_file, None, cx.executor());
2498
2499 if let Some(repo) = repo {
2500 // Test checkpoint functionality
2501 smol::fs::write(worktree_path.join("new_file.txt"), "new content")
2502 .await
2503 .unwrap();
2504
2505 // This should fail with the current implementation because
2506 // with_exclude_overrides() and path_for_index_id() assume .git is a directory
2507 let checkpoint_result = repo.checkpoint().await;
2508
2509 // In a working implementation, this should succeed
2510 assert!(
2511 checkpoint_result.is_ok(),
2512 "Checkpoint should work in worktrees but currently fails with: {:?}",
2513 checkpoint_result.err()
2514 );
2515
2516 if let Ok(checkpoint) = checkpoint_result {
2517 // Test restore
2518 smol::fs::write(worktree_path.join("another_file.txt"), "more content")
2519 .await
2520 .unwrap();
2521
2522 let restore_result = repo.restore_checkpoint(checkpoint).await;
2523 assert!(restore_result.is_ok(), "Restore should work in worktrees");
2524 }
2525 } else {
2526 // This might fail to even create the repository with worktrees
2527 panic!("Failed to create RealGitRepository with worktree - this is part of the bug");
2528 }
2529 }
2530
2531 #[gpui::test]
2532 async fn test_checkpoint_with_regular_repo(cx: &mut TestAppContext) {
2533 cx.executor().allow_parking();
2534
2535 // Create a regular repository (not a worktree)
2536 let repo_dir = tempfile::tempdir().unwrap();
2537 let repo_path = repo_dir.path();
2538
2539 git2::Repository::init(repo_path).unwrap();
2540
2541 // Create initial commit
2542 let file_path = repo_path.join("test.txt");
2543 smol::fs::write(&file_path, "initial content")
2544 .await
2545 .unwrap();
2546
2547 let git_repo = git2::Repository::open(repo_path).unwrap();
2548 let mut index = git_repo.index().unwrap();
2549 index.add_path(std::path::Path::new("test.txt")).unwrap();
2550 index.write().unwrap();
2551
2552 let tree_id = index.write_tree().unwrap();
2553 let tree = git_repo.find_tree(tree_id).unwrap();
2554 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2555 git_repo
2556 .commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
2557 .unwrap();
2558
2559 // Verify that .git is a directory in regular repo
2560 let git_dir = repo_path.join(".git");
2561 assert!(git_dir.exists(), ".git should exist");
2562 assert!(
2563 git_dir.is_dir(),
2564 ".git should be a directory in regular repo"
2565 );
2566
2567 // Create a RealGitRepository with the regular repo
2568 let repo = RealGitRepository::new(&git_dir, None, cx.executor()).unwrap();
2569
2570 // Test checkpoint functionality - this should work fine
2571 smol::fs::write(repo_path.join("new_file.txt"), "new content")
2572 .await
2573 .unwrap();
2574
2575 // The main point is that checkpoint creation succeeds in regular repos
2576 let checkpoint = repo.checkpoint().await;
2577 assert!(
2578 checkpoint.is_ok(),
2579 "Checkpoint should work in regular repos"
2580 );
2581
2582 // Test that we can also restore (even if it doesn't fully restore the worktree state)
2583 if let Ok(checkpoint) = checkpoint {
2584 let restore_result = repo.restore_checkpoint(checkpoint).await;
2585 assert!(
2586 restore_result.is_ok(),
2587 "Restore should not fail in regular repos"
2588 );
2589 }
2590 }
2591
2592 impl RealGitRepository {
2593 /// Force a Git garbage collection on the repository.
2594 fn gc(&self) -> BoxFuture<'_, Result<()>> {
2595 let working_directory = self.working_directory();
2596 let git_binary_path = self.git_binary_path.clone();
2597 let executor = self.executor.clone();
2598 self.executor
2599 .spawn(async move {
2600 let git_binary_path = git_binary_path.clone();
2601 let working_directory = working_directory?;
2602 let git = GitBinary::new(git_binary_path, working_directory, executor);
2603 git.run(&["gc", "--prune"]).await?;
2604 Ok(())
2605 })
2606 .boxed()
2607 }
2608 }
2609}