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