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