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