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