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