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