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 // This check is required because index.get_path() unwraps internally :(
480 check_path_to_repo_path_errors(path)?;
481
482 let mut index = repo.index()?;
483 index.read(false)?;
484
485 const STAGE_NORMAL: i32 = 0;
486 let oid = match index.get_path(path, STAGE_NORMAL) {
487 Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
488 _ => return Ok(None),
489 };
490
491 let content = repo.find_blob(oid)?.content().to_owned();
492 Ok(Some(String::from_utf8(content)?))
493 }
494 match logic(&repo.lock(), &path) {
495 Ok(value) => return value,
496 Err(err) => log::error!("Error loading index text: {:?}", err),
497 }
498 None
499 })
500 .boxed()
501 }
502
503 fn load_committed_text(&self, path: RepoPath, cx: AsyncApp) -> BoxFuture<Option<String>> {
504 let repo = self.repository.clone();
505 cx.background_spawn(async move {
506 let repo = repo.lock();
507 let head = repo.head().ok()?.peel_to_tree().log_err()?;
508 let entry = head.get_path(&path).ok()?;
509 if entry.filemode() == i32::from(git2::FileMode::Link) {
510 return None;
511 }
512 let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
513 let content = String::from_utf8(content).log_err()?;
514 Some(content)
515 })
516 .boxed()
517 }
518
519 fn set_index_text(
520 &self,
521 path: RepoPath,
522 content: Option<String>,
523 env: HashMap<String, String>,
524 cx: AsyncApp,
525 ) -> BoxFuture<anyhow::Result<()>> {
526 let working_directory = self.working_directory();
527 let git_binary_path = self.git_binary_path.clone();
528 cx.background_spawn(async move {
529 let working_directory = working_directory?;
530 if let Some(content) = content {
531 let mut child = new_smol_command(&git_binary_path)
532 .current_dir(&working_directory)
533 .envs(&env)
534 .args(["hash-object", "-w", "--stdin"])
535 .stdin(Stdio::piped())
536 .stdout(Stdio::piped())
537 .spawn()?;
538 child
539 .stdin
540 .take()
541 .unwrap()
542 .write_all(content.as_bytes())
543 .await?;
544 let output = child.output().await?.stdout;
545 let sha = String::from_utf8(output)?;
546
547 log::debug!("indexing SHA: {sha}, path {path:?}");
548
549 let output = new_smol_command(&git_binary_path)
550 .current_dir(&working_directory)
551 .envs(env)
552 .args(["update-index", "--add", "--cacheinfo", "100644", &sha])
553 .arg(path.as_ref())
554 .output()
555 .await?;
556
557 if !output.status.success() {
558 return Err(anyhow!(
559 "Failed to stage:\n{}",
560 String::from_utf8_lossy(&output.stderr)
561 ));
562 }
563 } else {
564 let output = new_smol_command(&git_binary_path)
565 .current_dir(&working_directory)
566 .envs(env)
567 .args(["update-index", "--force-remove"])
568 .arg(path.as_ref())
569 .output()
570 .await?;
571
572 if !output.status.success() {
573 return Err(anyhow!(
574 "Failed to unstage:\n{}",
575 String::from_utf8_lossy(&output.stderr)
576 ));
577 }
578 }
579
580 Ok(())
581 })
582 .boxed()
583 }
584
585 fn remote_url(&self, name: &str) -> Option<String> {
586 let repo = self.repository.lock();
587 let remote = repo.find_remote(name).ok()?;
588 remote.url().map(|url| url.to_string())
589 }
590
591 fn head_sha(&self) -> Option<String> {
592 Some(self.repository.lock().head().ok()?.target()?.to_string())
593 }
594
595 fn merge_head_shas(&self) -> Vec<String> {
596 let mut shas = Vec::default();
597 self.repository
598 .lock()
599 .mergehead_foreach(|oid| {
600 shas.push(oid.to_string());
601 true
602 })
603 .ok();
604 if let Some(oid) = self
605 .repository
606 .lock()
607 .find_reference("CHERRY_PICK_HEAD")
608 .ok()
609 .and_then(|reference| reference.target())
610 {
611 shas.push(oid.to_string())
612 }
613 shas
614 }
615
616 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
617 let working_directory = self
618 .repository
619 .lock()
620 .workdir()
621 .context("failed to read git work directory")?
622 .to_path_buf();
623 GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
624 }
625
626 fn branches(&self) -> BoxFuture<Result<Vec<Branch>>> {
627 let working_directory = self.working_directory();
628 let git_binary_path = self.git_binary_path.clone();
629 async move {
630 let fields = [
631 "%(HEAD)",
632 "%(objectname)",
633 "%(parent)",
634 "%(refname)",
635 "%(upstream)",
636 "%(upstream:track)",
637 "%(committerdate:unix)",
638 "%(contents:subject)",
639 ]
640 .join("%00");
641 let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
642 let working_directory = working_directory?;
643 let output = new_smol_command(&git_binary_path)
644 .current_dir(&working_directory)
645 .args(args)
646 .output()
647 .await?;
648
649 if !output.status.success() {
650 return Err(anyhow!(
651 "Failed to git git branches:\n{}",
652 String::from_utf8_lossy(&output.stderr)
653 ));
654 }
655
656 let input = String::from_utf8_lossy(&output.stdout);
657
658 let mut branches = parse_branch_input(&input)?;
659 if branches.is_empty() {
660 let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
661
662 let output = new_smol_command(&git_binary_path)
663 .current_dir(&working_directory)
664 .args(args)
665 .output()
666 .await?;
667
668 // git symbolic-ref returns a non-0 exit code if HEAD points
669 // to something other than a branch
670 if output.status.success() {
671 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
672
673 branches.push(Branch {
674 name: name.into(),
675 is_head: true,
676 upstream: None,
677 most_recent_commit: None,
678 });
679 }
680 }
681
682 Ok(branches)
683 }
684 .boxed()
685 }
686
687 fn change_branch(&self, name: String, cx: AsyncApp) -> BoxFuture<Result<()>> {
688 let repo = self.repository.clone();
689 cx.background_spawn(async move {
690 let repo = repo.lock();
691 let revision = repo.find_branch(&name, BranchType::Local)?;
692 let revision = revision.get();
693 let as_tree = revision.peel_to_tree()?;
694 repo.checkout_tree(as_tree.as_object(), None)?;
695 repo.set_head(
696 revision
697 .name()
698 .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
699 )?;
700 Ok(())
701 })
702 .boxed()
703 }
704
705 fn create_branch(&self, name: String, cx: AsyncApp) -> BoxFuture<Result<()>> {
706 let repo = self.repository.clone();
707 cx.background_spawn(async move {
708 let repo = repo.lock();
709 let current_commit = repo.head()?.peel_to_commit()?;
710 repo.branch(&name, ¤t_commit, false)?;
711 Ok(())
712 })
713 .boxed()
714 }
715
716 fn blame(
717 &self,
718 path: RepoPath,
719 content: Rope,
720 cx: &mut AsyncApp,
721 ) -> BoxFuture<Result<crate::blame::Blame>> {
722 let working_directory = self.working_directory();
723 let git_binary_path = self.git_binary_path.clone();
724
725 const REMOTE_NAME: &str = "origin";
726 let remote_url = self.remote_url(REMOTE_NAME);
727
728 cx.background_spawn(async move {
729 crate::blame::Blame::for_path(
730 &git_binary_path,
731 &working_directory?,
732 &path,
733 &content,
734 remote_url,
735 )
736 .await
737 })
738 .boxed()
739 }
740
741 fn diff(&self, diff: DiffType, cx: AsyncApp) -> BoxFuture<Result<String>> {
742 let working_directory = self.working_directory();
743 let git_binary_path = self.git_binary_path.clone();
744 cx.background_spawn(async move {
745 let args = match diff {
746 DiffType::HeadToIndex => Some("--staged"),
747 DiffType::HeadToWorktree => None,
748 };
749
750 let output = new_smol_command(&git_binary_path)
751 .current_dir(&working_directory?)
752 .args(["diff"])
753 .args(args)
754 .output()
755 .await?;
756
757 if !output.status.success() {
758 return Err(anyhow!(
759 "Failed to run git diff:\n{}",
760 String::from_utf8_lossy(&output.stderr)
761 ));
762 }
763 Ok(String::from_utf8_lossy(&output.stdout).to_string())
764 })
765 .boxed()
766 }
767
768 fn stage_paths(
769 &self,
770 paths: Vec<RepoPath>,
771 env: HashMap<String, String>,
772 cx: AsyncApp,
773 ) -> BoxFuture<Result<()>> {
774 let working_directory = self.working_directory();
775 let git_binary_path = self.git_binary_path.clone();
776 cx.background_spawn(async move {
777 if !paths.is_empty() {
778 let output = new_smol_command(&git_binary_path)
779 .current_dir(&working_directory?)
780 .envs(env)
781 .args(["update-index", "--add", "--remove", "--"])
782 .args(paths.iter().map(|p| p.as_ref()))
783 .output()
784 .await?;
785
786 if !output.status.success() {
787 return Err(anyhow!(
788 "Failed to stage paths:\n{}",
789 String::from_utf8_lossy(&output.stderr)
790 ));
791 }
792 }
793 Ok(())
794 })
795 .boxed()
796 }
797
798 fn unstage_paths(
799 &self,
800 paths: Vec<RepoPath>,
801 env: HashMap<String, String>,
802 cx: AsyncApp,
803 ) -> BoxFuture<Result<()>> {
804 let working_directory = self.working_directory();
805 let git_binary_path = self.git_binary_path.clone();
806
807 cx.background_spawn(async move {
808 if !paths.is_empty() {
809 let output = new_smol_command(&git_binary_path)
810 .current_dir(&working_directory?)
811 .envs(env)
812 .args(["reset", "--quiet", "--"])
813 .args(paths.iter().map(|p| p.as_ref()))
814 .output()
815 .await?;
816
817 if !output.status.success() {
818 return Err(anyhow!(
819 "Failed to unstage:\n{}",
820 String::from_utf8_lossy(&output.stderr)
821 ));
822 }
823 }
824 Ok(())
825 })
826 .boxed()
827 }
828
829 fn commit(
830 &self,
831 message: SharedString,
832 name_and_email: Option<(SharedString, SharedString)>,
833 env: HashMap<String, String>,
834 cx: AsyncApp,
835 ) -> BoxFuture<Result<()>> {
836 let working_directory = self.working_directory();
837 cx.background_spawn(async move {
838 let mut cmd = new_smol_command("git");
839 cmd.current_dir(&working_directory?)
840 .envs(env)
841 .args(["commit", "--quiet", "-m"])
842 .arg(&message.to_string())
843 .arg("--cleanup=strip");
844
845 if let Some((name, email)) = name_and_email {
846 cmd.arg("--author").arg(&format!("{name} <{email}>"));
847 }
848
849 let output = cmd.output().await?;
850
851 if !output.status.success() {
852 return Err(anyhow!(
853 "Failed to commit:\n{}",
854 String::from_utf8_lossy(&output.stderr)
855 ));
856 }
857 Ok(())
858 })
859 .boxed()
860 }
861
862 fn push(
863 &self,
864 branch_name: String,
865 remote_name: String,
866 options: Option<PushOptions>,
867 ask_pass: AskPassSession,
868 env: HashMap<String, String>,
869 // note: git push *must* be started on the main thread for
870 // git-credentials manager to work (hence taking an AsyncApp)
871 _cx: AsyncApp,
872 ) -> BoxFuture<Result<RemoteCommandOutput>> {
873 let working_directory = self.working_directory();
874 async move {
875 let working_directory = working_directory?;
876
877 let mut command = new_smol_command("git");
878 command
879 .envs(env)
880 .env("GIT_ASKPASS", ask_pass.script_path())
881 .env("SSH_ASKPASS", ask_pass.script_path())
882 .env("SSH_ASKPASS_REQUIRE", "force")
883 .env("GIT_HTTP_USER_AGENT", "Zed")
884 .current_dir(&working_directory)
885 .args(["push"])
886 .args(options.map(|option| match option {
887 PushOptions::SetUpstream => "--set-upstream",
888 PushOptions::Force => "--force-with-lease",
889 }))
890 .arg(remote_name)
891 .arg(format!("{}:{}", branch_name, branch_name))
892 .stdin(smol::process::Stdio::null())
893 .stdout(smol::process::Stdio::piped())
894 .stderr(smol::process::Stdio::piped());
895 let git_process = command.spawn()?;
896
897 run_remote_command(ask_pass, git_process).await
898 }
899 .boxed()
900 }
901
902 fn pull(
903 &self,
904 branch_name: String,
905 remote_name: String,
906 ask_pass: AskPassSession,
907 env: HashMap<String, String>,
908 _cx: AsyncApp,
909 ) -> BoxFuture<Result<RemoteCommandOutput>> {
910 let working_directory = self.working_directory();
911 async {
912 let mut command = new_smol_command("git");
913 command
914 .envs(env)
915 .env("GIT_ASKPASS", ask_pass.script_path())
916 .env("SSH_ASKPASS", ask_pass.script_path())
917 .env("SSH_ASKPASS_REQUIRE", "force")
918 .current_dir(&working_directory?)
919 .args(["pull"])
920 .arg(remote_name)
921 .arg(branch_name)
922 .stdout(smol::process::Stdio::piped())
923 .stderr(smol::process::Stdio::piped());
924 let git_process = command.spawn()?;
925
926 run_remote_command(ask_pass, git_process).await
927 }
928 .boxed()
929 }
930
931 fn fetch(
932 &self,
933 ask_pass: AskPassSession,
934 env: HashMap<String, String>,
935 _cx: AsyncApp,
936 ) -> BoxFuture<Result<RemoteCommandOutput>> {
937 let working_directory = self.working_directory();
938 async {
939 let mut command = new_smol_command("git");
940 command
941 .envs(env)
942 .env("GIT_ASKPASS", ask_pass.script_path())
943 .env("SSH_ASKPASS", ask_pass.script_path())
944 .env("SSH_ASKPASS_REQUIRE", "force")
945 .current_dir(&working_directory?)
946 .args(["fetch", "--all"])
947 .stdout(smol::process::Stdio::piped())
948 .stderr(smol::process::Stdio::piped());
949 let git_process = command.spawn()?;
950
951 run_remote_command(ask_pass, git_process).await
952 }
953 .boxed()
954 }
955
956 fn get_remotes(
957 &self,
958 branch_name: Option<String>,
959 cx: AsyncApp,
960 ) -> BoxFuture<Result<Vec<Remote>>> {
961 let working_directory = self.working_directory();
962 let git_binary_path = self.git_binary_path.clone();
963 cx.background_spawn(async move {
964 let working_directory = working_directory?;
965 if let Some(branch_name) = branch_name {
966 let output = new_smol_command(&git_binary_path)
967 .current_dir(&working_directory)
968 .args(["config", "--get"])
969 .arg(format!("branch.{}.remote", branch_name))
970 .output()
971 .await?;
972
973 if output.status.success() {
974 let remote_name = String::from_utf8_lossy(&output.stdout);
975
976 return Ok(vec![Remote {
977 name: remote_name.trim().to_string().into(),
978 }]);
979 }
980 }
981
982 let output = new_smol_command(&git_binary_path)
983 .current_dir(&working_directory)
984 .args(["remote"])
985 .output()
986 .await?;
987
988 if output.status.success() {
989 let remote_names = String::from_utf8_lossy(&output.stdout)
990 .split('\n')
991 .filter(|name| !name.is_empty())
992 .map(|name| Remote {
993 name: name.trim().to_string().into(),
994 })
995 .collect();
996
997 return Ok(remote_names);
998 } else {
999 return Err(anyhow!(
1000 "Failed to get remotes:\n{}",
1001 String::from_utf8_lossy(&output.stderr)
1002 ));
1003 }
1004 })
1005 .boxed()
1006 }
1007
1008 fn check_for_pushed_commit(&self, cx: AsyncApp) -> BoxFuture<Result<Vec<SharedString>>> {
1009 let working_directory = self.working_directory();
1010 let git_binary_path = self.git_binary_path.clone();
1011 cx.background_spawn(async move {
1012 let working_directory = working_directory?;
1013 let git_cmd = async |args: &[&str]| -> Result<String> {
1014 let output = new_smol_command(&git_binary_path)
1015 .current_dir(&working_directory)
1016 .args(args)
1017 .output()
1018 .await?;
1019 if output.status.success() {
1020 Ok(String::from_utf8(output.stdout)?)
1021 } else {
1022 Err(anyhow!(String::from_utf8_lossy(&output.stderr).to_string()))
1023 }
1024 };
1025
1026 let head = git_cmd(&["rev-parse", "HEAD"])
1027 .await
1028 .context("Failed to get HEAD")?
1029 .trim()
1030 .to_owned();
1031
1032 let mut remote_branches = vec![];
1033 let mut add_if_matching = async |remote_head: &str| {
1034 if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await {
1035 if merge_base.trim() == head {
1036 if let Some(s) = remote_head.strip_prefix("refs/remotes/") {
1037 remote_branches.push(s.to_owned().into());
1038 }
1039 }
1040 }
1041 };
1042
1043 // check the main branch of each remote
1044 let remotes = git_cmd(&["remote"])
1045 .await
1046 .context("Failed to get remotes")?;
1047 for remote in remotes.lines() {
1048 if let Ok(remote_head) =
1049 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
1050 {
1051 add_if_matching(remote_head.trim()).await;
1052 }
1053 }
1054
1055 // ... and the remote branch that the checked-out one is tracking
1056 if let Ok(remote_head) = git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await {
1057 add_if_matching(remote_head.trim()).await;
1058 }
1059
1060 Ok(remote_branches)
1061 })
1062 .boxed()
1063 }
1064
1065 fn checkpoint(&self, cx: AsyncApp) -> BoxFuture<Result<GitRepositoryCheckpoint>> {
1066 let working_directory = self.working_directory();
1067 let git_binary_path = self.git_binary_path.clone();
1068 let executor = cx.background_executor().clone();
1069 cx.background_spawn(async move {
1070 let working_directory = working_directory?;
1071 let mut git = GitBinary::new(git_binary_path, working_directory, executor)
1072 .envs(checkpoint_author_envs());
1073 git.with_temp_index(async |git| {
1074 let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
1075 git.run(&["add", "--all"]).await?;
1076 let tree = git.run(&["write-tree"]).await?;
1077 let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
1078 git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
1079 .await?
1080 } else {
1081 git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
1082 };
1083 let ref_name = format!("refs/zed/{}", Uuid::new_v4());
1084 git.run(&["update-ref", &ref_name, &checkpoint_sha]).await?;
1085
1086 Ok(GitRepositoryCheckpoint {
1087 ref_name,
1088 head_sha: if let Some(head_sha) = head_sha {
1089 Some(head_sha.parse()?)
1090 } else {
1091 None
1092 },
1093 commit_sha: checkpoint_sha.parse()?,
1094 })
1095 })
1096 .await
1097 })
1098 .boxed()
1099 }
1100
1101 fn restore_checkpoint(
1102 &self,
1103 checkpoint: GitRepositoryCheckpoint,
1104 cx: AsyncApp,
1105 ) -> BoxFuture<Result<()>> {
1106 let working_directory = self.working_directory();
1107 let git_binary_path = self.git_binary_path.clone();
1108
1109 let executor = cx.background_executor().clone();
1110 cx.background_spawn(async move {
1111 let working_directory = working_directory?;
1112
1113 let mut git = GitBinary::new(git_binary_path, working_directory, executor);
1114 git.run(&[
1115 "restore",
1116 "--source",
1117 &checkpoint.commit_sha.to_string(),
1118 "--worktree",
1119 ".",
1120 ])
1121 .await?;
1122
1123 git.with_temp_index(async move |git| {
1124 git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
1125 .await?;
1126 git.run(&["clean", "-d", "--force"]).await
1127 })
1128 .await?;
1129
1130 if let Some(head_sha) = checkpoint.head_sha {
1131 git.run(&["reset", "--mixed", &head_sha.to_string()])
1132 .await?;
1133 } else {
1134 git.run(&["update-ref", "-d", "HEAD"]).await?;
1135 }
1136
1137 Ok(())
1138 })
1139 .boxed()
1140 }
1141
1142 fn compare_checkpoints(
1143 &self,
1144 left: GitRepositoryCheckpoint,
1145 right: GitRepositoryCheckpoint,
1146 cx: AsyncApp,
1147 ) -> BoxFuture<Result<bool>> {
1148 if left.head_sha != right.head_sha {
1149 return future::ready(Ok(false)).boxed();
1150 }
1151
1152 let working_directory = self.working_directory();
1153 let git_binary_path = self.git_binary_path.clone();
1154
1155 let executor = cx.background_executor().clone();
1156 cx.background_spawn(async move {
1157 let working_directory = working_directory?;
1158 let git = GitBinary::new(git_binary_path, working_directory, executor);
1159 let result = git
1160 .run(&[
1161 "diff-tree",
1162 "--quiet",
1163 &left.commit_sha.to_string(),
1164 &right.commit_sha.to_string(),
1165 ])
1166 .await;
1167 match result {
1168 Ok(_) => Ok(true),
1169 Err(error) => {
1170 if let Some(GitBinaryCommandError { status, .. }) =
1171 error.downcast_ref::<GitBinaryCommandError>()
1172 {
1173 if status.code() == Some(1) {
1174 return Ok(false);
1175 }
1176 }
1177
1178 Err(error)
1179 }
1180 }
1181 })
1182 .boxed()
1183 }
1184
1185 fn delete_checkpoint(
1186 &self,
1187 checkpoint: GitRepositoryCheckpoint,
1188 cx: AsyncApp,
1189 ) -> BoxFuture<Result<()>> {
1190 let working_directory = self.working_directory();
1191 let git_binary_path = self.git_binary_path.clone();
1192
1193 let executor = cx.background_executor().clone();
1194 cx.background_spawn(async move {
1195 let working_directory = working_directory?;
1196 let git = GitBinary::new(git_binary_path, working_directory, executor);
1197 git.run(&["update-ref", "-d", &checkpoint.ref_name]).await?;
1198 Ok(())
1199 })
1200 .boxed()
1201 }
1202}
1203
1204struct GitBinary {
1205 git_binary_path: PathBuf,
1206 working_directory: PathBuf,
1207 executor: BackgroundExecutor,
1208 index_file_path: Option<PathBuf>,
1209 envs: HashMap<String, String>,
1210}
1211
1212impl GitBinary {
1213 fn new(
1214 git_binary_path: PathBuf,
1215 working_directory: PathBuf,
1216 executor: BackgroundExecutor,
1217 ) -> Self {
1218 Self {
1219 git_binary_path,
1220 working_directory,
1221 executor,
1222 index_file_path: None,
1223 envs: HashMap::default(),
1224 }
1225 }
1226
1227 fn envs(mut self, envs: HashMap<String, String>) -> Self {
1228 self.envs = envs;
1229 self
1230 }
1231
1232 pub async fn with_temp_index<R>(
1233 &mut self,
1234 f: impl AsyncFnOnce(&Self) -> Result<R>,
1235 ) -> Result<R> {
1236 let index_file_path = self.working_directory.join(".git/index.tmp");
1237
1238 let delete_temp_index = util::defer({
1239 let index_file_path = index_file_path.clone();
1240 let executor = self.executor.clone();
1241 move || {
1242 executor
1243 .spawn(async move {
1244 smol::fs::remove_file(index_file_path).await.log_err();
1245 })
1246 .detach();
1247 }
1248 });
1249
1250 self.index_file_path = Some(index_file_path.clone());
1251 let result = f(self).await;
1252 self.index_file_path = None;
1253 let result = result?;
1254
1255 smol::fs::remove_file(index_file_path).await.ok();
1256 delete_temp_index.abort();
1257
1258 Ok(result)
1259 }
1260
1261 pub async fn run(&self, args: &[&str]) -> Result<String> {
1262 let mut command = new_smol_command(&self.git_binary_path);
1263 command.current_dir(&self.working_directory);
1264 command.args(args);
1265 if let Some(index_file_path) = self.index_file_path.as_ref() {
1266 command.env("GIT_INDEX_FILE", index_file_path);
1267 }
1268 command.envs(&self.envs);
1269 let output = command.output().await?;
1270 if output.status.success() {
1271 anyhow::Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
1272 } else {
1273 Err(anyhow!(GitBinaryCommandError {
1274 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1275 status: output.status,
1276 }))
1277 }
1278 }
1279}
1280
1281#[derive(Error, Debug)]
1282#[error("Git command failed: {stdout}")]
1283struct GitBinaryCommandError {
1284 stdout: String,
1285 status: ExitStatus,
1286}
1287
1288async fn run_remote_command(
1289 mut ask_pass: AskPassSession,
1290 git_process: smol::process::Child,
1291) -> std::result::Result<RemoteCommandOutput, anyhow::Error> {
1292 select_biased! {
1293 result = ask_pass.run().fuse() => {
1294 match result {
1295 AskPassResult::CancelledByUser => {
1296 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
1297 }
1298 AskPassResult::Timedout => {
1299 Err(anyhow!("Connecting to host timed out"))?
1300 }
1301 }
1302 }
1303 output = git_process.output().fuse() => {
1304 let output = output?;
1305 if !output.status.success() {
1306 Err(anyhow!(
1307 "{}",
1308 String::from_utf8_lossy(&output.stderr)
1309 ))
1310 } else {
1311 Ok(RemoteCommandOutput {
1312 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1313 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1314 })
1315 }
1316 }
1317 }
1318}
1319
1320pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1321 LazyLock::new(|| RepoPath(Path::new("").into()));
1322
1323#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1324pub struct RepoPath(pub Arc<Path>);
1325
1326impl RepoPath {
1327 pub fn new(path: PathBuf) -> Self {
1328 debug_assert!(path.is_relative(), "Repo paths must be relative");
1329
1330 RepoPath(path.into())
1331 }
1332
1333 pub fn from_str(path: &str) -> Self {
1334 let path = Path::new(path);
1335 debug_assert!(path.is_relative(), "Repo paths must be relative");
1336
1337 RepoPath(path.into())
1338 }
1339}
1340
1341impl std::fmt::Display for RepoPath {
1342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1343 self.0.to_string_lossy().fmt(f)
1344 }
1345}
1346
1347impl From<&Path> for RepoPath {
1348 fn from(value: &Path) -> Self {
1349 RepoPath::new(value.into())
1350 }
1351}
1352
1353impl From<Arc<Path>> for RepoPath {
1354 fn from(value: Arc<Path>) -> Self {
1355 RepoPath(value)
1356 }
1357}
1358
1359impl From<PathBuf> for RepoPath {
1360 fn from(value: PathBuf) -> Self {
1361 RepoPath::new(value)
1362 }
1363}
1364
1365impl From<&str> for RepoPath {
1366 fn from(value: &str) -> Self {
1367 Self::from_str(value)
1368 }
1369}
1370
1371impl Default for RepoPath {
1372 fn default() -> Self {
1373 RepoPath(Path::new("").into())
1374 }
1375}
1376
1377impl AsRef<Path> for RepoPath {
1378 fn as_ref(&self) -> &Path {
1379 self.0.as_ref()
1380 }
1381}
1382
1383impl std::ops::Deref for RepoPath {
1384 type Target = Path;
1385
1386 fn deref(&self) -> &Self::Target {
1387 &self.0
1388 }
1389}
1390
1391impl Borrow<Path> for RepoPath {
1392 fn borrow(&self) -> &Path {
1393 self.0.as_ref()
1394 }
1395}
1396
1397#[derive(Debug)]
1398pub struct RepoPathDescendants<'a>(pub &'a Path);
1399
1400impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
1401 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1402 if key.starts_with(self.0) {
1403 Ordering::Greater
1404 } else {
1405 self.0.cmp(key)
1406 }
1407 }
1408}
1409
1410fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1411 let mut branches = Vec::new();
1412 for line in input.split('\n') {
1413 if line.is_empty() {
1414 continue;
1415 }
1416 let mut fields = line.split('\x00');
1417 let is_current_branch = fields.next().context("no HEAD")? == "*";
1418 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1419 let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
1420 let ref_name: SharedString = fields
1421 .next()
1422 .context("no refname")?
1423 .strip_prefix("refs/heads/")
1424 .context("unexpected format for refname")?
1425 .to_string()
1426 .into();
1427 let upstream_name = fields.next().context("no upstream")?.to_string();
1428 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1429 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1430 let subject: SharedString = fields
1431 .next()
1432 .context("no contents:subject")?
1433 .to_string()
1434 .into();
1435
1436 branches.push(Branch {
1437 is_head: is_current_branch,
1438 name: ref_name,
1439 most_recent_commit: Some(CommitSummary {
1440 sha: head_sha,
1441 subject,
1442 commit_timestamp: commiterdate,
1443 has_parent: !parent_sha.is_empty(),
1444 }),
1445 upstream: if upstream_name.is_empty() {
1446 None
1447 } else {
1448 Some(Upstream {
1449 ref_name: upstream_name.into(),
1450 tracking: upstream_tracking,
1451 })
1452 },
1453 })
1454 }
1455
1456 Ok(branches)
1457}
1458
1459fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1460 if upstream_track == "" {
1461 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1462 ahead: 0,
1463 behind: 0,
1464 }));
1465 }
1466
1467 let upstream_track = upstream_track
1468 .strip_prefix("[")
1469 .ok_or_else(|| anyhow!("missing ["))?;
1470 let upstream_track = upstream_track
1471 .strip_suffix("]")
1472 .ok_or_else(|| anyhow!("missing ["))?;
1473 let mut ahead: u32 = 0;
1474 let mut behind: u32 = 0;
1475 for component in upstream_track.split(", ") {
1476 if component == "gone" {
1477 return Ok(UpstreamTracking::Gone);
1478 }
1479 if let Some(ahead_num) = component.strip_prefix("ahead ") {
1480 ahead = ahead_num.parse::<u32>()?;
1481 }
1482 if let Some(behind_num) = component.strip_prefix("behind ") {
1483 behind = behind_num.parse::<u32>()?;
1484 }
1485 }
1486 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1487 ahead,
1488 behind,
1489 }))
1490}
1491
1492fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
1493 match relative_file_path.components().next() {
1494 None => anyhow::bail!("repo path should not be empty"),
1495 Some(Component::Prefix(_)) => anyhow::bail!(
1496 "repo path `{}` should be relative, not a windows prefix",
1497 relative_file_path.to_string_lossy()
1498 ),
1499 Some(Component::RootDir) => {
1500 anyhow::bail!(
1501 "repo path `{}` should be relative",
1502 relative_file_path.to_string_lossy()
1503 )
1504 }
1505 Some(Component::CurDir) => {
1506 anyhow::bail!(
1507 "repo path `{}` should not start with `.`",
1508 relative_file_path.to_string_lossy()
1509 )
1510 }
1511 Some(Component::ParentDir) => {
1512 anyhow::bail!(
1513 "repo path `{}` should not start with `..`",
1514 relative_file_path.to_string_lossy()
1515 )
1516 }
1517 _ => Ok(()),
1518 }
1519}
1520
1521fn checkpoint_author_envs() -> HashMap<String, String> {
1522 HashMap::from_iter([
1523 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
1524 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
1525 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
1526 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
1527 ])
1528}
1529
1530#[cfg(test)]
1531mod tests {
1532 use super::*;
1533 use crate::status::FileStatus;
1534 use gpui::TestAppContext;
1535
1536 #[gpui::test]
1537 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
1538 cx.executor().allow_parking();
1539
1540 let repo_dir = tempfile::tempdir().unwrap();
1541
1542 git2::Repository::init(repo_dir.path()).unwrap();
1543 let file_path = repo_dir.path().join("file");
1544 smol::fs::write(&file_path, "initial").await.unwrap();
1545
1546 let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1547 repo.stage_paths(
1548 vec![RepoPath::from_str("file")],
1549 HashMap::default(),
1550 cx.to_async(),
1551 )
1552 .await
1553 .unwrap();
1554 repo.commit(
1555 "Initial commit".into(),
1556 None,
1557 checkpoint_author_envs(),
1558 cx.to_async(),
1559 )
1560 .await
1561 .unwrap();
1562
1563 smol::fs::write(&file_path, "modified before checkpoint")
1564 .await
1565 .unwrap();
1566 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
1567 .await
1568 .unwrap();
1569 let sha_before_checkpoint = repo.head_sha().unwrap();
1570 let checkpoint = repo.checkpoint(cx.to_async()).await.unwrap();
1571
1572 // Ensure the user can't see any branches after creating a checkpoint.
1573 assert_eq!(repo.branches().await.unwrap().len(), 1);
1574
1575 smol::fs::write(&file_path, "modified after checkpoint")
1576 .await
1577 .unwrap();
1578 repo.stage_paths(
1579 vec![RepoPath::from_str("file")],
1580 HashMap::default(),
1581 cx.to_async(),
1582 )
1583 .await
1584 .unwrap();
1585 repo.commit(
1586 "Commit after checkpoint".into(),
1587 None,
1588 checkpoint_author_envs(),
1589 cx.to_async(),
1590 )
1591 .await
1592 .unwrap();
1593
1594 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
1595 .await
1596 .unwrap();
1597 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
1598 .await
1599 .unwrap();
1600
1601 // Ensure checkpoint stays alive even after a Git GC.
1602 repo.gc(cx.to_async()).await.unwrap();
1603 repo.restore_checkpoint(checkpoint.clone(), cx.to_async())
1604 .await
1605 .unwrap();
1606
1607 assert_eq!(repo.head_sha().unwrap(), sha_before_checkpoint);
1608 assert_eq!(
1609 smol::fs::read_to_string(&file_path).await.unwrap(),
1610 "modified before checkpoint"
1611 );
1612 assert_eq!(
1613 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
1614 .await
1615 .unwrap(),
1616 "1"
1617 );
1618 assert_eq!(
1619 smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
1620 .await
1621 .ok(),
1622 None
1623 );
1624
1625 // Garbage collecting after deleting a checkpoint makes it unreachable.
1626 repo.delete_checkpoint(checkpoint.clone(), cx.to_async())
1627 .await
1628 .unwrap();
1629 repo.gc(cx.to_async()).await.unwrap();
1630 repo.restore_checkpoint(checkpoint.clone(), cx.to_async())
1631 .await
1632 .unwrap_err();
1633 }
1634
1635 #[gpui::test]
1636 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
1637 cx.executor().allow_parking();
1638
1639 let repo_dir = tempfile::tempdir().unwrap();
1640 git2::Repository::init(repo_dir.path()).unwrap();
1641 let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1642
1643 smol::fs::write(repo_dir.path().join("foo"), "foo")
1644 .await
1645 .unwrap();
1646 let checkpoint_sha = repo.checkpoint(cx.to_async()).await.unwrap();
1647
1648 // Ensure the user can't see any branches after creating a checkpoint.
1649 assert_eq!(repo.branches().await.unwrap().len(), 1);
1650
1651 smol::fs::write(repo_dir.path().join("foo"), "bar")
1652 .await
1653 .unwrap();
1654 smol::fs::write(repo_dir.path().join("baz"), "qux")
1655 .await
1656 .unwrap();
1657 repo.restore_checkpoint(checkpoint_sha, cx.to_async())
1658 .await
1659 .unwrap();
1660 assert_eq!(
1661 smol::fs::read_to_string(repo_dir.path().join("foo"))
1662 .await
1663 .unwrap(),
1664 "foo"
1665 );
1666 assert_eq!(
1667 smol::fs::read_to_string(repo_dir.path().join("baz"))
1668 .await
1669 .ok(),
1670 None
1671 );
1672 }
1673
1674 #[gpui::test]
1675 async fn test_undoing_commit_via_checkpoint(cx: &mut TestAppContext) {
1676 cx.executor().allow_parking();
1677
1678 let repo_dir = tempfile::tempdir().unwrap();
1679
1680 git2::Repository::init(repo_dir.path()).unwrap();
1681 let file_path = repo_dir.path().join("file");
1682 smol::fs::write(&file_path, "initial").await.unwrap();
1683
1684 let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1685 repo.stage_paths(
1686 vec![RepoPath::from_str("file")],
1687 HashMap::default(),
1688 cx.to_async(),
1689 )
1690 .await
1691 .unwrap();
1692 repo.commit(
1693 "Initial commit".into(),
1694 None,
1695 checkpoint_author_envs(),
1696 cx.to_async(),
1697 )
1698 .await
1699 .unwrap();
1700
1701 let initial_commit_sha = repo.head_sha().unwrap();
1702
1703 smol::fs::write(repo_dir.path().join("new_file1"), "content1")
1704 .await
1705 .unwrap();
1706 smol::fs::write(repo_dir.path().join("new_file2"), "content2")
1707 .await
1708 .unwrap();
1709
1710 let checkpoint = repo.checkpoint(cx.to_async()).await.unwrap();
1711
1712 repo.stage_paths(
1713 vec![
1714 RepoPath::from_str("new_file1"),
1715 RepoPath::from_str("new_file2"),
1716 ],
1717 HashMap::default(),
1718 cx.to_async(),
1719 )
1720 .await
1721 .unwrap();
1722 repo.commit(
1723 "Commit new files".into(),
1724 None,
1725 checkpoint_author_envs(),
1726 cx.to_async(),
1727 )
1728 .await
1729 .unwrap();
1730
1731 repo.restore_checkpoint(checkpoint, cx.to_async())
1732 .await
1733 .unwrap();
1734 assert_eq!(repo.head_sha().unwrap(), initial_commit_sha);
1735 assert_eq!(
1736 smol::fs::read_to_string(repo_dir.path().join("new_file1"))
1737 .await
1738 .unwrap(),
1739 "content1"
1740 );
1741 assert_eq!(
1742 smol::fs::read_to_string(repo_dir.path().join("new_file2"))
1743 .await
1744 .unwrap(),
1745 "content2"
1746 );
1747 assert_eq!(
1748 repo.status(&[]).unwrap().entries.as_ref(),
1749 &[
1750 (RepoPath::from_str("new_file1"), FileStatus::Untracked),
1751 (RepoPath::from_str("new_file2"), FileStatus::Untracked)
1752 ]
1753 );
1754 }
1755
1756 #[gpui::test]
1757 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
1758 cx.executor().allow_parking();
1759
1760 let repo_dir = tempfile::tempdir().unwrap();
1761 git2::Repository::init(repo_dir.path()).unwrap();
1762 let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1763
1764 smol::fs::write(repo_dir.path().join("file1"), "content1")
1765 .await
1766 .unwrap();
1767 let checkpoint1 = repo.checkpoint(cx.to_async()).await.unwrap();
1768
1769 smol::fs::write(repo_dir.path().join("file2"), "content2")
1770 .await
1771 .unwrap();
1772 let checkpoint2 = repo.checkpoint(cx.to_async()).await.unwrap();
1773
1774 assert!(!repo
1775 .compare_checkpoints(checkpoint1, checkpoint2.clone(), cx.to_async())
1776 .await
1777 .unwrap());
1778
1779 let checkpoint3 = repo.checkpoint(cx.to_async()).await.unwrap();
1780 assert!(repo
1781 .compare_checkpoints(checkpoint2, checkpoint3, cx.to_async())
1782 .await
1783 .unwrap());
1784 }
1785
1786 #[test]
1787 fn test_branches_parsing() {
1788 // suppress "help: octal escapes are not supported, `\0` is always null"
1789 #[allow(clippy::octal_escapes)]
1790 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1791 assert_eq!(
1792 parse_branch_input(&input).unwrap(),
1793 vec![Branch {
1794 is_head: true,
1795 name: "zed-patches".into(),
1796 upstream: Some(Upstream {
1797 ref_name: "refs/remotes/origin/zed-patches".into(),
1798 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1799 ahead: 0,
1800 behind: 0
1801 })
1802 }),
1803 most_recent_commit: Some(CommitSummary {
1804 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1805 subject: "generated protobuf".into(),
1806 commit_timestamp: 1733187470,
1807 has_parent: false,
1808 })
1809 }]
1810 )
1811 }
1812
1813 impl RealGitRepository {
1814 /// Force a Git garbage collection on the repository.
1815 fn gc(&self, cx: AsyncApp) -> BoxFuture<Result<()>> {
1816 let working_directory = self.working_directory();
1817 let git_binary_path = self.git_binary_path.clone();
1818 let executor = cx.background_executor().clone();
1819 cx.background_spawn(async move {
1820 let git_binary_path = git_binary_path.clone();
1821 let working_directory = working_directory?;
1822 let git = GitBinary::new(git_binary_path, working_directory, executor);
1823 git.run(&["gc", "--prune=now"]).await?;
1824 Ok(())
1825 })
1826 .boxed()
1827 }
1828 }
1829}