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