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