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