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