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