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_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: AskPassSession,
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: AskPassSession,
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: AskPassSession,
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: AskPassSession,
872 env: HashMap<String, String>,
873 _cx: AsyncApp,
874 ) -> BoxFuture<Result<RemoteCommandOutput>> {
875 let working_directory = self.working_directory();
876 async move {
877 let working_directory = working_directory?;
878
879 let mut command = new_smol_command("git");
880 command
881 .envs(env)
882 .env("GIT_ASKPASS", ask_pass.script_path())
883 .env("SSH_ASKPASS", ask_pass.script_path())
884 .env("SSH_ASKPASS_REQUIRE", "force")
885 .env("GIT_HTTP_USER_AGENT", "Zed")
886 .current_dir(&working_directory)
887 .args(["push"])
888 .args(options.map(|option| match option {
889 PushOptions::SetUpstream => "--set-upstream",
890 PushOptions::Force => "--force-with-lease",
891 }))
892 .arg(remote_name)
893 .arg(format!("{}:{}", branch_name, branch_name))
894 .stdin(smol::process::Stdio::null())
895 .stdout(smol::process::Stdio::piped())
896 .stderr(smol::process::Stdio::piped());
897 let git_process = command.spawn()?;
898
899 run_remote_command(ask_pass, git_process).await
900 }
901 .boxed()
902 }
903
904 fn pull(
905 &self,
906 branch_name: String,
907 remote_name: String,
908 ask_pass: AskPassSession,
909 env: HashMap<String, String>,
910 _cx: AsyncApp,
911 ) -> BoxFuture<Result<RemoteCommandOutput>> {
912 let working_directory = self.working_directory();
913 async {
914 let mut command = new_smol_command("git");
915 command
916 .envs(env)
917 .env("GIT_ASKPASS", ask_pass.script_path())
918 .env("SSH_ASKPASS", ask_pass.script_path())
919 .env("SSH_ASKPASS_REQUIRE", "force")
920 .current_dir(&working_directory?)
921 .args(["pull"])
922 .arg(remote_name)
923 .arg(branch_name)
924 .stdout(smol::process::Stdio::piped())
925 .stderr(smol::process::Stdio::piped());
926 let git_process = command.spawn()?;
927
928 run_remote_command(ask_pass, git_process).await
929 }
930 .boxed()
931 }
932
933 fn fetch(
934 &self,
935 ask_pass: AskPassSession,
936 env: HashMap<String, String>,
937 _cx: AsyncApp,
938 ) -> BoxFuture<Result<RemoteCommandOutput>> {
939 let working_directory = self.working_directory();
940 async {
941 let mut command = new_smol_command("git");
942 command
943 .envs(env)
944 .env("GIT_ASKPASS", ask_pass.script_path())
945 .env("SSH_ASKPASS", ask_pass.script_path())
946 .env("SSH_ASKPASS_REQUIRE", "force")
947 .current_dir(&working_directory?)
948 .args(["fetch", "--all"])
949 .stdout(smol::process::Stdio::piped())
950 .stderr(smol::process::Stdio::piped());
951 let git_process = command.spawn()?;
952
953 run_remote_command(ask_pass, git_process).await
954 }
955 .boxed()
956 }
957
958 fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<Result<Vec<Remote>>> {
959 let working_directory = self.working_directory();
960 let git_binary_path = self.git_binary_path.clone();
961 self.executor
962 .spawn(async move {
963 let working_directory = working_directory?;
964 if let Some(branch_name) = branch_name {
965 let output = new_smol_command(&git_binary_path)
966 .current_dir(&working_directory)
967 .args(["config", "--get"])
968 .arg(format!("branch.{}.remote", branch_name))
969 .output()
970 .await?;
971
972 if output.status.success() {
973 let remote_name = String::from_utf8_lossy(&output.stdout);
974
975 return Ok(vec![Remote {
976 name: remote_name.trim().to_string().into(),
977 }]);
978 }
979 }
980
981 let output = new_smol_command(&git_binary_path)
982 .current_dir(&working_directory)
983 .args(["remote"])
984 .output()
985 .await?;
986
987 if output.status.success() {
988 let remote_names = String::from_utf8_lossy(&output.stdout)
989 .split('\n')
990 .filter(|name| !name.is_empty())
991 .map(|name| Remote {
992 name: name.trim().to_string().into(),
993 })
994 .collect();
995
996 return Ok(remote_names);
997 } else {
998 return Err(anyhow!(
999 "Failed to get remotes:\n{}",
1000 String::from_utf8_lossy(&output.stderr)
1001 ));
1002 }
1003 })
1004 .boxed()
1005 }
1006
1007 fn check_for_pushed_commit(&self) -> BoxFuture<Result<Vec<SharedString>>> {
1008 let working_directory = self.working_directory();
1009 let git_binary_path = self.git_binary_path.clone();
1010 self.executor
1011 .spawn(async move {
1012 let working_directory = working_directory?;
1013 let git_cmd = async |args: &[&str]| -> Result<String> {
1014 let output = new_smol_command(&git_binary_path)
1015 .current_dir(&working_directory)
1016 .args(args)
1017 .output()
1018 .await?;
1019 if output.status.success() {
1020 Ok(String::from_utf8(output.stdout)?)
1021 } else {
1022 Err(anyhow!(String::from_utf8_lossy(&output.stderr).to_string()))
1023 }
1024 };
1025
1026 let head = git_cmd(&["rev-parse", "HEAD"])
1027 .await
1028 .context("Failed to get HEAD")?
1029 .trim()
1030 .to_owned();
1031
1032 let mut remote_branches = vec![];
1033 let mut add_if_matching = async |remote_head: &str| {
1034 if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await {
1035 if merge_base.trim() == head {
1036 if let Some(s) = remote_head.strip_prefix("refs/remotes/") {
1037 remote_branches.push(s.to_owned().into());
1038 }
1039 }
1040 }
1041 };
1042
1043 // check the main branch of each remote
1044 let remotes = git_cmd(&["remote"])
1045 .await
1046 .context("Failed to get remotes")?;
1047 for remote in remotes.lines() {
1048 if let Ok(remote_head) =
1049 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
1050 {
1051 add_if_matching(remote_head.trim()).await;
1052 }
1053 }
1054
1055 // ... and the remote branch that the checked-out one is tracking
1056 if let Ok(remote_head) =
1057 git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
1058 {
1059 add_if_matching(remote_head.trim()).await;
1060 }
1061
1062 Ok(remote_branches)
1063 })
1064 .boxed()
1065 }
1066
1067 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
1068 let working_directory = self.working_directory();
1069 let git_binary_path = self.git_binary_path.clone();
1070 let executor = self.executor.clone();
1071 self.executor
1072 .spawn(async move {
1073 let working_directory = working_directory?;
1074 let mut git = GitBinary::new(git_binary_path, working_directory, executor)
1075 .envs(checkpoint_author_envs());
1076 git.with_temp_index(async |git| {
1077 let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
1078 git.run(&["add", "--all"]).await?;
1079 let tree = git.run(&["write-tree"]).await?;
1080 let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
1081 git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
1082 .await?
1083 } else {
1084 git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
1085 };
1086 let ref_name = format!("refs/zed/{}", Uuid::new_v4());
1087 git.run(&["update-ref", &ref_name, &checkpoint_sha]).await?;
1088
1089 Ok(GitRepositoryCheckpoint {
1090 ref_name,
1091 head_sha: if let Some(head_sha) = head_sha {
1092 Some(head_sha.parse()?)
1093 } else {
1094 None
1095 },
1096 commit_sha: checkpoint_sha.parse()?,
1097 })
1098 })
1099 .await
1100 })
1101 .boxed()
1102 }
1103
1104 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<Result<()>> {
1105 let working_directory = self.working_directory();
1106 let git_binary_path = self.git_binary_path.clone();
1107
1108 let executor = self.executor.clone();
1109 self.executor
1110 .spawn(async move {
1111 let working_directory = working_directory?;
1112
1113 let mut git = GitBinary::new(git_binary_path, working_directory, executor);
1114 git.run(&[
1115 "restore",
1116 "--source",
1117 &checkpoint.commit_sha.to_string(),
1118 "--worktree",
1119 ".",
1120 ])
1121 .await?;
1122
1123 git.with_temp_index(async move |git| {
1124 git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
1125 .await?;
1126 git.run(&["clean", "-d", "--force"]).await
1127 })
1128 .await?;
1129
1130 if let Some(head_sha) = checkpoint.head_sha {
1131 git.run(&["reset", "--mixed", &head_sha.to_string()])
1132 .await?;
1133 } else {
1134 git.run(&["update-ref", "-d", "HEAD"]).await?;
1135 }
1136
1137 Ok(())
1138 })
1139 .boxed()
1140 }
1141
1142 fn compare_checkpoints(
1143 &self,
1144 left: GitRepositoryCheckpoint,
1145 right: GitRepositoryCheckpoint,
1146 ) -> BoxFuture<Result<bool>> {
1147 if left.head_sha != right.head_sha {
1148 return future::ready(Ok(false)).boxed();
1149 }
1150
1151 let working_directory = self.working_directory();
1152 let git_binary_path = self.git_binary_path.clone();
1153
1154 let executor = self.executor.clone();
1155 self.executor
1156 .spawn(async move {
1157 let working_directory = working_directory?;
1158 let git = GitBinary::new(git_binary_path, working_directory, executor);
1159 let result = git
1160 .run(&[
1161 "diff-tree",
1162 "--quiet",
1163 &left.commit_sha.to_string(),
1164 &right.commit_sha.to_string(),
1165 ])
1166 .await;
1167 match result {
1168 Ok(_) => Ok(true),
1169 Err(error) => {
1170 if let Some(GitBinaryCommandError { status, .. }) =
1171 error.downcast_ref::<GitBinaryCommandError>()
1172 {
1173 if status.code() == Some(1) {
1174 return Ok(false);
1175 }
1176 }
1177
1178 Err(error)
1179 }
1180 }
1181 })
1182 .boxed()
1183 }
1184
1185 fn delete_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<Result<()>> {
1186 let working_directory = self.working_directory();
1187 let git_binary_path = self.git_binary_path.clone();
1188
1189 let executor = self.executor.clone();
1190 self.executor
1191 .spawn(async move {
1192 let working_directory = working_directory?;
1193 let git = GitBinary::new(git_binary_path, working_directory, executor);
1194 git.run(&["update-ref", "-d", &checkpoint.ref_name]).await?;
1195 Ok(())
1196 })
1197 .boxed()
1198 }
1199
1200 fn diff_checkpoints(
1201 &self,
1202 base_checkpoint: GitRepositoryCheckpoint,
1203 target_checkpoint: GitRepositoryCheckpoint,
1204 ) -> BoxFuture<Result<String>> {
1205 let working_directory = self.working_directory();
1206 let git_binary_path = self.git_binary_path.clone();
1207
1208 let executor = self.executor.clone();
1209 self.executor
1210 .spawn(async move {
1211 let working_directory = working_directory?;
1212 let git = GitBinary::new(git_binary_path, working_directory, executor);
1213 git.run(&[
1214 "diff",
1215 "--find-renames",
1216 "--patch",
1217 &base_checkpoint.ref_name,
1218 &target_checkpoint.ref_name,
1219 ])
1220 .await
1221 })
1222 .boxed()
1223 }
1224}
1225
1226fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
1227 let mut args = vec![
1228 OsString::from("--no-optional-locks"),
1229 OsString::from("status"),
1230 OsString::from("--porcelain=v1"),
1231 OsString::from("--untracked-files=all"),
1232 OsString::from("--no-renames"),
1233 OsString::from("-z"),
1234 ];
1235 args.extend(path_prefixes.iter().map(|path_prefix| {
1236 if path_prefix.0.as_ref() == Path::new("") {
1237 Path::new(".").into()
1238 } else {
1239 path_prefix.as_os_str().into()
1240 }
1241 }));
1242 args
1243}
1244
1245struct GitBinary {
1246 git_binary_path: PathBuf,
1247 working_directory: PathBuf,
1248 executor: BackgroundExecutor,
1249 index_file_path: Option<PathBuf>,
1250 envs: HashMap<String, String>,
1251}
1252
1253impl GitBinary {
1254 fn new(
1255 git_binary_path: PathBuf,
1256 working_directory: PathBuf,
1257 executor: BackgroundExecutor,
1258 ) -> Self {
1259 Self {
1260 git_binary_path,
1261 working_directory,
1262 executor,
1263 index_file_path: None,
1264 envs: HashMap::default(),
1265 }
1266 }
1267
1268 fn envs(mut self, envs: HashMap<String, String>) -> Self {
1269 self.envs = envs;
1270 self
1271 }
1272
1273 pub async fn with_temp_index<R>(
1274 &mut self,
1275 f: impl AsyncFnOnce(&Self) -> Result<R>,
1276 ) -> Result<R> {
1277 let index_file_path = self.path_for_index_id(Uuid::new_v4());
1278
1279 let delete_temp_index = util::defer({
1280 let index_file_path = index_file_path.clone();
1281 let executor = self.executor.clone();
1282 move || {
1283 executor
1284 .spawn(async move {
1285 smol::fs::remove_file(index_file_path).await.log_err();
1286 })
1287 .detach();
1288 }
1289 });
1290
1291 self.index_file_path = Some(index_file_path.clone());
1292 let result = f(self).await;
1293 self.index_file_path = None;
1294 let result = result?;
1295
1296 smol::fs::remove_file(index_file_path).await.ok();
1297 delete_temp_index.abort();
1298
1299 Ok(result)
1300 }
1301
1302 fn path_for_index_id(&self, id: Uuid) -> PathBuf {
1303 self.working_directory
1304 .join(".git")
1305 .join(format!("index-{}.tmp", id))
1306 }
1307
1308 pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1309 where
1310 S: AsRef<OsStr>,
1311 {
1312 let mut stdout = self.run_raw(args).await?;
1313 if stdout.chars().last() == Some('\n') {
1314 stdout.pop();
1315 }
1316 Ok(stdout)
1317 }
1318
1319 /// Returns the result of the command without trimming the trailing newline.
1320 pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1321 where
1322 S: AsRef<OsStr>,
1323 {
1324 let mut command = self.build_command(args);
1325 let output = command.output().await?;
1326 if output.status.success() {
1327 Ok(String::from_utf8(output.stdout)?)
1328 } else {
1329 Err(anyhow!(GitBinaryCommandError {
1330 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1331 status: output.status,
1332 }))
1333 }
1334 }
1335
1336 fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
1337 where
1338 S: AsRef<OsStr>,
1339 {
1340 let mut command = new_smol_command(&self.git_binary_path);
1341 command.current_dir(&self.working_directory);
1342 command.args(args);
1343 if let Some(index_file_path) = self.index_file_path.as_ref() {
1344 command.env("GIT_INDEX_FILE", index_file_path);
1345 }
1346 command.envs(&self.envs);
1347 command
1348 }
1349}
1350
1351#[derive(Error, Debug)]
1352#[error("Git command failed: {stdout}")]
1353struct GitBinaryCommandError {
1354 stdout: String,
1355 status: ExitStatus,
1356}
1357
1358async fn run_remote_command(
1359 mut ask_pass: AskPassSession,
1360 git_process: smol::process::Child,
1361) -> std::result::Result<RemoteCommandOutput, anyhow::Error> {
1362 select_biased! {
1363 result = ask_pass.run().fuse() => {
1364 match result {
1365 AskPassResult::CancelledByUser => {
1366 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
1367 }
1368 AskPassResult::Timedout => {
1369 Err(anyhow!("Connecting to host timed out"))?
1370 }
1371 }
1372 }
1373 output = git_process.output().fuse() => {
1374 let output = output?;
1375 if !output.status.success() {
1376 Err(anyhow!(
1377 "{}",
1378 String::from_utf8_lossy(&output.stderr)
1379 ))
1380 } else {
1381 Ok(RemoteCommandOutput {
1382 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1383 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1384 })
1385 }
1386 }
1387 }
1388}
1389
1390pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1391 LazyLock::new(|| RepoPath(Path::new("").into()));
1392
1393#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1394pub struct RepoPath(pub Arc<Path>);
1395
1396impl RepoPath {
1397 pub fn new(path: PathBuf) -> Self {
1398 debug_assert!(path.is_relative(), "Repo paths must be relative");
1399
1400 RepoPath(path.into())
1401 }
1402
1403 pub fn from_str(path: &str) -> Self {
1404 let path = Path::new(path);
1405 debug_assert!(path.is_relative(), "Repo paths must be relative");
1406
1407 RepoPath(path.into())
1408 }
1409
1410 pub fn to_unix_style(&self) -> Cow<'_, OsStr> {
1411 #[cfg(target_os = "windows")]
1412 {
1413 use std::ffi::OsString;
1414
1415 let path = self.0.as_os_str().to_string_lossy().replace("\\", "/");
1416 Cow::Owned(OsString::from(path))
1417 }
1418 #[cfg(not(target_os = "windows"))]
1419 {
1420 Cow::Borrowed(self.0.as_os_str())
1421 }
1422 }
1423}
1424
1425impl std::fmt::Display for RepoPath {
1426 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1427 self.0.to_string_lossy().fmt(f)
1428 }
1429}
1430
1431impl From<&Path> for RepoPath {
1432 fn from(value: &Path) -> Self {
1433 RepoPath::new(value.into())
1434 }
1435}
1436
1437impl From<Arc<Path>> for RepoPath {
1438 fn from(value: Arc<Path>) -> Self {
1439 RepoPath(value)
1440 }
1441}
1442
1443impl From<PathBuf> for RepoPath {
1444 fn from(value: PathBuf) -> Self {
1445 RepoPath::new(value)
1446 }
1447}
1448
1449impl From<&str> for RepoPath {
1450 fn from(value: &str) -> Self {
1451 Self::from_str(value)
1452 }
1453}
1454
1455impl Default for RepoPath {
1456 fn default() -> Self {
1457 RepoPath(Path::new("").into())
1458 }
1459}
1460
1461impl AsRef<Path> for RepoPath {
1462 fn as_ref(&self) -> &Path {
1463 self.0.as_ref()
1464 }
1465}
1466
1467impl std::ops::Deref for RepoPath {
1468 type Target = Path;
1469
1470 fn deref(&self) -> &Self::Target {
1471 &self.0
1472 }
1473}
1474
1475impl Borrow<Path> for RepoPath {
1476 fn borrow(&self) -> &Path {
1477 self.0.as_ref()
1478 }
1479}
1480
1481#[derive(Debug)]
1482pub struct RepoPathDescendants<'a>(pub &'a Path);
1483
1484impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
1485 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1486 if key.starts_with(self.0) {
1487 Ordering::Greater
1488 } else {
1489 self.0.cmp(key)
1490 }
1491 }
1492}
1493
1494fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1495 let mut branches = Vec::new();
1496 for line in input.split('\n') {
1497 if line.is_empty() {
1498 continue;
1499 }
1500 let mut fields = line.split('\x00');
1501 let is_current_branch = fields.next().context("no HEAD")? == "*";
1502 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1503 let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
1504 let ref_name: SharedString = fields
1505 .next()
1506 .context("no refname")?
1507 .strip_prefix("refs/heads/")
1508 .context("unexpected format for refname")?
1509 .to_string()
1510 .into();
1511 let upstream_name = fields.next().context("no upstream")?.to_string();
1512 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1513 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1514 let subject: SharedString = fields
1515 .next()
1516 .context("no contents:subject")?
1517 .to_string()
1518 .into();
1519
1520 branches.push(Branch {
1521 is_head: is_current_branch,
1522 name: ref_name,
1523 most_recent_commit: Some(CommitSummary {
1524 sha: head_sha,
1525 subject,
1526 commit_timestamp: commiterdate,
1527 has_parent: !parent_sha.is_empty(),
1528 }),
1529 upstream: if upstream_name.is_empty() {
1530 None
1531 } else {
1532 Some(Upstream {
1533 ref_name: upstream_name.into(),
1534 tracking: upstream_tracking,
1535 })
1536 },
1537 })
1538 }
1539
1540 Ok(branches)
1541}
1542
1543fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1544 if upstream_track == "" {
1545 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1546 ahead: 0,
1547 behind: 0,
1548 }));
1549 }
1550
1551 let upstream_track = upstream_track
1552 .strip_prefix("[")
1553 .ok_or_else(|| anyhow!("missing ["))?;
1554 let upstream_track = upstream_track
1555 .strip_suffix("]")
1556 .ok_or_else(|| anyhow!("missing ["))?;
1557 let mut ahead: u32 = 0;
1558 let mut behind: u32 = 0;
1559 for component in upstream_track.split(", ") {
1560 if component == "gone" {
1561 return Ok(UpstreamTracking::Gone);
1562 }
1563 if let Some(ahead_num) = component.strip_prefix("ahead ") {
1564 ahead = ahead_num.parse::<u32>()?;
1565 }
1566 if let Some(behind_num) = component.strip_prefix("behind ") {
1567 behind = behind_num.parse::<u32>()?;
1568 }
1569 }
1570 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1571 ahead,
1572 behind,
1573 }))
1574}
1575
1576fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
1577 match relative_file_path.components().next() {
1578 None => anyhow::bail!("repo path should not be empty"),
1579 Some(Component::Prefix(_)) => anyhow::bail!(
1580 "repo path `{}` should be relative, not a windows prefix",
1581 relative_file_path.to_string_lossy()
1582 ),
1583 Some(Component::RootDir) => {
1584 anyhow::bail!(
1585 "repo path `{}` should be relative",
1586 relative_file_path.to_string_lossy()
1587 )
1588 }
1589 Some(Component::CurDir) => {
1590 anyhow::bail!(
1591 "repo path `{}` should not start with `.`",
1592 relative_file_path.to_string_lossy()
1593 )
1594 }
1595 Some(Component::ParentDir) => {
1596 anyhow::bail!(
1597 "repo path `{}` should not start with `..`",
1598 relative_file_path.to_string_lossy()
1599 )
1600 }
1601 _ => Ok(()),
1602 }
1603}
1604
1605fn checkpoint_author_envs() -> HashMap<String, String> {
1606 HashMap::from_iter([
1607 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
1608 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
1609 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
1610 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
1611 ])
1612}
1613
1614#[cfg(test)]
1615mod tests {
1616 use super::*;
1617 use crate::status::FileStatus;
1618 use gpui::TestAppContext;
1619
1620 #[gpui::test]
1621 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
1622 cx.executor().allow_parking();
1623
1624 let repo_dir = tempfile::tempdir().unwrap();
1625
1626 git2::Repository::init(repo_dir.path()).unwrap();
1627 let file_path = repo_dir.path().join("file");
1628 smol::fs::write(&file_path, "initial").await.unwrap();
1629
1630 let repo =
1631 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
1632 repo.stage_paths(vec![RepoPath::from_str("file")], HashMap::default())
1633 .await
1634 .unwrap();
1635 repo.commit("Initial commit".into(), None, checkpoint_author_envs())
1636 .await
1637 .unwrap();
1638
1639 smol::fs::write(&file_path, "modified before checkpoint")
1640 .await
1641 .unwrap();
1642 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
1643 .await
1644 .unwrap();
1645 let sha_before_checkpoint = repo.head_sha().unwrap();
1646 let checkpoint = repo.checkpoint().await.unwrap();
1647
1648 // Ensure the user can't see any branches after creating a checkpoint.
1649 assert_eq!(repo.branches().await.unwrap().len(), 1);
1650
1651 smol::fs::write(&file_path, "modified after checkpoint")
1652 .await
1653 .unwrap();
1654 repo.stage_paths(vec![RepoPath::from_str("file")], HashMap::default())
1655 .await
1656 .unwrap();
1657 repo.commit(
1658 "Commit after checkpoint".into(),
1659 None,
1660 checkpoint_author_envs(),
1661 )
1662 .await
1663 .unwrap();
1664
1665 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
1666 .await
1667 .unwrap();
1668 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
1669 .await
1670 .unwrap();
1671
1672 // Ensure checkpoint stays alive even after a Git GC.
1673 repo.gc().await.unwrap();
1674 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
1675
1676 assert_eq!(repo.head_sha().unwrap(), sha_before_checkpoint);
1677 assert_eq!(
1678 smol::fs::read_to_string(&file_path).await.unwrap(),
1679 "modified before checkpoint"
1680 );
1681 assert_eq!(
1682 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
1683 .await
1684 .unwrap(),
1685 "1"
1686 );
1687 assert_eq!(
1688 smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
1689 .await
1690 .ok(),
1691 None
1692 );
1693
1694 // Garbage collecting after deleting a checkpoint makes it unreachable.
1695 repo.delete_checkpoint(checkpoint.clone()).await.unwrap();
1696 repo.gc().await.unwrap();
1697 repo.restore_checkpoint(checkpoint.clone())
1698 .await
1699 .unwrap_err();
1700 }
1701
1702 #[gpui::test]
1703 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
1704 cx.executor().allow_parking();
1705
1706 let repo_dir = tempfile::tempdir().unwrap();
1707 git2::Repository::init(repo_dir.path()).unwrap();
1708 let repo =
1709 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
1710
1711 smol::fs::write(repo_dir.path().join("foo"), "foo")
1712 .await
1713 .unwrap();
1714 let checkpoint_sha = repo.checkpoint().await.unwrap();
1715
1716 // Ensure the user can't see any branches after creating a checkpoint.
1717 assert_eq!(repo.branches().await.unwrap().len(), 1);
1718
1719 smol::fs::write(repo_dir.path().join("foo"), "bar")
1720 .await
1721 .unwrap();
1722 smol::fs::write(repo_dir.path().join("baz"), "qux")
1723 .await
1724 .unwrap();
1725 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
1726 assert_eq!(
1727 smol::fs::read_to_string(repo_dir.path().join("foo"))
1728 .await
1729 .unwrap(),
1730 "foo"
1731 );
1732 assert_eq!(
1733 smol::fs::read_to_string(repo_dir.path().join("baz"))
1734 .await
1735 .ok(),
1736 None
1737 );
1738 }
1739
1740 #[gpui::test]
1741 async fn test_undoing_commit_via_checkpoint(cx: &mut TestAppContext) {
1742 cx.executor().allow_parking();
1743
1744 let repo_dir = tempfile::tempdir().unwrap();
1745
1746 git2::Repository::init(repo_dir.path()).unwrap();
1747 let file_path = repo_dir.path().join("file");
1748 smol::fs::write(&file_path, "initial").await.unwrap();
1749
1750 let repo =
1751 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
1752 repo.stage_paths(vec![RepoPath::from_str("file")], HashMap::default())
1753 .await
1754 .unwrap();
1755 repo.commit("Initial commit".into(), None, checkpoint_author_envs())
1756 .await
1757 .unwrap();
1758
1759 let initial_commit_sha = repo.head_sha().unwrap();
1760
1761 smol::fs::write(repo_dir.path().join("new_file1"), "content1")
1762 .await
1763 .unwrap();
1764 smol::fs::write(repo_dir.path().join("new_file2"), "content2")
1765 .await
1766 .unwrap();
1767
1768 let checkpoint = repo.checkpoint().await.unwrap();
1769
1770 repo.stage_paths(
1771 vec![
1772 RepoPath::from_str("new_file1"),
1773 RepoPath::from_str("new_file2"),
1774 ],
1775 HashMap::default(),
1776 )
1777 .await
1778 .unwrap();
1779 repo.commit("Commit new files".into(), None, checkpoint_author_envs())
1780 .await
1781 .unwrap();
1782
1783 repo.restore_checkpoint(checkpoint).await.unwrap();
1784 assert_eq!(repo.head_sha().unwrap(), initial_commit_sha);
1785 assert_eq!(
1786 smol::fs::read_to_string(repo_dir.path().join("new_file1"))
1787 .await
1788 .unwrap(),
1789 "content1"
1790 );
1791 assert_eq!(
1792 smol::fs::read_to_string(repo_dir.path().join("new_file2"))
1793 .await
1794 .unwrap(),
1795 "content2"
1796 );
1797 assert_eq!(
1798 repo.status_blocking(&[]).unwrap().entries.as_ref(),
1799 &[
1800 (RepoPath::from_str("new_file1"), FileStatus::Untracked),
1801 (RepoPath::from_str("new_file2"), FileStatus::Untracked)
1802 ]
1803 );
1804 }
1805
1806 #[gpui::test]
1807 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
1808 cx.executor().allow_parking();
1809
1810 let repo_dir = tempfile::tempdir().unwrap();
1811 git2::Repository::init(repo_dir.path()).unwrap();
1812 let repo =
1813 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
1814
1815 smol::fs::write(repo_dir.path().join("file1"), "content1")
1816 .await
1817 .unwrap();
1818 let checkpoint1 = repo.checkpoint().await.unwrap();
1819
1820 smol::fs::write(repo_dir.path().join("file2"), "content2")
1821 .await
1822 .unwrap();
1823 let checkpoint2 = repo.checkpoint().await.unwrap();
1824
1825 assert!(!repo
1826 .compare_checkpoints(checkpoint1, checkpoint2.clone())
1827 .await
1828 .unwrap());
1829
1830 let checkpoint3 = repo.checkpoint().await.unwrap();
1831 assert!(repo
1832 .compare_checkpoints(checkpoint2, checkpoint3)
1833 .await
1834 .unwrap());
1835 }
1836
1837 #[test]
1838 fn test_branches_parsing() {
1839 // suppress "help: octal escapes are not supported, `\0` is always null"
1840 #[allow(clippy::octal_escapes)]
1841 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1842 assert_eq!(
1843 parse_branch_input(&input).unwrap(),
1844 vec![Branch {
1845 is_head: true,
1846 name: "zed-patches".into(),
1847 upstream: Some(Upstream {
1848 ref_name: "refs/remotes/origin/zed-patches".into(),
1849 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1850 ahead: 0,
1851 behind: 0
1852 })
1853 }),
1854 most_recent_commit: Some(CommitSummary {
1855 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1856 subject: "generated protobuf".into(),
1857 commit_timestamp: 1733187470,
1858 has_parent: false,
1859 })
1860 }]
1861 )
1862 }
1863
1864 impl RealGitRepository {
1865 /// Force a Git garbage collection on the repository.
1866 fn gc(&self) -> BoxFuture<Result<()>> {
1867 let working_directory = self.working_directory();
1868 let git_binary_path = self.git_binary_path.clone();
1869 let executor = self.executor.clone();
1870 self.executor
1871 .spawn(async move {
1872 let git_binary_path = git_binary_path.clone();
1873 let working_directory = working_directory?;
1874 let git = GitBinary::new(git_binary_path, working_directory, executor);
1875 git.run(&["gc", "--prune=now"]).await?;
1876 Ok(())
1877 })
1878 .boxed()
1879 }
1880 }
1881}