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