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