1use crate::commit::parse_git_diff_name_status;
2use crate::stash::GitStash;
3use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
4use crate::{Oid, RunHook, SHORT_SHA_LENGTH};
5use anyhow::{Context as _, Result, anyhow, bail};
6use collections::HashMap;
7use futures::future::BoxFuture;
8use futures::io::BufWriter;
9use futures::{AsyncWriteExt, FutureExt as _, select_biased};
10use git2::BranchType;
11use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
12use parking_lot::Mutex;
13use rope::Rope;
14use schemars::JsonSchema;
15use serde::Deserialize;
16use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
17use std::ffi::{OsStr, OsString};
18use std::process::{ExitStatus, Stdio};
19use std::{
20 cmp::Ordering,
21 future,
22 path::{Path, PathBuf},
23 sync::Arc,
24};
25use sum_tree::MapSeekTarget;
26use thiserror::Error;
27use util::command::new_smol_command;
28use util::paths::PathStyle;
29use util::rel_path::RelPath;
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 Worktree {
76 pub path: PathBuf,
77 pub ref_name: SharedString,
78 pub sha: SharedString,
79}
80
81impl Worktree {
82 pub fn branch(&self) -> &str {
83 self.ref_name
84 .as_ref()
85 .strip_prefix("refs/heads/")
86 .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
87 .unwrap_or(self.ref_name.as_ref())
88 }
89}
90
91pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
92 let mut worktrees = Vec::new();
93 let entries = raw_worktrees.as_ref().split("\n\n");
94 for entry in entries {
95 let mut parts = entry.splitn(3, '\n');
96 let path = parts
97 .next()
98 .and_then(|p| p.split_once(' ').map(|(_, path)| path.to_string()));
99 let sha = parts
100 .next()
101 .and_then(|p| p.split_once(' ').map(|(_, sha)| sha.to_string()));
102 let ref_name = parts
103 .next()
104 .and_then(|p| p.split_once(' ').map(|(_, ref_name)| ref_name.to_string()));
105
106 if let (Some(path), Some(sha), Some(ref_name)) = (path, sha, ref_name) {
107 worktrees.push(Worktree {
108 path: PathBuf::from(path),
109 ref_name: ref_name.into(),
110 sha: sha.into(),
111 })
112 }
113 }
114
115 worktrees
116}
117
118#[derive(Clone, Debug, Hash, PartialEq, Eq)]
119pub struct Upstream {
120 pub ref_name: SharedString,
121 pub tracking: UpstreamTracking,
122}
123
124impl Upstream {
125 pub fn is_remote(&self) -> bool {
126 self.remote_name().is_some()
127 }
128
129 pub fn remote_name(&self) -> Option<&str> {
130 self.ref_name
131 .strip_prefix("refs/remotes/")
132 .and_then(|stripped| stripped.split("/").next())
133 }
134
135 pub fn stripped_ref_name(&self) -> Option<&str> {
136 self.ref_name.strip_prefix("refs/remotes/")
137 }
138}
139
140#[derive(Clone, Copy, Default)]
141pub struct CommitOptions {
142 pub amend: bool,
143 pub signoff: bool,
144}
145
146#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
147pub enum UpstreamTracking {
148 /// Remote ref not present in local repository.
149 Gone,
150 /// Remote ref present in local repository (fetched from remote).
151 Tracked(UpstreamTrackingStatus),
152}
153
154impl From<UpstreamTrackingStatus> for UpstreamTracking {
155 fn from(status: UpstreamTrackingStatus) -> Self {
156 UpstreamTracking::Tracked(status)
157 }
158}
159
160impl UpstreamTracking {
161 pub fn is_gone(&self) -> bool {
162 matches!(self, UpstreamTracking::Gone)
163 }
164
165 pub fn status(&self) -> Option<UpstreamTrackingStatus> {
166 match self {
167 UpstreamTracking::Gone => None,
168 UpstreamTracking::Tracked(status) => Some(*status),
169 }
170 }
171}
172
173#[derive(Debug, Clone)]
174pub struct RemoteCommandOutput {
175 pub stdout: String,
176 pub stderr: String,
177}
178
179impl RemoteCommandOutput {
180 pub fn is_empty(&self) -> bool {
181 self.stdout.is_empty() && self.stderr.is_empty()
182 }
183}
184
185#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
186pub struct UpstreamTrackingStatus {
187 pub ahead: u32,
188 pub behind: u32,
189}
190
191#[derive(Clone, Debug, Hash, PartialEq, Eq)]
192pub struct CommitSummary {
193 pub sha: SharedString,
194 pub subject: SharedString,
195 /// This is a unix timestamp
196 pub commit_timestamp: i64,
197 pub author_name: SharedString,
198 pub has_parent: bool,
199}
200
201#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
202pub struct CommitDetails {
203 pub sha: SharedString,
204 pub message: SharedString,
205 pub commit_timestamp: i64,
206 pub author_email: SharedString,
207 pub author_name: SharedString,
208}
209
210#[derive(Clone, Debug, Hash, PartialEq, Eq)]
211pub struct FileHistoryEntry {
212 pub sha: SharedString,
213 pub subject: SharedString,
214 pub message: SharedString,
215 pub commit_timestamp: i64,
216 pub author_name: SharedString,
217 pub author_email: SharedString,
218}
219
220#[derive(Debug, Clone)]
221pub struct FileHistory {
222 pub entries: Vec<FileHistoryEntry>,
223 pub path: RepoPath,
224}
225
226#[derive(Debug)]
227pub struct CommitDiff {
228 pub files: Vec<CommitFile>,
229}
230
231#[derive(Debug)]
232pub struct CommitFile {
233 pub path: RepoPath,
234 pub old_text: Option<String>,
235 pub new_text: Option<String>,
236}
237
238impl CommitDetails {
239 pub fn short_sha(&self) -> SharedString {
240 self.sha[..SHORT_SHA_LENGTH].to_string().into()
241 }
242}
243
244#[derive(Debug, Clone, Hash, PartialEq, Eq)]
245pub struct Remote {
246 pub name: SharedString,
247}
248
249pub enum ResetMode {
250 /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
251 /// committed are now staged).
252 Soft,
253 /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
254 /// committed are now unstaged).
255 Mixed,
256}
257
258#[derive(Debug, Clone, Hash, PartialEq, Eq)]
259pub enum FetchOptions {
260 All,
261 Remote(Remote),
262}
263
264impl FetchOptions {
265 pub fn to_proto(&self) -> Option<String> {
266 match self {
267 FetchOptions::All => None,
268 FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
269 }
270 }
271
272 pub fn from_proto(remote_name: Option<String>) -> Self {
273 match remote_name {
274 Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
275 None => FetchOptions::All,
276 }
277 }
278
279 pub fn name(&self) -> SharedString {
280 match self {
281 Self::All => "Fetch all remotes".into(),
282 Self::Remote(remote) => remote.name.clone(),
283 }
284 }
285}
286
287impl std::fmt::Display for FetchOptions {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 match self {
290 FetchOptions::All => write!(f, "--all"),
291 FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
292 }
293 }
294}
295
296/// Modifies .git/info/exclude temporarily
297pub struct GitExcludeOverride {
298 git_exclude_path: PathBuf,
299 original_excludes: Option<String>,
300 added_excludes: Option<String>,
301}
302
303impl GitExcludeOverride {
304 const START_BLOCK_MARKER: &str = "\n\n# ====== Auto-added by Zed: =======\n";
305 const END_BLOCK_MARKER: &str = "\n# ====== End of auto-added by Zed =======\n";
306
307 pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
308 let original_excludes =
309 smol::fs::read_to_string(&git_exclude_path)
310 .await
311 .ok()
312 .map(|content| {
313 // Auto-generated lines are normally cleaned up in
314 // `restore_original()` or `drop()`, but may stuck in rare cases.
315 // Make sure to remove them.
316 Self::remove_auto_generated_block(&content)
317 });
318
319 Ok(GitExcludeOverride {
320 git_exclude_path,
321 original_excludes,
322 added_excludes: None,
323 })
324 }
325
326 pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
327 self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
328 format!("{already_added}\n{excludes}")
329 } else {
330 excludes.to_string()
331 });
332
333 let mut content = self.original_excludes.clone().unwrap_or_default();
334
335 content.push_str(Self::START_BLOCK_MARKER);
336 content.push_str(self.added_excludes.as_ref().unwrap());
337 content.push_str(Self::END_BLOCK_MARKER);
338
339 smol::fs::write(&self.git_exclude_path, content).await?;
340 Ok(())
341 }
342
343 pub async fn restore_original(&mut self) -> Result<()> {
344 if let Some(ref original) = self.original_excludes {
345 smol::fs::write(&self.git_exclude_path, original).await?;
346 } else if self.git_exclude_path.exists() {
347 smol::fs::remove_file(&self.git_exclude_path).await?;
348 }
349
350 self.added_excludes = None;
351
352 Ok(())
353 }
354
355 fn remove_auto_generated_block(content: &str) -> String {
356 let start_marker = Self::START_BLOCK_MARKER;
357 let end_marker = Self::END_BLOCK_MARKER;
358 let mut content = content.to_string();
359
360 let start_index = content.find(start_marker);
361 let end_index = content.rfind(end_marker);
362
363 if let (Some(start), Some(end)) = (start_index, end_index) {
364 if end > start {
365 content.replace_range(start..end + end_marker.len(), "");
366 }
367 }
368
369 // Older versions of Zed didn't have end-of-block markers,
370 // so it's impossible to determine auto-generated lines.
371 // Conservatively remove the standard list of excludes
372 let standard_excludes = format!(
373 "{}{}",
374 Self::START_BLOCK_MARKER,
375 include_str!("./checkpoint.gitignore")
376 );
377 content = content.replace(&standard_excludes, "");
378
379 content
380 }
381}
382
383impl Drop for GitExcludeOverride {
384 fn drop(&mut self) {
385 if self.added_excludes.is_some() {
386 let git_exclude_path = self.git_exclude_path.clone();
387 let original_excludes = self.original_excludes.clone();
388 smol::spawn(async move {
389 if let Some(original) = original_excludes {
390 smol::fs::write(&git_exclude_path, original).await
391 } else {
392 smol::fs::remove_file(&git_exclude_path).await
393 }
394 })
395 .detach();
396 }
397 }
398}
399
400pub trait GitRepository: Send + Sync {
401 fn reload_index(&self);
402
403 /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
404 ///
405 /// Also returns `None` for symlinks.
406 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
407
408 /// 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.
409 ///
410 /// Also returns `None` for symlinks.
411 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
412 fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
413
414 fn set_index_text(
415 &self,
416 path: RepoPath,
417 content: Option<String>,
418 env: Arc<HashMap<String, String>>,
419 is_executable: bool,
420 ) -> BoxFuture<'_, anyhow::Result<()>>;
421
422 /// Returns the URL of the remote with the given name.
423 fn remote_url(&self, name: &str) -> Option<String>;
424
425 /// Resolve a list of refs to SHAs.
426 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
427
428 fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
429 async move {
430 self.revparse_batch(vec!["HEAD".into()])
431 .await
432 .unwrap_or_default()
433 .into_iter()
434 .next()
435 .flatten()
436 }
437 .boxed()
438 }
439
440 fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
441
442 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
443 fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
444
445 fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
446
447 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
448
449 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
450 fn create_branch(&self, name: String, base_branch: Option<String>)
451 -> BoxFuture<'_, Result<()>>;
452 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
453
454 fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
455
456 fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
457
458 fn create_worktree(
459 &self,
460 name: String,
461 directory: PathBuf,
462 from_commit: Option<String>,
463 ) -> BoxFuture<'_, Result<()>>;
464
465 fn reset(
466 &self,
467 commit: String,
468 mode: ResetMode,
469 env: Arc<HashMap<String, String>>,
470 ) -> BoxFuture<'_, Result<()>>;
471
472 fn checkout_files(
473 &self,
474 commit: String,
475 paths: Vec<RepoPath>,
476 env: Arc<HashMap<String, String>>,
477 ) -> BoxFuture<'_, Result<()>>;
478
479 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
480
481 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
482 fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
483 fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>>;
484 fn file_history_paginated(
485 &self,
486 path: RepoPath,
487 skip: usize,
488 limit: Option<usize>,
489 ) -> BoxFuture<'_, Result<FileHistory>>;
490
491 /// Returns the absolute path to the repository. For worktrees, this will be the path to the
492 /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
493 fn path(&self) -> PathBuf;
494
495 fn main_repository_path(&self) -> PathBuf;
496
497 /// Updates the index to match the worktree at the given paths.
498 ///
499 /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
500 fn stage_paths(
501 &self,
502 paths: Vec<RepoPath>,
503 env: Arc<HashMap<String, String>>,
504 ) -> BoxFuture<'_, Result<()>>;
505 /// Updates the index to match HEAD at the given paths.
506 ///
507 /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
508 fn unstage_paths(
509 &self,
510 paths: Vec<RepoPath>,
511 env: Arc<HashMap<String, String>>,
512 ) -> BoxFuture<'_, Result<()>>;
513
514 fn run_hook(
515 &self,
516 hook: RunHook,
517 env: Arc<HashMap<String, String>>,
518 ) -> BoxFuture<'_, Result<()>>;
519
520 fn commit(
521 &self,
522 message: SharedString,
523 name_and_email: Option<(SharedString, SharedString)>,
524 options: CommitOptions,
525 askpass: AskPassDelegate,
526 env: Arc<HashMap<String, String>>,
527 ) -> BoxFuture<'_, Result<()>>;
528
529 fn stash_paths(
530 &self,
531 paths: Vec<RepoPath>,
532 env: Arc<HashMap<String, String>>,
533 ) -> BoxFuture<'_, Result<()>>;
534
535 fn stash_pop(
536 &self,
537 index: Option<usize>,
538 env: Arc<HashMap<String, String>>,
539 ) -> BoxFuture<'_, Result<()>>;
540
541 fn stash_apply(
542 &self,
543 index: Option<usize>,
544 env: Arc<HashMap<String, String>>,
545 ) -> BoxFuture<'_, Result<()>>;
546
547 fn stash_drop(
548 &self,
549 index: Option<usize>,
550 env: Arc<HashMap<String, String>>,
551 ) -> BoxFuture<'_, Result<()>>;
552
553 fn push(
554 &self,
555 branch_name: String,
556 upstream_name: String,
557 options: Option<PushOptions>,
558 askpass: AskPassDelegate,
559 env: Arc<HashMap<String, String>>,
560 // This method takes an AsyncApp to ensure it's invoked on the main thread,
561 // otherwise git-credentials-manager won't work.
562 cx: AsyncApp,
563 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
564
565 fn pull(
566 &self,
567 branch_name: Option<String>,
568 upstream_name: String,
569 rebase: bool,
570 askpass: AskPassDelegate,
571 env: Arc<HashMap<String, String>>,
572 // This method takes an AsyncApp to ensure it's invoked on the main thread,
573 // otherwise git-credentials-manager won't work.
574 cx: AsyncApp,
575 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
576
577 fn fetch(
578 &self,
579 fetch_options: FetchOptions,
580 askpass: AskPassDelegate,
581 env: Arc<HashMap<String, String>>,
582 // This method takes an AsyncApp to ensure it's invoked on the main thread,
583 // otherwise git-credentials-manager won't work.
584 cx: AsyncApp,
585 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
586
587 fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
588
589 fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
590
591 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>>;
592
593 /// returns a list of remote branches that contain HEAD
594 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
595
596 /// Run git diff
597 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
598
599 /// Creates a checkpoint for the repository.
600 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
601
602 /// Resets to a previously-created checkpoint.
603 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
604
605 /// Compares two checkpoints, returning true if they are equal
606 fn compare_checkpoints(
607 &self,
608 left: GitRepositoryCheckpoint,
609 right: GitRepositoryCheckpoint,
610 ) -> BoxFuture<'_, Result<bool>>;
611
612 /// Computes a diff between two checkpoints.
613 fn diff_checkpoints(
614 &self,
615 base_checkpoint: GitRepositoryCheckpoint,
616 target_checkpoint: GitRepositoryCheckpoint,
617 ) -> BoxFuture<'_, Result<String>>;
618
619 fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>>;
620}
621
622pub enum DiffType {
623 HeadToIndex,
624 HeadToWorktree,
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
628pub enum PushOptions {
629 SetUpstream,
630 Force,
631}
632
633impl std::fmt::Debug for dyn GitRepository {
634 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
635 f.debug_struct("dyn GitRepository<...>").finish()
636 }
637}
638
639pub struct RealGitRepository {
640 pub repository: Arc<Mutex<git2::Repository>>,
641 pub system_git_binary_path: Option<PathBuf>,
642 pub any_git_binary_path: PathBuf,
643 executor: BackgroundExecutor,
644}
645
646impl RealGitRepository {
647 pub fn new(
648 dotgit_path: &Path,
649 bundled_git_binary_path: Option<PathBuf>,
650 system_git_binary_path: Option<PathBuf>,
651 executor: BackgroundExecutor,
652 ) -> Option<Self> {
653 let any_git_binary_path = system_git_binary_path.clone().or(bundled_git_binary_path)?;
654 let workdir_root = dotgit_path.parent()?;
655 let repository = git2::Repository::open(workdir_root).log_err()?;
656 Some(Self {
657 repository: Arc::new(Mutex::new(repository)),
658 system_git_binary_path,
659 any_git_binary_path,
660 executor,
661 })
662 }
663
664 fn working_directory(&self) -> Result<PathBuf> {
665 self.repository
666 .lock()
667 .workdir()
668 .context("failed to read git work directory")
669 .map(Path::to_path_buf)
670 }
671}
672
673#[derive(Clone, Debug)]
674pub struct GitRepositoryCheckpoint {
675 pub commit_sha: Oid,
676}
677
678#[derive(Debug)]
679pub struct GitCommitter {
680 pub name: Option<String>,
681 pub email: Option<String>,
682}
683
684pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
685 if cfg!(any(feature = "test-support", test)) {
686 return GitCommitter {
687 name: None,
688 email: None,
689 };
690 }
691
692 let git_binary_path =
693 if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
694 cx.update(|cx| {
695 cx.path_for_auxiliary_executable("git")
696 .context("could not find git binary path")
697 .log_err()
698 })
699 .ok()
700 .flatten()
701 } else {
702 None
703 };
704
705 let git = GitBinary::new(
706 git_binary_path.unwrap_or(PathBuf::from("git")),
707 paths::home_dir().clone(),
708 cx.background_executor().clone(),
709 );
710
711 cx.background_spawn(async move {
712 let name = git.run(["config", "--global", "user.name"]).await.log_err();
713 let email = git
714 .run(["config", "--global", "user.email"])
715 .await
716 .log_err();
717 GitCommitter { name, email }
718 })
719 .await
720}
721
722impl GitRepository for RealGitRepository {
723 fn reload_index(&self) {
724 if let Ok(mut index) = self.repository.lock().index() {
725 _ = index.read(false);
726 }
727 }
728
729 fn path(&self) -> PathBuf {
730 let repo = self.repository.lock();
731 repo.path().into()
732 }
733
734 fn main_repository_path(&self) -> PathBuf {
735 let repo = self.repository.lock();
736 repo.commondir().into()
737 }
738
739 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
740 let git_binary_path = self.any_git_binary_path.clone();
741 let working_directory = self.working_directory();
742 self.executor
743 .spawn(async move {
744 let working_directory = working_directory?;
745 let output = new_smol_command(git_binary_path)
746 .current_dir(&working_directory)
747 .args([
748 "--no-optional-locks",
749 "show",
750 "--no-patch",
751 "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
752 &commit,
753 ])
754 .output()
755 .await?;
756 let output = std::str::from_utf8(&output.stdout)?;
757 let fields = output.split('\0').collect::<Vec<_>>();
758 if fields.len() != 6 {
759 bail!("unexpected git-show output for {commit:?}: {output:?}")
760 }
761 let sha = fields[0].to_string().into();
762 let message = fields[1].to_string().into();
763 let commit_timestamp = fields[2].parse()?;
764 let author_email = fields[3].to_string().into();
765 let author_name = fields[4].to_string().into();
766 Ok(CommitDetails {
767 sha,
768 message,
769 commit_timestamp,
770 author_email,
771 author_name,
772 })
773 })
774 .boxed()
775 }
776
777 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
778 let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
779 else {
780 return future::ready(Err(anyhow!("no working directory"))).boxed();
781 };
782 let git_binary_path = self.any_git_binary_path.clone();
783 cx.background_spawn(async move {
784 let show_output = util::command::new_smol_command(&git_binary_path)
785 .current_dir(&working_directory)
786 .args([
787 "--no-optional-locks",
788 "show",
789 "--format=",
790 "-z",
791 "--no-renames",
792 "--name-status",
793 "--first-parent",
794 ])
795 .arg(&commit)
796 .stdin(Stdio::null())
797 .stdout(Stdio::piped())
798 .stderr(Stdio::piped())
799 .output()
800 .await
801 .context("starting git show process")?;
802
803 let show_stdout = String::from_utf8_lossy(&show_output.stdout);
804 let changes = parse_git_diff_name_status(&show_stdout);
805 let parent_sha = format!("{}^", commit);
806
807 let mut cat_file_process = util::command::new_smol_command(&git_binary_path)
808 .current_dir(&working_directory)
809 .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
810 .stdin(Stdio::piped())
811 .stdout(Stdio::piped())
812 .stderr(Stdio::piped())
813 .spawn()
814 .context("starting git cat-file process")?;
815
816 let mut files = Vec::<CommitFile>::new();
817 let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
818 let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
819 let mut info_line = String::new();
820 let mut newline = [b'\0'];
821 for (path, status_code) in changes {
822 // git-show outputs `/`-delimited paths even on Windows.
823 let Some(rel_path) = RelPath::unix(path).log_err() else {
824 continue;
825 };
826
827 match status_code {
828 StatusCode::Modified => {
829 stdin.write_all(commit.as_bytes()).await?;
830 stdin.write_all(b":").await?;
831 stdin.write_all(path.as_bytes()).await?;
832 stdin.write_all(b"\n").await?;
833 stdin.write_all(parent_sha.as_bytes()).await?;
834 stdin.write_all(b":").await?;
835 stdin.write_all(path.as_bytes()).await?;
836 stdin.write_all(b"\n").await?;
837 }
838 StatusCode::Added => {
839 stdin.write_all(commit.as_bytes()).await?;
840 stdin.write_all(b":").await?;
841 stdin.write_all(path.as_bytes()).await?;
842 stdin.write_all(b"\n").await?;
843 }
844 StatusCode::Deleted => {
845 stdin.write_all(parent_sha.as_bytes()).await?;
846 stdin.write_all(b":").await?;
847 stdin.write_all(path.as_bytes()).await?;
848 stdin.write_all(b"\n").await?;
849 }
850 _ => continue,
851 }
852 stdin.flush().await?;
853
854 info_line.clear();
855 stdout.read_line(&mut info_line).await?;
856
857 let len = info_line.trim_end().parse().with_context(|| {
858 format!("invalid object size output from cat-file {info_line}")
859 })?;
860 let mut text = vec![0; len];
861 stdout.read_exact(&mut text).await?;
862 stdout.read_exact(&mut newline).await?;
863 let text = String::from_utf8_lossy(&text).to_string();
864
865 let mut old_text = None;
866 let mut new_text = None;
867 match status_code {
868 StatusCode::Modified => {
869 info_line.clear();
870 stdout.read_line(&mut info_line).await?;
871 let len = info_line.trim_end().parse().with_context(|| {
872 format!("invalid object size output from cat-file {}", info_line)
873 })?;
874 let mut parent_text = vec![0; len];
875 stdout.read_exact(&mut parent_text).await?;
876 stdout.read_exact(&mut newline).await?;
877 old_text = Some(String::from_utf8_lossy(&parent_text).to_string());
878 new_text = Some(text);
879 }
880 StatusCode::Added => new_text = Some(text),
881 StatusCode::Deleted => old_text = Some(text),
882 _ => continue,
883 }
884
885 files.push(CommitFile {
886 path: RepoPath(Arc::from(rel_path)),
887 old_text,
888 new_text,
889 })
890 }
891
892 Ok(CommitDiff { files })
893 })
894 .boxed()
895 }
896
897 fn reset(
898 &self,
899 commit: String,
900 mode: ResetMode,
901 env: Arc<HashMap<String, String>>,
902 ) -> BoxFuture<'_, Result<()>> {
903 async move {
904 let working_directory = self.working_directory();
905
906 let mode_flag = match mode {
907 ResetMode::Mixed => "--mixed",
908 ResetMode::Soft => "--soft",
909 };
910
911 let output = new_smol_command(&self.any_git_binary_path)
912 .envs(env.iter())
913 .current_dir(&working_directory?)
914 .args(["reset", mode_flag, &commit])
915 .output()
916 .await?;
917 anyhow::ensure!(
918 output.status.success(),
919 "Failed to reset:\n{}",
920 String::from_utf8_lossy(&output.stderr),
921 );
922 Ok(())
923 }
924 .boxed()
925 }
926
927 fn checkout_files(
928 &self,
929 commit: String,
930 paths: Vec<RepoPath>,
931 env: Arc<HashMap<String, String>>,
932 ) -> BoxFuture<'_, Result<()>> {
933 let working_directory = self.working_directory();
934 let git_binary_path = self.any_git_binary_path.clone();
935 async move {
936 if paths.is_empty() {
937 return Ok(());
938 }
939
940 let output = new_smol_command(&git_binary_path)
941 .current_dir(&working_directory?)
942 .envs(env.iter())
943 .args(["checkout", &commit, "--"])
944 .args(paths.iter().map(|path| path.as_unix_str()))
945 .output()
946 .await?;
947 anyhow::ensure!(
948 output.status.success(),
949 "Failed to checkout files:\n{}",
950 String::from_utf8_lossy(&output.stderr),
951 );
952 Ok(())
953 }
954 .boxed()
955 }
956
957 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
958 // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
959 const GIT_MODE_SYMLINK: u32 = 0o120000;
960
961 let repo = self.repository.clone();
962 self.executor
963 .spawn(async move {
964 fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
965 // This check is required because index.get_path() unwraps internally :(
966 let mut index = repo.index()?;
967 index.read(false)?;
968
969 const STAGE_NORMAL: i32 = 0;
970 let oid = match index.get_path(path.as_std_path(), STAGE_NORMAL) {
971 Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
972 _ => return Ok(None),
973 };
974
975 let content = repo.find_blob(oid)?.content().to_owned();
976 Ok(String::from_utf8(content).ok())
977 }
978
979 match logic(&repo.lock(), &path) {
980 Ok(value) => return value,
981 Err(err) => log::error!("Error loading index text: {:?}", err),
982 }
983 None
984 })
985 .boxed()
986 }
987
988 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
989 let repo = self.repository.clone();
990 self.executor
991 .spawn(async move {
992 let repo = repo.lock();
993 let head = repo.head().ok()?.peel_to_tree().log_err()?;
994 let entry = head.get_path(path.as_std_path()).ok()?;
995 if entry.filemode() == i32::from(git2::FileMode::Link) {
996 return None;
997 }
998 let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
999 String::from_utf8(content).ok()
1000 })
1001 .boxed()
1002 }
1003
1004 fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
1005 let repo = self.repository.clone();
1006 self.executor
1007 .spawn(async move {
1008 let repo = repo.lock();
1009 let content = repo.find_blob(oid.0)?.content().to_owned();
1010 Ok(String::from_utf8(content)?)
1011 })
1012 .boxed()
1013 }
1014
1015 fn set_index_text(
1016 &self,
1017 path: RepoPath,
1018 content: Option<String>,
1019 env: Arc<HashMap<String, String>>,
1020 is_executable: bool,
1021 ) -> BoxFuture<'_, anyhow::Result<()>> {
1022 let working_directory = self.working_directory();
1023 let git_binary_path = self.any_git_binary_path.clone();
1024 self.executor
1025 .spawn(async move {
1026 let working_directory = working_directory?;
1027 let mode = if is_executable { "100755" } else { "100644" };
1028
1029 if let Some(content) = content {
1030 let mut child = new_smol_command(&git_binary_path)
1031 .current_dir(&working_directory)
1032 .envs(env.iter())
1033 .args(["hash-object", "-w", "--stdin"])
1034 .stdin(Stdio::piped())
1035 .stdout(Stdio::piped())
1036 .spawn()?;
1037 let mut stdin = child.stdin.take().unwrap();
1038 stdin.write_all(content.as_bytes()).await?;
1039 stdin.flush().await?;
1040 drop(stdin);
1041 let output = child.output().await?.stdout;
1042 let sha = str::from_utf8(&output)?.trim();
1043
1044 log::debug!("indexing SHA: {sha}, path {path:?}");
1045
1046 let output = new_smol_command(&git_binary_path)
1047 .current_dir(&working_directory)
1048 .envs(env.iter())
1049 .args(["update-index", "--add", "--cacheinfo", mode, sha])
1050 .arg(path.as_unix_str())
1051 .output()
1052 .await?;
1053
1054 anyhow::ensure!(
1055 output.status.success(),
1056 "Failed to stage:\n{}",
1057 String::from_utf8_lossy(&output.stderr)
1058 );
1059 } else {
1060 log::debug!("removing path {path:?} from the index");
1061 let output = new_smol_command(&git_binary_path)
1062 .current_dir(&working_directory)
1063 .envs(env.iter())
1064 .args(["update-index", "--force-remove"])
1065 .arg(path.as_unix_str())
1066 .output()
1067 .await?;
1068 anyhow::ensure!(
1069 output.status.success(),
1070 "Failed to unstage:\n{}",
1071 String::from_utf8_lossy(&output.stderr)
1072 );
1073 }
1074
1075 Ok(())
1076 })
1077 .boxed()
1078 }
1079
1080 fn remote_url(&self, name: &str) -> Option<String> {
1081 let repo = self.repository.lock();
1082 let remote = repo.find_remote(name).ok()?;
1083 remote.url().map(|url| url.to_string())
1084 }
1085
1086 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
1087 let working_directory = self.working_directory();
1088 let git_binary_path = self.any_git_binary_path.clone();
1089 self.executor
1090 .spawn(async move {
1091 let working_directory = working_directory?;
1092 let mut process = new_smol_command(&git_binary_path)
1093 .current_dir(&working_directory)
1094 .args([
1095 "--no-optional-locks",
1096 "cat-file",
1097 "--batch-check=%(objectname)",
1098 ])
1099 .stdin(Stdio::piped())
1100 .stdout(Stdio::piped())
1101 .stderr(Stdio::piped())
1102 .spawn()?;
1103
1104 let stdin = process
1105 .stdin
1106 .take()
1107 .context("no stdin for git cat-file subprocess")?;
1108 let mut stdin = BufWriter::new(stdin);
1109 for rev in &revs {
1110 stdin.write_all(rev.as_bytes()).await?;
1111 stdin.write_all(b"\n").await?;
1112 }
1113 stdin.flush().await?;
1114 drop(stdin);
1115
1116 let output = process.output().await?;
1117 let output = std::str::from_utf8(&output.stdout)?;
1118 let shas = output
1119 .lines()
1120 .map(|line| {
1121 if line.ends_with("missing") {
1122 None
1123 } else {
1124 Some(line.to_string())
1125 }
1126 })
1127 .collect::<Vec<_>>();
1128
1129 if shas.len() != revs.len() {
1130 // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
1131 bail!("unexpected number of shas")
1132 }
1133
1134 Ok(shas)
1135 })
1136 .boxed()
1137 }
1138
1139 fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
1140 let path = self.path().join("MERGE_MSG");
1141 self.executor
1142 .spawn(async move { std::fs::read_to_string(&path).ok() })
1143 .boxed()
1144 }
1145
1146 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
1147 let git_binary_path = self.any_git_binary_path.clone();
1148 let working_directory = match self.working_directory() {
1149 Ok(working_directory) => working_directory,
1150 Err(e) => return Task::ready(Err(e)),
1151 };
1152 let args = git_status_args(path_prefixes);
1153 log::debug!("Checking for git status in {path_prefixes:?}");
1154 self.executor.spawn(async move {
1155 let output = new_smol_command(&git_binary_path)
1156 .current_dir(working_directory)
1157 .args(args)
1158 .output()
1159 .await?;
1160 if output.status.success() {
1161 let stdout = String::from_utf8_lossy(&output.stdout);
1162 stdout.parse()
1163 } else {
1164 let stderr = String::from_utf8_lossy(&output.stderr);
1165 anyhow::bail!("git status failed: {stderr}");
1166 }
1167 })
1168 }
1169
1170 fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
1171 let git_binary_path = self.any_git_binary_path.clone();
1172 let working_directory = match self.working_directory() {
1173 Ok(working_directory) => working_directory,
1174 Err(e) => return Task::ready(Err(e)).boxed(),
1175 };
1176
1177 let mut args = vec![
1178 OsString::from("--no-optional-locks"),
1179 OsString::from("diff-tree"),
1180 OsString::from("-r"),
1181 OsString::from("-z"),
1182 OsString::from("--no-renames"),
1183 ];
1184 match request {
1185 DiffTreeType::MergeBase { base, head } => {
1186 args.push("--merge-base".into());
1187 args.push(OsString::from(base.as_str()));
1188 args.push(OsString::from(head.as_str()));
1189 }
1190 DiffTreeType::Since { base, head } => {
1191 args.push(OsString::from(base.as_str()));
1192 args.push(OsString::from(head.as_str()));
1193 }
1194 }
1195
1196 self.executor
1197 .spawn(async move {
1198 let output = new_smol_command(&git_binary_path)
1199 .current_dir(working_directory)
1200 .args(args)
1201 .output()
1202 .await?;
1203 if output.status.success() {
1204 let stdout = String::from_utf8_lossy(&output.stdout);
1205 stdout.parse()
1206 } else {
1207 let stderr = String::from_utf8_lossy(&output.stderr);
1208 anyhow::bail!("git status failed: {stderr}");
1209 }
1210 })
1211 .boxed()
1212 }
1213
1214 fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
1215 let git_binary_path = self.any_git_binary_path.clone();
1216 let working_directory = self.working_directory();
1217 self.executor
1218 .spawn(async move {
1219 let output = new_smol_command(&git_binary_path)
1220 .current_dir(working_directory?)
1221 .args(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
1222 .output()
1223 .await?;
1224 if output.status.success() {
1225 let stdout = String::from_utf8_lossy(&output.stdout);
1226 stdout.parse()
1227 } else {
1228 let stderr = String::from_utf8_lossy(&output.stderr);
1229 anyhow::bail!("git status failed: {stderr}");
1230 }
1231 })
1232 .boxed()
1233 }
1234
1235 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
1236 let working_directory = self.working_directory();
1237 let git_binary_path = self.any_git_binary_path.clone();
1238 self.executor
1239 .spawn(async move {
1240 let fields = [
1241 "%(HEAD)",
1242 "%(objectname)",
1243 "%(parent)",
1244 "%(refname)",
1245 "%(upstream)",
1246 "%(upstream:track)",
1247 "%(committerdate:unix)",
1248 "%(authorname)",
1249 "%(contents:subject)",
1250 ]
1251 .join("%00");
1252 let args = vec![
1253 "for-each-ref",
1254 "refs/heads/**/*",
1255 "refs/remotes/**/*",
1256 "--format",
1257 &fields,
1258 ];
1259 let working_directory = working_directory?;
1260 let output = new_smol_command(&git_binary_path)
1261 .current_dir(&working_directory)
1262 .args(args)
1263 .output()
1264 .await?;
1265
1266 anyhow::ensure!(
1267 output.status.success(),
1268 "Failed to git git branches:\n{}",
1269 String::from_utf8_lossy(&output.stderr)
1270 );
1271
1272 let input = String::from_utf8_lossy(&output.stdout);
1273
1274 let mut branches = parse_branch_input(&input)?;
1275 if branches.is_empty() {
1276 let args = vec!["symbolic-ref", "--quiet", "HEAD"];
1277
1278 let output = new_smol_command(&git_binary_path)
1279 .current_dir(&working_directory)
1280 .args(args)
1281 .output()
1282 .await?;
1283
1284 // git symbolic-ref returns a non-0 exit code if HEAD points
1285 // to something other than a branch
1286 if output.status.success() {
1287 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
1288
1289 branches.push(Branch {
1290 ref_name: name.into(),
1291 is_head: true,
1292 upstream: None,
1293 most_recent_commit: None,
1294 });
1295 }
1296 }
1297
1298 Ok(branches)
1299 })
1300 .boxed()
1301 }
1302
1303 fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
1304 let git_binary_path = self.any_git_binary_path.clone();
1305 let working_directory = self.working_directory();
1306 self.executor
1307 .spawn(async move {
1308 let output = new_smol_command(&git_binary_path)
1309 .current_dir(working_directory?)
1310 .args(&["--no-optional-locks", "worktree", "list", "--porcelain"])
1311 .output()
1312 .await?;
1313 if output.status.success() {
1314 let stdout = String::from_utf8_lossy(&output.stdout);
1315 Ok(parse_worktrees_from_str(&stdout))
1316 } else {
1317 let stderr = String::from_utf8_lossy(&output.stderr);
1318 anyhow::bail!("git worktree list failed: {stderr}");
1319 }
1320 })
1321 .boxed()
1322 }
1323
1324 fn create_worktree(
1325 &self,
1326 name: String,
1327 directory: PathBuf,
1328 from_commit: Option<String>,
1329 ) -> BoxFuture<'_, Result<()>> {
1330 let git_binary_path = self.any_git_binary_path.clone();
1331 let working_directory = self.working_directory();
1332 let final_path = directory.join(&name);
1333 let mut args = vec![
1334 OsString::from("--no-optional-locks"),
1335 OsString::from("worktree"),
1336 OsString::from("add"),
1337 OsString::from(final_path.as_os_str()),
1338 ];
1339 if let Some(from_commit) = from_commit {
1340 args.extend([
1341 OsString::from("-b"),
1342 OsString::from(name.as_str()),
1343 OsString::from(from_commit),
1344 ]);
1345 }
1346 self.executor
1347 .spawn(async move {
1348 let output = new_smol_command(&git_binary_path)
1349 .current_dir(working_directory?)
1350 .args(args)
1351 .output()
1352 .await?;
1353 if output.status.success() {
1354 Ok(())
1355 } else {
1356 let stderr = String::from_utf8_lossy(&output.stderr);
1357 anyhow::bail!("git worktree list failed: {stderr}");
1358 }
1359 })
1360 .boxed()
1361 }
1362
1363 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1364 let repo = self.repository.clone();
1365 let working_directory = self.working_directory();
1366 let git_binary_path = self.any_git_binary_path.clone();
1367 let executor = self.executor.clone();
1368 let branch = self.executor.spawn(async move {
1369 let repo = repo.lock();
1370 let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
1371 branch
1372 } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
1373 let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
1374 let revision = revision.get();
1375 let branch_commit = revision.peel_to_commit()?;
1376 let mut branch = repo.branch(&branch_name, &branch_commit, false)?;
1377 branch.set_upstream(Some(&name))?;
1378 branch
1379 } else {
1380 anyhow::bail!("Branch '{}' not found", name);
1381 };
1382
1383 Ok(branch
1384 .name()?
1385 .context("cannot checkout anonymous branch")?
1386 .to_string())
1387 });
1388
1389 self.executor
1390 .spawn(async move {
1391 let branch = branch.await?;
1392
1393 GitBinary::new(git_binary_path, working_directory?, executor)
1394 .run(&["checkout", &branch])
1395 .await?;
1396 anyhow::Ok(())
1397 })
1398 .boxed()
1399 }
1400
1401 fn create_branch(
1402 &self,
1403 name: String,
1404 base_branch: Option<String>,
1405 ) -> BoxFuture<'_, Result<()>> {
1406 let git_binary_path = self.any_git_binary_path.clone();
1407 let working_directory = self.working_directory();
1408 let executor = self.executor.clone();
1409
1410 self.executor
1411 .spawn(async move {
1412 let mut args = vec!["switch", "-c", &name];
1413 let base_branch_str;
1414 if let Some(ref base) = base_branch {
1415 base_branch_str = base.clone();
1416 args.push(&base_branch_str);
1417 }
1418
1419 GitBinary::new(git_binary_path, working_directory?, executor)
1420 .run(&args)
1421 .await?;
1422 anyhow::Ok(())
1423 })
1424 .boxed()
1425 }
1426
1427 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
1428 let git_binary_path = self.any_git_binary_path.clone();
1429 let working_directory = self.working_directory();
1430 let executor = self.executor.clone();
1431
1432 self.executor
1433 .spawn(async move {
1434 GitBinary::new(git_binary_path, working_directory?, executor)
1435 .run(&["branch", "-m", &branch, &new_name])
1436 .await?;
1437 anyhow::Ok(())
1438 })
1439 .boxed()
1440 }
1441
1442 fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1443 let git_binary_path = self.any_git_binary_path.clone();
1444 let working_directory = self.working_directory();
1445 let executor = self.executor.clone();
1446
1447 self.executor
1448 .spawn(async move {
1449 GitBinary::new(git_binary_path, working_directory?, executor)
1450 .run(&["branch", "-d", &name])
1451 .await?;
1452 anyhow::Ok(())
1453 })
1454 .boxed()
1455 }
1456
1457 fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>> {
1458 let working_directory = self.working_directory();
1459 let git_binary_path = self.any_git_binary_path.clone();
1460
1461 let remote_url = self
1462 .remote_url("upstream")
1463 .or_else(|| self.remote_url("origin"));
1464
1465 async move {
1466 crate::blame::Blame::for_path(
1467 &git_binary_path,
1468 &working_directory?,
1469 &path,
1470 &content,
1471 remote_url,
1472 )
1473 .await
1474 }
1475 .boxed()
1476 }
1477
1478 fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>> {
1479 self.file_history_paginated(path, 0, None)
1480 }
1481
1482 fn file_history_paginated(
1483 &self,
1484 path: RepoPath,
1485 skip: usize,
1486 limit: Option<usize>,
1487 ) -> BoxFuture<'_, Result<FileHistory>> {
1488 let working_directory = self.working_directory();
1489 let git_binary_path = self.any_git_binary_path.clone();
1490 self.executor
1491 .spawn(async move {
1492 let working_directory = working_directory?;
1493 // Use a unique delimiter with a hardcoded UUID to separate commits
1494 // This essentially eliminates any chance of encountering the delimiter in actual commit data
1495 let commit_delimiter =
1496 concat!("<<COMMIT_END-", "3f8a9c2e-7d4b-4e1a-9f6c-8b5d2a1e4c3f>>",);
1497
1498 let format_string = format!(
1499 "--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}",
1500 commit_delimiter
1501 );
1502
1503 let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string];
1504
1505 let skip_str;
1506 let limit_str;
1507 if skip > 0 {
1508 skip_str = skip.to_string();
1509 args.push("--skip");
1510 args.push(&skip_str);
1511 }
1512 if let Some(n) = limit {
1513 limit_str = n.to_string();
1514 args.push("-n");
1515 args.push(&limit_str);
1516 }
1517
1518 args.push("--");
1519
1520 let output = new_smol_command(&git_binary_path)
1521 .current_dir(&working_directory)
1522 .args(&args)
1523 .arg(path.as_unix_str())
1524 .output()
1525 .await?;
1526
1527 if !output.status.success() {
1528 let stderr = String::from_utf8_lossy(&output.stderr);
1529 bail!("git log failed: {stderr}");
1530 }
1531
1532 let stdout = std::str::from_utf8(&output.stdout)?;
1533 let mut entries = Vec::new();
1534
1535 for commit_block in stdout.split(commit_delimiter) {
1536 let commit_block = commit_block.trim();
1537 if commit_block.is_empty() {
1538 continue;
1539 }
1540
1541 let fields: Vec<&str> = commit_block.split('\0').collect();
1542 if fields.len() >= 6 {
1543 let sha = fields[0].trim().to_string().into();
1544 let subject = fields[1].trim().to_string().into();
1545 let message = fields[2].trim().to_string().into();
1546 let commit_timestamp = fields[3].trim().parse().unwrap_or(0);
1547 let author_name = fields[4].trim().to_string().into();
1548 let author_email = fields[5].trim().to_string().into();
1549
1550 entries.push(FileHistoryEntry {
1551 sha,
1552 subject,
1553 message,
1554 commit_timestamp,
1555 author_name,
1556 author_email,
1557 });
1558 }
1559 }
1560
1561 Ok(FileHistory { entries, path })
1562 })
1563 .boxed()
1564 }
1565
1566 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
1567 let working_directory = self.working_directory();
1568 let git_binary_path = self.any_git_binary_path.clone();
1569 self.executor
1570 .spawn(async move {
1571 let args = match diff {
1572 DiffType::HeadToIndex => Some("--staged"),
1573 DiffType::HeadToWorktree => None,
1574 };
1575
1576 let output = new_smol_command(&git_binary_path)
1577 .current_dir(&working_directory?)
1578 .args(["diff"])
1579 .args(args)
1580 .output()
1581 .await?;
1582
1583 anyhow::ensure!(
1584 output.status.success(),
1585 "Failed to run git diff:\n{}",
1586 String::from_utf8_lossy(&output.stderr)
1587 );
1588 Ok(String::from_utf8_lossy(&output.stdout).to_string())
1589 })
1590 .boxed()
1591 }
1592
1593 fn stage_paths(
1594 &self,
1595 paths: Vec<RepoPath>,
1596 env: Arc<HashMap<String, String>>,
1597 ) -> BoxFuture<'_, Result<()>> {
1598 let working_directory = self.working_directory();
1599 let git_binary_path = self.any_git_binary_path.clone();
1600 self.executor
1601 .spawn(async move {
1602 if !paths.is_empty() {
1603 let output = new_smol_command(&git_binary_path)
1604 .current_dir(&working_directory?)
1605 .envs(env.iter())
1606 .args(["update-index", "--add", "--remove", "--"])
1607 .args(paths.iter().map(|p| p.as_unix_str()))
1608 .output()
1609 .await?;
1610 anyhow::ensure!(
1611 output.status.success(),
1612 "Failed to stage paths:\n{}",
1613 String::from_utf8_lossy(&output.stderr),
1614 );
1615 }
1616 Ok(())
1617 })
1618 .boxed()
1619 }
1620
1621 fn unstage_paths(
1622 &self,
1623 paths: Vec<RepoPath>,
1624 env: Arc<HashMap<String, String>>,
1625 ) -> BoxFuture<'_, Result<()>> {
1626 let working_directory = self.working_directory();
1627 let git_binary_path = self.any_git_binary_path.clone();
1628
1629 self.executor
1630 .spawn(async move {
1631 if !paths.is_empty() {
1632 let output = new_smol_command(&git_binary_path)
1633 .current_dir(&working_directory?)
1634 .envs(env.iter())
1635 .args(["reset", "--quiet", "--"])
1636 .args(paths.iter().map(|p| p.as_std_path()))
1637 .output()
1638 .await?;
1639
1640 anyhow::ensure!(
1641 output.status.success(),
1642 "Failed to unstage:\n{}",
1643 String::from_utf8_lossy(&output.stderr),
1644 );
1645 }
1646 Ok(())
1647 })
1648 .boxed()
1649 }
1650
1651 fn stash_paths(
1652 &self,
1653 paths: Vec<RepoPath>,
1654 env: Arc<HashMap<String, String>>,
1655 ) -> BoxFuture<'_, Result<()>> {
1656 let working_directory = self.working_directory();
1657 let git_binary_path = self.any_git_binary_path.clone();
1658 self.executor
1659 .spawn(async move {
1660 let mut cmd = new_smol_command(&git_binary_path);
1661 cmd.current_dir(&working_directory?)
1662 .envs(env.iter())
1663 .args(["stash", "push", "--quiet"])
1664 .arg("--include-untracked");
1665
1666 cmd.args(paths.iter().map(|p| p.as_unix_str()));
1667
1668 let output = cmd.output().await?;
1669
1670 anyhow::ensure!(
1671 output.status.success(),
1672 "Failed to stash:\n{}",
1673 String::from_utf8_lossy(&output.stderr)
1674 );
1675 Ok(())
1676 })
1677 .boxed()
1678 }
1679
1680 fn stash_pop(
1681 &self,
1682 index: Option<usize>,
1683 env: Arc<HashMap<String, String>>,
1684 ) -> BoxFuture<'_, Result<()>> {
1685 let working_directory = self.working_directory();
1686 let git_binary_path = self.any_git_binary_path.clone();
1687 self.executor
1688 .spawn(async move {
1689 let mut cmd = new_smol_command(git_binary_path);
1690 let mut args = vec!["stash".to_string(), "pop".to_string()];
1691 if let Some(index) = index {
1692 args.push(format!("stash@{{{}}}", index));
1693 }
1694 cmd.current_dir(&working_directory?)
1695 .envs(env.iter())
1696 .args(args);
1697
1698 let output = cmd.output().await?;
1699
1700 anyhow::ensure!(
1701 output.status.success(),
1702 "Failed to stash pop:\n{}",
1703 String::from_utf8_lossy(&output.stderr)
1704 );
1705 Ok(())
1706 })
1707 .boxed()
1708 }
1709
1710 fn stash_apply(
1711 &self,
1712 index: Option<usize>,
1713 env: Arc<HashMap<String, String>>,
1714 ) -> BoxFuture<'_, Result<()>> {
1715 let working_directory = self.working_directory();
1716 let git_binary_path = self.any_git_binary_path.clone();
1717 self.executor
1718 .spawn(async move {
1719 let mut cmd = new_smol_command(git_binary_path);
1720 let mut args = vec!["stash".to_string(), "apply".to_string()];
1721 if let Some(index) = index {
1722 args.push(format!("stash@{{{}}}", index));
1723 }
1724 cmd.current_dir(&working_directory?)
1725 .envs(env.iter())
1726 .args(args);
1727
1728 let output = cmd.output().await?;
1729
1730 anyhow::ensure!(
1731 output.status.success(),
1732 "Failed to apply stash:\n{}",
1733 String::from_utf8_lossy(&output.stderr)
1734 );
1735 Ok(())
1736 })
1737 .boxed()
1738 }
1739
1740 fn stash_drop(
1741 &self,
1742 index: Option<usize>,
1743 env: Arc<HashMap<String, String>>,
1744 ) -> BoxFuture<'_, Result<()>> {
1745 let working_directory = self.working_directory();
1746 let git_binary_path = self.any_git_binary_path.clone();
1747 self.executor
1748 .spawn(async move {
1749 let mut cmd = new_smol_command(git_binary_path);
1750 let mut args = vec!["stash".to_string(), "drop".to_string()];
1751 if let Some(index) = index {
1752 args.push(format!("stash@{{{}}}", index));
1753 }
1754 cmd.current_dir(&working_directory?)
1755 .envs(env.iter())
1756 .args(args);
1757
1758 let output = cmd.output().await?;
1759
1760 anyhow::ensure!(
1761 output.status.success(),
1762 "Failed to stash drop:\n{}",
1763 String::from_utf8_lossy(&output.stderr)
1764 );
1765 Ok(())
1766 })
1767 .boxed()
1768 }
1769
1770 fn commit(
1771 &self,
1772 message: SharedString,
1773 name_and_email: Option<(SharedString, SharedString)>,
1774 options: CommitOptions,
1775 ask_pass: AskPassDelegate,
1776 env: Arc<HashMap<String, String>>,
1777 ) -> BoxFuture<'_, Result<()>> {
1778 let working_directory = self.working_directory();
1779 let git_binary_path = self.any_git_binary_path.clone();
1780 let executor = self.executor.clone();
1781 async move {
1782 let mut cmd = new_smol_command(git_binary_path);
1783 cmd.current_dir(&working_directory?)
1784 .envs(env.iter())
1785 .args(["commit", "--quiet", "-m"])
1786 .arg(&message.to_string())
1787 .arg("--cleanup=strip")
1788 .arg("--no-verify")
1789 .stdout(smol::process::Stdio::piped())
1790 .stderr(smol::process::Stdio::piped());
1791
1792 if options.amend {
1793 cmd.arg("--amend");
1794 }
1795
1796 if options.signoff {
1797 cmd.arg("--signoff");
1798 }
1799
1800 if let Some((name, email)) = name_and_email {
1801 cmd.arg("--author").arg(&format!("{name} <{email}>"));
1802 }
1803
1804 run_git_command(env, ask_pass, cmd, &executor).await?;
1805
1806 Ok(())
1807 }
1808 .boxed()
1809 }
1810
1811 fn push(
1812 &self,
1813 branch_name: String,
1814 remote_name: String,
1815 options: Option<PushOptions>,
1816 ask_pass: AskPassDelegate,
1817 env: Arc<HashMap<String, String>>,
1818 cx: AsyncApp,
1819 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1820 let working_directory = self.working_directory();
1821 let executor = cx.background_executor().clone();
1822 let git_binary_path = self.system_git_binary_path.clone();
1823 async move {
1824 let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?;
1825 let working_directory = working_directory?;
1826 let mut command = new_smol_command(git_binary_path);
1827 command
1828 .envs(env.iter())
1829 .current_dir(&working_directory)
1830 .args(["push"])
1831 .args(options.map(|option| match option {
1832 PushOptions::SetUpstream => "--set-upstream",
1833 PushOptions::Force => "--force-with-lease",
1834 }))
1835 .arg(remote_name)
1836 .arg(format!("{}:{}", branch_name, branch_name))
1837 .stdin(smol::process::Stdio::null())
1838 .stdout(smol::process::Stdio::piped())
1839 .stderr(smol::process::Stdio::piped());
1840
1841 run_git_command(env, ask_pass, command, &executor).await
1842 }
1843 .boxed()
1844 }
1845
1846 fn pull(
1847 &self,
1848 branch_name: Option<String>,
1849 remote_name: String,
1850 rebase: bool,
1851 ask_pass: AskPassDelegate,
1852 env: Arc<HashMap<String, String>>,
1853 cx: AsyncApp,
1854 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1855 let working_directory = self.working_directory();
1856 let executor = cx.background_executor().clone();
1857 let git_binary_path = self.system_git_binary_path.clone();
1858 async move {
1859 let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?;
1860 let mut command = new_smol_command(git_binary_path);
1861 command
1862 .envs(env.iter())
1863 .current_dir(&working_directory?)
1864 .arg("pull");
1865
1866 if rebase {
1867 command.arg("--rebase");
1868 }
1869
1870 command
1871 .arg(remote_name)
1872 .args(branch_name)
1873 .stdout(smol::process::Stdio::piped())
1874 .stderr(smol::process::Stdio::piped());
1875
1876 run_git_command(env, ask_pass, command, &executor).await
1877 }
1878 .boxed()
1879 }
1880
1881 fn fetch(
1882 &self,
1883 fetch_options: FetchOptions,
1884 ask_pass: AskPassDelegate,
1885 env: Arc<HashMap<String, String>>,
1886 cx: AsyncApp,
1887 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1888 let working_directory = self.working_directory();
1889 let remote_name = format!("{}", fetch_options);
1890 let git_binary_path = self.system_git_binary_path.clone();
1891 let executor = cx.background_executor().clone();
1892 async move {
1893 let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?;
1894 let mut command = new_smol_command(git_binary_path);
1895 command
1896 .envs(env.iter())
1897 .current_dir(&working_directory?)
1898 .args(["fetch", &remote_name])
1899 .stdout(smol::process::Stdio::piped())
1900 .stderr(smol::process::Stdio::piped());
1901
1902 run_git_command(env, ask_pass, command, &executor).await
1903 }
1904 .boxed()
1905 }
1906
1907 fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
1908 let working_directory = self.working_directory();
1909 let git_binary_path = self.any_git_binary_path.clone();
1910 self.executor
1911 .spawn(async move {
1912 let working_directory = working_directory?;
1913 let output = new_smol_command(&git_binary_path)
1914 .current_dir(&working_directory)
1915 .args(["rev-parse", "--abbrev-ref"])
1916 .arg(format!("{branch}@{{push}}"))
1917 .output()
1918 .await?;
1919 if !output.status.success() {
1920 return Ok(None);
1921 }
1922 let remote_name = String::from_utf8_lossy(&output.stdout)
1923 .split('/')
1924 .next()
1925 .map(|name| Remote {
1926 name: name.trim().to_string().into(),
1927 });
1928
1929 Ok(remote_name)
1930 })
1931 .boxed()
1932 }
1933
1934 fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
1935 let working_directory = self.working_directory();
1936 let git_binary_path = self.any_git_binary_path.clone();
1937 self.executor
1938 .spawn(async move {
1939 let working_directory = working_directory?;
1940 let output = new_smol_command(&git_binary_path)
1941 .current_dir(&working_directory)
1942 .args(["config", "--get"])
1943 .arg(format!("branch.{branch}.remote"))
1944 .output()
1945 .await?;
1946 if !output.status.success() {
1947 return Ok(None);
1948 }
1949
1950 let remote_name = String::from_utf8_lossy(&output.stdout);
1951 return Ok(Some(Remote {
1952 name: remote_name.trim().to_string().into(),
1953 }));
1954 })
1955 .boxed()
1956 }
1957
1958 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
1959 let working_directory = self.working_directory();
1960 let git_binary_path = self.any_git_binary_path.clone();
1961 self.executor
1962 .spawn(async move {
1963 let working_directory = working_directory?;
1964 let output = new_smol_command(&git_binary_path)
1965 .current_dir(&working_directory)
1966 .args(["remote"])
1967 .output()
1968 .await?;
1969
1970 anyhow::ensure!(
1971 output.status.success(),
1972 "Failed to get all remotes:\n{}",
1973 String::from_utf8_lossy(&output.stderr)
1974 );
1975 let remote_names = String::from_utf8_lossy(&output.stdout)
1976 .split('\n')
1977 .filter(|name| !name.is_empty())
1978 .map(|name| Remote {
1979 name: name.trim().to_string().into(),
1980 })
1981 .collect();
1982 Ok(remote_names)
1983 })
1984 .boxed()
1985 }
1986
1987 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
1988 let working_directory = self.working_directory();
1989 let git_binary_path = self.any_git_binary_path.clone();
1990 self.executor
1991 .spawn(async move {
1992 let working_directory = working_directory?;
1993 let git_cmd = async |args: &[&str]| -> Result<String> {
1994 let output = new_smol_command(&git_binary_path)
1995 .current_dir(&working_directory)
1996 .args(args)
1997 .output()
1998 .await?;
1999 anyhow::ensure!(
2000 output.status.success(),
2001 String::from_utf8_lossy(&output.stderr).to_string()
2002 );
2003 Ok(String::from_utf8(output.stdout)?)
2004 };
2005
2006 let head = git_cmd(&["rev-parse", "HEAD"])
2007 .await
2008 .context("Failed to get HEAD")?
2009 .trim()
2010 .to_owned();
2011
2012 let mut remote_branches = vec![];
2013 let mut add_if_matching = async |remote_head: &str| {
2014 if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
2015 && merge_base.trim() == head
2016 && let Some(s) = remote_head.strip_prefix("refs/remotes/")
2017 {
2018 remote_branches.push(s.to_owned().into());
2019 }
2020 };
2021
2022 // check the main branch of each remote
2023 let remotes = git_cmd(&["remote"])
2024 .await
2025 .context("Failed to get remotes")?;
2026 for remote in remotes.lines() {
2027 if let Ok(remote_head) =
2028 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
2029 {
2030 add_if_matching(remote_head.trim()).await;
2031 }
2032 }
2033
2034 // ... and the remote branch that the checked-out one is tracking
2035 if let Ok(remote_head) =
2036 git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
2037 {
2038 add_if_matching(remote_head.trim()).await;
2039 }
2040
2041 Ok(remote_branches)
2042 })
2043 .boxed()
2044 }
2045
2046 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
2047 let working_directory = self.working_directory();
2048 let git_binary_path = self.any_git_binary_path.clone();
2049 let executor = self.executor.clone();
2050 self.executor
2051 .spawn(async move {
2052 let working_directory = working_directory?;
2053 let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
2054 .envs(checkpoint_author_envs());
2055 git.with_temp_index(async |git| {
2056 let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
2057 let mut excludes = exclude_files(git).await?;
2058
2059 git.run(&["add", "--all"]).await?;
2060 let tree = git.run(&["write-tree"]).await?;
2061 let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
2062 git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
2063 .await?
2064 } else {
2065 git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
2066 };
2067
2068 excludes.restore_original().await?;
2069
2070 Ok(GitRepositoryCheckpoint {
2071 commit_sha: checkpoint_sha.parse()?,
2072 })
2073 })
2074 .await
2075 })
2076 .boxed()
2077 }
2078
2079 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
2080 let working_directory = self.working_directory();
2081 let git_binary_path = self.any_git_binary_path.clone();
2082
2083 let executor = self.executor.clone();
2084 self.executor
2085 .spawn(async move {
2086 let working_directory = working_directory?;
2087
2088 let git = GitBinary::new(git_binary_path, working_directory, executor);
2089 git.run(&[
2090 "restore",
2091 "--source",
2092 &checkpoint.commit_sha.to_string(),
2093 "--worktree",
2094 ".",
2095 ])
2096 .await?;
2097
2098 // TODO: We don't track binary and large files anymore,
2099 // so the following call would delete them.
2100 // Implement an alternative way to track files added by agent.
2101 //
2102 // git.with_temp_index(async move |git| {
2103 // git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
2104 // .await?;
2105 // git.run(&["clean", "-d", "--force"]).await
2106 // })
2107 // .await?;
2108
2109 Ok(())
2110 })
2111 .boxed()
2112 }
2113
2114 fn compare_checkpoints(
2115 &self,
2116 left: GitRepositoryCheckpoint,
2117 right: GitRepositoryCheckpoint,
2118 ) -> BoxFuture<'_, Result<bool>> {
2119 let working_directory = self.working_directory();
2120 let git_binary_path = self.any_git_binary_path.clone();
2121
2122 let executor = self.executor.clone();
2123 self.executor
2124 .spawn(async move {
2125 let working_directory = working_directory?;
2126 let git = GitBinary::new(git_binary_path, working_directory, executor);
2127 let result = git
2128 .run(&[
2129 "diff-tree",
2130 "--quiet",
2131 &left.commit_sha.to_string(),
2132 &right.commit_sha.to_string(),
2133 ])
2134 .await;
2135 match result {
2136 Ok(_) => Ok(true),
2137 Err(error) => {
2138 if let Some(GitBinaryCommandError { status, .. }) =
2139 error.downcast_ref::<GitBinaryCommandError>()
2140 && status.code() == Some(1)
2141 {
2142 return Ok(false);
2143 }
2144
2145 Err(error)
2146 }
2147 }
2148 })
2149 .boxed()
2150 }
2151
2152 fn diff_checkpoints(
2153 &self,
2154 base_checkpoint: GitRepositoryCheckpoint,
2155 target_checkpoint: GitRepositoryCheckpoint,
2156 ) -> BoxFuture<'_, Result<String>> {
2157 let working_directory = self.working_directory();
2158 let git_binary_path = self.any_git_binary_path.clone();
2159
2160 let executor = self.executor.clone();
2161 self.executor
2162 .spawn(async move {
2163 let working_directory = working_directory?;
2164 let git = GitBinary::new(git_binary_path, working_directory, executor);
2165 git.run(&[
2166 "diff",
2167 "--find-renames",
2168 "--patch",
2169 &base_checkpoint.commit_sha.to_string(),
2170 &target_checkpoint.commit_sha.to_string(),
2171 ])
2172 .await
2173 })
2174 .boxed()
2175 }
2176
2177 fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
2178 let working_directory = self.working_directory();
2179 let git_binary_path = self.any_git_binary_path.clone();
2180
2181 let executor = self.executor.clone();
2182 self.executor
2183 .spawn(async move {
2184 let working_directory = working_directory?;
2185 let git = GitBinary::new(git_binary_path, working_directory, executor);
2186
2187 if let Ok(output) = git
2188 .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
2189 .await
2190 {
2191 let output = output
2192 .strip_prefix("refs/remotes/upstream/")
2193 .map(|s| SharedString::from(s.to_owned()));
2194 return Ok(output);
2195 }
2196
2197 if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
2198 return Ok(output
2199 .strip_prefix("refs/remotes/origin/")
2200 .map(|s| SharedString::from(s.to_owned())));
2201 }
2202
2203 if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
2204 if git.run(&["rev-parse", &default_branch]).await.is_ok() {
2205 return Ok(Some(default_branch.into()));
2206 }
2207 }
2208
2209 if git.run(&["rev-parse", "master"]).await.is_ok() {
2210 return Ok(Some("master".into()));
2211 }
2212
2213 Ok(None)
2214 })
2215 .boxed()
2216 }
2217
2218 fn run_hook(
2219 &self,
2220 hook: RunHook,
2221 env: Arc<HashMap<String, String>>,
2222 ) -> BoxFuture<'_, Result<()>> {
2223 let working_directory = self.working_directory();
2224 let git_binary_path = self.any_git_binary_path.clone();
2225 let executor = self.executor.clone();
2226 self.executor
2227 .spawn(async move {
2228 let working_directory = working_directory?;
2229 let git = GitBinary::new(git_binary_path, working_directory, executor)
2230 .envs(HashMap::clone(&env));
2231 git.run(&["hook", "run", "--ignore-missing", hook.as_str()])
2232 .await?;
2233 Ok(())
2234 })
2235 .boxed()
2236 }
2237}
2238
2239fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
2240 let mut args = vec![
2241 OsString::from("--no-optional-locks"),
2242 OsString::from("status"),
2243 OsString::from("--porcelain=v1"),
2244 OsString::from("--untracked-files=all"),
2245 OsString::from("--no-renames"),
2246 OsString::from("-z"),
2247 ];
2248 args.extend(
2249 path_prefixes
2250 .iter()
2251 .map(|path_prefix| path_prefix.as_std_path().into()),
2252 );
2253 args.extend(path_prefixes.iter().map(|path_prefix| {
2254 if path_prefix.is_empty() {
2255 Path::new(".").into()
2256 } else {
2257 path_prefix.as_std_path().into()
2258 }
2259 }));
2260 args
2261}
2262
2263/// Temporarily git-ignore commonly ignored files and files over 2MB
2264async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
2265 const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
2266 let mut excludes = git.with_exclude_overrides().await?;
2267 excludes
2268 .add_excludes(include_str!("./checkpoint.gitignore"))
2269 .await?;
2270
2271 let working_directory = git.working_directory.clone();
2272 let untracked_files = git.list_untracked_files().await?;
2273 let excluded_paths = untracked_files.into_iter().map(|path| {
2274 let working_directory = working_directory.clone();
2275 smol::spawn(async move {
2276 let full_path = working_directory.join(path.clone());
2277 match smol::fs::metadata(&full_path).await {
2278 Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
2279 Some(PathBuf::from("/").join(path.clone()))
2280 }
2281 _ => None,
2282 }
2283 })
2284 });
2285
2286 let excluded_paths = futures::future::join_all(excluded_paths).await;
2287 let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
2288
2289 if !excluded_paths.is_empty() {
2290 let exclude_patterns = excluded_paths
2291 .into_iter()
2292 .map(|path| path.to_string_lossy().into_owned())
2293 .collect::<Vec<_>>()
2294 .join("\n");
2295 excludes.add_excludes(&exclude_patterns).await?;
2296 }
2297
2298 Ok(excludes)
2299}
2300
2301struct GitBinary {
2302 git_binary_path: PathBuf,
2303 working_directory: PathBuf,
2304 executor: BackgroundExecutor,
2305 index_file_path: Option<PathBuf>,
2306 envs: HashMap<String, String>,
2307}
2308
2309impl GitBinary {
2310 fn new(
2311 git_binary_path: PathBuf,
2312 working_directory: PathBuf,
2313 executor: BackgroundExecutor,
2314 ) -> Self {
2315 Self {
2316 git_binary_path,
2317 working_directory,
2318 executor,
2319 index_file_path: None,
2320 envs: HashMap::default(),
2321 }
2322 }
2323
2324 async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
2325 let status_output = self
2326 .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
2327 .await?;
2328
2329 let paths = status_output
2330 .split('\0')
2331 .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
2332 .map(|entry| PathBuf::from(&entry[3..]))
2333 .collect::<Vec<_>>();
2334 Ok(paths)
2335 }
2336
2337 fn envs(mut self, envs: HashMap<String, String>) -> Self {
2338 self.envs = envs;
2339 self
2340 }
2341
2342 pub async fn with_temp_index<R>(
2343 &mut self,
2344 f: impl AsyncFnOnce(&Self) -> Result<R>,
2345 ) -> Result<R> {
2346 let index_file_path = self.path_for_index_id(Uuid::new_v4());
2347
2348 let delete_temp_index = util::defer({
2349 let index_file_path = index_file_path.clone();
2350 let executor = self.executor.clone();
2351 move || {
2352 executor
2353 .spawn(async move {
2354 smol::fs::remove_file(index_file_path).await.log_err();
2355 })
2356 .detach();
2357 }
2358 });
2359
2360 // Copy the default index file so that Git doesn't have to rebuild the
2361 // whole index from scratch. This might fail if this is an empty repository.
2362 smol::fs::copy(
2363 self.working_directory.join(".git").join("index"),
2364 &index_file_path,
2365 )
2366 .await
2367 .ok();
2368
2369 self.index_file_path = Some(index_file_path.clone());
2370 let result = f(self).await;
2371 self.index_file_path = None;
2372 let result = result?;
2373
2374 smol::fs::remove_file(index_file_path).await.ok();
2375 delete_temp_index.abort();
2376
2377 Ok(result)
2378 }
2379
2380 pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
2381 let path = self
2382 .working_directory
2383 .join(".git")
2384 .join("info")
2385 .join("exclude");
2386
2387 GitExcludeOverride::new(path).await
2388 }
2389
2390 fn path_for_index_id(&self, id: Uuid) -> PathBuf {
2391 self.working_directory
2392 .join(".git")
2393 .join(format!("index-{}.tmp", id))
2394 }
2395
2396 pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
2397 where
2398 S: AsRef<OsStr>,
2399 {
2400 let mut stdout = self.run_raw(args).await?;
2401 if stdout.chars().last() == Some('\n') {
2402 stdout.pop();
2403 }
2404 Ok(stdout)
2405 }
2406
2407 /// Returns the result of the command without trimming the trailing newline.
2408 pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
2409 where
2410 S: AsRef<OsStr>,
2411 {
2412 let mut command = self.build_command(args);
2413 let output = command.output().await?;
2414 anyhow::ensure!(
2415 output.status.success(),
2416 GitBinaryCommandError {
2417 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
2418 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
2419 status: output.status,
2420 }
2421 );
2422 Ok(String::from_utf8(output.stdout)?)
2423 }
2424
2425 fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
2426 where
2427 S: AsRef<OsStr>,
2428 {
2429 let mut command = new_smol_command(&self.git_binary_path);
2430 command.current_dir(&self.working_directory);
2431 command.args(args);
2432 if let Some(index_file_path) = self.index_file_path.as_ref() {
2433 command.env("GIT_INDEX_FILE", index_file_path);
2434 }
2435 command.envs(&self.envs);
2436 command
2437 }
2438}
2439
2440#[derive(Error, Debug)]
2441#[error("Git command failed:\n{stdout}{stderr}\n")]
2442struct GitBinaryCommandError {
2443 stdout: String,
2444 stderr: String,
2445 status: ExitStatus,
2446}
2447
2448async fn run_git_command(
2449 env: Arc<HashMap<String, String>>,
2450 ask_pass: AskPassDelegate,
2451 mut command: smol::process::Command,
2452 executor: &BackgroundExecutor,
2453) -> Result<RemoteCommandOutput> {
2454 if env.contains_key("GIT_ASKPASS") {
2455 let git_process = command.spawn()?;
2456 let output = git_process.output().await?;
2457 anyhow::ensure!(
2458 output.status.success(),
2459 "{}",
2460 String::from_utf8_lossy(&output.stderr)
2461 );
2462 Ok(RemoteCommandOutput {
2463 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
2464 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
2465 })
2466 } else {
2467 let ask_pass = AskPassSession::new(executor, ask_pass).await?;
2468 command
2469 .env("GIT_ASKPASS", ask_pass.script_path())
2470 .env("SSH_ASKPASS", ask_pass.script_path())
2471 .env("SSH_ASKPASS_REQUIRE", "force");
2472 let git_process = command.spawn()?;
2473
2474 run_askpass_command(ask_pass, git_process).await
2475 }
2476}
2477
2478async fn run_askpass_command(
2479 mut ask_pass: AskPassSession,
2480 git_process: smol::process::Child,
2481) -> anyhow::Result<RemoteCommandOutput> {
2482 select_biased! {
2483 result = ask_pass.run().fuse() => {
2484 match result {
2485 AskPassResult::CancelledByUser => {
2486 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
2487 }
2488 AskPassResult::Timedout => {
2489 Err(anyhow!("Connecting to host timed out"))?
2490 }
2491 }
2492 }
2493 output = git_process.output().fuse() => {
2494 let output = output?;
2495 anyhow::ensure!(
2496 output.status.success(),
2497 "{}",
2498 String::from_utf8_lossy(&output.stderr)
2499 );
2500 Ok(RemoteCommandOutput {
2501 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
2502 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
2503 })
2504 }
2505 }
2506}
2507
2508#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
2509pub struct RepoPath(Arc<RelPath>);
2510
2511impl std::fmt::Debug for RepoPath {
2512 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2513 self.0.fmt(f)
2514 }
2515}
2516
2517impl RepoPath {
2518 pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
2519 let rel_path = RelPath::unix(s.as_ref())?;
2520 Ok(Self::from_rel_path(rel_path))
2521 }
2522
2523 pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
2524 let rel_path = RelPath::new(path, path_style)?;
2525 Ok(Self::from_rel_path(&rel_path))
2526 }
2527
2528 pub fn from_proto(proto: &str) -> Result<Self> {
2529 let rel_path = RelPath::from_proto(proto)?;
2530 Ok(Self(rel_path))
2531 }
2532
2533 pub fn from_rel_path(path: &RelPath) -> RepoPath {
2534 Self(Arc::from(path))
2535 }
2536
2537 pub fn as_std_path(&self) -> &Path {
2538 // git2 does not like empty paths and our RelPath infra turns `.` into ``
2539 // so undo that here
2540 if self.is_empty() {
2541 Path::new(".")
2542 } else {
2543 self.0.as_std_path()
2544 }
2545 }
2546}
2547
2548#[cfg(any(test, feature = "test-support"))]
2549pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
2550 RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
2551}
2552
2553impl AsRef<Arc<RelPath>> for RepoPath {
2554 fn as_ref(&self) -> &Arc<RelPath> {
2555 &self.0
2556 }
2557}
2558
2559impl std::ops::Deref for RepoPath {
2560 type Target = RelPath;
2561
2562 fn deref(&self) -> &Self::Target {
2563 &self.0
2564 }
2565}
2566
2567#[derive(Debug)]
2568pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
2569
2570impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
2571 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
2572 if key.starts_with(self.0) {
2573 Ordering::Greater
2574 } else {
2575 self.0.cmp(key)
2576 }
2577 }
2578}
2579
2580fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
2581 let mut branches = Vec::new();
2582 for line in input.split('\n') {
2583 if line.is_empty() {
2584 continue;
2585 }
2586 let mut fields = line.split('\x00');
2587 let Some(head) = fields.next() else {
2588 continue;
2589 };
2590 let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
2591 continue;
2592 };
2593 let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
2594 continue;
2595 };
2596 let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
2597 continue;
2598 };
2599 let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
2600 continue;
2601 };
2602 let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
2603 else {
2604 continue;
2605 };
2606 let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
2607 continue;
2608 };
2609 let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
2610 continue;
2611 };
2612 let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
2613 continue;
2614 };
2615
2616 branches.push(Branch {
2617 is_head: head == "*",
2618 ref_name,
2619 most_recent_commit: Some(CommitSummary {
2620 sha: head_sha,
2621 subject,
2622 commit_timestamp: commiterdate,
2623 author_name: author_name,
2624 has_parent: !parent_sha.is_empty(),
2625 }),
2626 upstream: if upstream_name.is_empty() {
2627 None
2628 } else {
2629 Some(Upstream {
2630 ref_name: upstream_name.into(),
2631 tracking: upstream_tracking,
2632 })
2633 },
2634 })
2635 }
2636
2637 Ok(branches)
2638}
2639
2640fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
2641 if upstream_track.is_empty() {
2642 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2643 ahead: 0,
2644 behind: 0,
2645 }));
2646 }
2647
2648 let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
2649 let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
2650 let mut ahead: u32 = 0;
2651 let mut behind: u32 = 0;
2652 for component in upstream_track.split(", ") {
2653 if component == "gone" {
2654 return Ok(UpstreamTracking::Gone);
2655 }
2656 if let Some(ahead_num) = component.strip_prefix("ahead ") {
2657 ahead = ahead_num.parse::<u32>()?;
2658 }
2659 if let Some(behind_num) = component.strip_prefix("behind ") {
2660 behind = behind_num.parse::<u32>()?;
2661 }
2662 }
2663 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2664 ahead,
2665 behind,
2666 }))
2667}
2668
2669fn checkpoint_author_envs() -> HashMap<String, String> {
2670 HashMap::from_iter([
2671 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
2672 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
2673 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
2674 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
2675 ])
2676}
2677
2678#[cfg(test)]
2679mod tests {
2680 use super::*;
2681 use gpui::TestAppContext;
2682
2683 fn disable_git_global_config() {
2684 unsafe {
2685 std::env::set_var("GIT_CONFIG_GLOBAL", "");
2686 std::env::set_var("GIT_CONFIG_SYSTEM", "");
2687 }
2688 }
2689
2690 #[gpui::test]
2691 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
2692 disable_git_global_config();
2693
2694 cx.executor().allow_parking();
2695
2696 let repo_dir = tempfile::tempdir().unwrap();
2697
2698 git2::Repository::init(repo_dir.path()).unwrap();
2699 let file_path = repo_dir.path().join("file");
2700 smol::fs::write(&file_path, "initial").await.unwrap();
2701
2702 let repo = RealGitRepository::new(
2703 &repo_dir.path().join(".git"),
2704 None,
2705 Some("git".into()),
2706 cx.executor(),
2707 )
2708 .unwrap();
2709
2710 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
2711 .await
2712 .unwrap();
2713 repo.commit(
2714 "Initial commit".into(),
2715 None,
2716 CommitOptions::default(),
2717 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
2718 Arc::new(checkpoint_author_envs()),
2719 )
2720 .await
2721 .unwrap();
2722
2723 smol::fs::write(&file_path, "modified before checkpoint")
2724 .await
2725 .unwrap();
2726 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
2727 .await
2728 .unwrap();
2729 let checkpoint = repo.checkpoint().await.unwrap();
2730
2731 // Ensure the user can't see any branches after creating a checkpoint.
2732 assert_eq!(repo.branches().await.unwrap().len(), 1);
2733
2734 smol::fs::write(&file_path, "modified after checkpoint")
2735 .await
2736 .unwrap();
2737 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
2738 .await
2739 .unwrap();
2740 repo.commit(
2741 "Commit after checkpoint".into(),
2742 None,
2743 CommitOptions::default(),
2744 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
2745 Arc::new(checkpoint_author_envs()),
2746 )
2747 .await
2748 .unwrap();
2749
2750 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
2751 .await
2752 .unwrap();
2753 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
2754 .await
2755 .unwrap();
2756
2757 // Ensure checkpoint stays alive even after a Git GC.
2758 repo.gc().await.unwrap();
2759 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
2760
2761 assert_eq!(
2762 smol::fs::read_to_string(&file_path).await.unwrap(),
2763 "modified before checkpoint"
2764 );
2765 assert_eq!(
2766 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
2767 .await
2768 .unwrap(),
2769 "1"
2770 );
2771 // See TODO above
2772 // assert_eq!(
2773 // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
2774 // .await
2775 // .ok(),
2776 // None
2777 // );
2778 }
2779
2780 #[gpui::test]
2781 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
2782 disable_git_global_config();
2783
2784 cx.executor().allow_parking();
2785
2786 let repo_dir = tempfile::tempdir().unwrap();
2787 git2::Repository::init(repo_dir.path()).unwrap();
2788 let repo = RealGitRepository::new(
2789 &repo_dir.path().join(".git"),
2790 None,
2791 Some("git".into()),
2792 cx.executor(),
2793 )
2794 .unwrap();
2795
2796 smol::fs::write(repo_dir.path().join("foo"), "foo")
2797 .await
2798 .unwrap();
2799 let checkpoint_sha = repo.checkpoint().await.unwrap();
2800
2801 // Ensure the user can't see any branches after creating a checkpoint.
2802 assert_eq!(repo.branches().await.unwrap().len(), 1);
2803
2804 smol::fs::write(repo_dir.path().join("foo"), "bar")
2805 .await
2806 .unwrap();
2807 smol::fs::write(repo_dir.path().join("baz"), "qux")
2808 .await
2809 .unwrap();
2810 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
2811 assert_eq!(
2812 smol::fs::read_to_string(repo_dir.path().join("foo"))
2813 .await
2814 .unwrap(),
2815 "foo"
2816 );
2817 // See TODOs above
2818 // assert_eq!(
2819 // smol::fs::read_to_string(repo_dir.path().join("baz"))
2820 // .await
2821 // .ok(),
2822 // None
2823 // );
2824 }
2825
2826 #[gpui::test]
2827 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
2828 disable_git_global_config();
2829
2830 cx.executor().allow_parking();
2831
2832 let repo_dir = tempfile::tempdir().unwrap();
2833 git2::Repository::init(repo_dir.path()).unwrap();
2834 let repo = RealGitRepository::new(
2835 &repo_dir.path().join(".git"),
2836 None,
2837 Some("git".into()),
2838 cx.executor(),
2839 )
2840 .unwrap();
2841
2842 smol::fs::write(repo_dir.path().join("file1"), "content1")
2843 .await
2844 .unwrap();
2845 let checkpoint1 = repo.checkpoint().await.unwrap();
2846
2847 smol::fs::write(repo_dir.path().join("file2"), "content2")
2848 .await
2849 .unwrap();
2850 let checkpoint2 = repo.checkpoint().await.unwrap();
2851
2852 assert!(
2853 !repo
2854 .compare_checkpoints(checkpoint1, checkpoint2.clone())
2855 .await
2856 .unwrap()
2857 );
2858
2859 let checkpoint3 = repo.checkpoint().await.unwrap();
2860 assert!(
2861 repo.compare_checkpoints(checkpoint2, checkpoint3)
2862 .await
2863 .unwrap()
2864 );
2865 }
2866
2867 #[gpui::test]
2868 async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
2869 disable_git_global_config();
2870
2871 cx.executor().allow_parking();
2872
2873 let repo_dir = tempfile::tempdir().unwrap();
2874 let text_path = repo_dir.path().join("main.rs");
2875 let bin_path = repo_dir.path().join("binary.o");
2876
2877 git2::Repository::init(repo_dir.path()).unwrap();
2878
2879 smol::fs::write(&text_path, "fn main() {}").await.unwrap();
2880
2881 smol::fs::write(&bin_path, "some binary file here")
2882 .await
2883 .unwrap();
2884
2885 let repo = RealGitRepository::new(
2886 &repo_dir.path().join(".git"),
2887 None,
2888 Some("git".into()),
2889 cx.executor(),
2890 )
2891 .unwrap();
2892
2893 // initial commit
2894 repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
2895 .await
2896 .unwrap();
2897 repo.commit(
2898 "Initial commit".into(),
2899 None,
2900 CommitOptions::default(),
2901 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
2902 Arc::new(checkpoint_author_envs()),
2903 )
2904 .await
2905 .unwrap();
2906
2907 let checkpoint = repo.checkpoint().await.unwrap();
2908
2909 smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
2910 .await
2911 .unwrap();
2912 smol::fs::write(&bin_path, "Modified binary file")
2913 .await
2914 .unwrap();
2915
2916 repo.restore_checkpoint(checkpoint).await.unwrap();
2917
2918 // Text files should be restored to checkpoint state,
2919 // but binaries should not (they aren't tracked)
2920 assert_eq!(
2921 smol::fs::read_to_string(&text_path).await.unwrap(),
2922 "fn main() {}"
2923 );
2924
2925 assert_eq!(
2926 smol::fs::read_to_string(&bin_path).await.unwrap(),
2927 "Modified binary file"
2928 );
2929 }
2930
2931 #[test]
2932 fn test_branches_parsing() {
2933 // suppress "help: octal escapes are not supported, `\0` is always null"
2934 #[allow(clippy::octal_escapes)]
2935 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
2936 assert_eq!(
2937 parse_branch_input(input).unwrap(),
2938 vec![Branch {
2939 is_head: true,
2940 ref_name: "refs/heads/zed-patches".into(),
2941 upstream: Some(Upstream {
2942 ref_name: "refs/remotes/origin/zed-patches".into(),
2943 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
2944 ahead: 0,
2945 behind: 0
2946 })
2947 }),
2948 most_recent_commit: Some(CommitSummary {
2949 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
2950 subject: "generated protobuf".into(),
2951 commit_timestamp: 1733187470,
2952 author_name: SharedString::new("John Doe"),
2953 has_parent: false,
2954 })
2955 }]
2956 )
2957 }
2958
2959 #[test]
2960 fn test_branches_parsing_containing_refs_with_missing_fields() {
2961 #[allow(clippy::octal_escapes)]
2962 let input = " \090012116c03db04344ab10d50348553aa94f1ea0\0refs/heads/broken\n \0eb0cae33272689bd11030822939dd2701c52f81e\0895951d681e5561478c0acdd6905e8aacdfd2249\0refs/heads/dev\0\0\01762948725\0Zed\0Add feature\n*\0895951d681e5561478c0acdd6905e8aacdfd2249\0\0refs/heads/main\0\0\01762948695\0Zed\0Initial commit\n";
2963
2964 let branches = parse_branch_input(input).unwrap();
2965 assert_eq!(branches.len(), 2);
2966 assert_eq!(
2967 branches,
2968 vec![
2969 Branch {
2970 is_head: false,
2971 ref_name: "refs/heads/dev".into(),
2972 upstream: None,
2973 most_recent_commit: Some(CommitSummary {
2974 sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
2975 subject: "Add feature".into(),
2976 commit_timestamp: 1762948725,
2977 author_name: SharedString::new("Zed"),
2978 has_parent: true,
2979 })
2980 },
2981 Branch {
2982 is_head: true,
2983 ref_name: "refs/heads/main".into(),
2984 upstream: None,
2985 most_recent_commit: Some(CommitSummary {
2986 sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
2987 subject: "Initial commit".into(),
2988 commit_timestamp: 1762948695,
2989 author_name: SharedString::new("Zed"),
2990 has_parent: false,
2991 })
2992 }
2993 ]
2994 )
2995 }
2996
2997 impl RealGitRepository {
2998 /// Force a Git garbage collection on the repository.
2999 fn gc(&self) -> BoxFuture<'_, Result<()>> {
3000 let working_directory = self.working_directory();
3001 let git_binary_path = self.any_git_binary_path.clone();
3002 let executor = self.executor.clone();
3003 self.executor
3004 .spawn(async move {
3005 let git_binary_path = git_binary_path.clone();
3006 let working_directory = working_directory?;
3007 let git = GitBinary::new(git_binary_path, working_directory, executor);
3008 git.run(&["gc", "--prune"]).await?;
3009 Ok(())
3010 })
3011 .boxed()
3012 }
3013 }
3014}