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