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