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