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