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