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