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