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