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