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 self.executor
1466 .spawn(async move {
1467 crate::blame::Blame::for_path(
1468 &git_binary_path,
1469 &working_directory?,
1470 &path,
1471 &content,
1472 remote_url,
1473 )
1474 .await
1475 })
1476 .boxed()
1477 }
1478
1479 fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>> {
1480 self.file_history_paginated(path, 0, None)
1481 }
1482
1483 fn file_history_paginated(
1484 &self,
1485 path: RepoPath,
1486 skip: usize,
1487 limit: Option<usize>,
1488 ) -> BoxFuture<'_, Result<FileHistory>> {
1489 let working_directory = self.working_directory();
1490 let git_binary_path = self.any_git_binary_path.clone();
1491 self.executor
1492 .spawn(async move {
1493 let working_directory = working_directory?;
1494 // Use a unique delimiter with a hardcoded UUID to separate commits
1495 // This essentially eliminates any chance of encountering the delimiter in actual commit data
1496 let commit_delimiter =
1497 concat!("<<COMMIT_END-", "3f8a9c2e-7d4b-4e1a-9f6c-8b5d2a1e4c3f>>",);
1498
1499 let format_string = format!(
1500 "--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}",
1501 commit_delimiter
1502 );
1503
1504 let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string];
1505
1506 let skip_str;
1507 let limit_str;
1508 if skip > 0 {
1509 skip_str = skip.to_string();
1510 args.push("--skip");
1511 args.push(&skip_str);
1512 }
1513 if let Some(n) = limit {
1514 limit_str = n.to_string();
1515 args.push("-n");
1516 args.push(&limit_str);
1517 }
1518
1519 args.push("--");
1520
1521 let output = new_smol_command(&git_binary_path)
1522 .current_dir(&working_directory)
1523 .args(&args)
1524 .arg(path.as_unix_str())
1525 .output()
1526 .await?;
1527
1528 if !output.status.success() {
1529 let stderr = String::from_utf8_lossy(&output.stderr);
1530 bail!("git log failed: {stderr}");
1531 }
1532
1533 let stdout = std::str::from_utf8(&output.stdout)?;
1534 let mut entries = Vec::new();
1535
1536 for commit_block in stdout.split(commit_delimiter) {
1537 let commit_block = commit_block.trim();
1538 if commit_block.is_empty() {
1539 continue;
1540 }
1541
1542 let fields: Vec<&str> = commit_block.split('\0').collect();
1543 if fields.len() >= 6 {
1544 let sha = fields[0].trim().to_string().into();
1545 let subject = fields[1].trim().to_string().into();
1546 let message = fields[2].trim().to_string().into();
1547 let commit_timestamp = fields[3].trim().parse().unwrap_or(0);
1548 let author_name = fields[4].trim().to_string().into();
1549 let author_email = fields[5].trim().to_string().into();
1550
1551 entries.push(FileHistoryEntry {
1552 sha,
1553 subject,
1554 message,
1555 commit_timestamp,
1556 author_name,
1557 author_email,
1558 });
1559 }
1560 }
1561
1562 Ok(FileHistory { entries, path })
1563 })
1564 .boxed()
1565 }
1566
1567 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
1568 let working_directory = self.working_directory();
1569 let git_binary_path = self.any_git_binary_path.clone();
1570 self.executor
1571 .spawn(async move {
1572 let args = match diff {
1573 DiffType::HeadToIndex => Some("--staged"),
1574 DiffType::HeadToWorktree => None,
1575 };
1576
1577 let output = new_smol_command(&git_binary_path)
1578 .current_dir(&working_directory?)
1579 .args(["diff"])
1580 .args(args)
1581 .output()
1582 .await?;
1583
1584 anyhow::ensure!(
1585 output.status.success(),
1586 "Failed to run git diff:\n{}",
1587 String::from_utf8_lossy(&output.stderr)
1588 );
1589 Ok(String::from_utf8_lossy(&output.stdout).to_string())
1590 })
1591 .boxed()
1592 }
1593
1594 fn stage_paths(
1595 &self,
1596 paths: Vec<RepoPath>,
1597 env: Arc<HashMap<String, String>>,
1598 ) -> BoxFuture<'_, Result<()>> {
1599 let working_directory = self.working_directory();
1600 let git_binary_path = self.any_git_binary_path.clone();
1601 self.executor
1602 .spawn(async move {
1603 if !paths.is_empty() {
1604 let output = new_smol_command(&git_binary_path)
1605 .current_dir(&working_directory?)
1606 .envs(env.iter())
1607 .args(["update-index", "--add", "--remove", "--"])
1608 .args(paths.iter().map(|p| p.as_unix_str()))
1609 .output()
1610 .await?;
1611 anyhow::ensure!(
1612 output.status.success(),
1613 "Failed to stage paths:\n{}",
1614 String::from_utf8_lossy(&output.stderr),
1615 );
1616 }
1617 Ok(())
1618 })
1619 .boxed()
1620 }
1621
1622 fn unstage_paths(
1623 &self,
1624 paths: Vec<RepoPath>,
1625 env: Arc<HashMap<String, String>>,
1626 ) -> BoxFuture<'_, Result<()>> {
1627 let working_directory = self.working_directory();
1628 let git_binary_path = self.any_git_binary_path.clone();
1629
1630 self.executor
1631 .spawn(async move {
1632 if !paths.is_empty() {
1633 let output = new_smol_command(&git_binary_path)
1634 .current_dir(&working_directory?)
1635 .envs(env.iter())
1636 .args(["reset", "--quiet", "--"])
1637 .args(paths.iter().map(|p| p.as_std_path()))
1638 .output()
1639 .await?;
1640
1641 anyhow::ensure!(
1642 output.status.success(),
1643 "Failed to unstage:\n{}",
1644 String::from_utf8_lossy(&output.stderr),
1645 );
1646 }
1647 Ok(())
1648 })
1649 .boxed()
1650 }
1651
1652 fn stash_paths(
1653 &self,
1654 paths: Vec<RepoPath>,
1655 env: Arc<HashMap<String, String>>,
1656 ) -> BoxFuture<'_, Result<()>> {
1657 let working_directory = self.working_directory();
1658 let git_binary_path = self.any_git_binary_path.clone();
1659 self.executor
1660 .spawn(async move {
1661 let mut cmd = new_smol_command(&git_binary_path);
1662 cmd.current_dir(&working_directory?)
1663 .envs(env.iter())
1664 .args(["stash", "push", "--quiet"])
1665 .arg("--include-untracked");
1666
1667 cmd.args(paths.iter().map(|p| p.as_unix_str()));
1668
1669 let output = cmd.output().await?;
1670
1671 anyhow::ensure!(
1672 output.status.success(),
1673 "Failed to stash:\n{}",
1674 String::from_utf8_lossy(&output.stderr)
1675 );
1676 Ok(())
1677 })
1678 .boxed()
1679 }
1680
1681 fn stash_pop(
1682 &self,
1683 index: Option<usize>,
1684 env: Arc<HashMap<String, String>>,
1685 ) -> BoxFuture<'_, Result<()>> {
1686 let working_directory = self.working_directory();
1687 let git_binary_path = self.any_git_binary_path.clone();
1688 self.executor
1689 .spawn(async move {
1690 let mut cmd = new_smol_command(git_binary_path);
1691 let mut args = vec!["stash".to_string(), "pop".to_string()];
1692 if let Some(index) = index {
1693 args.push(format!("stash@{{{}}}", index));
1694 }
1695 cmd.current_dir(&working_directory?)
1696 .envs(env.iter())
1697 .args(args);
1698
1699 let output = cmd.output().await?;
1700
1701 anyhow::ensure!(
1702 output.status.success(),
1703 "Failed to stash pop:\n{}",
1704 String::from_utf8_lossy(&output.stderr)
1705 );
1706 Ok(())
1707 })
1708 .boxed()
1709 }
1710
1711 fn stash_apply(
1712 &self,
1713 index: Option<usize>,
1714 env: Arc<HashMap<String, String>>,
1715 ) -> BoxFuture<'_, Result<()>> {
1716 let working_directory = self.working_directory();
1717 let git_binary_path = self.any_git_binary_path.clone();
1718 self.executor
1719 .spawn(async move {
1720 let mut cmd = new_smol_command(git_binary_path);
1721 let mut args = vec!["stash".to_string(), "apply".to_string()];
1722 if let Some(index) = index {
1723 args.push(format!("stash@{{{}}}", index));
1724 }
1725 cmd.current_dir(&working_directory?)
1726 .envs(env.iter())
1727 .args(args);
1728
1729 let output = cmd.output().await?;
1730
1731 anyhow::ensure!(
1732 output.status.success(),
1733 "Failed to apply stash:\n{}",
1734 String::from_utf8_lossy(&output.stderr)
1735 );
1736 Ok(())
1737 })
1738 .boxed()
1739 }
1740
1741 fn stash_drop(
1742 &self,
1743 index: Option<usize>,
1744 env: Arc<HashMap<String, String>>,
1745 ) -> BoxFuture<'_, Result<()>> {
1746 let working_directory = self.working_directory();
1747 let git_binary_path = self.any_git_binary_path.clone();
1748 self.executor
1749 .spawn(async move {
1750 let mut cmd = new_smol_command(git_binary_path);
1751 let mut args = vec!["stash".to_string(), "drop".to_string()];
1752 if let Some(index) = index {
1753 args.push(format!("stash@{{{}}}", index));
1754 }
1755 cmd.current_dir(&working_directory?)
1756 .envs(env.iter())
1757 .args(args);
1758
1759 let output = cmd.output().await?;
1760
1761 anyhow::ensure!(
1762 output.status.success(),
1763 "Failed to stash drop:\n{}",
1764 String::from_utf8_lossy(&output.stderr)
1765 );
1766 Ok(())
1767 })
1768 .boxed()
1769 }
1770
1771 fn commit(
1772 &self,
1773 message: SharedString,
1774 name_and_email: Option<(SharedString, SharedString)>,
1775 options: CommitOptions,
1776 ask_pass: AskPassDelegate,
1777 env: Arc<HashMap<String, String>>,
1778 ) -> BoxFuture<'_, Result<()>> {
1779 let working_directory = self.working_directory();
1780 let git_binary_path = self.any_git_binary_path.clone();
1781 let executor = self.executor.clone();
1782 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
1783 // which we want to block on.
1784 async move {
1785 let mut cmd = new_smol_command(git_binary_path);
1786 cmd.current_dir(&working_directory?)
1787 .envs(env.iter())
1788 .args(["commit", "--quiet", "-m"])
1789 .arg(&message.to_string())
1790 .arg("--cleanup=strip")
1791 .arg("--no-verify")
1792 .stdout(smol::process::Stdio::piped())
1793 .stderr(smol::process::Stdio::piped());
1794
1795 if options.amend {
1796 cmd.arg("--amend");
1797 }
1798
1799 if options.signoff {
1800 cmd.arg("--signoff");
1801 }
1802
1803 if let Some((name, email)) = name_and_email {
1804 cmd.arg("--author").arg(&format!("{name} <{email}>"));
1805 }
1806
1807 run_git_command(env, ask_pass, cmd, &executor).await?;
1808
1809 Ok(())
1810 }
1811 .boxed()
1812 }
1813
1814 fn push(
1815 &self,
1816 branch_name: String,
1817 remote_name: String,
1818 options: Option<PushOptions>,
1819 ask_pass: AskPassDelegate,
1820 env: Arc<HashMap<String, String>>,
1821 cx: AsyncApp,
1822 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1823 let working_directory = self.working_directory();
1824 let executor = cx.background_executor().clone();
1825 let git_binary_path = self.system_git_binary_path.clone();
1826 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
1827 // which we want to block on.
1828 async move {
1829 let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?;
1830 let working_directory = working_directory?;
1831 let mut command = new_smol_command(git_binary_path);
1832 command
1833 .envs(env.iter())
1834 .current_dir(&working_directory)
1835 .args(["push"])
1836 .args(options.map(|option| match option {
1837 PushOptions::SetUpstream => "--set-upstream",
1838 PushOptions::Force => "--force-with-lease",
1839 }))
1840 .arg(remote_name)
1841 .arg(format!("{}:{}", branch_name, branch_name))
1842 .stdin(smol::process::Stdio::null())
1843 .stdout(smol::process::Stdio::piped())
1844 .stderr(smol::process::Stdio::piped());
1845
1846 run_git_command(env, ask_pass, command, &executor).await
1847 }
1848 .boxed()
1849 }
1850
1851 fn pull(
1852 &self,
1853 branch_name: Option<String>,
1854 remote_name: String,
1855 rebase: bool,
1856 ask_pass: AskPassDelegate,
1857 env: Arc<HashMap<String, String>>,
1858 cx: AsyncApp,
1859 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1860 let working_directory = self.working_directory();
1861 let executor = cx.background_executor().clone();
1862 let git_binary_path = self.system_git_binary_path.clone();
1863 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
1864 // which we want to block on.
1865 async move {
1866 let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?;
1867 let mut command = new_smol_command(git_binary_path);
1868 command
1869 .envs(env.iter())
1870 .current_dir(&working_directory?)
1871 .arg("pull");
1872
1873 if rebase {
1874 command.arg("--rebase");
1875 }
1876
1877 command
1878 .arg(remote_name)
1879 .args(branch_name)
1880 .stdout(smol::process::Stdio::piped())
1881 .stderr(smol::process::Stdio::piped());
1882
1883 run_git_command(env, ask_pass, command, &executor).await
1884 }
1885 .boxed()
1886 }
1887
1888 fn fetch(
1889 &self,
1890 fetch_options: FetchOptions,
1891 ask_pass: AskPassDelegate,
1892 env: Arc<HashMap<String, String>>,
1893 cx: AsyncApp,
1894 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1895 let working_directory = self.working_directory();
1896 let remote_name = format!("{}", fetch_options);
1897 let git_binary_path = self.system_git_binary_path.clone();
1898 let executor = cx.background_executor().clone();
1899 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
1900 // which we want to block on.
1901 async move {
1902 let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?;
1903 let mut command = new_smol_command(git_binary_path);
1904 command
1905 .envs(env.iter())
1906 .current_dir(&working_directory?)
1907 .args(["fetch", &remote_name])
1908 .stdout(smol::process::Stdio::piped())
1909 .stderr(smol::process::Stdio::piped());
1910
1911 run_git_command(env, ask_pass, command, &executor).await
1912 }
1913 .boxed()
1914 }
1915
1916 fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
1917 let working_directory = self.working_directory();
1918 let git_binary_path = self.any_git_binary_path.clone();
1919 self.executor
1920 .spawn(async move {
1921 let working_directory = working_directory?;
1922 let output = new_smol_command(&git_binary_path)
1923 .current_dir(&working_directory)
1924 .args(["rev-parse", "--abbrev-ref"])
1925 .arg(format!("{branch}@{{push}}"))
1926 .output()
1927 .await?;
1928 if !output.status.success() {
1929 return Ok(None);
1930 }
1931 let remote_name = String::from_utf8_lossy(&output.stdout)
1932 .split('/')
1933 .next()
1934 .map(|name| Remote {
1935 name: name.trim().to_string().into(),
1936 });
1937
1938 Ok(remote_name)
1939 })
1940 .boxed()
1941 }
1942
1943 fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
1944 let working_directory = self.working_directory();
1945 let git_binary_path = self.any_git_binary_path.clone();
1946 self.executor
1947 .spawn(async move {
1948 let working_directory = working_directory?;
1949 let output = new_smol_command(&git_binary_path)
1950 .current_dir(&working_directory)
1951 .args(["config", "--get"])
1952 .arg(format!("branch.{branch}.remote"))
1953 .output()
1954 .await?;
1955 if !output.status.success() {
1956 return Ok(None);
1957 }
1958
1959 let remote_name = String::from_utf8_lossy(&output.stdout);
1960 return Ok(Some(Remote {
1961 name: remote_name.trim().to_string().into(),
1962 }));
1963 })
1964 .boxed()
1965 }
1966
1967 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
1968 let working_directory = self.working_directory();
1969 let git_binary_path = self.any_git_binary_path.clone();
1970 self.executor
1971 .spawn(async move {
1972 let working_directory = working_directory?;
1973 let output = new_smol_command(&git_binary_path)
1974 .current_dir(&working_directory)
1975 .args(["remote"])
1976 .output()
1977 .await?;
1978
1979 anyhow::ensure!(
1980 output.status.success(),
1981 "Failed to get all remotes:\n{}",
1982 String::from_utf8_lossy(&output.stderr)
1983 );
1984 let remote_names = String::from_utf8_lossy(&output.stdout)
1985 .split('\n')
1986 .filter(|name| !name.is_empty())
1987 .map(|name| Remote {
1988 name: name.trim().to_string().into(),
1989 })
1990 .collect();
1991 Ok(remote_names)
1992 })
1993 .boxed()
1994 }
1995
1996 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
1997 let working_directory = self.working_directory();
1998 let git_binary_path = self.any_git_binary_path.clone();
1999 self.executor
2000 .spawn(async move {
2001 let working_directory = working_directory?;
2002 let git_cmd = async |args: &[&str]| -> Result<String> {
2003 let output = new_smol_command(&git_binary_path)
2004 .current_dir(&working_directory)
2005 .args(args)
2006 .output()
2007 .await?;
2008 anyhow::ensure!(
2009 output.status.success(),
2010 String::from_utf8_lossy(&output.stderr).to_string()
2011 );
2012 Ok(String::from_utf8(output.stdout)?)
2013 };
2014
2015 let head = git_cmd(&["rev-parse", "HEAD"])
2016 .await
2017 .context("Failed to get HEAD")?
2018 .trim()
2019 .to_owned();
2020
2021 let mut remote_branches = vec![];
2022 let mut add_if_matching = async |remote_head: &str| {
2023 if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
2024 && merge_base.trim() == head
2025 && let Some(s) = remote_head.strip_prefix("refs/remotes/")
2026 {
2027 remote_branches.push(s.to_owned().into());
2028 }
2029 };
2030
2031 // check the main branch of each remote
2032 let remotes = git_cmd(&["remote"])
2033 .await
2034 .context("Failed to get remotes")?;
2035 for remote in remotes.lines() {
2036 if let Ok(remote_head) =
2037 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
2038 {
2039 add_if_matching(remote_head.trim()).await;
2040 }
2041 }
2042
2043 // ... and the remote branch that the checked-out one is tracking
2044 if let Ok(remote_head) =
2045 git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
2046 {
2047 add_if_matching(remote_head.trim()).await;
2048 }
2049
2050 Ok(remote_branches)
2051 })
2052 .boxed()
2053 }
2054
2055 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
2056 let working_directory = self.working_directory();
2057 let git_binary_path = self.any_git_binary_path.clone();
2058 let executor = self.executor.clone();
2059 self.executor
2060 .spawn(async move {
2061 let working_directory = working_directory?;
2062 let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
2063 .envs(checkpoint_author_envs());
2064 git.with_temp_index(async |git| {
2065 let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
2066 let mut excludes = exclude_files(git).await?;
2067
2068 git.run(&["add", "--all"]).await?;
2069 let tree = git.run(&["write-tree"]).await?;
2070 let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
2071 git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
2072 .await?
2073 } else {
2074 git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
2075 };
2076
2077 excludes.restore_original().await?;
2078
2079 Ok(GitRepositoryCheckpoint {
2080 commit_sha: checkpoint_sha.parse()?,
2081 })
2082 })
2083 .await
2084 })
2085 .boxed()
2086 }
2087
2088 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
2089 let working_directory = self.working_directory();
2090 let git_binary_path = self.any_git_binary_path.clone();
2091
2092 let executor = self.executor.clone();
2093 self.executor
2094 .spawn(async move {
2095 let working_directory = working_directory?;
2096
2097 let git = GitBinary::new(git_binary_path, working_directory, executor);
2098 git.run(&[
2099 "restore",
2100 "--source",
2101 &checkpoint.commit_sha.to_string(),
2102 "--worktree",
2103 ".",
2104 ])
2105 .await?;
2106
2107 // TODO: We don't track binary and large files anymore,
2108 // so the following call would delete them.
2109 // Implement an alternative way to track files added by agent.
2110 //
2111 // git.with_temp_index(async move |git| {
2112 // git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
2113 // .await?;
2114 // git.run(&["clean", "-d", "--force"]).await
2115 // })
2116 // .await?;
2117
2118 Ok(())
2119 })
2120 .boxed()
2121 }
2122
2123 fn compare_checkpoints(
2124 &self,
2125 left: GitRepositoryCheckpoint,
2126 right: GitRepositoryCheckpoint,
2127 ) -> BoxFuture<'_, Result<bool>> {
2128 let working_directory = self.working_directory();
2129 let git_binary_path = self.any_git_binary_path.clone();
2130
2131 let executor = self.executor.clone();
2132 self.executor
2133 .spawn(async move {
2134 let working_directory = working_directory?;
2135 let git = GitBinary::new(git_binary_path, working_directory, executor);
2136 let result = git
2137 .run(&[
2138 "diff-tree",
2139 "--quiet",
2140 &left.commit_sha.to_string(),
2141 &right.commit_sha.to_string(),
2142 ])
2143 .await;
2144 match result {
2145 Ok(_) => Ok(true),
2146 Err(error) => {
2147 if let Some(GitBinaryCommandError { status, .. }) =
2148 error.downcast_ref::<GitBinaryCommandError>()
2149 && status.code() == Some(1)
2150 {
2151 return Ok(false);
2152 }
2153
2154 Err(error)
2155 }
2156 }
2157 })
2158 .boxed()
2159 }
2160
2161 fn diff_checkpoints(
2162 &self,
2163 base_checkpoint: GitRepositoryCheckpoint,
2164 target_checkpoint: GitRepositoryCheckpoint,
2165 ) -> BoxFuture<'_, Result<String>> {
2166 let working_directory = self.working_directory();
2167 let git_binary_path = self.any_git_binary_path.clone();
2168
2169 let executor = self.executor.clone();
2170 self.executor
2171 .spawn(async move {
2172 let working_directory = working_directory?;
2173 let git = GitBinary::new(git_binary_path, working_directory, executor);
2174 git.run(&[
2175 "diff",
2176 "--find-renames",
2177 "--patch",
2178 &base_checkpoint.commit_sha.to_string(),
2179 &target_checkpoint.commit_sha.to_string(),
2180 ])
2181 .await
2182 })
2183 .boxed()
2184 }
2185
2186 fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
2187 let working_directory = self.working_directory();
2188 let git_binary_path = self.any_git_binary_path.clone();
2189
2190 let executor = self.executor.clone();
2191 self.executor
2192 .spawn(async move {
2193 let working_directory = working_directory?;
2194 let git = GitBinary::new(git_binary_path, working_directory, executor);
2195
2196 if let Ok(output) = git
2197 .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
2198 .await
2199 {
2200 let output = output
2201 .strip_prefix("refs/remotes/upstream/")
2202 .map(|s| SharedString::from(s.to_owned()));
2203 return Ok(output);
2204 }
2205
2206 if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
2207 return Ok(output
2208 .strip_prefix("refs/remotes/origin/")
2209 .map(|s| SharedString::from(s.to_owned())));
2210 }
2211
2212 if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
2213 if git.run(&["rev-parse", &default_branch]).await.is_ok() {
2214 return Ok(Some(default_branch.into()));
2215 }
2216 }
2217
2218 if git.run(&["rev-parse", "master"]).await.is_ok() {
2219 return Ok(Some("master".into()));
2220 }
2221
2222 Ok(None)
2223 })
2224 .boxed()
2225 }
2226
2227 fn run_hook(
2228 &self,
2229 hook: RunHook,
2230 env: Arc<HashMap<String, String>>,
2231 ) -> BoxFuture<'_, Result<()>> {
2232 let working_directory = self.working_directory();
2233 let git_binary_path = self.any_git_binary_path.clone();
2234 let executor = self.executor.clone();
2235 self.executor
2236 .spawn(async move {
2237 let working_directory = working_directory?;
2238 let git = GitBinary::new(git_binary_path, working_directory, executor)
2239 .envs(HashMap::clone(&env));
2240 git.run(&["hook", "run", "--ignore-missing", hook.as_str()])
2241 .await?;
2242 Ok(())
2243 })
2244 .boxed()
2245 }
2246}
2247
2248fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
2249 let mut args = vec![
2250 OsString::from("--no-optional-locks"),
2251 OsString::from("status"),
2252 OsString::from("--porcelain=v1"),
2253 OsString::from("--untracked-files=all"),
2254 OsString::from("--no-renames"),
2255 OsString::from("-z"),
2256 ];
2257 args.extend(
2258 path_prefixes
2259 .iter()
2260 .map(|path_prefix| path_prefix.as_std_path().into()),
2261 );
2262 args.extend(path_prefixes.iter().map(|path_prefix| {
2263 if path_prefix.is_empty() {
2264 Path::new(".").into()
2265 } else {
2266 path_prefix.as_std_path().into()
2267 }
2268 }));
2269 args
2270}
2271
2272/// Temporarily git-ignore commonly ignored files and files over 2MB
2273async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
2274 const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
2275 let mut excludes = git.with_exclude_overrides().await?;
2276 excludes
2277 .add_excludes(include_str!("./checkpoint.gitignore"))
2278 .await?;
2279
2280 let working_directory = git.working_directory.clone();
2281 let untracked_files = git.list_untracked_files().await?;
2282 let excluded_paths = untracked_files.into_iter().map(|path| {
2283 let working_directory = working_directory.clone();
2284 smol::spawn(async move {
2285 let full_path = working_directory.join(path.clone());
2286 match smol::fs::metadata(&full_path).await {
2287 Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
2288 Some(PathBuf::from("/").join(path.clone()))
2289 }
2290 _ => None,
2291 }
2292 })
2293 });
2294
2295 let excluded_paths = futures::future::join_all(excluded_paths).await;
2296 let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
2297
2298 if !excluded_paths.is_empty() {
2299 let exclude_patterns = excluded_paths
2300 .into_iter()
2301 .map(|path| path.to_string_lossy().into_owned())
2302 .collect::<Vec<_>>()
2303 .join("\n");
2304 excludes.add_excludes(&exclude_patterns).await?;
2305 }
2306
2307 Ok(excludes)
2308}
2309
2310struct GitBinary {
2311 git_binary_path: PathBuf,
2312 working_directory: PathBuf,
2313 executor: BackgroundExecutor,
2314 index_file_path: Option<PathBuf>,
2315 envs: HashMap<String, String>,
2316}
2317
2318impl GitBinary {
2319 fn new(
2320 git_binary_path: PathBuf,
2321 working_directory: PathBuf,
2322 executor: BackgroundExecutor,
2323 ) -> Self {
2324 Self {
2325 git_binary_path,
2326 working_directory,
2327 executor,
2328 index_file_path: None,
2329 envs: HashMap::default(),
2330 }
2331 }
2332
2333 async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
2334 let status_output = self
2335 .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
2336 .await?;
2337
2338 let paths = status_output
2339 .split('\0')
2340 .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
2341 .map(|entry| PathBuf::from(&entry[3..]))
2342 .collect::<Vec<_>>();
2343 Ok(paths)
2344 }
2345
2346 fn envs(mut self, envs: HashMap<String, String>) -> Self {
2347 self.envs = envs;
2348 self
2349 }
2350
2351 pub async fn with_temp_index<R>(
2352 &mut self,
2353 f: impl AsyncFnOnce(&Self) -> Result<R>,
2354 ) -> Result<R> {
2355 let index_file_path = self.path_for_index_id(Uuid::new_v4());
2356
2357 let delete_temp_index = util::defer({
2358 let index_file_path = index_file_path.clone();
2359 let executor = self.executor.clone();
2360 move || {
2361 executor
2362 .spawn(async move {
2363 smol::fs::remove_file(index_file_path).await.log_err();
2364 })
2365 .detach();
2366 }
2367 });
2368
2369 // Copy the default index file so that Git doesn't have to rebuild the
2370 // whole index from scratch. This might fail if this is an empty repository.
2371 smol::fs::copy(
2372 self.working_directory.join(".git").join("index"),
2373 &index_file_path,
2374 )
2375 .await
2376 .ok();
2377
2378 self.index_file_path = Some(index_file_path.clone());
2379 let result = f(self).await;
2380 self.index_file_path = None;
2381 let result = result?;
2382
2383 smol::fs::remove_file(index_file_path).await.ok();
2384 delete_temp_index.abort();
2385
2386 Ok(result)
2387 }
2388
2389 pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
2390 let path = self
2391 .working_directory
2392 .join(".git")
2393 .join("info")
2394 .join("exclude");
2395
2396 GitExcludeOverride::new(path).await
2397 }
2398
2399 fn path_for_index_id(&self, id: Uuid) -> PathBuf {
2400 self.working_directory
2401 .join(".git")
2402 .join(format!("index-{}.tmp", id))
2403 }
2404
2405 pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
2406 where
2407 S: AsRef<OsStr>,
2408 {
2409 let mut stdout = self.run_raw(args).await?;
2410 if stdout.chars().last() == Some('\n') {
2411 stdout.pop();
2412 }
2413 Ok(stdout)
2414 }
2415
2416 /// Returns the result of the command without trimming the trailing newline.
2417 pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
2418 where
2419 S: AsRef<OsStr>,
2420 {
2421 let mut command = self.build_command(args);
2422 let output = command.output().await?;
2423 anyhow::ensure!(
2424 output.status.success(),
2425 GitBinaryCommandError {
2426 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
2427 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
2428 status: output.status,
2429 }
2430 );
2431 Ok(String::from_utf8(output.stdout)?)
2432 }
2433
2434 fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
2435 where
2436 S: AsRef<OsStr>,
2437 {
2438 let mut command = new_smol_command(&self.git_binary_path);
2439 command.current_dir(&self.working_directory);
2440 command.args(args);
2441 if let Some(index_file_path) = self.index_file_path.as_ref() {
2442 command.env("GIT_INDEX_FILE", index_file_path);
2443 }
2444 command.envs(&self.envs);
2445 command
2446 }
2447}
2448
2449#[derive(Error, Debug)]
2450#[error("Git command failed:\n{stdout}{stderr}\n")]
2451struct GitBinaryCommandError {
2452 stdout: String,
2453 stderr: String,
2454 status: ExitStatus,
2455}
2456
2457async fn run_git_command(
2458 env: Arc<HashMap<String, String>>,
2459 ask_pass: AskPassDelegate,
2460 mut command: smol::process::Command,
2461 executor: &BackgroundExecutor,
2462) -> Result<RemoteCommandOutput> {
2463 if env.contains_key("GIT_ASKPASS") {
2464 let git_process = command.spawn()?;
2465 let output = git_process.output().await?;
2466 anyhow::ensure!(
2467 output.status.success(),
2468 "{}",
2469 String::from_utf8_lossy(&output.stderr)
2470 );
2471 Ok(RemoteCommandOutput {
2472 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
2473 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
2474 })
2475 } else {
2476 let ask_pass = AskPassSession::new(executor, ask_pass).await?;
2477 command
2478 .env("GIT_ASKPASS", ask_pass.script_path())
2479 .env("SSH_ASKPASS", ask_pass.script_path())
2480 .env("SSH_ASKPASS_REQUIRE", "force");
2481 let git_process = command.spawn()?;
2482
2483 run_askpass_command(ask_pass, git_process).await
2484 }
2485}
2486
2487async fn run_askpass_command(
2488 mut ask_pass: AskPassSession,
2489 git_process: smol::process::Child,
2490) -> anyhow::Result<RemoteCommandOutput> {
2491 select_biased! {
2492 result = ask_pass.run().fuse() => {
2493 match result {
2494 AskPassResult::CancelledByUser => {
2495 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
2496 }
2497 AskPassResult::Timedout => {
2498 Err(anyhow!("Connecting to host timed out"))?
2499 }
2500 }
2501 }
2502 output = git_process.output().fuse() => {
2503 let output = output?;
2504 anyhow::ensure!(
2505 output.status.success(),
2506 "{}",
2507 String::from_utf8_lossy(&output.stderr)
2508 );
2509 Ok(RemoteCommandOutput {
2510 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
2511 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
2512 })
2513 }
2514 }
2515}
2516
2517#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
2518pub struct RepoPath(Arc<RelPath>);
2519
2520impl std::fmt::Debug for RepoPath {
2521 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2522 self.0.fmt(f)
2523 }
2524}
2525
2526impl RepoPath {
2527 pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
2528 let rel_path = RelPath::unix(s.as_ref())?;
2529 Ok(Self::from_rel_path(rel_path))
2530 }
2531
2532 pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
2533 let rel_path = RelPath::new(path, path_style)?;
2534 Ok(Self::from_rel_path(&rel_path))
2535 }
2536
2537 pub fn from_proto(proto: &str) -> Result<Self> {
2538 let rel_path = RelPath::from_proto(proto)?;
2539 Ok(Self(rel_path))
2540 }
2541
2542 pub fn from_rel_path(path: &RelPath) -> RepoPath {
2543 Self(Arc::from(path))
2544 }
2545
2546 pub fn as_std_path(&self) -> &Path {
2547 // git2 does not like empty paths and our RelPath infra turns `.` into ``
2548 // so undo that here
2549 if self.is_empty() {
2550 Path::new(".")
2551 } else {
2552 self.0.as_std_path()
2553 }
2554 }
2555}
2556
2557#[cfg(any(test, feature = "test-support"))]
2558pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
2559 RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
2560}
2561
2562impl AsRef<Arc<RelPath>> for RepoPath {
2563 fn as_ref(&self) -> &Arc<RelPath> {
2564 &self.0
2565 }
2566}
2567
2568impl std::ops::Deref for RepoPath {
2569 type Target = RelPath;
2570
2571 fn deref(&self) -> &Self::Target {
2572 &self.0
2573 }
2574}
2575
2576#[derive(Debug)]
2577pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
2578
2579impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
2580 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
2581 if key.starts_with(self.0) {
2582 Ordering::Greater
2583 } else {
2584 self.0.cmp(key)
2585 }
2586 }
2587}
2588
2589fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
2590 let mut branches = Vec::new();
2591 for line in input.split('\n') {
2592 if line.is_empty() {
2593 continue;
2594 }
2595 let mut fields = line.split('\x00');
2596 let Some(head) = fields.next() else {
2597 continue;
2598 };
2599 let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
2600 continue;
2601 };
2602 let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
2603 continue;
2604 };
2605 let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
2606 continue;
2607 };
2608 let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
2609 continue;
2610 };
2611 let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
2612 else {
2613 continue;
2614 };
2615 let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
2616 continue;
2617 };
2618 let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
2619 continue;
2620 };
2621 let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
2622 continue;
2623 };
2624
2625 branches.push(Branch {
2626 is_head: head == "*",
2627 ref_name,
2628 most_recent_commit: Some(CommitSummary {
2629 sha: head_sha,
2630 subject,
2631 commit_timestamp: commiterdate,
2632 author_name: author_name,
2633 has_parent: !parent_sha.is_empty(),
2634 }),
2635 upstream: if upstream_name.is_empty() {
2636 None
2637 } else {
2638 Some(Upstream {
2639 ref_name: upstream_name.into(),
2640 tracking: upstream_tracking,
2641 })
2642 },
2643 })
2644 }
2645
2646 Ok(branches)
2647}
2648
2649fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
2650 if upstream_track.is_empty() {
2651 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2652 ahead: 0,
2653 behind: 0,
2654 }));
2655 }
2656
2657 let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
2658 let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
2659 let mut ahead: u32 = 0;
2660 let mut behind: u32 = 0;
2661 for component in upstream_track.split(", ") {
2662 if component == "gone" {
2663 return Ok(UpstreamTracking::Gone);
2664 }
2665 if let Some(ahead_num) = component.strip_prefix("ahead ") {
2666 ahead = ahead_num.parse::<u32>()?;
2667 }
2668 if let Some(behind_num) = component.strip_prefix("behind ") {
2669 behind = behind_num.parse::<u32>()?;
2670 }
2671 }
2672 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2673 ahead,
2674 behind,
2675 }))
2676}
2677
2678fn checkpoint_author_envs() -> HashMap<String, String> {
2679 HashMap::from_iter([
2680 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
2681 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
2682 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
2683 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
2684 ])
2685}
2686
2687#[cfg(test)]
2688mod tests {
2689 use super::*;
2690 use gpui::TestAppContext;
2691
2692 fn disable_git_global_config() {
2693 unsafe {
2694 std::env::set_var("GIT_CONFIG_GLOBAL", "");
2695 std::env::set_var("GIT_CONFIG_SYSTEM", "");
2696 }
2697 }
2698
2699 #[gpui::test]
2700 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
2701 disable_git_global_config();
2702
2703 cx.executor().allow_parking();
2704
2705 let repo_dir = tempfile::tempdir().unwrap();
2706
2707 git2::Repository::init(repo_dir.path()).unwrap();
2708 let file_path = repo_dir.path().join("file");
2709 smol::fs::write(&file_path, "initial").await.unwrap();
2710
2711 let repo = RealGitRepository::new(
2712 &repo_dir.path().join(".git"),
2713 None,
2714 Some("git".into()),
2715 cx.executor(),
2716 )
2717 .unwrap();
2718
2719 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
2720 .await
2721 .unwrap();
2722 repo.commit(
2723 "Initial commit".into(),
2724 None,
2725 CommitOptions::default(),
2726 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
2727 Arc::new(checkpoint_author_envs()),
2728 )
2729 .await
2730 .unwrap();
2731
2732 smol::fs::write(&file_path, "modified before checkpoint")
2733 .await
2734 .unwrap();
2735 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
2736 .await
2737 .unwrap();
2738 let checkpoint = repo.checkpoint().await.unwrap();
2739
2740 // Ensure the user can't see any branches after creating a checkpoint.
2741 assert_eq!(repo.branches().await.unwrap().len(), 1);
2742
2743 smol::fs::write(&file_path, "modified after checkpoint")
2744 .await
2745 .unwrap();
2746 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
2747 .await
2748 .unwrap();
2749 repo.commit(
2750 "Commit after checkpoint".into(),
2751 None,
2752 CommitOptions::default(),
2753 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
2754 Arc::new(checkpoint_author_envs()),
2755 )
2756 .await
2757 .unwrap();
2758
2759 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
2760 .await
2761 .unwrap();
2762 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
2763 .await
2764 .unwrap();
2765
2766 // Ensure checkpoint stays alive even after a Git GC.
2767 repo.gc().await.unwrap();
2768 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
2769
2770 assert_eq!(
2771 smol::fs::read_to_string(&file_path).await.unwrap(),
2772 "modified before checkpoint"
2773 );
2774 assert_eq!(
2775 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
2776 .await
2777 .unwrap(),
2778 "1"
2779 );
2780 // See TODO above
2781 // assert_eq!(
2782 // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
2783 // .await
2784 // .ok(),
2785 // None
2786 // );
2787 }
2788
2789 #[gpui::test]
2790 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
2791 disable_git_global_config();
2792
2793 cx.executor().allow_parking();
2794
2795 let repo_dir = tempfile::tempdir().unwrap();
2796 git2::Repository::init(repo_dir.path()).unwrap();
2797 let repo = RealGitRepository::new(
2798 &repo_dir.path().join(".git"),
2799 None,
2800 Some("git".into()),
2801 cx.executor(),
2802 )
2803 .unwrap();
2804
2805 smol::fs::write(repo_dir.path().join("foo"), "foo")
2806 .await
2807 .unwrap();
2808 let checkpoint_sha = repo.checkpoint().await.unwrap();
2809
2810 // Ensure the user can't see any branches after creating a checkpoint.
2811 assert_eq!(repo.branches().await.unwrap().len(), 1);
2812
2813 smol::fs::write(repo_dir.path().join("foo"), "bar")
2814 .await
2815 .unwrap();
2816 smol::fs::write(repo_dir.path().join("baz"), "qux")
2817 .await
2818 .unwrap();
2819 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
2820 assert_eq!(
2821 smol::fs::read_to_string(repo_dir.path().join("foo"))
2822 .await
2823 .unwrap(),
2824 "foo"
2825 );
2826 // See TODOs above
2827 // assert_eq!(
2828 // smol::fs::read_to_string(repo_dir.path().join("baz"))
2829 // .await
2830 // .ok(),
2831 // None
2832 // );
2833 }
2834
2835 #[gpui::test]
2836 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
2837 disable_git_global_config();
2838
2839 cx.executor().allow_parking();
2840
2841 let repo_dir = tempfile::tempdir().unwrap();
2842 git2::Repository::init(repo_dir.path()).unwrap();
2843 let repo = RealGitRepository::new(
2844 &repo_dir.path().join(".git"),
2845 None,
2846 Some("git".into()),
2847 cx.executor(),
2848 )
2849 .unwrap();
2850
2851 smol::fs::write(repo_dir.path().join("file1"), "content1")
2852 .await
2853 .unwrap();
2854 let checkpoint1 = repo.checkpoint().await.unwrap();
2855
2856 smol::fs::write(repo_dir.path().join("file2"), "content2")
2857 .await
2858 .unwrap();
2859 let checkpoint2 = repo.checkpoint().await.unwrap();
2860
2861 assert!(
2862 !repo
2863 .compare_checkpoints(checkpoint1, checkpoint2.clone())
2864 .await
2865 .unwrap()
2866 );
2867
2868 let checkpoint3 = repo.checkpoint().await.unwrap();
2869 assert!(
2870 repo.compare_checkpoints(checkpoint2, checkpoint3)
2871 .await
2872 .unwrap()
2873 );
2874 }
2875
2876 #[gpui::test]
2877 async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
2878 disable_git_global_config();
2879
2880 cx.executor().allow_parking();
2881
2882 let repo_dir = tempfile::tempdir().unwrap();
2883 let text_path = repo_dir.path().join("main.rs");
2884 let bin_path = repo_dir.path().join("binary.o");
2885
2886 git2::Repository::init(repo_dir.path()).unwrap();
2887
2888 smol::fs::write(&text_path, "fn main() {}").await.unwrap();
2889
2890 smol::fs::write(&bin_path, "some binary file here")
2891 .await
2892 .unwrap();
2893
2894 let repo = RealGitRepository::new(
2895 &repo_dir.path().join(".git"),
2896 None,
2897 Some("git".into()),
2898 cx.executor(),
2899 )
2900 .unwrap();
2901
2902 // initial commit
2903 repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
2904 .await
2905 .unwrap();
2906 repo.commit(
2907 "Initial commit".into(),
2908 None,
2909 CommitOptions::default(),
2910 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
2911 Arc::new(checkpoint_author_envs()),
2912 )
2913 .await
2914 .unwrap();
2915
2916 let checkpoint = repo.checkpoint().await.unwrap();
2917
2918 smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
2919 .await
2920 .unwrap();
2921 smol::fs::write(&bin_path, "Modified binary file")
2922 .await
2923 .unwrap();
2924
2925 repo.restore_checkpoint(checkpoint).await.unwrap();
2926
2927 // Text files should be restored to checkpoint state,
2928 // but binaries should not (they aren't tracked)
2929 assert_eq!(
2930 smol::fs::read_to_string(&text_path).await.unwrap(),
2931 "fn main() {}"
2932 );
2933
2934 assert_eq!(
2935 smol::fs::read_to_string(&bin_path).await.unwrap(),
2936 "Modified binary file"
2937 );
2938 }
2939
2940 #[test]
2941 fn test_branches_parsing() {
2942 // suppress "help: octal escapes are not supported, `\0` is always null"
2943 #[allow(clippy::octal_escapes)]
2944 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
2945 assert_eq!(
2946 parse_branch_input(input).unwrap(),
2947 vec![Branch {
2948 is_head: true,
2949 ref_name: "refs/heads/zed-patches".into(),
2950 upstream: Some(Upstream {
2951 ref_name: "refs/remotes/origin/zed-patches".into(),
2952 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
2953 ahead: 0,
2954 behind: 0
2955 })
2956 }),
2957 most_recent_commit: Some(CommitSummary {
2958 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
2959 subject: "generated protobuf".into(),
2960 commit_timestamp: 1733187470,
2961 author_name: SharedString::new("John Doe"),
2962 has_parent: false,
2963 })
2964 }]
2965 )
2966 }
2967
2968 #[test]
2969 fn test_branches_parsing_containing_refs_with_missing_fields() {
2970 #[allow(clippy::octal_escapes)]
2971 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";
2972
2973 let branches = parse_branch_input(input).unwrap();
2974 assert_eq!(branches.len(), 2);
2975 assert_eq!(
2976 branches,
2977 vec![
2978 Branch {
2979 is_head: false,
2980 ref_name: "refs/heads/dev".into(),
2981 upstream: None,
2982 most_recent_commit: Some(CommitSummary {
2983 sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
2984 subject: "Add feature".into(),
2985 commit_timestamp: 1762948725,
2986 author_name: SharedString::new("Zed"),
2987 has_parent: true,
2988 })
2989 },
2990 Branch {
2991 is_head: true,
2992 ref_name: "refs/heads/main".into(),
2993 upstream: None,
2994 most_recent_commit: Some(CommitSummary {
2995 sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
2996 subject: "Initial commit".into(),
2997 commit_timestamp: 1762948695,
2998 author_name: SharedString::new("Zed"),
2999 has_parent: false,
3000 })
3001 }
3002 ]
3003 )
3004 }
3005
3006 impl RealGitRepository {
3007 /// Force a Git garbage collection on the repository.
3008 fn gc(&self) -> BoxFuture<'_, Result<()>> {
3009 let working_directory = self.working_directory();
3010 let git_binary_path = self.any_git_binary_path.clone();
3011 let executor = self.executor.clone();
3012 self.executor
3013 .spawn(async move {
3014 let git_binary_path = git_binary_path.clone();
3015 let working_directory = working_directory?;
3016 let git = GitBinary::new(git_binary_path, working_directory, executor);
3017 git.run(&["gc", "--prune"]).await?;
3018 Ok(())
3019 })
3020 .boxed()
3021 }
3022 }
3023}