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