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