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