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