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