1use crate::status::GitStatus;
2use crate::{Oid, SHORT_SHA_LENGTH};
3use anyhow::{anyhow, Context as _, Result};
4use collections::HashMap;
5use futures::future::BoxFuture;
6use futures::{select_biased, AsyncWriteExt, FutureExt as _};
7use git2::BranchType;
8use gpui::{AppContext, AsyncApp, BackgroundExecutor, SharedString};
9use parking_lot::Mutex;
10use rope::Rope;
11use schemars::JsonSchema;
12use serde::Deserialize;
13use std::borrow::Borrow;
14use std::future;
15use std::path::Component;
16use std::process::{ExitStatus, Stdio};
17use std::sync::LazyLock;
18use std::{
19 cmp::Ordering,
20 path::{Path, PathBuf},
21 sync::Arc,
22};
23use sum_tree::MapSeekTarget;
24use thiserror::Error;
25use util::command::new_smol_command;
26use util::ResultExt;
27use uuid::Uuid;
28
29pub use askpass::{AskPassResult, AskPassSession};
30
31pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
32
33#[derive(Clone, Debug, Hash, PartialEq, Eq)]
34pub struct Branch {
35 pub is_head: bool,
36 pub name: SharedString,
37 pub upstream: Option<Upstream>,
38 pub most_recent_commit: Option<CommitSummary>,
39}
40
41impl Branch {
42 pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
43 self.upstream
44 .as_ref()
45 .and_then(|upstream| upstream.tracking.status())
46 }
47
48 pub fn priority_key(&self) -> (bool, Option<i64>) {
49 (
50 self.is_head,
51 self.most_recent_commit
52 .as_ref()
53 .map(|commit| commit.commit_timestamp),
54 )
55 }
56}
57
58#[derive(Clone, Debug, Hash, PartialEq, Eq)]
59pub struct Upstream {
60 pub ref_name: SharedString,
61 pub tracking: UpstreamTracking,
62}
63
64impl Upstream {
65 pub fn remote_name(&self) -> Option<&str> {
66 self.ref_name
67 .strip_prefix("refs/remotes/")
68 .and_then(|stripped| stripped.split("/").next())
69 }
70}
71
72#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
73pub enum UpstreamTracking {
74 /// Remote ref not present in local repository.
75 Gone,
76 /// Remote ref present in local repository (fetched from remote).
77 Tracked(UpstreamTrackingStatus),
78}
79
80impl From<UpstreamTrackingStatus> for UpstreamTracking {
81 fn from(status: UpstreamTrackingStatus) -> Self {
82 UpstreamTracking::Tracked(status)
83 }
84}
85
86impl UpstreamTracking {
87 pub fn is_gone(&self) -> bool {
88 matches!(self, UpstreamTracking::Gone)
89 }
90
91 pub fn status(&self) -> Option<UpstreamTrackingStatus> {
92 match self {
93 UpstreamTracking::Gone => None,
94 UpstreamTracking::Tracked(status) => Some(*status),
95 }
96 }
97}
98
99#[derive(Debug, Clone)]
100pub struct RemoteCommandOutput {
101 pub stdout: String,
102 pub stderr: String,
103}
104
105impl RemoteCommandOutput {
106 pub fn is_empty(&self) -> bool {
107 self.stdout.is_empty() && self.stderr.is_empty()
108 }
109}
110
111#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
112pub struct UpstreamTrackingStatus {
113 pub ahead: u32,
114 pub behind: u32,
115}
116
117#[derive(Clone, Debug, Hash, PartialEq, Eq)]
118pub struct CommitSummary {
119 pub sha: SharedString,
120 pub subject: SharedString,
121 /// This is a unix timestamp
122 pub commit_timestamp: i64,
123 pub has_parent: bool,
124}
125
126#[derive(Clone, Debug, Hash, PartialEq, Eq)]
127pub struct CommitDetails {
128 pub sha: SharedString,
129 pub message: SharedString,
130 pub commit_timestamp: i64,
131 pub committer_email: SharedString,
132 pub committer_name: SharedString,
133}
134
135impl CommitDetails {
136 pub fn short_sha(&self) -> SharedString {
137 self.sha[..SHORT_SHA_LENGTH].to_string().into()
138 }
139}
140
141#[derive(Debug, Clone, Hash, PartialEq, Eq)]
142pub struct Remote {
143 pub name: SharedString,
144}
145
146pub enum ResetMode {
147 // reset the branch pointer, leave index and worktree unchanged
148 // (this will make it look like things that were committed are now
149 // staged)
150 Soft,
151 // reset the branch pointer and index, leave worktree unchanged
152 // (this makes it look as though things that were committed are now
153 // unstaged)
154 Mixed,
155}
156
157pub trait GitRepository: Send + Sync {
158 fn reload_index(&self);
159
160 /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
161 ///
162 /// Also returns `None` for symlinks.
163 fn load_index_text(&self, path: RepoPath, cx: AsyncApp) -> BoxFuture<Option<String>>;
164
165 /// 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.
166 ///
167 /// Also returns `None` for symlinks.
168 fn load_committed_text(&self, path: RepoPath, cx: AsyncApp) -> BoxFuture<Option<String>>;
169
170 fn set_index_text(
171 &self,
172 path: RepoPath,
173 content: Option<String>,
174 env: HashMap<String, String>,
175 cx: AsyncApp,
176 ) -> BoxFuture<anyhow::Result<()>>;
177
178 /// Returns the URL of the remote with the given name.
179 fn remote_url(&self, name: &str) -> Option<String>;
180
181 /// Returns the SHA of the current HEAD.
182 fn head_sha(&self) -> Option<String>;
183
184 fn merge_head_shas(&self) -> Vec<String>;
185
186 // Note: this method blocks the current thread!
187 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus>;
188
189 fn branches(&self) -> BoxFuture<Result<Vec<Branch>>>;
190
191 fn change_branch(&self, _: String, _: AsyncApp) -> BoxFuture<Result<()>>;
192 fn create_branch(&self, _: String, _: AsyncApp) -> BoxFuture<Result<()>>;
193
194 fn reset(
195 &self,
196 commit: String,
197 mode: ResetMode,
198 env: HashMap<String, String>,
199 ) -> BoxFuture<Result<()>>;
200
201 fn checkout_files(
202 &self,
203 commit: String,
204 paths: Vec<RepoPath>,
205 env: HashMap<String, String>,
206 ) -> BoxFuture<Result<()>>;
207
208 fn show(&self, commit: String, cx: AsyncApp) -> BoxFuture<Result<CommitDetails>>;
209
210 fn blame(
211 &self,
212 path: RepoPath,
213 content: Rope,
214 cx: &mut AsyncApp,
215 ) -> BoxFuture<Result<crate::blame::Blame>>;
216
217 /// Returns the absolute path to the repository. For worktrees, this will be the path to the
218 /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
219 fn path(&self) -> PathBuf;
220
221 /// Returns the absolute path to the ".git" dir for the main repository, typically a `.git`
222 /// folder. For worktrees, this will be the path to the repository the worktree was created
223 /// from. Otherwise, this is the same value as `path()`.
224 ///
225 /// Git documentation calls this the "commondir", and for git CLI is overridden by
226 /// `GIT_COMMON_DIR`.
227 fn main_repository_path(&self) -> PathBuf;
228
229 /// Updates the index to match the worktree at the given paths.
230 ///
231 /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
232 fn stage_paths(
233 &self,
234 paths: Vec<RepoPath>,
235 env: HashMap<String, String>,
236 cx: AsyncApp,
237 ) -> BoxFuture<Result<()>>;
238 /// Updates the index to match HEAD at the given paths.
239 ///
240 /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
241 fn unstage_paths(
242 &self,
243 paths: Vec<RepoPath>,
244 env: HashMap<String, String>,
245 cx: AsyncApp,
246 ) -> BoxFuture<Result<()>>;
247
248 fn commit(
249 &self,
250 message: SharedString,
251 name_and_email: Option<(SharedString, SharedString)>,
252 env: HashMap<String, String>,
253 cx: AsyncApp,
254 ) -> BoxFuture<Result<()>>;
255
256 fn push(
257 &self,
258 branch_name: String,
259 upstream_name: String,
260 options: Option<PushOptions>,
261 askpass: AskPassSession,
262 env: HashMap<String, String>,
263 cx: AsyncApp,
264 ) -> BoxFuture<Result<RemoteCommandOutput>>;
265
266 fn pull(
267 &self,
268 branch_name: String,
269 upstream_name: String,
270 askpass: AskPassSession,
271 env: HashMap<String, String>,
272 cx: AsyncApp,
273 ) -> BoxFuture<Result<RemoteCommandOutput>>;
274
275 fn fetch(
276 &self,
277 askpass: AskPassSession,
278 env: HashMap<String, String>,
279 cx: AsyncApp,
280 ) -> BoxFuture<Result<RemoteCommandOutput>>;
281
282 fn get_remotes(
283 &self,
284 branch_name: Option<String>,
285 cx: AsyncApp,
286 ) -> BoxFuture<Result<Vec<Remote>>>;
287
288 /// returns a list of remote branches that contain HEAD
289 fn check_for_pushed_commit(&self, cx: AsyncApp) -> BoxFuture<Result<Vec<SharedString>>>;
290
291 /// Run git diff
292 fn diff(&self, diff: DiffType, cx: AsyncApp) -> BoxFuture<Result<String>>;
293
294 /// Creates a checkpoint for the repository.
295 fn checkpoint(&self, cx: AsyncApp) -> BoxFuture<Result<GitRepositoryCheckpoint>>;
296
297 /// Resets to a previously-created checkpoint.
298 fn restore_checkpoint(
299 &self,
300 checkpoint: GitRepositoryCheckpoint,
301 cx: AsyncApp,
302 ) -> BoxFuture<Result<()>>;
303
304 /// Compares two checkpoints, returning true if they are equal
305 fn compare_checkpoints(
306 &self,
307 left: GitRepositoryCheckpoint,
308 right: GitRepositoryCheckpoint,
309 cx: AsyncApp,
310 ) -> BoxFuture<Result<bool>>;
311
312 /// Deletes a previously-created checkpoint.
313 fn delete_checkpoint(
314 &self,
315 checkpoint: GitRepositoryCheckpoint,
316 cx: AsyncApp,
317 ) -> BoxFuture<Result<()>>;
318}
319
320pub enum DiffType {
321 HeadToIndex,
322 HeadToWorktree,
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
326pub enum PushOptions {
327 SetUpstream,
328 Force,
329}
330
331impl std::fmt::Debug for dyn GitRepository {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 f.debug_struct("dyn GitRepository<...>").finish()
334 }
335}
336
337pub struct RealGitRepository {
338 pub repository: Arc<Mutex<git2::Repository>>,
339 pub git_binary_path: PathBuf,
340}
341
342impl RealGitRepository {
343 pub fn new(dotgit_path: &Path, git_binary_path: Option<PathBuf>) -> Option<Self> {
344 let workdir_root = dotgit_path.parent()?;
345 let repository = git2::Repository::open(workdir_root).log_err()?;
346 Some(Self {
347 repository: Arc::new(Mutex::new(repository)),
348 git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
349 })
350 }
351
352 fn working_directory(&self) -> Result<PathBuf> {
353 self.repository
354 .lock()
355 .workdir()
356 .context("failed to read git work directory")
357 .map(Path::to_path_buf)
358 }
359}
360
361#[derive(Clone, Debug)]
362pub struct GitRepositoryCheckpoint {
363 ref_name: String,
364 head_sha: Option<Oid>,
365 commit_sha: Oid,
366}
367
368// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
369const GIT_MODE_SYMLINK: u32 = 0o120000;
370
371impl GitRepository for RealGitRepository {
372 fn reload_index(&self) {
373 if let Ok(mut index) = self.repository.lock().index() {
374 _ = index.read(false);
375 }
376 }
377
378 fn path(&self) -> PathBuf {
379 let repo = self.repository.lock();
380 repo.path().into()
381 }
382
383 fn main_repository_path(&self) -> PathBuf {
384 let repo = self.repository.lock();
385 repo.commondir().into()
386 }
387
388 fn show(&self, commit: String, cx: AsyncApp) -> BoxFuture<Result<CommitDetails>> {
389 let repo = self.repository.clone();
390 cx.background_spawn(async move {
391 let repo = repo.lock();
392 let Ok(commit) = repo.revparse_single(&commit)?.into_commit() else {
393 anyhow::bail!("{} is not a commit", commit);
394 };
395 let details = CommitDetails {
396 sha: commit.id().to_string().into(),
397 message: String::from_utf8_lossy(commit.message_raw_bytes())
398 .to_string()
399 .into(),
400 commit_timestamp: commit.time().seconds(),
401 committer_email: String::from_utf8_lossy(commit.committer().email_bytes())
402 .to_string()
403 .into(),
404 committer_name: String::from_utf8_lossy(commit.committer().name_bytes())
405 .to_string()
406 .into(),
407 };
408 Ok(details)
409 })
410 .boxed()
411 }
412
413 fn reset(
414 &self,
415 commit: String,
416 mode: ResetMode,
417 env: HashMap<String, String>,
418 ) -> BoxFuture<Result<()>> {
419 async move {
420 let working_directory = self.working_directory();
421
422 let mode_flag = match mode {
423 ResetMode::Mixed => "--mixed",
424 ResetMode::Soft => "--soft",
425 };
426
427 let output = new_smol_command(&self.git_binary_path)
428 .envs(env)
429 .current_dir(&working_directory?)
430 .args(["reset", mode_flag, &commit])
431 .output()
432 .await?;
433 if !output.status.success() {
434 return Err(anyhow!(
435 "Failed to reset:\n{}",
436 String::from_utf8_lossy(&output.stderr)
437 ));
438 }
439 Ok(())
440 }
441 .boxed()
442 }
443
444 fn checkout_files(
445 &self,
446 commit: String,
447 paths: Vec<RepoPath>,
448 env: HashMap<String, String>,
449 ) -> BoxFuture<Result<()>> {
450 let working_directory = self.working_directory();
451 let git_binary_path = self.git_binary_path.clone();
452 async move {
453 if paths.is_empty() {
454 return Ok(());
455 }
456
457 let output = new_smol_command(&git_binary_path)
458 .current_dir(&working_directory?)
459 .envs(env)
460 .args(["checkout", &commit, "--"])
461 .args(paths.iter().map(|path| path.as_ref()))
462 .output()
463 .await?;
464 if !output.status.success() {
465 return Err(anyhow!(
466 "Failed to checkout files:\n{}",
467 String::from_utf8_lossy(&output.stderr)
468 ));
469 }
470 Ok(())
471 }
472 .boxed()
473 }
474
475 fn load_index_text(&self, path: RepoPath, cx: AsyncApp) -> BoxFuture<Option<String>> {
476 let repo = self.repository.clone();
477 cx.background_spawn(async move {
478 fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
479 const STAGE_NORMAL: i32 = 0;
480 let index = repo.index()?;
481
482 // This check is required because index.get_path() unwraps internally :(
483 check_path_to_repo_path_errors(path)?;
484
485 let oid = match index.get_path(path, STAGE_NORMAL) {
486 Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
487 _ => return Ok(None),
488 };
489
490 let content = repo.find_blob(oid)?.content().to_owned();
491 Ok(Some(String::from_utf8(content)?))
492 }
493 match logic(&repo.lock(), &path) {
494 Ok(value) => return value,
495 Err(err) => log::error!("Error loading index text: {:?}", err),
496 }
497 None
498 })
499 .boxed()
500 }
501
502 fn load_committed_text(&self, path: RepoPath, cx: AsyncApp) -> BoxFuture<Option<String>> {
503 let repo = self.repository.clone();
504 cx.background_spawn(async move {
505 let repo = repo.lock();
506 let head = repo.head().ok()?.peel_to_tree().log_err()?;
507 let entry = head.get_path(&path).ok()?;
508 if entry.filemode() == i32::from(git2::FileMode::Link) {
509 return None;
510 }
511 let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
512 let content = String::from_utf8(content).log_err()?;
513 Some(content)
514 })
515 .boxed()
516 }
517
518 fn set_index_text(
519 &self,
520 path: RepoPath,
521 content: Option<String>,
522 env: HashMap<String, String>,
523 cx: AsyncApp,
524 ) -> BoxFuture<anyhow::Result<()>> {
525 let working_directory = self.working_directory();
526 let git_binary_path = self.git_binary_path.clone();
527 cx.background_spawn(async move {
528 let working_directory = working_directory?;
529 if let Some(content) = content {
530 let mut child = new_smol_command(&git_binary_path)
531 .current_dir(&working_directory)
532 .envs(&env)
533 .args(["hash-object", "-w", "--stdin"])
534 .stdin(Stdio::piped())
535 .stdout(Stdio::piped())
536 .spawn()?;
537 child
538 .stdin
539 .take()
540 .unwrap()
541 .write_all(content.as_bytes())
542 .await?;
543 let output = child.output().await?.stdout;
544 let sha = String::from_utf8(output)?;
545
546 log::debug!("indexing SHA: {sha}, path {path:?}");
547
548 let output = new_smol_command(&git_binary_path)
549 .current_dir(&working_directory)
550 .envs(env)
551 .args(["update-index", "--add", "--cacheinfo", "100644", &sha])
552 .arg(path.as_ref())
553 .output()
554 .await?;
555
556 if !output.status.success() {
557 return Err(anyhow!(
558 "Failed to stage:\n{}",
559 String::from_utf8_lossy(&output.stderr)
560 ));
561 }
562 } else {
563 let output = new_smol_command(&git_binary_path)
564 .current_dir(&working_directory)
565 .envs(env)
566 .args(["update-index", "--force-remove"])
567 .arg(path.as_ref())
568 .output()
569 .await?;
570
571 if !output.status.success() {
572 return Err(anyhow!(
573 "Failed to unstage:\n{}",
574 String::from_utf8_lossy(&output.stderr)
575 ));
576 }
577 }
578
579 Ok(())
580 })
581 .boxed()
582 }
583
584 fn remote_url(&self, name: &str) -> Option<String> {
585 let repo = self.repository.lock();
586 let remote = repo.find_remote(name).ok()?;
587 remote.url().map(|url| url.to_string())
588 }
589
590 fn head_sha(&self) -> Option<String> {
591 Some(self.repository.lock().head().ok()?.target()?.to_string())
592 }
593
594 fn merge_head_shas(&self) -> Vec<String> {
595 let mut shas = Vec::default();
596 self.repository
597 .lock()
598 .mergehead_foreach(|oid| {
599 shas.push(oid.to_string());
600 true
601 })
602 .ok();
603 if let Some(oid) = self
604 .repository
605 .lock()
606 .find_reference("CHERRY_PICK_HEAD")
607 .ok()
608 .and_then(|reference| reference.target())
609 {
610 shas.push(oid.to_string())
611 }
612 shas
613 }
614
615 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
616 let working_directory = self
617 .repository
618 .lock()
619 .workdir()
620 .context("failed to read git work directory")?
621 .to_path_buf();
622 GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
623 }
624
625 fn branches(&self) -> BoxFuture<Result<Vec<Branch>>> {
626 let working_directory = self.working_directory();
627 let git_binary_path = self.git_binary_path.clone();
628 async move {
629 let fields = [
630 "%(HEAD)",
631 "%(objectname)",
632 "%(parent)",
633 "%(refname)",
634 "%(upstream)",
635 "%(upstream:track)",
636 "%(committerdate:unix)",
637 "%(contents:subject)",
638 ]
639 .join("%00");
640 let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
641 let working_directory = working_directory?;
642 let output = new_smol_command(&git_binary_path)
643 .current_dir(&working_directory)
644 .args(args)
645 .output()
646 .await?;
647
648 if !output.status.success() {
649 return Err(anyhow!(
650 "Failed to git git branches:\n{}",
651 String::from_utf8_lossy(&output.stderr)
652 ));
653 }
654
655 let input = String::from_utf8_lossy(&output.stdout);
656
657 let mut branches = parse_branch_input(&input)?;
658 if branches.is_empty() {
659 let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
660
661 let output = new_smol_command(&git_binary_path)
662 .current_dir(&working_directory)
663 .args(args)
664 .output()
665 .await?;
666
667 // git symbolic-ref returns a non-0 exit code if HEAD points
668 // to something other than a branch
669 if output.status.success() {
670 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
671
672 branches.push(Branch {
673 name: name.into(),
674 is_head: true,
675 upstream: None,
676 most_recent_commit: None,
677 });
678 }
679 }
680
681 Ok(branches)
682 }
683 .boxed()
684 }
685
686 fn change_branch(&self, name: String, cx: AsyncApp) -> BoxFuture<Result<()>> {
687 let repo = self.repository.clone();
688 cx.background_spawn(async move {
689 let repo = repo.lock();
690 let revision = repo.find_branch(&name, BranchType::Local)?;
691 let revision = revision.get();
692 let as_tree = revision.peel_to_tree()?;
693 repo.checkout_tree(as_tree.as_object(), None)?;
694 repo.set_head(
695 revision
696 .name()
697 .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
698 )?;
699 Ok(())
700 })
701 .boxed()
702 }
703
704 fn create_branch(&self, name: String, cx: AsyncApp) -> BoxFuture<Result<()>> {
705 let repo = self.repository.clone();
706 cx.background_spawn(async move {
707 let repo = repo.lock();
708 let current_commit = repo.head()?.peel_to_commit()?;
709 repo.branch(&name, ¤t_commit, false)?;
710 Ok(())
711 })
712 .boxed()
713 }
714
715 fn blame(
716 &self,
717 path: RepoPath,
718 content: Rope,
719 cx: &mut AsyncApp,
720 ) -> BoxFuture<Result<crate::blame::Blame>> {
721 let working_directory = self.working_directory();
722 let git_binary_path = self.git_binary_path.clone();
723
724 const REMOTE_NAME: &str = "origin";
725 let remote_url = self.remote_url(REMOTE_NAME);
726
727 cx.background_spawn(async move {
728 crate::blame::Blame::for_path(
729 &git_binary_path,
730 &working_directory?,
731 &path,
732 &content,
733 remote_url,
734 )
735 .await
736 })
737 .boxed()
738 }
739
740 fn diff(&self, diff: DiffType, cx: AsyncApp) -> BoxFuture<Result<String>> {
741 let working_directory = self.working_directory();
742 let git_binary_path = self.git_binary_path.clone();
743 cx.background_spawn(async move {
744 let args = match diff {
745 DiffType::HeadToIndex => Some("--staged"),
746 DiffType::HeadToWorktree => None,
747 };
748
749 let output = new_smol_command(&git_binary_path)
750 .current_dir(&working_directory?)
751 .args(["diff"])
752 .args(args)
753 .output()
754 .await?;
755
756 if !output.status.success() {
757 return Err(anyhow!(
758 "Failed to run git diff:\n{}",
759 String::from_utf8_lossy(&output.stderr)
760 ));
761 }
762 Ok(String::from_utf8_lossy(&output.stdout).to_string())
763 })
764 .boxed()
765 }
766
767 fn stage_paths(
768 &self,
769 paths: Vec<RepoPath>,
770 env: HashMap<String, String>,
771 cx: AsyncApp,
772 ) -> BoxFuture<Result<()>> {
773 let working_directory = self.working_directory();
774 let git_binary_path = self.git_binary_path.clone();
775 cx.background_spawn(async move {
776 if !paths.is_empty() {
777 let output = new_smol_command(&git_binary_path)
778 .current_dir(&working_directory?)
779 .envs(env)
780 .args(["update-index", "--add", "--remove", "--"])
781 .args(paths.iter().map(|p| p.as_ref()))
782 .output()
783 .await?;
784
785 if !output.status.success() {
786 return Err(anyhow!(
787 "Failed to stage paths:\n{}",
788 String::from_utf8_lossy(&output.stderr)
789 ));
790 }
791 }
792 Ok(())
793 })
794 .boxed()
795 }
796
797 fn unstage_paths(
798 &self,
799 paths: Vec<RepoPath>,
800 env: HashMap<String, String>,
801 cx: AsyncApp,
802 ) -> BoxFuture<Result<()>> {
803 let working_directory = self.working_directory();
804 let git_binary_path = self.git_binary_path.clone();
805
806 cx.background_spawn(async move {
807 if !paths.is_empty() {
808 let output = new_smol_command(&git_binary_path)
809 .current_dir(&working_directory?)
810 .envs(env)
811 .args(["reset", "--quiet", "--"])
812 .args(paths.iter().map(|p| p.as_ref()))
813 .output()
814 .await?;
815
816 if !output.status.success() {
817 return Err(anyhow!(
818 "Failed to unstage:\n{}",
819 String::from_utf8_lossy(&output.stderr)
820 ));
821 }
822 }
823 Ok(())
824 })
825 .boxed()
826 }
827
828 fn commit(
829 &self,
830 message: SharedString,
831 name_and_email: Option<(SharedString, SharedString)>,
832 env: HashMap<String, String>,
833 cx: AsyncApp,
834 ) -> BoxFuture<Result<()>> {
835 let working_directory = self.working_directory();
836 cx.background_spawn(async move {
837 let mut cmd = new_smol_command("git");
838 cmd.current_dir(&working_directory?)
839 .envs(env)
840 .args(["commit", "--quiet", "-m"])
841 .arg(&message.to_string())
842 .arg("--cleanup=strip");
843
844 if let Some((name, email)) = name_and_email {
845 cmd.arg("--author").arg(&format!("{name} <{email}>"));
846 }
847
848 let output = cmd.output().await?;
849
850 if !output.status.success() {
851 return Err(anyhow!(
852 "Failed to commit:\n{}",
853 String::from_utf8_lossy(&output.stderr)
854 ));
855 }
856 Ok(())
857 })
858 .boxed()
859 }
860
861 fn push(
862 &self,
863 branch_name: String,
864 remote_name: String,
865 options: Option<PushOptions>,
866 ask_pass: AskPassSession,
867 env: HashMap<String, String>,
868 // note: git push *must* be started on the main thread for
869 // git-credentials manager to work (hence taking an AsyncApp)
870 _cx: AsyncApp,
871 ) -> BoxFuture<Result<RemoteCommandOutput>> {
872 let working_directory = self.working_directory();
873 async move {
874 let working_directory = working_directory?;
875
876 let mut command = new_smol_command("git");
877 command
878 .envs(env)
879 .env("GIT_ASKPASS", ask_pass.script_path())
880 .env("SSH_ASKPASS", ask_pass.script_path())
881 .env("SSH_ASKPASS_REQUIRE", "force")
882 .env("GIT_HTTP_USER_AGENT", "Zed")
883 .current_dir(&working_directory)
884 .args(["push"])
885 .args(options.map(|option| match option {
886 PushOptions::SetUpstream => "--set-upstream",
887 PushOptions::Force => "--force-with-lease",
888 }))
889 .arg(remote_name)
890 .arg(format!("{}:{}", branch_name, branch_name))
891 .stdin(smol::process::Stdio::null())
892 .stdout(smol::process::Stdio::piped())
893 .stderr(smol::process::Stdio::piped());
894 let git_process = command.spawn()?;
895
896 run_remote_command(ask_pass, git_process).await
897 }
898 .boxed()
899 }
900
901 fn pull(
902 &self,
903 branch_name: String,
904 remote_name: String,
905 ask_pass: AskPassSession,
906 env: HashMap<String, String>,
907 _cx: AsyncApp,
908 ) -> BoxFuture<Result<RemoteCommandOutput>> {
909 let working_directory = self.working_directory();
910 async {
911 let mut command = new_smol_command("git");
912 command
913 .envs(env)
914 .env("GIT_ASKPASS", ask_pass.script_path())
915 .env("SSH_ASKPASS", ask_pass.script_path())
916 .env("SSH_ASKPASS_REQUIRE", "force")
917 .current_dir(&working_directory?)
918 .args(["pull"])
919 .arg(remote_name)
920 .arg(branch_name)
921 .stdout(smol::process::Stdio::piped())
922 .stderr(smol::process::Stdio::piped());
923 let git_process = command.spawn()?;
924
925 run_remote_command(ask_pass, git_process).await
926 }
927 .boxed()
928 }
929
930 fn fetch(
931 &self,
932 ask_pass: AskPassSession,
933 env: HashMap<String, String>,
934 _cx: AsyncApp,
935 ) -> BoxFuture<Result<RemoteCommandOutput>> {
936 let working_directory = self.working_directory();
937 async {
938 let mut command = new_smol_command("git");
939 command
940 .envs(env)
941 .env("GIT_ASKPASS", ask_pass.script_path())
942 .env("SSH_ASKPASS", ask_pass.script_path())
943 .env("SSH_ASKPASS_REQUIRE", "force")
944 .current_dir(&working_directory?)
945 .args(["fetch", "--all"])
946 .stdout(smol::process::Stdio::piped())
947 .stderr(smol::process::Stdio::piped());
948 let git_process = command.spawn()?;
949
950 run_remote_command(ask_pass, git_process).await
951 }
952 .boxed()
953 }
954
955 fn get_remotes(
956 &self,
957 branch_name: Option<String>,
958 cx: AsyncApp,
959 ) -> BoxFuture<Result<Vec<Remote>>> {
960 let working_directory = self.working_directory();
961 let git_binary_path = self.git_binary_path.clone();
962 cx.background_spawn(async move {
963 let working_directory = working_directory?;
964 if let Some(branch_name) = branch_name {
965 let output = new_smol_command(&git_binary_path)
966 .current_dir(&working_directory)
967 .args(["config", "--get"])
968 .arg(format!("branch.{}.remote", branch_name))
969 .output()
970 .await?;
971
972 if output.status.success() {
973 let remote_name = String::from_utf8_lossy(&output.stdout);
974
975 return Ok(vec![Remote {
976 name: remote_name.trim().to_string().into(),
977 }]);
978 }
979 }
980
981 let output = new_smol_command(&git_binary_path)
982 .current_dir(&working_directory)
983 .args(["remote"])
984 .output()
985 .await?;
986
987 if output.status.success() {
988 let remote_names = String::from_utf8_lossy(&output.stdout)
989 .split('\n')
990 .filter(|name| !name.is_empty())
991 .map(|name| Remote {
992 name: name.trim().to_string().into(),
993 })
994 .collect();
995
996 return Ok(remote_names);
997 } else {
998 return Err(anyhow!(
999 "Failed to get remotes:\n{}",
1000 String::from_utf8_lossy(&output.stderr)
1001 ));
1002 }
1003 })
1004 .boxed()
1005 }
1006
1007 fn check_for_pushed_commit(&self, cx: AsyncApp) -> BoxFuture<Result<Vec<SharedString>>> {
1008 let working_directory = self.working_directory();
1009 let git_binary_path = self.git_binary_path.clone();
1010 cx.background_spawn(async move {
1011 let working_directory = working_directory?;
1012 let git_cmd = async |args: &[&str]| -> Result<String> {
1013 let output = new_smol_command(&git_binary_path)
1014 .current_dir(&working_directory)
1015 .args(args)
1016 .output()
1017 .await?;
1018 if output.status.success() {
1019 Ok(String::from_utf8(output.stdout)?)
1020 } else {
1021 Err(anyhow!(String::from_utf8_lossy(&output.stderr).to_string()))
1022 }
1023 };
1024
1025 let head = git_cmd(&["rev-parse", "HEAD"])
1026 .await
1027 .context("Failed to get HEAD")?
1028 .trim()
1029 .to_owned();
1030
1031 let mut remote_branches = vec![];
1032 let mut add_if_matching = async |remote_head: &str| {
1033 if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await {
1034 if merge_base.trim() == head {
1035 if let Some(s) = remote_head.strip_prefix("refs/remotes/") {
1036 remote_branches.push(s.to_owned().into());
1037 }
1038 }
1039 }
1040 };
1041
1042 // check the main branch of each remote
1043 let remotes = git_cmd(&["remote"])
1044 .await
1045 .context("Failed to get remotes")?;
1046 for remote in remotes.lines() {
1047 if let Ok(remote_head) =
1048 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
1049 {
1050 add_if_matching(remote_head.trim()).await;
1051 }
1052 }
1053
1054 // ... and the remote branch that the checked-out one is tracking
1055 if let Ok(remote_head) = git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await {
1056 add_if_matching(remote_head.trim()).await;
1057 }
1058
1059 Ok(remote_branches)
1060 })
1061 .boxed()
1062 }
1063
1064 fn checkpoint(&self, cx: AsyncApp) -> BoxFuture<Result<GitRepositoryCheckpoint>> {
1065 let working_directory = self.working_directory();
1066 let git_binary_path = self.git_binary_path.clone();
1067 let executor = cx.background_executor().clone();
1068 cx.background_spawn(async move {
1069 let working_directory = working_directory?;
1070 let mut git = GitBinary::new(git_binary_path, working_directory, executor)
1071 .envs(checkpoint_author_envs());
1072 git.with_temp_index(async |git| {
1073 let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
1074 git.run(&["add", "--all"]).await?;
1075 let tree = git.run(&["write-tree"]).await?;
1076 let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
1077 git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
1078 .await?
1079 } else {
1080 git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
1081 };
1082 let ref_name = format!("refs/zed/{}", Uuid::new_v4());
1083 git.run(&["update-ref", &ref_name, &checkpoint_sha]).await?;
1084
1085 Ok(GitRepositoryCheckpoint {
1086 ref_name,
1087 head_sha: if let Some(head_sha) = head_sha {
1088 Some(head_sha.parse()?)
1089 } else {
1090 None
1091 },
1092 commit_sha: checkpoint_sha.parse()?,
1093 })
1094 })
1095 .await
1096 })
1097 .boxed()
1098 }
1099
1100 fn restore_checkpoint(
1101 &self,
1102 checkpoint: GitRepositoryCheckpoint,
1103 cx: AsyncApp,
1104 ) -> BoxFuture<Result<()>> {
1105 let working_directory = self.working_directory();
1106 let git_binary_path = self.git_binary_path.clone();
1107
1108 let executor = cx.background_executor().clone();
1109 cx.background_spawn(async move {
1110 let working_directory = working_directory?;
1111
1112 let mut git = GitBinary::new(git_binary_path, working_directory, executor);
1113 git.run(&[
1114 "restore",
1115 "--source",
1116 &checkpoint.commit_sha.to_string(),
1117 "--worktree",
1118 ".",
1119 ])
1120 .await?;
1121
1122 git.with_temp_index(async move |git| {
1123 git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
1124 .await?;
1125 git.run(&["clean", "-d", "--force"]).await
1126 })
1127 .await?;
1128
1129 if let Some(head_sha) = checkpoint.head_sha {
1130 git.run(&["reset", "--mixed", &head_sha.to_string()])
1131 .await?;
1132 } else {
1133 git.run(&["update-ref", "-d", "HEAD"]).await?;
1134 }
1135
1136 Ok(())
1137 })
1138 .boxed()
1139 }
1140
1141 fn compare_checkpoints(
1142 &self,
1143 left: GitRepositoryCheckpoint,
1144 right: GitRepositoryCheckpoint,
1145 cx: AsyncApp,
1146 ) -> BoxFuture<Result<bool>> {
1147 if left.head_sha != right.head_sha {
1148 return future::ready(Ok(false)).boxed();
1149 }
1150
1151 let working_directory = self.working_directory();
1152 let git_binary_path = self.git_binary_path.clone();
1153
1154 let executor = cx.background_executor().clone();
1155 cx.background_spawn(async move {
1156 let working_directory = working_directory?;
1157 let git = GitBinary::new(git_binary_path, working_directory, executor);
1158 let result = git
1159 .run(&[
1160 "diff-tree",
1161 "--quiet",
1162 &left.commit_sha.to_string(),
1163 &right.commit_sha.to_string(),
1164 ])
1165 .await;
1166 match result {
1167 Ok(_) => Ok(true),
1168 Err(error) => {
1169 if let Some(GitBinaryCommandError { status, .. }) =
1170 error.downcast_ref::<GitBinaryCommandError>()
1171 {
1172 if status.code() == Some(1) {
1173 return Ok(false);
1174 }
1175 }
1176
1177 Err(error)
1178 }
1179 }
1180 })
1181 .boxed()
1182 }
1183
1184 fn delete_checkpoint(
1185 &self,
1186 checkpoint: GitRepositoryCheckpoint,
1187 cx: AsyncApp,
1188 ) -> BoxFuture<Result<()>> {
1189 let working_directory = self.working_directory();
1190 let git_binary_path = self.git_binary_path.clone();
1191
1192 let executor = cx.background_executor().clone();
1193 cx.background_spawn(async move {
1194 let working_directory = working_directory?;
1195 let git = GitBinary::new(git_binary_path, working_directory, executor);
1196 git.run(&["update-ref", "-d", &checkpoint.ref_name]).await?;
1197 Ok(())
1198 })
1199 .boxed()
1200 }
1201}
1202
1203struct GitBinary {
1204 git_binary_path: PathBuf,
1205 working_directory: PathBuf,
1206 executor: BackgroundExecutor,
1207 index_file_path: Option<PathBuf>,
1208 envs: HashMap<String, String>,
1209}
1210
1211impl GitBinary {
1212 fn new(
1213 git_binary_path: PathBuf,
1214 working_directory: PathBuf,
1215 executor: BackgroundExecutor,
1216 ) -> Self {
1217 Self {
1218 git_binary_path,
1219 working_directory,
1220 executor,
1221 index_file_path: None,
1222 envs: HashMap::default(),
1223 }
1224 }
1225
1226 fn envs(mut self, envs: HashMap<String, String>) -> Self {
1227 self.envs = envs;
1228 self
1229 }
1230
1231 pub async fn with_temp_index<R>(
1232 &mut self,
1233 f: impl AsyncFnOnce(&Self) -> Result<R>,
1234 ) -> Result<R> {
1235 let index_file_path = self.working_directory.join(".git/index.tmp");
1236
1237 let delete_temp_index = util::defer({
1238 let index_file_path = index_file_path.clone();
1239 let executor = self.executor.clone();
1240 move || {
1241 executor
1242 .spawn(async move {
1243 smol::fs::remove_file(index_file_path).await.log_err();
1244 })
1245 .detach();
1246 }
1247 });
1248
1249 self.index_file_path = Some(index_file_path.clone());
1250 let result = f(self).await;
1251 self.index_file_path = None;
1252 let result = result?;
1253
1254 smol::fs::remove_file(index_file_path).await.ok();
1255 delete_temp_index.abort();
1256
1257 Ok(result)
1258 }
1259
1260 pub async fn run(&self, args: &[&str]) -> Result<String> {
1261 let mut command = new_smol_command(&self.git_binary_path);
1262 command.current_dir(&self.working_directory);
1263 command.args(args);
1264 if let Some(index_file_path) = self.index_file_path.as_ref() {
1265 command.env("GIT_INDEX_FILE", index_file_path);
1266 }
1267 command.envs(&self.envs);
1268 let output = command.output().await?;
1269 if output.status.success() {
1270 anyhow::Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
1271 } else {
1272 Err(anyhow!(GitBinaryCommandError {
1273 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1274 status: output.status,
1275 }))
1276 }
1277 }
1278}
1279
1280#[derive(Error, Debug)]
1281#[error("Git command failed: {stdout}")]
1282struct GitBinaryCommandError {
1283 stdout: String,
1284 status: ExitStatus,
1285}
1286
1287async fn run_remote_command(
1288 mut ask_pass: AskPassSession,
1289 git_process: smol::process::Child,
1290) -> std::result::Result<RemoteCommandOutput, anyhow::Error> {
1291 select_biased! {
1292 result = ask_pass.run().fuse() => {
1293 match result {
1294 AskPassResult::CancelledByUser => {
1295 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
1296 }
1297 AskPassResult::Timedout => {
1298 Err(anyhow!("Connecting to host timed out"))?
1299 }
1300 }
1301 }
1302 output = git_process.output().fuse() => {
1303 let output = output?;
1304 if !output.status.success() {
1305 Err(anyhow!(
1306 "{}",
1307 String::from_utf8_lossy(&output.stderr)
1308 ))
1309 } else {
1310 Ok(RemoteCommandOutput {
1311 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1312 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1313 })
1314 }
1315 }
1316 }
1317}
1318
1319pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1320 LazyLock::new(|| RepoPath(Path::new("").into()));
1321
1322#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1323pub struct RepoPath(pub Arc<Path>);
1324
1325impl RepoPath {
1326 pub fn new(path: PathBuf) -> Self {
1327 debug_assert!(path.is_relative(), "Repo paths must be relative");
1328
1329 RepoPath(path.into())
1330 }
1331
1332 pub fn from_str(path: &str) -> Self {
1333 let path = Path::new(path);
1334 debug_assert!(path.is_relative(), "Repo paths must be relative");
1335
1336 RepoPath(path.into())
1337 }
1338}
1339
1340impl std::fmt::Display for RepoPath {
1341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1342 self.0.to_string_lossy().fmt(f)
1343 }
1344}
1345
1346impl From<&Path> for RepoPath {
1347 fn from(value: &Path) -> Self {
1348 RepoPath::new(value.into())
1349 }
1350}
1351
1352impl From<Arc<Path>> for RepoPath {
1353 fn from(value: Arc<Path>) -> Self {
1354 RepoPath(value)
1355 }
1356}
1357
1358impl From<PathBuf> for RepoPath {
1359 fn from(value: PathBuf) -> Self {
1360 RepoPath::new(value)
1361 }
1362}
1363
1364impl From<&str> for RepoPath {
1365 fn from(value: &str) -> Self {
1366 Self::from_str(value)
1367 }
1368}
1369
1370impl Default for RepoPath {
1371 fn default() -> Self {
1372 RepoPath(Path::new("").into())
1373 }
1374}
1375
1376impl AsRef<Path> for RepoPath {
1377 fn as_ref(&self) -> &Path {
1378 self.0.as_ref()
1379 }
1380}
1381
1382impl std::ops::Deref for RepoPath {
1383 type Target = Path;
1384
1385 fn deref(&self) -> &Self::Target {
1386 &self.0
1387 }
1388}
1389
1390impl Borrow<Path> for RepoPath {
1391 fn borrow(&self) -> &Path {
1392 self.0.as_ref()
1393 }
1394}
1395
1396#[derive(Debug)]
1397pub struct RepoPathDescendants<'a>(pub &'a Path);
1398
1399impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
1400 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1401 if key.starts_with(self.0) {
1402 Ordering::Greater
1403 } else {
1404 self.0.cmp(key)
1405 }
1406 }
1407}
1408
1409fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1410 let mut branches = Vec::new();
1411 for line in input.split('\n') {
1412 if line.is_empty() {
1413 continue;
1414 }
1415 let mut fields = line.split('\x00');
1416 let is_current_branch = fields.next().context("no HEAD")? == "*";
1417 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1418 let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
1419 let ref_name: SharedString = fields
1420 .next()
1421 .context("no refname")?
1422 .strip_prefix("refs/heads/")
1423 .context("unexpected format for refname")?
1424 .to_string()
1425 .into();
1426 let upstream_name = fields.next().context("no upstream")?.to_string();
1427 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1428 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1429 let subject: SharedString = fields
1430 .next()
1431 .context("no contents:subject")?
1432 .to_string()
1433 .into();
1434
1435 branches.push(Branch {
1436 is_head: is_current_branch,
1437 name: ref_name,
1438 most_recent_commit: Some(CommitSummary {
1439 sha: head_sha,
1440 subject,
1441 commit_timestamp: commiterdate,
1442 has_parent: !parent_sha.is_empty(),
1443 }),
1444 upstream: if upstream_name.is_empty() {
1445 None
1446 } else {
1447 Some(Upstream {
1448 ref_name: upstream_name.into(),
1449 tracking: upstream_tracking,
1450 })
1451 },
1452 })
1453 }
1454
1455 Ok(branches)
1456}
1457
1458fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1459 if upstream_track == "" {
1460 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1461 ahead: 0,
1462 behind: 0,
1463 }));
1464 }
1465
1466 let upstream_track = upstream_track
1467 .strip_prefix("[")
1468 .ok_or_else(|| anyhow!("missing ["))?;
1469 let upstream_track = upstream_track
1470 .strip_suffix("]")
1471 .ok_or_else(|| anyhow!("missing ["))?;
1472 let mut ahead: u32 = 0;
1473 let mut behind: u32 = 0;
1474 for component in upstream_track.split(", ") {
1475 if component == "gone" {
1476 return Ok(UpstreamTracking::Gone);
1477 }
1478 if let Some(ahead_num) = component.strip_prefix("ahead ") {
1479 ahead = ahead_num.parse::<u32>()?;
1480 }
1481 if let Some(behind_num) = component.strip_prefix("behind ") {
1482 behind = behind_num.parse::<u32>()?;
1483 }
1484 }
1485 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1486 ahead,
1487 behind,
1488 }))
1489}
1490
1491fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
1492 match relative_file_path.components().next() {
1493 None => anyhow::bail!("repo path should not be empty"),
1494 Some(Component::Prefix(_)) => anyhow::bail!(
1495 "repo path `{}` should be relative, not a windows prefix",
1496 relative_file_path.to_string_lossy()
1497 ),
1498 Some(Component::RootDir) => {
1499 anyhow::bail!(
1500 "repo path `{}` should be relative",
1501 relative_file_path.to_string_lossy()
1502 )
1503 }
1504 Some(Component::CurDir) => {
1505 anyhow::bail!(
1506 "repo path `{}` should not start with `.`",
1507 relative_file_path.to_string_lossy()
1508 )
1509 }
1510 Some(Component::ParentDir) => {
1511 anyhow::bail!(
1512 "repo path `{}` should not start with `..`",
1513 relative_file_path.to_string_lossy()
1514 )
1515 }
1516 _ => Ok(()),
1517 }
1518}
1519
1520fn checkpoint_author_envs() -> HashMap<String, String> {
1521 HashMap::from_iter([
1522 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
1523 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
1524 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
1525 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
1526 ])
1527}
1528
1529#[cfg(test)]
1530mod tests {
1531 use super::*;
1532 use crate::status::FileStatus;
1533 use gpui::TestAppContext;
1534
1535 #[gpui::test]
1536 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
1537 cx.executor().allow_parking();
1538
1539 let repo_dir = tempfile::tempdir().unwrap();
1540
1541 git2::Repository::init(repo_dir.path()).unwrap();
1542 let file_path = repo_dir.path().join("file");
1543 smol::fs::write(&file_path, "initial").await.unwrap();
1544
1545 let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1546 repo.stage_paths(
1547 vec![RepoPath::from_str("file")],
1548 HashMap::default(),
1549 cx.to_async(),
1550 )
1551 .await
1552 .unwrap();
1553 repo.commit(
1554 "Initial commit".into(),
1555 None,
1556 checkpoint_author_envs(),
1557 cx.to_async(),
1558 )
1559 .await
1560 .unwrap();
1561
1562 smol::fs::write(&file_path, "modified before checkpoint")
1563 .await
1564 .unwrap();
1565 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
1566 .await
1567 .unwrap();
1568 let sha_before_checkpoint = repo.head_sha().unwrap();
1569 let checkpoint = repo.checkpoint(cx.to_async()).await.unwrap();
1570
1571 // Ensure the user can't see any branches after creating a checkpoint.
1572 assert_eq!(repo.branches().await.unwrap().len(), 1);
1573
1574 smol::fs::write(&file_path, "modified after checkpoint")
1575 .await
1576 .unwrap();
1577 repo.stage_paths(
1578 vec![RepoPath::from_str("file")],
1579 HashMap::default(),
1580 cx.to_async(),
1581 )
1582 .await
1583 .unwrap();
1584 repo.commit(
1585 "Commit after checkpoint".into(),
1586 None,
1587 checkpoint_author_envs(),
1588 cx.to_async(),
1589 )
1590 .await
1591 .unwrap();
1592
1593 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
1594 .await
1595 .unwrap();
1596 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
1597 .await
1598 .unwrap();
1599
1600 // Ensure checkpoint stays alive even after a Git GC.
1601 repo.gc(cx.to_async()).await.unwrap();
1602 repo.restore_checkpoint(checkpoint.clone(), cx.to_async())
1603 .await
1604 .unwrap();
1605
1606 assert_eq!(repo.head_sha().unwrap(), sha_before_checkpoint);
1607 assert_eq!(
1608 smol::fs::read_to_string(&file_path).await.unwrap(),
1609 "modified before checkpoint"
1610 );
1611 assert_eq!(
1612 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
1613 .await
1614 .unwrap(),
1615 "1"
1616 );
1617 assert_eq!(
1618 smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
1619 .await
1620 .ok(),
1621 None
1622 );
1623
1624 // Garbage collecting after deleting a checkpoint makes it unreachable.
1625 repo.delete_checkpoint(checkpoint.clone(), cx.to_async())
1626 .await
1627 .unwrap();
1628 repo.gc(cx.to_async()).await.unwrap();
1629 repo.restore_checkpoint(checkpoint.clone(), cx.to_async())
1630 .await
1631 .unwrap_err();
1632 }
1633
1634 #[gpui::test]
1635 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
1636 cx.executor().allow_parking();
1637
1638 let repo_dir = tempfile::tempdir().unwrap();
1639 git2::Repository::init(repo_dir.path()).unwrap();
1640 let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1641
1642 smol::fs::write(repo_dir.path().join("foo"), "foo")
1643 .await
1644 .unwrap();
1645 let checkpoint_sha = repo.checkpoint(cx.to_async()).await.unwrap();
1646
1647 // Ensure the user can't see any branches after creating a checkpoint.
1648 assert_eq!(repo.branches().await.unwrap().len(), 1);
1649
1650 smol::fs::write(repo_dir.path().join("foo"), "bar")
1651 .await
1652 .unwrap();
1653 smol::fs::write(repo_dir.path().join("baz"), "qux")
1654 .await
1655 .unwrap();
1656 repo.restore_checkpoint(checkpoint_sha, cx.to_async())
1657 .await
1658 .unwrap();
1659 assert_eq!(
1660 smol::fs::read_to_string(repo_dir.path().join("foo"))
1661 .await
1662 .unwrap(),
1663 "foo"
1664 );
1665 assert_eq!(
1666 smol::fs::read_to_string(repo_dir.path().join("baz"))
1667 .await
1668 .ok(),
1669 None
1670 );
1671 }
1672
1673 #[gpui::test]
1674 async fn test_undoing_commit_via_checkpoint(cx: &mut TestAppContext) {
1675 cx.executor().allow_parking();
1676
1677 let repo_dir = tempfile::tempdir().unwrap();
1678
1679 git2::Repository::init(repo_dir.path()).unwrap();
1680 let file_path = repo_dir.path().join("file");
1681 smol::fs::write(&file_path, "initial").await.unwrap();
1682
1683 let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1684 repo.stage_paths(
1685 vec![RepoPath::from_str("file")],
1686 HashMap::default(),
1687 cx.to_async(),
1688 )
1689 .await
1690 .unwrap();
1691 repo.commit(
1692 "Initial commit".into(),
1693 None,
1694 checkpoint_author_envs(),
1695 cx.to_async(),
1696 )
1697 .await
1698 .unwrap();
1699
1700 let initial_commit_sha = repo.head_sha().unwrap();
1701
1702 smol::fs::write(repo_dir.path().join("new_file1"), "content1")
1703 .await
1704 .unwrap();
1705 smol::fs::write(repo_dir.path().join("new_file2"), "content2")
1706 .await
1707 .unwrap();
1708
1709 let checkpoint = repo.checkpoint(cx.to_async()).await.unwrap();
1710
1711 repo.stage_paths(
1712 vec![
1713 RepoPath::from_str("new_file1"),
1714 RepoPath::from_str("new_file2"),
1715 ],
1716 HashMap::default(),
1717 cx.to_async(),
1718 )
1719 .await
1720 .unwrap();
1721 repo.commit(
1722 "Commit new files".into(),
1723 None,
1724 checkpoint_author_envs(),
1725 cx.to_async(),
1726 )
1727 .await
1728 .unwrap();
1729
1730 repo.restore_checkpoint(checkpoint, cx.to_async())
1731 .await
1732 .unwrap();
1733 assert_eq!(repo.head_sha().unwrap(), initial_commit_sha);
1734 assert_eq!(
1735 smol::fs::read_to_string(repo_dir.path().join("new_file1"))
1736 .await
1737 .unwrap(),
1738 "content1"
1739 );
1740 assert_eq!(
1741 smol::fs::read_to_string(repo_dir.path().join("new_file2"))
1742 .await
1743 .unwrap(),
1744 "content2"
1745 );
1746 assert_eq!(
1747 repo.status(&[]).unwrap().entries.as_ref(),
1748 &[
1749 (RepoPath::from_str("new_file1"), FileStatus::Untracked),
1750 (RepoPath::from_str("new_file2"), FileStatus::Untracked)
1751 ]
1752 );
1753 }
1754
1755 #[gpui::test]
1756 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
1757 cx.executor().allow_parking();
1758
1759 let repo_dir = tempfile::tempdir().unwrap();
1760 git2::Repository::init(repo_dir.path()).unwrap();
1761 let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1762
1763 smol::fs::write(repo_dir.path().join("file1"), "content1")
1764 .await
1765 .unwrap();
1766 let checkpoint1 = repo.checkpoint(cx.to_async()).await.unwrap();
1767
1768 smol::fs::write(repo_dir.path().join("file2"), "content2")
1769 .await
1770 .unwrap();
1771 let checkpoint2 = repo.checkpoint(cx.to_async()).await.unwrap();
1772
1773 assert!(!repo
1774 .compare_checkpoints(checkpoint1, checkpoint2.clone(), cx.to_async())
1775 .await
1776 .unwrap());
1777
1778 let checkpoint3 = repo.checkpoint(cx.to_async()).await.unwrap();
1779 assert!(repo
1780 .compare_checkpoints(checkpoint2, checkpoint3, cx.to_async())
1781 .await
1782 .unwrap());
1783 }
1784
1785 #[test]
1786 fn test_branches_parsing() {
1787 // suppress "help: octal escapes are not supported, `\0` is always null"
1788 #[allow(clippy::octal_escapes)]
1789 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1790 assert_eq!(
1791 parse_branch_input(&input).unwrap(),
1792 vec![Branch {
1793 is_head: true,
1794 name: "zed-patches".into(),
1795 upstream: Some(Upstream {
1796 ref_name: "refs/remotes/origin/zed-patches".into(),
1797 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1798 ahead: 0,
1799 behind: 0
1800 })
1801 }),
1802 most_recent_commit: Some(CommitSummary {
1803 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1804 subject: "generated protobuf".into(),
1805 commit_timestamp: 1733187470,
1806 has_parent: false,
1807 })
1808 }]
1809 )
1810 }
1811
1812 impl RealGitRepository {
1813 /// Force a Git garbage collection on the repository.
1814 fn gc(&self, cx: AsyncApp) -> BoxFuture<Result<()>> {
1815 let working_directory = self.working_directory();
1816 let git_binary_path = self.git_binary_path.clone();
1817 let executor = cx.background_executor().clone();
1818 cx.background_spawn(async move {
1819 let git_binary_path = git_binary_path.clone();
1820 let working_directory = working_directory?;
1821 let git = GitBinary::new(git_binary_path, working_directory, executor);
1822 git.run(&["gc", "--prune=now"]).await?;
1823 Ok(())
1824 })
1825 .boxed()
1826 }
1827 }
1828}