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