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