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 author_name: SharedString,
180 pub has_parent: bool,
181}
182
183#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
184pub struct CommitDetails {
185 pub sha: SharedString,
186 pub message: SharedString,
187 pub commit_timestamp: i64,
188 pub author_email: SharedString,
189 pub author_name: SharedString,
190}
191
192#[derive(Debug)]
193pub struct CommitDiff {
194 pub files: Vec<CommitFile>,
195}
196
197#[derive(Debug)]
198pub struct CommitFile {
199 pub path: RepoPath,
200 pub old_text: Option<String>,
201 pub new_text: Option<String>,
202}
203
204impl CommitDetails {
205 pub fn short_sha(&self) -> SharedString {
206 self.sha[..SHORT_SHA_LENGTH].to_string().into()
207 }
208}
209
210#[derive(Debug, Clone, Hash, PartialEq, Eq)]
211pub struct Remote {
212 pub name: SharedString,
213}
214
215pub enum ResetMode {
216 /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
217 /// committed are now staged).
218 Soft,
219 /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
220 /// committed are now unstaged).
221 Mixed,
222}
223
224#[derive(Debug, Clone, Hash, PartialEq, Eq)]
225pub enum FetchOptions {
226 All,
227 Remote(Remote),
228}
229
230impl FetchOptions {
231 pub fn to_proto(&self) -> Option<String> {
232 match self {
233 FetchOptions::All => None,
234 FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
235 }
236 }
237
238 pub fn from_proto(remote_name: Option<String>) -> Self {
239 match remote_name {
240 Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
241 None => FetchOptions::All,
242 }
243 }
244
245 pub fn name(&self) -> SharedString {
246 match self {
247 Self::All => "Fetch all remotes".into(),
248 Self::Remote(remote) => remote.name.clone(),
249 }
250 }
251}
252
253impl std::fmt::Display for FetchOptions {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 match self {
256 FetchOptions::All => write!(f, "--all"),
257 FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
258 }
259 }
260}
261
262/// Modifies .git/info/exclude temporarily
263pub struct GitExcludeOverride {
264 git_exclude_path: PathBuf,
265 original_excludes: Option<String>,
266 added_excludes: Option<String>,
267}
268
269impl GitExcludeOverride {
270 pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
271 let original_excludes = smol::fs::read_to_string(&git_exclude_path).await.ok();
272
273 Ok(GitExcludeOverride {
274 git_exclude_path,
275 original_excludes,
276 added_excludes: None,
277 })
278 }
279
280 pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
281 self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
282 format!("{already_added}\n{excludes}")
283 } else {
284 excludes.to_string()
285 });
286
287 let mut content = self.original_excludes.clone().unwrap_or_default();
288 content.push_str("\n\n# ====== Auto-added by Zed: =======\n");
289 content.push_str(self.added_excludes.as_ref().unwrap());
290 content.push('\n');
291
292 smol::fs::write(&self.git_exclude_path, content).await?;
293 Ok(())
294 }
295
296 pub async fn restore_original(&mut self) -> Result<()> {
297 if let Some(ref original) = self.original_excludes {
298 smol::fs::write(&self.git_exclude_path, original).await?;
299 } else if self.git_exclude_path.exists() {
300 smol::fs::remove_file(&self.git_exclude_path).await?;
301 }
302
303 self.added_excludes = None;
304
305 Ok(())
306 }
307}
308
309impl Drop for GitExcludeOverride {
310 fn drop(&mut self) {
311 if self.added_excludes.is_some() {
312 let git_exclude_path = self.git_exclude_path.clone();
313 let original_excludes = self.original_excludes.clone();
314 smol::spawn(async move {
315 if let Some(original) = original_excludes {
316 smol::fs::write(&git_exclude_path, original).await
317 } else {
318 smol::fs::remove_file(&git_exclude_path).await
319 }
320 })
321 .detach();
322 }
323 }
324}
325
326pub trait GitRepository: Send + Sync {
327 fn reload_index(&self);
328
329 /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
330 ///
331 /// Also returns `None` for symlinks.
332 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
333
334 /// 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.
335 ///
336 /// Also returns `None` for symlinks.
337 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
338
339 fn set_index_text(
340 &self,
341 path: RepoPath,
342 content: Option<String>,
343 env: Arc<HashMap<String, String>>,
344 ) -> BoxFuture<'_, anyhow::Result<()>>;
345
346 /// Returns the URL of the remote with the given name.
347 fn remote_url(&self, name: &str) -> Option<String>;
348
349 /// Resolve a list of refs to SHAs.
350 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
351
352 fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
353 async move {
354 self.revparse_batch(vec!["HEAD".into()])
355 .await
356 .unwrap_or_default()
357 .into_iter()
358 .next()
359 .flatten()
360 }
361 .boxed()
362 }
363
364 fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
365
366 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
367
368 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
369
370 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
371 fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
372 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
373
374 fn reset(
375 &self,
376 commit: String,
377 mode: ResetMode,
378 env: Arc<HashMap<String, String>>,
379 ) -> BoxFuture<'_, Result<()>>;
380
381 fn checkout_files(
382 &self,
383 commit: String,
384 paths: Vec<RepoPath>,
385 env: Arc<HashMap<String, String>>,
386 ) -> BoxFuture<'_, Result<()>>;
387
388 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
389
390 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
391 fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
392
393 /// Returns the absolute path to the repository. For worktrees, this will be the path to the
394 /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
395 fn path(&self) -> PathBuf;
396
397 fn main_repository_path(&self) -> PathBuf;
398
399 /// Updates the index to match the worktree at the given paths.
400 ///
401 /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
402 fn stage_paths(
403 &self,
404 paths: Vec<RepoPath>,
405 env: Arc<HashMap<String, String>>,
406 ) -> BoxFuture<'_, Result<()>>;
407 /// Updates the index to match HEAD at the given paths.
408 ///
409 /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
410 fn unstage_paths(
411 &self,
412 paths: Vec<RepoPath>,
413 env: Arc<HashMap<String, String>>,
414 ) -> BoxFuture<'_, Result<()>>;
415
416 fn commit(
417 &self,
418 message: SharedString,
419 name_and_email: Option<(SharedString, SharedString)>,
420 options: CommitOptions,
421 env: Arc<HashMap<String, String>>,
422 ) -> BoxFuture<'_, Result<()>>;
423
424 fn stash_paths(
425 &self,
426 paths: Vec<RepoPath>,
427 env: Arc<HashMap<String, String>>,
428 ) -> BoxFuture<'_, Result<()>>;
429
430 fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>>;
431
432 fn push(
433 &self,
434 branch_name: String,
435 upstream_name: String,
436 options: Option<PushOptions>,
437 askpass: AskPassDelegate,
438 env: Arc<HashMap<String, String>>,
439 // This method takes an AsyncApp to ensure it's invoked on the main thread,
440 // otherwise git-credentials-manager won't work.
441 cx: AsyncApp,
442 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
443
444 fn pull(
445 &self,
446 branch_name: String,
447 upstream_name: String,
448 askpass: AskPassDelegate,
449 env: Arc<HashMap<String, String>>,
450 // This method takes an AsyncApp to ensure it's invoked on the main thread,
451 // otherwise git-credentials-manager won't work.
452 cx: AsyncApp,
453 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
454
455 fn fetch(
456 &self,
457 fetch_options: FetchOptions,
458 askpass: AskPassDelegate,
459 env: Arc<HashMap<String, String>>,
460 // This method takes an AsyncApp to ensure it's invoked on the main thread,
461 // otherwise git-credentials-manager won't work.
462 cx: AsyncApp,
463 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
464
465 fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>>;
466
467 /// returns a list of remote branches that contain HEAD
468 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
469
470 /// Run git diff
471 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
472
473 /// Creates a checkpoint for the repository.
474 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
475
476 /// Resets to a previously-created checkpoint.
477 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
478
479 /// Compares two checkpoints, returning true if they are equal
480 fn compare_checkpoints(
481 &self,
482 left: GitRepositoryCheckpoint,
483 right: GitRepositoryCheckpoint,
484 ) -> BoxFuture<'_, Result<bool>>;
485
486 /// Computes a diff between two checkpoints.
487 fn diff_checkpoints(
488 &self,
489 base_checkpoint: GitRepositoryCheckpoint,
490 target_checkpoint: GitRepositoryCheckpoint,
491 ) -> BoxFuture<'_, Result<String>>;
492
493 fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>>;
494}
495
496pub enum DiffType {
497 HeadToIndex,
498 HeadToWorktree,
499}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
502pub enum PushOptions {
503 SetUpstream,
504 Force,
505}
506
507impl std::fmt::Debug for dyn GitRepository {
508 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509 f.debug_struct("dyn GitRepository<...>").finish()
510 }
511}
512
513pub struct RealGitRepository {
514 pub repository: Arc<Mutex<git2::Repository>>,
515 pub git_binary_path: PathBuf,
516 executor: BackgroundExecutor,
517}
518
519impl RealGitRepository {
520 pub fn new(
521 dotgit_path: &Path,
522 git_binary_path: Option<PathBuf>,
523 executor: BackgroundExecutor,
524 ) -> Option<Self> {
525 let workdir_root = dotgit_path.parent()?;
526 let repository = git2::Repository::open(workdir_root).log_err()?;
527 Some(Self {
528 repository: Arc::new(Mutex::new(repository)),
529 git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
530 executor,
531 })
532 }
533
534 fn working_directory(&self) -> Result<PathBuf> {
535 self.repository
536 .lock()
537 .workdir()
538 .context("failed to read git work directory")
539 .map(Path::to_path_buf)
540 }
541}
542
543#[derive(Clone, Debug)]
544pub struct GitRepositoryCheckpoint {
545 pub commit_sha: Oid,
546}
547
548#[derive(Debug)]
549pub struct GitCommitter {
550 pub name: Option<String>,
551 pub email: Option<String>,
552}
553
554pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
555 if cfg!(any(feature = "test-support", test)) {
556 return GitCommitter {
557 name: None,
558 email: None,
559 };
560 }
561
562 let git_binary_path =
563 if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
564 cx.update(|cx| {
565 cx.path_for_auxiliary_executable("git")
566 .context("could not find git binary path")
567 .log_err()
568 })
569 .ok()
570 .flatten()
571 } else {
572 None
573 };
574
575 let git = GitBinary::new(
576 git_binary_path.unwrap_or(PathBuf::from("git")),
577 paths::home_dir().clone(),
578 cx.background_executor().clone(),
579 );
580
581 cx.background_spawn(async move {
582 let name = git.run(["config", "--global", "user.name"]).await.log_err();
583 let email = git
584 .run(["config", "--global", "user.email"])
585 .await
586 .log_err();
587 GitCommitter { name, email }
588 })
589 .await
590}
591
592impl GitRepository for RealGitRepository {
593 fn reload_index(&self) {
594 if let Ok(mut index) = self.repository.lock().index() {
595 _ = index.read(false);
596 }
597 }
598
599 fn path(&self) -> PathBuf {
600 let repo = self.repository.lock();
601 repo.path().into()
602 }
603
604 fn main_repository_path(&self) -> PathBuf {
605 let repo = self.repository.lock();
606 repo.commondir().into()
607 }
608
609 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
610 let working_directory = self.working_directory();
611 self.executor
612 .spawn(async move {
613 let working_directory = working_directory?;
614 let output = new_std_command("git")
615 .current_dir(&working_directory)
616 .args([
617 "--no-optional-locks",
618 "show",
619 "--no-patch",
620 "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
621 &commit,
622 ])
623 .output()?;
624 let output = std::str::from_utf8(&output.stdout)?;
625 let fields = output.split('\0').collect::<Vec<_>>();
626 if fields.len() != 6 {
627 bail!("unexpected git-show output for {commit:?}: {output:?}")
628 }
629 let sha = fields[0].to_string().into();
630 let message = fields[1].to_string().into();
631 let commit_timestamp = fields[2].parse()?;
632 let author_email = fields[3].to_string().into();
633 let author_name = fields[4].to_string().into();
634 Ok(CommitDetails {
635 sha,
636 message,
637 commit_timestamp,
638 author_email,
639 author_name,
640 })
641 })
642 .boxed()
643 }
644
645 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
646 let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
647 else {
648 return future::ready(Err(anyhow!("no working directory"))).boxed();
649 };
650 cx.background_spawn(async move {
651 let show_output = util::command::new_std_command("git")
652 .current_dir(&working_directory)
653 .args([
654 "--no-optional-locks",
655 "show",
656 "--format=%P",
657 "-z",
658 "--no-renames",
659 "--name-status",
660 ])
661 .arg(&commit)
662 .stdin(Stdio::null())
663 .stdout(Stdio::piped())
664 .stderr(Stdio::piped())
665 .output()
666 .context("starting git show process")?;
667
668 let show_stdout = String::from_utf8_lossy(&show_output.stdout);
669 let mut lines = show_stdout.split('\n');
670 let parent_sha = lines.next().unwrap().trim().trim_end_matches('\0');
671 let changes = parse_git_diff_name_status(lines.next().unwrap_or(""));
672
673 let mut cat_file_process = util::command::new_std_command("git")
674 .current_dir(&working_directory)
675 .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
676 .stdin(Stdio::piped())
677 .stdout(Stdio::piped())
678 .stderr(Stdio::piped())
679 .spawn()
680 .context("starting git cat-file process")?;
681
682 use std::io::Write as _;
683 let mut files = Vec::<CommitFile>::new();
684 let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
685 let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
686 let mut info_line = String::new();
687 let mut newline = [b'\0'];
688 for (path, status_code) in changes {
689 match status_code {
690 StatusCode::Modified => {
691 writeln!(&mut stdin, "{commit}:{}", path.display())?;
692 writeln!(&mut stdin, "{parent_sha}:{}", path.display())?;
693 }
694 StatusCode::Added => {
695 writeln!(&mut stdin, "{commit}:{}", path.display())?;
696 }
697 StatusCode::Deleted => {
698 writeln!(&mut stdin, "{parent_sha}:{}", path.display())?;
699 }
700 _ => continue,
701 }
702 stdin.flush()?;
703
704 info_line.clear();
705 stdout.read_line(&mut info_line)?;
706
707 let len = info_line.trim_end().parse().with_context(|| {
708 format!("invalid object size output from cat-file {info_line}")
709 })?;
710 let mut text = vec![0; len];
711 stdout.read_exact(&mut text)?;
712 stdout.read_exact(&mut newline)?;
713 let text = String::from_utf8_lossy(&text).to_string();
714
715 let mut old_text = None;
716 let mut new_text = None;
717 match status_code {
718 StatusCode::Modified => {
719 info_line.clear();
720 stdout.read_line(&mut info_line)?;
721 let len = info_line.trim_end().parse().with_context(|| {
722 format!("invalid object size output from cat-file {}", info_line)
723 })?;
724 let mut parent_text = vec![0; len];
725 stdout.read_exact(&mut parent_text)?;
726 stdout.read_exact(&mut newline)?;
727 old_text = Some(String::from_utf8_lossy(&parent_text).to_string());
728 new_text = Some(text);
729 }
730 StatusCode::Added => new_text = Some(text),
731 StatusCode::Deleted => old_text = Some(text),
732 _ => continue,
733 }
734
735 files.push(CommitFile {
736 path: path.into(),
737 old_text,
738 new_text,
739 })
740 }
741
742 Ok(CommitDiff { files })
743 })
744 .boxed()
745 }
746
747 fn reset(
748 &self,
749 commit: String,
750 mode: ResetMode,
751 env: Arc<HashMap<String, String>>,
752 ) -> BoxFuture<'_, Result<()>> {
753 async move {
754 let working_directory = self.working_directory();
755
756 let mode_flag = match mode {
757 ResetMode::Mixed => "--mixed",
758 ResetMode::Soft => "--soft",
759 };
760
761 let output = new_smol_command(&self.git_binary_path)
762 .envs(env.iter())
763 .current_dir(&working_directory?)
764 .args(["reset", mode_flag, &commit])
765 .output()
766 .await?;
767 anyhow::ensure!(
768 output.status.success(),
769 "Failed to reset:\n{}",
770 String::from_utf8_lossy(&output.stderr),
771 );
772 Ok(())
773 }
774 .boxed()
775 }
776
777 fn checkout_files(
778 &self,
779 commit: String,
780 paths: Vec<RepoPath>,
781 env: Arc<HashMap<String, String>>,
782 ) -> BoxFuture<'_, Result<()>> {
783 let working_directory = self.working_directory();
784 let git_binary_path = self.git_binary_path.clone();
785 async move {
786 if paths.is_empty() {
787 return Ok(());
788 }
789
790 let output = new_smol_command(&git_binary_path)
791 .current_dir(&working_directory?)
792 .envs(env.iter())
793 .args(["checkout", &commit, "--"])
794 .args(paths.iter().map(|path| path.as_ref()))
795 .output()
796 .await?;
797 anyhow::ensure!(
798 output.status.success(),
799 "Failed to checkout files:\n{}",
800 String::from_utf8_lossy(&output.stderr),
801 );
802 Ok(())
803 }
804 .boxed()
805 }
806
807 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
808 // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
809 const GIT_MODE_SYMLINK: u32 = 0o120000;
810
811 let repo = self.repository.clone();
812 self.executor
813 .spawn(async move {
814 fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
815 // This check is required because index.get_path() unwraps internally :(
816 check_path_to_repo_path_errors(path)?;
817
818 let mut index = repo.index()?;
819 index.read(false)?;
820
821 const STAGE_NORMAL: i32 = 0;
822 let oid = match index.get_path(path, STAGE_NORMAL) {
823 Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
824 _ => return Ok(None),
825 };
826
827 let content = repo.find_blob(oid)?.content().to_owned();
828 Ok(String::from_utf8(content).ok())
829 }
830
831 match logic(&repo.lock(), &path) {
832 Ok(value) => return value,
833 Err(err) => log::error!("Error loading index text: {:?}", err),
834 }
835 None
836 })
837 .boxed()
838 }
839
840 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
841 let repo = self.repository.clone();
842 self.executor
843 .spawn(async move {
844 let repo = repo.lock();
845 let head = repo.head().ok()?.peel_to_tree().log_err()?;
846 let entry = head.get_path(&path).ok()?;
847 if entry.filemode() == i32::from(git2::FileMode::Link) {
848 return None;
849 }
850 let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
851 String::from_utf8(content).ok()
852 })
853 .boxed()
854 }
855
856 fn set_index_text(
857 &self,
858 path: RepoPath,
859 content: Option<String>,
860 env: Arc<HashMap<String, String>>,
861 ) -> BoxFuture<'_, anyhow::Result<()>> {
862 let working_directory = self.working_directory();
863 let git_binary_path = self.git_binary_path.clone();
864 self.executor
865 .spawn(async move {
866 let working_directory = working_directory?;
867 if let Some(content) = content {
868 let mut child = new_smol_command(&git_binary_path)
869 .current_dir(&working_directory)
870 .envs(env.iter())
871 .args(["hash-object", "-w", "--stdin"])
872 .stdin(Stdio::piped())
873 .stdout(Stdio::piped())
874 .spawn()?;
875 let mut stdin = child.stdin.take().unwrap();
876 stdin.write_all(content.as_bytes()).await?;
877 stdin.flush().await?;
878 drop(stdin);
879 let output = child.output().await?.stdout;
880 let sha = str::from_utf8(&output)?.trim();
881
882 log::debug!("indexing SHA: {sha}, path {path:?}");
883
884 let output = new_smol_command(&git_binary_path)
885 .current_dir(&working_directory)
886 .envs(env.iter())
887 .args(["update-index", "--add", "--cacheinfo", "100644", sha])
888 .arg(path.to_unix_style())
889 .output()
890 .await?;
891
892 anyhow::ensure!(
893 output.status.success(),
894 "Failed to stage:\n{}",
895 String::from_utf8_lossy(&output.stderr)
896 );
897 } else {
898 log::debug!("removing path {path:?} from the index");
899 let output = new_smol_command(&git_binary_path)
900 .current_dir(&working_directory)
901 .envs(env.iter())
902 .args(["update-index", "--force-remove"])
903 .arg(path.to_unix_style())
904 .output()
905 .await?;
906 anyhow::ensure!(
907 output.status.success(),
908 "Failed to unstage:\n{}",
909 String::from_utf8_lossy(&output.stderr)
910 );
911 }
912
913 Ok(())
914 })
915 .boxed()
916 }
917
918 fn remote_url(&self, name: &str) -> Option<String> {
919 let repo = self.repository.lock();
920 let remote = repo.find_remote(name).ok()?;
921 remote.url().map(|url| url.to_string())
922 }
923
924 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
925 let working_directory = self.working_directory();
926 self.executor
927 .spawn(async move {
928 let working_directory = working_directory?;
929 let mut process = new_std_command("git")
930 .current_dir(&working_directory)
931 .args([
932 "--no-optional-locks",
933 "cat-file",
934 "--batch-check=%(objectname)",
935 ])
936 .stdin(Stdio::piped())
937 .stdout(Stdio::piped())
938 .stderr(Stdio::piped())
939 .spawn()?;
940
941 let stdin = process
942 .stdin
943 .take()
944 .context("no stdin for git cat-file subprocess")?;
945 let mut stdin = BufWriter::new(stdin);
946 for rev in &revs {
947 writeln!(&mut stdin, "{rev}")?;
948 }
949 stdin.flush()?;
950 drop(stdin);
951
952 let output = process.wait_with_output()?;
953 let output = std::str::from_utf8(&output.stdout)?;
954 let shas = output
955 .lines()
956 .map(|line| {
957 if line.ends_with("missing") {
958 None
959 } else {
960 Some(line.to_string())
961 }
962 })
963 .collect::<Vec<_>>();
964
965 if shas.len() != revs.len() {
966 // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
967 bail!("unexpected number of shas")
968 }
969
970 Ok(shas)
971 })
972 .boxed()
973 }
974
975 fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
976 let path = self.path().join("MERGE_MSG");
977 self.executor
978 .spawn(async move { std::fs::read_to_string(&path).ok() })
979 .boxed()
980 }
981
982 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
983 let git_binary_path = self.git_binary_path.clone();
984 let working_directory = match self.working_directory() {
985 Ok(working_directory) => working_directory,
986 Err(e) => return Task::ready(Err(e)),
987 };
988 let args = git_status_args(path_prefixes);
989 log::debug!("Checking for git status in {path_prefixes:?}");
990 self.executor.spawn(async move {
991 let output = new_std_command(&git_binary_path)
992 .current_dir(working_directory)
993 .args(args)
994 .output()?;
995 if output.status.success() {
996 let stdout = String::from_utf8_lossy(&output.stdout);
997 stdout.parse()
998 } else {
999 let stderr = String::from_utf8_lossy(&output.stderr);
1000 anyhow::bail!("git status failed: {stderr}");
1001 }
1002 })
1003 }
1004
1005 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
1006 let working_directory = self.working_directory();
1007 let git_binary_path = self.git_binary_path.clone();
1008 self.executor
1009 .spawn(async move {
1010 let fields = [
1011 "%(HEAD)",
1012 "%(objectname)",
1013 "%(parent)",
1014 "%(refname)",
1015 "%(upstream)",
1016 "%(upstream:track)",
1017 "%(committerdate:unix)",
1018 "%(authorname)",
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 && merge_base.trim() == head
1512 && let Some(s) = remote_head.strip_prefix("refs/remotes/")
1513 {
1514 remote_branches.push(s.to_owned().into());
1515 }
1516 };
1517
1518 // check the main branch of each remote
1519 let remotes = git_cmd(&["remote"])
1520 .await
1521 .context("Failed to get remotes")?;
1522 for remote in remotes.lines() {
1523 if let Ok(remote_head) =
1524 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
1525 {
1526 add_if_matching(remote_head.trim()).await;
1527 }
1528 }
1529
1530 // ... and the remote branch that the checked-out one is tracking
1531 if let Ok(remote_head) =
1532 git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
1533 {
1534 add_if_matching(remote_head.trim()).await;
1535 }
1536
1537 Ok(remote_branches)
1538 })
1539 .boxed()
1540 }
1541
1542 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
1543 let working_directory = self.working_directory();
1544 let git_binary_path = self.git_binary_path.clone();
1545 let executor = self.executor.clone();
1546 self.executor
1547 .spawn(async move {
1548 let working_directory = working_directory?;
1549 let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
1550 .envs(checkpoint_author_envs());
1551 git.with_temp_index(async |git| {
1552 let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
1553 let mut excludes = exclude_files(git).await?;
1554
1555 git.run(&["add", "--all"]).await?;
1556 let tree = git.run(&["write-tree"]).await?;
1557 let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
1558 git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
1559 .await?
1560 } else {
1561 git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
1562 };
1563
1564 excludes.restore_original().await?;
1565
1566 Ok(GitRepositoryCheckpoint {
1567 commit_sha: checkpoint_sha.parse()?,
1568 })
1569 })
1570 .await
1571 })
1572 .boxed()
1573 }
1574
1575 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
1576 let working_directory = self.working_directory();
1577 let git_binary_path = self.git_binary_path.clone();
1578
1579 let executor = self.executor.clone();
1580 self.executor
1581 .spawn(async move {
1582 let working_directory = working_directory?;
1583
1584 let git = GitBinary::new(git_binary_path, working_directory, executor);
1585 git.run(&[
1586 "restore",
1587 "--source",
1588 &checkpoint.commit_sha.to_string(),
1589 "--worktree",
1590 ".",
1591 ])
1592 .await?;
1593
1594 // TODO: We don't track binary and large files anymore,
1595 // so the following call would delete them.
1596 // Implement an alternative way to track files added by agent.
1597 //
1598 // git.with_temp_index(async move |git| {
1599 // git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
1600 // .await?;
1601 // git.run(&["clean", "-d", "--force"]).await
1602 // })
1603 // .await?;
1604
1605 Ok(())
1606 })
1607 .boxed()
1608 }
1609
1610 fn compare_checkpoints(
1611 &self,
1612 left: GitRepositoryCheckpoint,
1613 right: GitRepositoryCheckpoint,
1614 ) -> BoxFuture<'_, Result<bool>> {
1615 let working_directory = self.working_directory();
1616 let git_binary_path = self.git_binary_path.clone();
1617
1618 let executor = self.executor.clone();
1619 self.executor
1620 .spawn(async move {
1621 let working_directory = working_directory?;
1622 let git = GitBinary::new(git_binary_path, working_directory, executor);
1623 let result = git
1624 .run(&[
1625 "diff-tree",
1626 "--quiet",
1627 &left.commit_sha.to_string(),
1628 &right.commit_sha.to_string(),
1629 ])
1630 .await;
1631 match result {
1632 Ok(_) => Ok(true),
1633 Err(error) => {
1634 if let Some(GitBinaryCommandError { status, .. }) =
1635 error.downcast_ref::<GitBinaryCommandError>()
1636 && status.code() == Some(1)
1637 {
1638 return Ok(false);
1639 }
1640
1641 Err(error)
1642 }
1643 }
1644 })
1645 .boxed()
1646 }
1647
1648 fn diff_checkpoints(
1649 &self,
1650 base_checkpoint: GitRepositoryCheckpoint,
1651 target_checkpoint: GitRepositoryCheckpoint,
1652 ) -> BoxFuture<'_, Result<String>> {
1653 let working_directory = self.working_directory();
1654 let git_binary_path = self.git_binary_path.clone();
1655
1656 let executor = self.executor.clone();
1657 self.executor
1658 .spawn(async move {
1659 let working_directory = working_directory?;
1660 let git = GitBinary::new(git_binary_path, working_directory, executor);
1661 git.run(&[
1662 "diff",
1663 "--find-renames",
1664 "--patch",
1665 &base_checkpoint.commit_sha.to_string(),
1666 &target_checkpoint.commit_sha.to_string(),
1667 ])
1668 .await
1669 })
1670 .boxed()
1671 }
1672
1673 fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
1674 let working_directory = self.working_directory();
1675 let git_binary_path = self.git_binary_path.clone();
1676
1677 let executor = self.executor.clone();
1678 self.executor
1679 .spawn(async move {
1680 let working_directory = working_directory?;
1681 let git = GitBinary::new(git_binary_path, working_directory, executor);
1682
1683 if let Ok(output) = git
1684 .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
1685 .await
1686 {
1687 let output = output
1688 .strip_prefix("refs/remotes/upstream/")
1689 .map(|s| SharedString::from(s.to_owned()));
1690 return Ok(output);
1691 }
1692
1693 let output = git
1694 .run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
1695 .await?;
1696
1697 Ok(output
1698 .strip_prefix("refs/remotes/origin/")
1699 .map(|s| SharedString::from(s.to_owned())))
1700 })
1701 .boxed()
1702 }
1703}
1704
1705fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
1706 let mut args = vec![
1707 OsString::from("--no-optional-locks"),
1708 OsString::from("status"),
1709 OsString::from("--porcelain=v1"),
1710 OsString::from("--untracked-files=all"),
1711 OsString::from("--no-renames"),
1712 OsString::from("-z"),
1713 ];
1714 args.extend(path_prefixes.iter().map(|path_prefix| {
1715 if path_prefix.0.as_ref() == Path::new("") {
1716 Path::new(".").into()
1717 } else {
1718 path_prefix.as_os_str().into()
1719 }
1720 }));
1721 args
1722}
1723
1724/// Temporarily git-ignore commonly ignored files and files over 2MB
1725async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
1726 const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
1727 let mut excludes = git.with_exclude_overrides().await?;
1728 excludes
1729 .add_excludes(include_str!("./checkpoint.gitignore"))
1730 .await?;
1731
1732 let working_directory = git.working_directory.clone();
1733 let untracked_files = git.list_untracked_files().await?;
1734 let excluded_paths = untracked_files.into_iter().map(|path| {
1735 let working_directory = working_directory.clone();
1736 smol::spawn(async move {
1737 let full_path = working_directory.join(path.clone());
1738 match smol::fs::metadata(&full_path).await {
1739 Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
1740 Some(PathBuf::from("/").join(path.clone()))
1741 }
1742 _ => None,
1743 }
1744 })
1745 });
1746
1747 let excluded_paths = futures::future::join_all(excluded_paths).await;
1748 let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
1749
1750 if !excluded_paths.is_empty() {
1751 let exclude_patterns = excluded_paths
1752 .into_iter()
1753 .map(|path| path.to_string_lossy().to_string())
1754 .collect::<Vec<_>>()
1755 .join("\n");
1756 excludes.add_excludes(&exclude_patterns).await?;
1757 }
1758
1759 Ok(excludes)
1760}
1761
1762struct GitBinary {
1763 git_binary_path: PathBuf,
1764 working_directory: PathBuf,
1765 executor: BackgroundExecutor,
1766 index_file_path: Option<PathBuf>,
1767 envs: HashMap<String, String>,
1768}
1769
1770impl GitBinary {
1771 fn new(
1772 git_binary_path: PathBuf,
1773 working_directory: PathBuf,
1774 executor: BackgroundExecutor,
1775 ) -> Self {
1776 Self {
1777 git_binary_path,
1778 working_directory,
1779 executor,
1780 index_file_path: None,
1781 envs: HashMap::default(),
1782 }
1783 }
1784
1785 async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
1786 let status_output = self
1787 .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
1788 .await?;
1789
1790 let paths = status_output
1791 .split('\0')
1792 .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
1793 .map(|entry| PathBuf::from(&entry[3..]))
1794 .collect::<Vec<_>>();
1795 Ok(paths)
1796 }
1797
1798 fn envs(mut self, envs: HashMap<String, String>) -> Self {
1799 self.envs = envs;
1800 self
1801 }
1802
1803 pub async fn with_temp_index<R>(
1804 &mut self,
1805 f: impl AsyncFnOnce(&Self) -> Result<R>,
1806 ) -> Result<R> {
1807 let index_file_path = self.path_for_index_id(Uuid::new_v4());
1808
1809 let delete_temp_index = util::defer({
1810 let index_file_path = index_file_path.clone();
1811 let executor = self.executor.clone();
1812 move || {
1813 executor
1814 .spawn(async move {
1815 smol::fs::remove_file(index_file_path).await.log_err();
1816 })
1817 .detach();
1818 }
1819 });
1820
1821 // Copy the default index file so that Git doesn't have to rebuild the
1822 // whole index from scratch. This might fail if this is an empty repository.
1823 smol::fs::copy(
1824 self.working_directory.join(".git").join("index"),
1825 &index_file_path,
1826 )
1827 .await
1828 .ok();
1829
1830 self.index_file_path = Some(index_file_path.clone());
1831 let result = f(self).await;
1832 self.index_file_path = None;
1833 let result = result?;
1834
1835 smol::fs::remove_file(index_file_path).await.ok();
1836 delete_temp_index.abort();
1837
1838 Ok(result)
1839 }
1840
1841 pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
1842 let path = self
1843 .working_directory
1844 .join(".git")
1845 .join("info")
1846 .join("exclude");
1847
1848 GitExcludeOverride::new(path).await
1849 }
1850
1851 fn path_for_index_id(&self, id: Uuid) -> PathBuf {
1852 self.working_directory
1853 .join(".git")
1854 .join(format!("index-{}.tmp", id))
1855 }
1856
1857 pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1858 where
1859 S: AsRef<OsStr>,
1860 {
1861 let mut stdout = self.run_raw(args).await?;
1862 if stdout.chars().last() == Some('\n') {
1863 stdout.pop();
1864 }
1865 Ok(stdout)
1866 }
1867
1868 pub async fn run_with_output<S>(
1869 &self,
1870 args: impl IntoIterator<Item = S>,
1871 ) -> Result<GitCommandOutput>
1872 where
1873 S: AsRef<OsStr>,
1874 {
1875 let mut command = self.build_command(args);
1876 let output = command.output().await?;
1877
1878 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1879 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1880
1881 if !output.status.success() {
1882 return Err(GitBinaryCommandError {
1883 stdout: stdout.clone(),
1884 stderr: stderr.clone(),
1885 status: output.status,
1886 }
1887 .into());
1888 }
1889
1890 Ok(GitCommandOutput { stdout, stderr })
1891 }
1892
1893 /// Returns the result of the command without trimming the trailing newline.
1894 pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1895 where
1896 S: AsRef<OsStr>,
1897 {
1898 let mut command = self.build_command(args);
1899 let output = command.output().await?;
1900 anyhow::ensure!(
1901 output.status.success(),
1902 GitBinaryCommandError {
1903 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1904 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1905 status: output.status,
1906 }
1907 );
1908 Ok(String::from_utf8(output.stdout)?)
1909 }
1910
1911 fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
1912 where
1913 S: AsRef<OsStr>,
1914 {
1915 let mut command = new_smol_command(&self.git_binary_path);
1916 command.current_dir(&self.working_directory);
1917 command.args(args);
1918 if let Some(index_file_path) = self.index_file_path.as_ref() {
1919 command.env("GIT_INDEX_FILE", index_file_path);
1920 }
1921 command.envs(&self.envs);
1922 command
1923 }
1924}
1925
1926#[derive(Error, Debug)]
1927#[error("Git command failed:\n{stdout}{stderr}\n")]
1928struct GitBinaryCommandError {
1929 stdout: String,
1930 stderr: String,
1931 status: ExitStatus,
1932}
1933
1934async fn run_git_command(
1935 env: Arc<HashMap<String, String>>,
1936 ask_pass: AskPassDelegate,
1937 mut command: smol::process::Command,
1938 executor: &BackgroundExecutor,
1939) -> Result<RemoteCommandOutput> {
1940 if env.contains_key("GIT_ASKPASS") {
1941 let git_process = command.spawn()?;
1942 let output = git_process.output().await?;
1943 anyhow::ensure!(
1944 output.status.success(),
1945 "{}",
1946 String::from_utf8_lossy(&output.stderr)
1947 );
1948 Ok(RemoteCommandOutput {
1949 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1950 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1951 })
1952 } else {
1953 let ask_pass = AskPassSession::new(executor, ask_pass).await?;
1954 command
1955 .env("GIT_ASKPASS", ask_pass.script_path())
1956 .env("SSH_ASKPASS", ask_pass.script_path())
1957 .env("SSH_ASKPASS_REQUIRE", "force");
1958 let git_process = command.spawn()?;
1959
1960 run_askpass_command(ask_pass, git_process).await
1961 }
1962}
1963
1964async fn run_askpass_command(
1965 mut ask_pass: AskPassSession,
1966 git_process: smol::process::Child,
1967) -> anyhow::Result<RemoteCommandOutput> {
1968 select_biased! {
1969 result = ask_pass.run().fuse() => {
1970 match result {
1971 AskPassResult::CancelledByUser => {
1972 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
1973 }
1974 AskPassResult::Timedout => {
1975 Err(anyhow!("Connecting to host timed out"))?
1976 }
1977 }
1978 }
1979 output = git_process.output().fuse() => {
1980 let output = output?;
1981 anyhow::ensure!(
1982 output.status.success(),
1983 "{}",
1984 String::from_utf8_lossy(&output.stderr)
1985 );
1986 Ok(RemoteCommandOutput {
1987 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1988 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1989 })
1990 }
1991 }
1992}
1993
1994pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1995 LazyLock::new(|| RepoPath(Path::new("").into()));
1996
1997#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1998pub struct RepoPath(pub Arc<Path>);
1999
2000impl RepoPath {
2001 pub fn new(path: PathBuf) -> Self {
2002 debug_assert!(path.is_relative(), "Repo paths must be relative");
2003
2004 RepoPath(path.into())
2005 }
2006
2007 pub fn from_str(path: &str) -> Self {
2008 let path = Path::new(path);
2009 debug_assert!(path.is_relative(), "Repo paths must be relative");
2010
2011 RepoPath(path.into())
2012 }
2013
2014 pub fn to_unix_style(&self) -> Cow<'_, OsStr> {
2015 #[cfg(target_os = "windows")]
2016 {
2017 use std::ffi::OsString;
2018
2019 let path = self.0.as_os_str().to_string_lossy().replace("\\", "/");
2020 Cow::Owned(OsString::from(path))
2021 }
2022 #[cfg(not(target_os = "windows"))]
2023 {
2024 Cow::Borrowed(self.0.as_os_str())
2025 }
2026 }
2027}
2028
2029impl std::fmt::Display for RepoPath {
2030 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2031 self.0.to_string_lossy().fmt(f)
2032 }
2033}
2034
2035impl From<&Path> for RepoPath {
2036 fn from(value: &Path) -> Self {
2037 RepoPath::new(value.into())
2038 }
2039}
2040
2041impl From<Arc<Path>> for RepoPath {
2042 fn from(value: Arc<Path>) -> Self {
2043 RepoPath(value)
2044 }
2045}
2046
2047impl From<PathBuf> for RepoPath {
2048 fn from(value: PathBuf) -> Self {
2049 RepoPath::new(value)
2050 }
2051}
2052
2053impl From<&str> for RepoPath {
2054 fn from(value: &str) -> Self {
2055 Self::from_str(value)
2056 }
2057}
2058
2059impl Default for RepoPath {
2060 fn default() -> Self {
2061 RepoPath(Path::new("").into())
2062 }
2063}
2064
2065impl AsRef<Path> for RepoPath {
2066 fn as_ref(&self) -> &Path {
2067 self.0.as_ref()
2068 }
2069}
2070
2071impl std::ops::Deref for RepoPath {
2072 type Target = Path;
2073
2074 fn deref(&self) -> &Self::Target {
2075 &self.0
2076 }
2077}
2078
2079impl Borrow<Path> for RepoPath {
2080 fn borrow(&self) -> &Path {
2081 self.0.as_ref()
2082 }
2083}
2084
2085#[derive(Debug)]
2086pub struct RepoPathDescendants<'a>(pub &'a Path);
2087
2088impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
2089 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
2090 if key.starts_with(self.0) {
2091 Ordering::Greater
2092 } else {
2093 self.0.cmp(key)
2094 }
2095 }
2096}
2097
2098fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
2099 let mut branches = Vec::new();
2100 for line in input.split('\n') {
2101 if line.is_empty() {
2102 continue;
2103 }
2104 let mut fields = line.split('\x00');
2105 let is_current_branch = fields.next().context("no HEAD")? == "*";
2106 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
2107 let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
2108 let ref_name = fields.next().context("no refname")?.to_string().into();
2109 let upstream_name = fields.next().context("no upstream")?.to_string();
2110 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
2111 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
2112 let author_name = fields.next().context("no authorname")?.to_string().into();
2113 let subject: SharedString = fields
2114 .next()
2115 .context("no contents:subject")?
2116 .to_string()
2117 .into();
2118
2119 branches.push(Branch {
2120 is_head: is_current_branch,
2121 ref_name,
2122 most_recent_commit: Some(CommitSummary {
2123 sha: head_sha,
2124 subject,
2125 commit_timestamp: commiterdate,
2126 author_name: author_name,
2127 has_parent: !parent_sha.is_empty(),
2128 }),
2129 upstream: if upstream_name.is_empty() {
2130 None
2131 } else {
2132 Some(Upstream {
2133 ref_name: upstream_name.into(),
2134 tracking: upstream_tracking,
2135 })
2136 },
2137 })
2138 }
2139
2140 Ok(branches)
2141}
2142
2143fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
2144 if upstream_track.is_empty() {
2145 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2146 ahead: 0,
2147 behind: 0,
2148 }));
2149 }
2150
2151 let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
2152 let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
2153 let mut ahead: u32 = 0;
2154 let mut behind: u32 = 0;
2155 for component in upstream_track.split(", ") {
2156 if component == "gone" {
2157 return Ok(UpstreamTracking::Gone);
2158 }
2159 if let Some(ahead_num) = component.strip_prefix("ahead ") {
2160 ahead = ahead_num.parse::<u32>()?;
2161 }
2162 if let Some(behind_num) = component.strip_prefix("behind ") {
2163 behind = behind_num.parse::<u32>()?;
2164 }
2165 }
2166 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2167 ahead,
2168 behind,
2169 }))
2170}
2171
2172fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
2173 match relative_file_path.components().next() {
2174 None => anyhow::bail!("repo path should not be empty"),
2175 Some(Component::Prefix(_)) => anyhow::bail!(
2176 "repo path `{}` should be relative, not a windows prefix",
2177 relative_file_path.to_string_lossy()
2178 ),
2179 Some(Component::RootDir) => {
2180 anyhow::bail!(
2181 "repo path `{}` should be relative",
2182 relative_file_path.to_string_lossy()
2183 )
2184 }
2185 Some(Component::CurDir) => {
2186 anyhow::bail!(
2187 "repo path `{}` should not start with `.`",
2188 relative_file_path.to_string_lossy()
2189 )
2190 }
2191 Some(Component::ParentDir) => {
2192 anyhow::bail!(
2193 "repo path `{}` should not start with `..`",
2194 relative_file_path.to_string_lossy()
2195 )
2196 }
2197 _ => Ok(()),
2198 }
2199}
2200
2201fn checkpoint_author_envs() -> HashMap<String, String> {
2202 HashMap::from_iter([
2203 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
2204 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
2205 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
2206 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
2207 ])
2208}
2209
2210#[cfg(test)]
2211mod tests {
2212 use super::*;
2213 use gpui::TestAppContext;
2214
2215 #[gpui::test]
2216 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
2217 cx.executor().allow_parking();
2218
2219 let repo_dir = tempfile::tempdir().unwrap();
2220
2221 git2::Repository::init(repo_dir.path()).unwrap();
2222 let file_path = repo_dir.path().join("file");
2223 smol::fs::write(&file_path, "initial").await.unwrap();
2224
2225 let repo =
2226 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2227 repo.stage_paths(
2228 vec![RepoPath::from_str("file")],
2229 Arc::new(HashMap::default()),
2230 )
2231 .await
2232 .unwrap();
2233 repo.commit(
2234 "Initial commit".into(),
2235 None,
2236 CommitOptions::default(),
2237 Arc::new(checkpoint_author_envs()),
2238 )
2239 .await
2240 .unwrap();
2241
2242 smol::fs::write(&file_path, "modified before checkpoint")
2243 .await
2244 .unwrap();
2245 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
2246 .await
2247 .unwrap();
2248 let checkpoint = repo.checkpoint().await.unwrap();
2249
2250 // Ensure the user can't see any branches after creating a checkpoint.
2251 assert_eq!(repo.branches().await.unwrap().len(), 1);
2252
2253 smol::fs::write(&file_path, "modified after checkpoint")
2254 .await
2255 .unwrap();
2256 repo.stage_paths(
2257 vec![RepoPath::from_str("file")],
2258 Arc::new(HashMap::default()),
2259 )
2260 .await
2261 .unwrap();
2262 repo.commit(
2263 "Commit after checkpoint".into(),
2264 None,
2265 CommitOptions::default(),
2266 Arc::new(checkpoint_author_envs()),
2267 )
2268 .await
2269 .unwrap();
2270
2271 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
2272 .await
2273 .unwrap();
2274 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
2275 .await
2276 .unwrap();
2277
2278 // Ensure checkpoint stays alive even after a Git GC.
2279 repo.gc().await.unwrap();
2280 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
2281
2282 assert_eq!(
2283 smol::fs::read_to_string(&file_path).await.unwrap(),
2284 "modified before checkpoint"
2285 );
2286 assert_eq!(
2287 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
2288 .await
2289 .unwrap(),
2290 "1"
2291 );
2292 // See TODO above
2293 // assert_eq!(
2294 // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
2295 // .await
2296 // .ok(),
2297 // None
2298 // );
2299 }
2300
2301 #[gpui::test]
2302 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
2303 cx.executor().allow_parking();
2304
2305 let repo_dir = tempfile::tempdir().unwrap();
2306 git2::Repository::init(repo_dir.path()).unwrap();
2307 let repo =
2308 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2309
2310 smol::fs::write(repo_dir.path().join("foo"), "foo")
2311 .await
2312 .unwrap();
2313 let checkpoint_sha = repo.checkpoint().await.unwrap();
2314
2315 // Ensure the user can't see any branches after creating a checkpoint.
2316 assert_eq!(repo.branches().await.unwrap().len(), 1);
2317
2318 smol::fs::write(repo_dir.path().join("foo"), "bar")
2319 .await
2320 .unwrap();
2321 smol::fs::write(repo_dir.path().join("baz"), "qux")
2322 .await
2323 .unwrap();
2324 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
2325 assert_eq!(
2326 smol::fs::read_to_string(repo_dir.path().join("foo"))
2327 .await
2328 .unwrap(),
2329 "foo"
2330 );
2331 // See TODOs above
2332 // assert_eq!(
2333 // smol::fs::read_to_string(repo_dir.path().join("baz"))
2334 // .await
2335 // .ok(),
2336 // None
2337 // );
2338 }
2339
2340 #[gpui::test]
2341 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
2342 cx.executor().allow_parking();
2343
2344 let repo_dir = tempfile::tempdir().unwrap();
2345 git2::Repository::init(repo_dir.path()).unwrap();
2346 let repo =
2347 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2348
2349 smol::fs::write(repo_dir.path().join("file1"), "content1")
2350 .await
2351 .unwrap();
2352 let checkpoint1 = repo.checkpoint().await.unwrap();
2353
2354 smol::fs::write(repo_dir.path().join("file2"), "content2")
2355 .await
2356 .unwrap();
2357 let checkpoint2 = repo.checkpoint().await.unwrap();
2358
2359 assert!(
2360 !repo
2361 .compare_checkpoints(checkpoint1, checkpoint2.clone())
2362 .await
2363 .unwrap()
2364 );
2365
2366 let checkpoint3 = repo.checkpoint().await.unwrap();
2367 assert!(
2368 repo.compare_checkpoints(checkpoint2, checkpoint3)
2369 .await
2370 .unwrap()
2371 );
2372 }
2373
2374 #[gpui::test]
2375 async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
2376 cx.executor().allow_parking();
2377
2378 let repo_dir = tempfile::tempdir().unwrap();
2379 let text_path = repo_dir.path().join("main.rs");
2380 let bin_path = repo_dir.path().join("binary.o");
2381
2382 git2::Repository::init(repo_dir.path()).unwrap();
2383
2384 smol::fs::write(&text_path, "fn main() {}").await.unwrap();
2385
2386 smol::fs::write(&bin_path, "some binary file here")
2387 .await
2388 .unwrap();
2389
2390 let repo =
2391 RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2392
2393 // initial commit
2394 repo.stage_paths(
2395 vec![RepoPath::from_str("main.rs")],
2396 Arc::new(HashMap::default()),
2397 )
2398 .await
2399 .unwrap();
2400 repo.commit(
2401 "Initial commit".into(),
2402 None,
2403 CommitOptions::default(),
2404 Arc::new(checkpoint_author_envs()),
2405 )
2406 .await
2407 .unwrap();
2408
2409 let checkpoint = repo.checkpoint().await.unwrap();
2410
2411 smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
2412 .await
2413 .unwrap();
2414 smol::fs::write(&bin_path, "Modified binary file")
2415 .await
2416 .unwrap();
2417
2418 repo.restore_checkpoint(checkpoint).await.unwrap();
2419
2420 // Text files should be restored to checkpoint state,
2421 // but binaries should not (they aren't tracked)
2422 assert_eq!(
2423 smol::fs::read_to_string(&text_path).await.unwrap(),
2424 "fn main() {}"
2425 );
2426
2427 assert_eq!(
2428 smol::fs::read_to_string(&bin_path).await.unwrap(),
2429 "Modified binary file"
2430 );
2431 }
2432
2433 #[test]
2434 fn test_branches_parsing() {
2435 // suppress "help: octal escapes are not supported, `\0` is always null"
2436 #[allow(clippy::octal_escapes)]
2437 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
2438 assert_eq!(
2439 parse_branch_input(input).unwrap(),
2440 vec![Branch {
2441 is_head: true,
2442 ref_name: "refs/heads/zed-patches".into(),
2443 upstream: Some(Upstream {
2444 ref_name: "refs/remotes/origin/zed-patches".into(),
2445 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
2446 ahead: 0,
2447 behind: 0
2448 })
2449 }),
2450 most_recent_commit: Some(CommitSummary {
2451 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
2452 subject: "generated protobuf".into(),
2453 commit_timestamp: 1733187470,
2454 author_name: SharedString::new("John Doe"),
2455 has_parent: false,
2456 })
2457 }]
2458 )
2459 }
2460
2461 impl RealGitRepository {
2462 /// Force a Git garbage collection on the repository.
2463 fn gc(&self) -> BoxFuture<'_, Result<()>> {
2464 let working_directory = self.working_directory();
2465 let git_binary_path = self.git_binary_path.clone();
2466 let executor = self.executor.clone();
2467 self.executor
2468 .spawn(async move {
2469 let git_binary_path = git_binary_path.clone();
2470 let working_directory = working_directory?;
2471 let git = GitBinary::new(git_binary_path, working_directory, executor);
2472 git.run(&["gc", "--prune"]).await?;
2473 Ok(())
2474 })
2475 .boxed()
2476 }
2477 }
2478}