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