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