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