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