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