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