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