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