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