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