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