1use crate::commit::parse_git_diff_name_status;
2use crate::stash::GitStash;
3use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
4use crate::{Oid, RunHook, SHORT_SHA_LENGTH};
5use anyhow::{Context as _, Result, anyhow, bail};
6use collections::HashMap;
7use futures::channel::oneshot;
8use futures::future::BoxFuture;
9use futures::io::BufWriter;
10use futures::{AsyncWriteExt, FutureExt as _, select_biased};
11use git2::{BranchType, ErrorCode};
12use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
13use parking_lot::Mutex;
14use rope::Rope;
15use schemars::JsonSchema;
16use serde::Deserialize;
17use smallvec::SmallVec;
18use smol::channel::Sender;
19use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
20use text::LineEnding;
21
22use std::collections::HashSet;
23use std::ffi::{OsStr, OsString};
24use std::process::{ExitStatus, Stdio};
25use std::str::FromStr;
26use std::{
27 cmp::Ordering,
28 future,
29 path::{Path, PathBuf},
30 sync::Arc,
31};
32use sum_tree::MapSeekTarget;
33use thiserror::Error;
34use util::command::new_smol_command;
35use util::paths::PathStyle;
36use util::rel_path::RelPath;
37use util::{ResultExt, paths};
38use uuid::Uuid;
39
40pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
41
42pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
43
44/// Format string used in graph log to get initial data for the git graph
45/// %H - Full commit hash
46/// %P - Parent hashes
47/// %D - Ref names
48/// %x00 - Null byte separator, used to split up commit data
49static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D";
50
51/// Number of commits to load per chunk for the git graph.
52pub const GRAPH_CHUNK_SIZE: usize = 1000;
53
54/// Commit data needed for the git graph visualization.
55#[derive(Debug, Clone)]
56pub struct GraphCommitData {
57 pub sha: Oid,
58 /// Most commits have a single parent, so we use a SmallVec to avoid allocations.
59 pub parents: SmallVec<[Oid; 1]>,
60 pub author_name: SharedString,
61 pub author_email: SharedString,
62 pub commit_timestamp: i64,
63 pub subject: SharedString,
64}
65
66#[derive(Debug)]
67pub struct InitialGraphCommitData {
68 pub sha: Oid,
69 pub parents: SmallVec<[Oid; 1]>,
70 pub ref_names: Vec<SharedString>,
71}
72
73struct CommitDataRequest {
74 sha: Oid,
75 response_tx: oneshot::Sender<Result<GraphCommitData>>,
76}
77
78pub struct CommitDataReader {
79 request_tx: smol::channel::Sender<CommitDataRequest>,
80 _task: Task<()>,
81}
82
83impl CommitDataReader {
84 pub async fn read(&self, sha: Oid) -> Result<GraphCommitData> {
85 let (response_tx, response_rx) = oneshot::channel();
86 self.request_tx
87 .send(CommitDataRequest { sha, response_tx })
88 .await
89 .map_err(|_| anyhow!("commit data reader task closed"))?;
90 response_rx
91 .await
92 .map_err(|_| anyhow!("commit data reader task dropped response"))?
93 }
94}
95
96fn parse_cat_file_commit(sha: Oid, content: &str) -> Option<GraphCommitData> {
97 let mut parents = SmallVec::new();
98 let mut author_name = SharedString::default();
99 let mut author_email = SharedString::default();
100 let mut commit_timestamp = 0i64;
101 let mut in_headers = true;
102 let mut subject = None;
103
104 for line in content.lines() {
105 if in_headers {
106 if line.is_empty() {
107 in_headers = false;
108 continue;
109 }
110
111 if let Some(parent_sha) = line.strip_prefix("parent ") {
112 if let Ok(oid) = Oid::from_str(parent_sha.trim()) {
113 parents.push(oid);
114 }
115 } else if let Some(author_line) = line.strip_prefix("author ") {
116 if let Some((name_email, _timestamp_tz)) = author_line.rsplit_once(' ') {
117 if let Some((name_email, timestamp_str)) = name_email.rsplit_once(' ') {
118 if let Ok(ts) = timestamp_str.parse::<i64>() {
119 commit_timestamp = ts;
120 }
121 if let Some((name, email)) = name_email.rsplit_once(" <") {
122 author_name = SharedString::from(name.to_string());
123 author_email =
124 SharedString::from(email.trim_end_matches('>').to_string());
125 }
126 }
127 }
128 }
129 } else if subject.is_none() {
130 subject = Some(SharedString::from(line.to_string()));
131 }
132 }
133
134 Some(GraphCommitData {
135 sha,
136 parents,
137 author_name,
138 author_email,
139 commit_timestamp,
140 subject: subject.unwrap_or_default(),
141 })
142}
143
144#[derive(Clone, Debug, Hash, PartialEq, Eq)]
145pub struct Branch {
146 pub is_head: bool,
147 pub ref_name: SharedString,
148 pub upstream: Option<Upstream>,
149 pub most_recent_commit: Option<CommitSummary>,
150}
151
152impl Branch {
153 pub fn name(&self) -> &str {
154 self.ref_name
155 .as_ref()
156 .strip_prefix("refs/heads/")
157 .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
158 .unwrap_or(self.ref_name.as_ref())
159 }
160
161 pub fn is_remote(&self) -> bool {
162 self.ref_name.starts_with("refs/remotes/")
163 }
164
165 pub fn remote_name(&self) -> Option<&str> {
166 self.ref_name
167 .strip_prefix("refs/remotes/")
168 .and_then(|stripped| stripped.split("/").next())
169 }
170
171 pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
172 self.upstream
173 .as_ref()
174 .and_then(|upstream| upstream.tracking.status())
175 }
176
177 pub fn priority_key(&self) -> (bool, Option<i64>) {
178 (
179 self.is_head,
180 self.most_recent_commit
181 .as_ref()
182 .map(|commit| commit.commit_timestamp),
183 )
184 }
185}
186
187#[derive(Clone, Debug, Hash, PartialEq, Eq)]
188pub struct Worktree {
189 pub path: PathBuf,
190 pub ref_name: SharedString,
191 pub sha: SharedString,
192}
193
194impl Worktree {
195 pub fn branch(&self) -> &str {
196 self.ref_name
197 .as_ref()
198 .strip_prefix("refs/heads/")
199 .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
200 .unwrap_or(self.ref_name.as_ref())
201 }
202}
203
204pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
205 let mut worktrees = Vec::new();
206 let entries = raw_worktrees.as_ref().split("\n\n");
207 for entry in entries {
208 let mut parts = entry.splitn(3, '\n');
209 let path = parts
210 .next()
211 .and_then(|p| p.split_once(' ').map(|(_, path)| path.to_string()));
212 let sha = parts
213 .next()
214 .and_then(|p| p.split_once(' ').map(|(_, sha)| sha.to_string()));
215 let ref_name = parts
216 .next()
217 .and_then(|p| p.split_once(' ').map(|(_, ref_name)| ref_name.to_string()));
218
219 if let (Some(path), Some(sha), Some(ref_name)) = (path, sha, ref_name) {
220 worktrees.push(Worktree {
221 path: PathBuf::from(path),
222 ref_name: ref_name.into(),
223 sha: sha.into(),
224 })
225 }
226 }
227
228 worktrees
229}
230
231#[derive(Clone, Debug, Hash, PartialEq, Eq)]
232pub struct Upstream {
233 pub ref_name: SharedString,
234 pub tracking: UpstreamTracking,
235}
236
237impl Upstream {
238 pub fn is_remote(&self) -> bool {
239 self.remote_name().is_some()
240 }
241
242 pub fn remote_name(&self) -> Option<&str> {
243 self.ref_name
244 .strip_prefix("refs/remotes/")
245 .and_then(|stripped| stripped.split("/").next())
246 }
247
248 pub fn stripped_ref_name(&self) -> Option<&str> {
249 self.ref_name.strip_prefix("refs/remotes/")
250 }
251
252 pub fn branch_name(&self) -> Option<&str> {
253 self.ref_name
254 .strip_prefix("refs/remotes/")
255 .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
256 }
257}
258
259#[derive(Clone, Copy, Default)]
260pub struct CommitOptions {
261 pub amend: bool,
262 pub signoff: bool,
263}
264
265#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
266pub enum UpstreamTracking {
267 /// Remote ref not present in local repository.
268 Gone,
269 /// Remote ref present in local repository (fetched from remote).
270 Tracked(UpstreamTrackingStatus),
271}
272
273impl From<UpstreamTrackingStatus> for UpstreamTracking {
274 fn from(status: UpstreamTrackingStatus) -> Self {
275 UpstreamTracking::Tracked(status)
276 }
277}
278
279impl UpstreamTracking {
280 pub fn is_gone(&self) -> bool {
281 matches!(self, UpstreamTracking::Gone)
282 }
283
284 pub fn status(&self) -> Option<UpstreamTrackingStatus> {
285 match self {
286 UpstreamTracking::Gone => None,
287 UpstreamTracking::Tracked(status) => Some(*status),
288 }
289 }
290}
291
292#[derive(Debug, Clone)]
293pub struct RemoteCommandOutput {
294 pub stdout: String,
295 pub stderr: String,
296}
297
298impl RemoteCommandOutput {
299 pub fn is_empty(&self) -> bool {
300 self.stdout.is_empty() && self.stderr.is_empty()
301 }
302}
303
304#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
305pub struct UpstreamTrackingStatus {
306 pub ahead: u32,
307 pub behind: u32,
308}
309
310#[derive(Clone, Debug, Hash, PartialEq, Eq)]
311pub struct CommitSummary {
312 pub sha: SharedString,
313 pub subject: SharedString,
314 /// This is a unix timestamp
315 pub commit_timestamp: i64,
316 pub author_name: SharedString,
317 pub has_parent: bool,
318}
319
320#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
321pub struct CommitDetails {
322 pub sha: SharedString,
323 pub message: SharedString,
324 pub commit_timestamp: i64,
325 pub author_email: SharedString,
326 pub author_name: SharedString,
327}
328
329#[derive(Clone, Debug, Hash, PartialEq, Eq)]
330pub struct FileHistoryEntry {
331 pub sha: SharedString,
332 pub subject: SharedString,
333 pub message: SharedString,
334 pub commit_timestamp: i64,
335 pub author_name: SharedString,
336 pub author_email: SharedString,
337}
338
339#[derive(Debug, Clone)]
340pub struct FileHistory {
341 pub entries: Vec<FileHistoryEntry>,
342 pub path: RepoPath,
343}
344
345#[derive(Debug)]
346pub struct CommitDiff {
347 pub files: Vec<CommitFile>,
348}
349
350#[derive(Debug)]
351pub struct CommitFile {
352 pub path: RepoPath,
353 pub old_text: Option<String>,
354 pub new_text: Option<String>,
355 pub is_binary: bool,
356}
357
358impl CommitDetails {
359 pub fn short_sha(&self) -> SharedString {
360 self.sha[..SHORT_SHA_LENGTH].to_string().into()
361 }
362}
363
364/// Detects if content is binary by checking for NUL bytes in the first 8000 bytes.
365/// This matches git's binary detection heuristic.
366pub fn is_binary_content(content: &[u8]) -> bool {
367 let check_len = content.len().min(8000);
368 content[..check_len].contains(&0)
369}
370
371#[derive(Debug, Clone, Hash, PartialEq, Eq)]
372pub struct Remote {
373 pub name: SharedString,
374}
375
376pub enum ResetMode {
377 /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
378 /// committed are now staged).
379 Soft,
380 /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
381 /// committed are now unstaged).
382 Mixed,
383}
384
385#[derive(Debug, Clone, Hash, PartialEq, Eq)]
386pub enum FetchOptions {
387 All,
388 Remote(Remote),
389}
390
391impl FetchOptions {
392 pub fn to_proto(&self) -> Option<String> {
393 match self {
394 FetchOptions::All => None,
395 FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
396 }
397 }
398
399 pub fn from_proto(remote_name: Option<String>) -> Self {
400 match remote_name {
401 Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
402 None => FetchOptions::All,
403 }
404 }
405
406 pub fn name(&self) -> SharedString {
407 match self {
408 Self::All => "Fetch all remotes".into(),
409 Self::Remote(remote) => remote.name.clone(),
410 }
411 }
412}
413
414impl std::fmt::Display for FetchOptions {
415 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416 match self {
417 FetchOptions::All => write!(f, "--all"),
418 FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
419 }
420 }
421}
422
423/// Modifies .git/info/exclude temporarily
424pub struct GitExcludeOverride {
425 git_exclude_path: PathBuf,
426 original_excludes: Option<String>,
427 added_excludes: Option<String>,
428}
429
430impl GitExcludeOverride {
431 const START_BLOCK_MARKER: &str = "\n\n# ====== Auto-added by Zed: =======\n";
432 const END_BLOCK_MARKER: &str = "\n# ====== End of auto-added by Zed =======\n";
433
434 pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
435 let original_excludes =
436 smol::fs::read_to_string(&git_exclude_path)
437 .await
438 .ok()
439 .map(|content| {
440 // Auto-generated lines are normally cleaned up in
441 // `restore_original()` or `drop()`, but may stuck in rare cases.
442 // Make sure to remove them.
443 Self::remove_auto_generated_block(&content)
444 });
445
446 Ok(GitExcludeOverride {
447 git_exclude_path,
448 original_excludes,
449 added_excludes: None,
450 })
451 }
452
453 pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
454 self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
455 format!("{already_added}\n{excludes}")
456 } else {
457 excludes.to_string()
458 });
459
460 let mut content = self.original_excludes.clone().unwrap_or_default();
461
462 content.push_str(Self::START_BLOCK_MARKER);
463 content.push_str(self.added_excludes.as_ref().unwrap());
464 content.push_str(Self::END_BLOCK_MARKER);
465
466 smol::fs::write(&self.git_exclude_path, content).await?;
467 Ok(())
468 }
469
470 pub async fn restore_original(&mut self) -> Result<()> {
471 if let Some(ref original) = self.original_excludes {
472 smol::fs::write(&self.git_exclude_path, original).await?;
473 } else if self.git_exclude_path.exists() {
474 smol::fs::remove_file(&self.git_exclude_path).await?;
475 }
476
477 self.added_excludes = None;
478
479 Ok(())
480 }
481
482 fn remove_auto_generated_block(content: &str) -> String {
483 let start_marker = Self::START_BLOCK_MARKER;
484 let end_marker = Self::END_BLOCK_MARKER;
485 let mut content = content.to_string();
486
487 let start_index = content.find(start_marker);
488 let end_index = content.rfind(end_marker);
489
490 if let (Some(start), Some(end)) = (start_index, end_index) {
491 if end > start {
492 content.replace_range(start..end + end_marker.len(), "");
493 }
494 }
495
496 // Older versions of Zed didn't have end-of-block markers,
497 // so it's impossible to determine auto-generated lines.
498 // Conservatively remove the standard list of excludes
499 let standard_excludes = format!(
500 "{}{}",
501 Self::START_BLOCK_MARKER,
502 include_str!("./checkpoint.gitignore")
503 );
504 content = content.replace(&standard_excludes, "");
505
506 content
507 }
508}
509
510impl Drop for GitExcludeOverride {
511 fn drop(&mut self) {
512 if self.added_excludes.is_some() {
513 let git_exclude_path = self.git_exclude_path.clone();
514 let original_excludes = self.original_excludes.clone();
515 smol::spawn(async move {
516 if let Some(original) = original_excludes {
517 smol::fs::write(&git_exclude_path, original).await
518 } else {
519 smol::fs::remove_file(&git_exclude_path).await
520 }
521 })
522 .detach();
523 }
524 }
525}
526
527#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Copy)]
528pub enum LogOrder {
529 #[default]
530 DateOrder,
531 TopoOrder,
532 AuthorDateOrder,
533 ReverseChronological,
534}
535
536impl LogOrder {
537 pub fn as_arg(&self) -> &'static str {
538 match self {
539 LogOrder::DateOrder => "--date-order",
540 LogOrder::TopoOrder => "--topo-order",
541 LogOrder::AuthorDateOrder => "--author-date-order",
542 LogOrder::ReverseChronological => "--reverse",
543 }
544 }
545}
546
547#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
548pub enum LogSource {
549 #[default]
550 All,
551 Branch(SharedString),
552 Sha(Oid),
553}
554
555impl LogSource {
556 fn get_arg(&self) -> Result<&str> {
557 match self {
558 LogSource::All => Ok("--all"),
559 LogSource::Branch(branch) => Ok(branch.as_str()),
560 LogSource::Sha(oid) => {
561 str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")
562 }
563 }
564 }
565}
566
567pub trait GitRepository: Send + Sync {
568 fn reload_index(&self);
569
570 /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
571 ///
572 /// Also returns `None` for symlinks.
573 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
574
575 /// 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.
576 ///
577 /// Also returns `None` for symlinks.
578 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
579 fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
580
581 fn set_index_text(
582 &self,
583 path: RepoPath,
584 content: Option<String>,
585 env: Arc<HashMap<String, String>>,
586 is_executable: bool,
587 ) -> BoxFuture<'_, anyhow::Result<()>>;
588
589 /// Returns the URL of the remote with the given name.
590 fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>>;
591
592 /// Resolve a list of refs to SHAs.
593 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
594
595 fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
596 async move {
597 self.revparse_batch(vec!["HEAD".into()])
598 .await
599 .unwrap_or_default()
600 .into_iter()
601 .next()
602 .flatten()
603 }
604 .boxed()
605 }
606
607 fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
608
609 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
610 fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
611
612 fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
613
614 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
615
616 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
617 fn create_branch(&self, name: String, base_branch: Option<String>)
618 -> BoxFuture<'_, Result<()>>;
619 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
620
621 fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
622
623 fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
624
625 fn create_worktree(
626 &self,
627 name: String,
628 directory: PathBuf,
629 from_commit: Option<String>,
630 ) -> BoxFuture<'_, Result<()>>;
631
632 fn reset(
633 &self,
634 commit: String,
635 mode: ResetMode,
636 env: Arc<HashMap<String, String>>,
637 ) -> BoxFuture<'_, Result<()>>;
638
639 fn checkout_files(
640 &self,
641 commit: String,
642 paths: Vec<RepoPath>,
643 env: Arc<HashMap<String, String>>,
644 ) -> BoxFuture<'_, Result<()>>;
645
646 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
647
648 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
649 fn blame(
650 &self,
651 path: RepoPath,
652 content: Rope,
653 line_ending: LineEnding,
654 ) -> BoxFuture<'_, Result<crate::blame::Blame>>;
655 fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>>;
656 fn file_history_paginated(
657 &self,
658 path: RepoPath,
659 skip: usize,
660 limit: Option<usize>,
661 ) -> BoxFuture<'_, Result<FileHistory>>;
662
663 /// Returns the absolute path to the repository. For worktrees, this will be the path to the
664 /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
665 fn path(&self) -> PathBuf;
666
667 fn main_repository_path(&self) -> PathBuf;
668
669 /// Updates the index to match the worktree at the given paths.
670 ///
671 /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
672 fn stage_paths(
673 &self,
674 paths: Vec<RepoPath>,
675 env: Arc<HashMap<String, String>>,
676 ) -> BoxFuture<'_, Result<()>>;
677 /// Updates the index to match HEAD at the given paths.
678 ///
679 /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
680 fn unstage_paths(
681 &self,
682 paths: Vec<RepoPath>,
683 env: Arc<HashMap<String, String>>,
684 ) -> BoxFuture<'_, Result<()>>;
685
686 fn run_hook(
687 &self,
688 hook: RunHook,
689 env: Arc<HashMap<String, String>>,
690 ) -> BoxFuture<'_, Result<()>>;
691
692 fn commit(
693 &self,
694 message: SharedString,
695 name_and_email: Option<(SharedString, SharedString)>,
696 options: CommitOptions,
697 askpass: AskPassDelegate,
698 env: Arc<HashMap<String, String>>,
699 ) -> BoxFuture<'_, Result<()>>;
700
701 fn stash_paths(
702 &self,
703 paths: Vec<RepoPath>,
704 env: Arc<HashMap<String, String>>,
705 ) -> BoxFuture<'_, Result<()>>;
706
707 fn stash_pop(
708 &self,
709 index: Option<usize>,
710 env: Arc<HashMap<String, String>>,
711 ) -> BoxFuture<'_, Result<()>>;
712
713 fn stash_apply(
714 &self,
715 index: Option<usize>,
716 env: Arc<HashMap<String, String>>,
717 ) -> BoxFuture<'_, Result<()>>;
718
719 fn stash_drop(
720 &self,
721 index: Option<usize>,
722 env: Arc<HashMap<String, String>>,
723 ) -> BoxFuture<'_, Result<()>>;
724
725 fn push(
726 &self,
727 branch_name: String,
728 remote_branch_name: String,
729 upstream_name: String,
730 options: Option<PushOptions>,
731 askpass: AskPassDelegate,
732 env: Arc<HashMap<String, String>>,
733 // This method takes an AsyncApp to ensure it's invoked on the main thread,
734 // otherwise git-credentials-manager won't work.
735 cx: AsyncApp,
736 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
737
738 fn pull(
739 &self,
740 branch_name: Option<String>,
741 upstream_name: String,
742 rebase: bool,
743 askpass: AskPassDelegate,
744 env: Arc<HashMap<String, String>>,
745 // This method takes an AsyncApp to ensure it's invoked on the main thread,
746 // otherwise git-credentials-manager won't work.
747 cx: AsyncApp,
748 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
749
750 fn fetch(
751 &self,
752 fetch_options: FetchOptions,
753 askpass: AskPassDelegate,
754 env: Arc<HashMap<String, String>>,
755 // This method takes an AsyncApp to ensure it's invoked on the main thread,
756 // otherwise git-credentials-manager won't work.
757 cx: AsyncApp,
758 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
759
760 fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
761
762 fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
763
764 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>>;
765
766 fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>;
767
768 fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>;
769
770 /// returns a list of remote branches that contain HEAD
771 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
772
773 /// Run git diff
774 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
775
776 /// Creates a checkpoint for the repository.
777 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
778
779 /// Resets to a previously-created checkpoint.
780 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
781
782 /// Compares two checkpoints, returning true if they are equal
783 fn compare_checkpoints(
784 &self,
785 left: GitRepositoryCheckpoint,
786 right: GitRepositoryCheckpoint,
787 ) -> BoxFuture<'_, Result<bool>>;
788
789 /// Computes a diff between two checkpoints.
790 fn diff_checkpoints(
791 &self,
792 base_checkpoint: GitRepositoryCheckpoint,
793 target_checkpoint: GitRepositoryCheckpoint,
794 ) -> BoxFuture<'_, Result<String>>;
795
796 fn default_branch(
797 &self,
798 include_remote_name: bool,
799 ) -> BoxFuture<'_, Result<Option<SharedString>>>;
800
801 /// Runs `git rev-list --parents` to get the commit graph structure.
802 /// Returns commit SHAs and their parent SHAs for building the graph visualization.
803 fn initial_graph_data(
804 &self,
805 log_source: LogSource,
806 log_order: LogOrder,
807 request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
808 ) -> BoxFuture<'_, Result<()>>;
809
810 fn commit_data_reader(&self) -> Result<CommitDataReader>;
811}
812
813pub enum DiffType {
814 HeadToIndex,
815 HeadToWorktree,
816}
817
818#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
819pub enum PushOptions {
820 SetUpstream,
821 Force,
822}
823
824impl std::fmt::Debug for dyn GitRepository {
825 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
826 f.debug_struct("dyn GitRepository<...>").finish()
827 }
828}
829
830pub struct RealGitRepository {
831 pub repository: Arc<Mutex<git2::Repository>>,
832 pub system_git_binary_path: Option<PathBuf>,
833 pub any_git_binary_path: PathBuf,
834 any_git_binary_help_output: Arc<Mutex<Option<SharedString>>>,
835 executor: BackgroundExecutor,
836}
837
838impl RealGitRepository {
839 pub fn new(
840 dotgit_path: &Path,
841 bundled_git_binary_path: Option<PathBuf>,
842 system_git_binary_path: Option<PathBuf>,
843 executor: BackgroundExecutor,
844 ) -> Option<Self> {
845 let any_git_binary_path = system_git_binary_path.clone().or(bundled_git_binary_path)?;
846 let workdir_root = dotgit_path.parent()?;
847 let repository = git2::Repository::open(workdir_root).log_err()?;
848 Some(Self {
849 repository: Arc::new(Mutex::new(repository)),
850 system_git_binary_path,
851 any_git_binary_path,
852 executor,
853 any_git_binary_help_output: Arc::new(Mutex::new(None)),
854 })
855 }
856
857 fn working_directory(&self) -> Result<PathBuf> {
858 self.repository
859 .lock()
860 .workdir()
861 .context("failed to read git work directory")
862 .map(Path::to_path_buf)
863 }
864
865 async fn any_git_binary_help_output(&self) -> SharedString {
866 if let Some(output) = self.any_git_binary_help_output.lock().clone() {
867 return output;
868 }
869 let git_binary_path = self.any_git_binary_path.clone();
870 let executor = self.executor.clone();
871 let working_directory = self.working_directory();
872 let output: SharedString = self
873 .executor
874 .spawn(async move {
875 GitBinary::new(git_binary_path, working_directory?, executor)
876 .run(["help", "-a"])
877 .await
878 })
879 .await
880 .unwrap_or_default()
881 .into();
882 *self.any_git_binary_help_output.lock() = Some(output.clone());
883 output
884 }
885}
886
887#[derive(Clone, Debug)]
888pub struct GitRepositoryCheckpoint {
889 pub commit_sha: Oid,
890}
891
892#[derive(Debug)]
893pub struct GitCommitter {
894 pub name: Option<String>,
895 pub email: Option<String>,
896}
897
898pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
899 if cfg!(any(feature = "test-support", test)) {
900 return GitCommitter {
901 name: None,
902 email: None,
903 };
904 }
905
906 let git_binary_path =
907 if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
908 cx.update(|cx| {
909 cx.path_for_auxiliary_executable("git")
910 .context("could not find git binary path")
911 .log_err()
912 })
913 } else {
914 None
915 };
916
917 let git = GitBinary::new(
918 git_binary_path.unwrap_or(PathBuf::from("git")),
919 paths::home_dir().clone(),
920 cx.background_executor().clone(),
921 );
922
923 cx.background_spawn(async move {
924 let name = git.run(["config", "--global", "user.name"]).await.log_err();
925 let email = git
926 .run(["config", "--global", "user.email"])
927 .await
928 .log_err();
929 GitCommitter { name, email }
930 })
931 .await
932}
933
934impl GitRepository for RealGitRepository {
935 fn reload_index(&self) {
936 if let Ok(mut index) = self.repository.lock().index() {
937 _ = index.read(false);
938 }
939 }
940
941 fn path(&self) -> PathBuf {
942 let repo = self.repository.lock();
943 repo.path().into()
944 }
945
946 fn main_repository_path(&self) -> PathBuf {
947 let repo = self.repository.lock();
948 repo.commondir().into()
949 }
950
951 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
952 let git_binary_path = self.any_git_binary_path.clone();
953 let working_directory = self.working_directory();
954 self.executor
955 .spawn(async move {
956 let working_directory = working_directory?;
957 let output = new_smol_command(git_binary_path)
958 .current_dir(&working_directory)
959 .args([
960 "--no-optional-locks",
961 "show",
962 "--no-patch",
963 "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
964 &commit,
965 ])
966 .output()
967 .await?;
968 let output = std::str::from_utf8(&output.stdout)?;
969 let fields = output.split('\0').collect::<Vec<_>>();
970 if fields.len() != 6 {
971 bail!("unexpected git-show output for {commit:?}: {output:?}")
972 }
973 let sha = fields[0].to_string().into();
974 let message = fields[1].to_string().into();
975 let commit_timestamp = fields[2].parse()?;
976 let author_email = fields[3].to_string().into();
977 let author_name = fields[4].to_string().into();
978 Ok(CommitDetails {
979 sha,
980 message,
981 commit_timestamp,
982 author_email,
983 author_name,
984 })
985 })
986 .boxed()
987 }
988
989 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
990 let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
991 else {
992 return future::ready(Err(anyhow!("no working directory"))).boxed();
993 };
994 let git_binary_path = self.any_git_binary_path.clone();
995 cx.background_spawn(async move {
996 let show_output = util::command::new_smol_command(&git_binary_path)
997 .current_dir(&working_directory)
998 .args([
999 "--no-optional-locks",
1000 "show",
1001 "--format=",
1002 "-z",
1003 "--no-renames",
1004 "--name-status",
1005 "--first-parent",
1006 ])
1007 .arg(&commit)
1008 .stdin(Stdio::null())
1009 .stdout(Stdio::piped())
1010 .stderr(Stdio::piped())
1011 .output()
1012 .await
1013 .context("starting git show process")?;
1014
1015 let show_stdout = String::from_utf8_lossy(&show_output.stdout);
1016 let changes = parse_git_diff_name_status(&show_stdout);
1017 let parent_sha = format!("{}^", commit);
1018
1019 let mut cat_file_process = util::command::new_smol_command(&git_binary_path)
1020 .current_dir(&working_directory)
1021 .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
1022 .stdin(Stdio::piped())
1023 .stdout(Stdio::piped())
1024 .stderr(Stdio::piped())
1025 .spawn()
1026 .context("starting git cat-file process")?;
1027
1028 let mut files = Vec::<CommitFile>::new();
1029 let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
1030 let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
1031 let mut info_line = String::new();
1032 let mut newline = [b'\0'];
1033 for (path, status_code) in changes {
1034 // git-show outputs `/`-delimited paths even on Windows.
1035 let Some(rel_path) = RelPath::unix(path).log_err() else {
1036 continue;
1037 };
1038
1039 match status_code {
1040 StatusCode::Modified => {
1041 stdin.write_all(commit.as_bytes()).await?;
1042 stdin.write_all(b":").await?;
1043 stdin.write_all(path.as_bytes()).await?;
1044 stdin.write_all(b"\n").await?;
1045 stdin.write_all(parent_sha.as_bytes()).await?;
1046 stdin.write_all(b":").await?;
1047 stdin.write_all(path.as_bytes()).await?;
1048 stdin.write_all(b"\n").await?;
1049 }
1050 StatusCode::Added => {
1051 stdin.write_all(commit.as_bytes()).await?;
1052 stdin.write_all(b":").await?;
1053 stdin.write_all(path.as_bytes()).await?;
1054 stdin.write_all(b"\n").await?;
1055 }
1056 StatusCode::Deleted => {
1057 stdin.write_all(parent_sha.as_bytes()).await?;
1058 stdin.write_all(b":").await?;
1059 stdin.write_all(path.as_bytes()).await?;
1060 stdin.write_all(b"\n").await?;
1061 }
1062 _ => continue,
1063 }
1064 stdin.flush().await?;
1065
1066 info_line.clear();
1067 stdout.read_line(&mut info_line).await?;
1068
1069 let len = info_line.trim_end().parse().with_context(|| {
1070 format!("invalid object size output from cat-file {info_line}")
1071 })?;
1072 let mut text_bytes = vec![0; len];
1073 stdout.read_exact(&mut text_bytes).await?;
1074 stdout.read_exact(&mut newline).await?;
1075
1076 let mut old_text = None;
1077 let mut new_text = None;
1078 let mut is_binary = is_binary_content(&text_bytes);
1079 let text = if is_binary {
1080 String::new()
1081 } else {
1082 String::from_utf8_lossy(&text_bytes).to_string()
1083 };
1084
1085 match status_code {
1086 StatusCode::Modified => {
1087 info_line.clear();
1088 stdout.read_line(&mut info_line).await?;
1089 let len = info_line.trim_end().parse().with_context(|| {
1090 format!("invalid object size output from cat-file {}", info_line)
1091 })?;
1092 let mut parent_bytes = vec![0; len];
1093 stdout.read_exact(&mut parent_bytes).await?;
1094 stdout.read_exact(&mut newline).await?;
1095 is_binary = is_binary || is_binary_content(&parent_bytes);
1096 if is_binary {
1097 old_text = Some(String::new());
1098 new_text = Some(String::new());
1099 } else {
1100 old_text = Some(String::from_utf8_lossy(&parent_bytes).to_string());
1101 new_text = Some(text);
1102 }
1103 }
1104 StatusCode::Added => new_text = Some(text),
1105 StatusCode::Deleted => old_text = Some(text),
1106 _ => continue,
1107 }
1108
1109 files.push(CommitFile {
1110 path: RepoPath(Arc::from(rel_path)),
1111 old_text,
1112 new_text,
1113 is_binary,
1114 })
1115 }
1116
1117 Ok(CommitDiff { files })
1118 })
1119 .boxed()
1120 }
1121
1122 fn reset(
1123 &self,
1124 commit: String,
1125 mode: ResetMode,
1126 env: Arc<HashMap<String, String>>,
1127 ) -> BoxFuture<'_, Result<()>> {
1128 async move {
1129 let working_directory = self.working_directory();
1130
1131 let mode_flag = match mode {
1132 ResetMode::Mixed => "--mixed",
1133 ResetMode::Soft => "--soft",
1134 };
1135
1136 let output = new_smol_command(&self.any_git_binary_path)
1137 .envs(env.iter())
1138 .current_dir(&working_directory?)
1139 .args(["reset", mode_flag, &commit])
1140 .output()
1141 .await?;
1142 anyhow::ensure!(
1143 output.status.success(),
1144 "Failed to reset:\n{}",
1145 String::from_utf8_lossy(&output.stderr),
1146 );
1147 Ok(())
1148 }
1149 .boxed()
1150 }
1151
1152 fn checkout_files(
1153 &self,
1154 commit: String,
1155 paths: Vec<RepoPath>,
1156 env: Arc<HashMap<String, String>>,
1157 ) -> BoxFuture<'_, Result<()>> {
1158 let working_directory = self.working_directory();
1159 let git_binary_path = self.any_git_binary_path.clone();
1160 async move {
1161 if paths.is_empty() {
1162 return Ok(());
1163 }
1164
1165 let output = new_smol_command(&git_binary_path)
1166 .current_dir(&working_directory?)
1167 .envs(env.iter())
1168 .args(["checkout", &commit, "--"])
1169 .args(paths.iter().map(|path| path.as_unix_str()))
1170 .output()
1171 .await?;
1172 anyhow::ensure!(
1173 output.status.success(),
1174 "Failed to checkout files:\n{}",
1175 String::from_utf8_lossy(&output.stderr),
1176 );
1177 Ok(())
1178 }
1179 .boxed()
1180 }
1181
1182 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
1183 // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
1184 const GIT_MODE_SYMLINK: u32 = 0o120000;
1185
1186 let repo = self.repository.clone();
1187 self.executor
1188 .spawn(async move {
1189 fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
1190 // This check is required because index.get_path() unwraps internally :(
1191 let mut index = repo.index()?;
1192 index.read(false)?;
1193
1194 const STAGE_NORMAL: i32 = 0;
1195 let path = path.as_std_path();
1196 // `RepoPath` contains a `RelPath` which normalizes `.` into an empty path
1197 // `get_path` unwraps on empty paths though, so undo that normalization here
1198 let path = if path.components().next().is_none() {
1199 ".".as_ref()
1200 } else {
1201 path
1202 };
1203 let oid = match index.get_path(path, STAGE_NORMAL) {
1204 Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
1205 _ => return Ok(None),
1206 };
1207
1208 let content = repo.find_blob(oid)?.content().to_owned();
1209 Ok(String::from_utf8(content).ok())
1210 }
1211
1212 match logic(&repo.lock(), &path) {
1213 Ok(value) => return value,
1214 Err(err) => log::error!("Error loading index text: {:?}", err),
1215 }
1216 None
1217 })
1218 .boxed()
1219 }
1220
1221 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
1222 let repo = self.repository.clone();
1223 self.executor
1224 .spawn(async move {
1225 let repo = repo.lock();
1226 let head = repo.head().ok()?.peel_to_tree().log_err()?;
1227 let entry = head.get_path(path.as_std_path()).ok()?;
1228 if entry.filemode() == i32::from(git2::FileMode::Link) {
1229 return None;
1230 }
1231 let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
1232 String::from_utf8(content).ok()
1233 })
1234 .boxed()
1235 }
1236
1237 fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
1238 let repo = self.repository.clone();
1239 self.executor
1240 .spawn(async move {
1241 let repo = repo.lock();
1242 let content = repo.find_blob(oid.0)?.content().to_owned();
1243 Ok(String::from_utf8(content)?)
1244 })
1245 .boxed()
1246 }
1247
1248 fn set_index_text(
1249 &self,
1250 path: RepoPath,
1251 content: Option<String>,
1252 env: Arc<HashMap<String, String>>,
1253 is_executable: bool,
1254 ) -> BoxFuture<'_, anyhow::Result<()>> {
1255 let working_directory = self.working_directory();
1256 let git_binary_path = self.any_git_binary_path.clone();
1257 self.executor
1258 .spawn(async move {
1259 let working_directory = working_directory?;
1260 let mode = if is_executable { "100755" } else { "100644" };
1261
1262 if let Some(content) = content {
1263 let mut child = new_smol_command(&git_binary_path)
1264 .current_dir(&working_directory)
1265 .envs(env.iter())
1266 .args(["hash-object", "-w", "--stdin"])
1267 .stdin(Stdio::piped())
1268 .stdout(Stdio::piped())
1269 .spawn()?;
1270 let mut stdin = child.stdin.take().unwrap();
1271 stdin.write_all(content.as_bytes()).await?;
1272 stdin.flush().await?;
1273 drop(stdin);
1274 let output = child.output().await?.stdout;
1275 let sha = str::from_utf8(&output)?.trim();
1276
1277 log::debug!("indexing SHA: {sha}, path {path:?}");
1278
1279 let output = new_smol_command(&git_binary_path)
1280 .current_dir(&working_directory)
1281 .envs(env.iter())
1282 .args(["update-index", "--add", "--cacheinfo", mode, sha])
1283 .arg(path.as_unix_str())
1284 .output()
1285 .await?;
1286
1287 anyhow::ensure!(
1288 output.status.success(),
1289 "Failed to stage:\n{}",
1290 String::from_utf8_lossy(&output.stderr)
1291 );
1292 } else {
1293 log::debug!("removing path {path:?} from the index");
1294 let output = new_smol_command(&git_binary_path)
1295 .current_dir(&working_directory)
1296 .envs(env.iter())
1297 .args(["update-index", "--force-remove"])
1298 .arg(path.as_unix_str())
1299 .output()
1300 .await?;
1301 anyhow::ensure!(
1302 output.status.success(),
1303 "Failed to unstage:\n{}",
1304 String::from_utf8_lossy(&output.stderr)
1305 );
1306 }
1307
1308 Ok(())
1309 })
1310 .boxed()
1311 }
1312
1313 fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
1314 let repo = self.repository.clone();
1315 let name = name.to_owned();
1316 self.executor
1317 .spawn(async move {
1318 let repo = repo.lock();
1319 let remote = repo.find_remote(&name).ok()?;
1320 remote.url().map(|url| url.to_string())
1321 })
1322 .boxed()
1323 }
1324
1325 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
1326 let working_directory = self.working_directory();
1327 let git_binary_path = self.any_git_binary_path.clone();
1328 self.executor
1329 .spawn(async move {
1330 let working_directory = working_directory?;
1331 let mut process = new_smol_command(&git_binary_path)
1332 .current_dir(&working_directory)
1333 .args([
1334 "--no-optional-locks",
1335 "cat-file",
1336 "--batch-check=%(objectname)",
1337 ])
1338 .stdin(Stdio::piped())
1339 .stdout(Stdio::piped())
1340 .stderr(Stdio::piped())
1341 .spawn()?;
1342
1343 let stdin = process
1344 .stdin
1345 .take()
1346 .context("no stdin for git cat-file subprocess")?;
1347 let mut stdin = BufWriter::new(stdin);
1348 for rev in &revs {
1349 stdin.write_all(rev.as_bytes()).await?;
1350 stdin.write_all(b"\n").await?;
1351 }
1352 stdin.flush().await?;
1353 drop(stdin);
1354
1355 let output = process.output().await?;
1356 let output = std::str::from_utf8(&output.stdout)?;
1357 let shas = output
1358 .lines()
1359 .map(|line| {
1360 if line.ends_with("missing") {
1361 None
1362 } else {
1363 Some(line.to_string())
1364 }
1365 })
1366 .collect::<Vec<_>>();
1367
1368 if shas.len() != revs.len() {
1369 // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
1370 bail!("unexpected number of shas")
1371 }
1372
1373 Ok(shas)
1374 })
1375 .boxed()
1376 }
1377
1378 fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
1379 let path = self.path().join("MERGE_MSG");
1380 self.executor
1381 .spawn(async move { std::fs::read_to_string(&path).ok() })
1382 .boxed()
1383 }
1384
1385 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
1386 let git_binary_path = self.any_git_binary_path.clone();
1387 let working_directory = match self.working_directory() {
1388 Ok(working_directory) => working_directory,
1389 Err(e) => return Task::ready(Err(e)),
1390 };
1391 let args = git_status_args(path_prefixes);
1392 log::debug!("Checking for git status in {path_prefixes:?}");
1393 self.executor.spawn(async move {
1394 let output = new_smol_command(&git_binary_path)
1395 .current_dir(working_directory)
1396 .args(args)
1397 .output()
1398 .await?;
1399 if output.status.success() {
1400 let stdout = String::from_utf8_lossy(&output.stdout);
1401 stdout.parse()
1402 } else {
1403 let stderr = String::from_utf8_lossy(&output.stderr);
1404 anyhow::bail!("git status failed: {stderr}");
1405 }
1406 })
1407 }
1408
1409 fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
1410 let git_binary_path = self.any_git_binary_path.clone();
1411 let working_directory = match self.working_directory() {
1412 Ok(working_directory) => working_directory,
1413 Err(e) => return Task::ready(Err(e)).boxed(),
1414 };
1415
1416 let mut args = vec![
1417 OsString::from("--no-optional-locks"),
1418 OsString::from("diff-tree"),
1419 OsString::from("-r"),
1420 OsString::from("-z"),
1421 OsString::from("--no-renames"),
1422 ];
1423 match request {
1424 DiffTreeType::MergeBase { base, head } => {
1425 args.push("--merge-base".into());
1426 args.push(OsString::from(base.as_str()));
1427 args.push(OsString::from(head.as_str()));
1428 }
1429 DiffTreeType::Since { base, head } => {
1430 args.push(OsString::from(base.as_str()));
1431 args.push(OsString::from(head.as_str()));
1432 }
1433 }
1434
1435 self.executor
1436 .spawn(async move {
1437 let output = new_smol_command(&git_binary_path)
1438 .current_dir(working_directory)
1439 .args(args)
1440 .output()
1441 .await?;
1442 if output.status.success() {
1443 let stdout = String::from_utf8_lossy(&output.stdout);
1444 stdout.parse()
1445 } else {
1446 let stderr = String::from_utf8_lossy(&output.stderr);
1447 anyhow::bail!("git status failed: {stderr}");
1448 }
1449 })
1450 .boxed()
1451 }
1452
1453 fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
1454 let git_binary_path = self.any_git_binary_path.clone();
1455 let working_directory = self.working_directory();
1456 self.executor
1457 .spawn(async move {
1458 let output = new_smol_command(&git_binary_path)
1459 .current_dir(working_directory?)
1460 .args(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
1461 .output()
1462 .await?;
1463 if output.status.success() {
1464 let stdout = String::from_utf8_lossy(&output.stdout);
1465 stdout.parse()
1466 } else {
1467 let stderr = String::from_utf8_lossy(&output.stderr);
1468 anyhow::bail!("git status failed: {stderr}");
1469 }
1470 })
1471 .boxed()
1472 }
1473
1474 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
1475 let working_directory = self.working_directory();
1476 let git_binary_path = self.any_git_binary_path.clone();
1477 self.executor
1478 .spawn(async move {
1479 let fields = [
1480 "%(HEAD)",
1481 "%(objectname)",
1482 "%(parent)",
1483 "%(refname)",
1484 "%(upstream)",
1485 "%(upstream:track)",
1486 "%(committerdate:unix)",
1487 "%(authorname)",
1488 "%(contents:subject)",
1489 ]
1490 .join("%00");
1491 let args = vec![
1492 "for-each-ref",
1493 "refs/heads/**/*",
1494 "refs/remotes/**/*",
1495 "--format",
1496 &fields,
1497 ];
1498 let working_directory = working_directory?;
1499 let output = new_smol_command(&git_binary_path)
1500 .current_dir(&working_directory)
1501 .args(args)
1502 .output()
1503 .await?;
1504
1505 anyhow::ensure!(
1506 output.status.success(),
1507 "Failed to git git branches:\n{}",
1508 String::from_utf8_lossy(&output.stderr)
1509 );
1510
1511 let input = String::from_utf8_lossy(&output.stdout);
1512
1513 let mut branches = parse_branch_input(&input)?;
1514 if branches.is_empty() {
1515 let args = vec!["symbolic-ref", "--quiet", "HEAD"];
1516
1517 let output = new_smol_command(&git_binary_path)
1518 .current_dir(&working_directory)
1519 .args(args)
1520 .output()
1521 .await?;
1522
1523 // git symbolic-ref returns a non-0 exit code if HEAD points
1524 // to something other than a branch
1525 if output.status.success() {
1526 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
1527
1528 branches.push(Branch {
1529 ref_name: name.into(),
1530 is_head: true,
1531 upstream: None,
1532 most_recent_commit: None,
1533 });
1534 }
1535 }
1536
1537 Ok(branches)
1538 })
1539 .boxed()
1540 }
1541
1542 fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
1543 let git_binary_path = self.any_git_binary_path.clone();
1544 let working_directory = self.working_directory();
1545 self.executor
1546 .spawn(async move {
1547 let output = new_smol_command(&git_binary_path)
1548 .current_dir(working_directory?)
1549 .args(&["--no-optional-locks", "worktree", "list", "--porcelain"])
1550 .output()
1551 .await?;
1552 if output.status.success() {
1553 let stdout = String::from_utf8_lossy(&output.stdout);
1554 Ok(parse_worktrees_from_str(&stdout))
1555 } else {
1556 let stderr = String::from_utf8_lossy(&output.stderr);
1557 anyhow::bail!("git worktree list failed: {stderr}");
1558 }
1559 })
1560 .boxed()
1561 }
1562
1563 fn create_worktree(
1564 &self,
1565 name: String,
1566 directory: PathBuf,
1567 from_commit: Option<String>,
1568 ) -> BoxFuture<'_, Result<()>> {
1569 let git_binary_path = self.any_git_binary_path.clone();
1570 let working_directory = self.working_directory();
1571 let final_path = directory.join(&name);
1572 let mut args = vec![
1573 OsString::from("--no-optional-locks"),
1574 OsString::from("worktree"),
1575 OsString::from("add"),
1576 OsString::from(final_path.as_os_str()),
1577 ];
1578 if let Some(from_commit) = from_commit {
1579 args.extend([
1580 OsString::from("-b"),
1581 OsString::from(name.as_str()),
1582 OsString::from(from_commit),
1583 ]);
1584 }
1585 self.executor
1586 .spawn(async move {
1587 let output = new_smol_command(&git_binary_path)
1588 .current_dir(working_directory?)
1589 .args(args)
1590 .output()
1591 .await?;
1592 if output.status.success() {
1593 Ok(())
1594 } else {
1595 let stderr = String::from_utf8_lossy(&output.stderr);
1596 anyhow::bail!("git worktree list failed: {stderr}");
1597 }
1598 })
1599 .boxed()
1600 }
1601
1602 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1603 let repo = self.repository.clone();
1604 let working_directory = self.working_directory();
1605 let git_binary_path = self.any_git_binary_path.clone();
1606 let executor = self.executor.clone();
1607 let branch = self.executor.spawn(async move {
1608 let repo = repo.lock();
1609 let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
1610 branch
1611 } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
1612 let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
1613
1614 let revision = revision.get();
1615 let branch_commit = revision.peel_to_commit()?;
1616 let mut branch = match repo.branch(&branch_name, &branch_commit, false) {
1617 Ok(branch) => branch,
1618 Err(err) if err.code() == ErrorCode::Exists => {
1619 repo.find_branch(&branch_name, BranchType::Local)?
1620 }
1621 Err(err) => {
1622 return Err(err.into());
1623 }
1624 };
1625
1626 branch.set_upstream(Some(&name))?;
1627 branch
1628 } else {
1629 anyhow::bail!("Branch '{}' not found", name);
1630 };
1631
1632 Ok(branch
1633 .name()?
1634 .context("cannot checkout anonymous branch")?
1635 .to_string())
1636 });
1637
1638 self.executor
1639 .spawn(async move {
1640 let branch = branch.await?;
1641 GitBinary::new(git_binary_path, working_directory?, executor)
1642 .run(&["checkout", &branch])
1643 .await?;
1644 anyhow::Ok(())
1645 })
1646 .boxed()
1647 }
1648
1649 fn create_branch(
1650 &self,
1651 name: String,
1652 base_branch: Option<String>,
1653 ) -> BoxFuture<'_, Result<()>> {
1654 let git_binary_path = self.any_git_binary_path.clone();
1655 let working_directory = self.working_directory();
1656 let executor = self.executor.clone();
1657
1658 self.executor
1659 .spawn(async move {
1660 let mut args = vec!["switch", "-c", &name];
1661 let base_branch_str;
1662 if let Some(ref base) = base_branch {
1663 base_branch_str = base.clone();
1664 args.push(&base_branch_str);
1665 }
1666
1667 GitBinary::new(git_binary_path, working_directory?, executor)
1668 .run(&args)
1669 .await?;
1670 anyhow::Ok(())
1671 })
1672 .boxed()
1673 }
1674
1675 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
1676 let git_binary_path = self.any_git_binary_path.clone();
1677 let working_directory = self.working_directory();
1678 let executor = self.executor.clone();
1679
1680 self.executor
1681 .spawn(async move {
1682 GitBinary::new(git_binary_path, working_directory?, executor)
1683 .run(&["branch", "-m", &branch, &new_name])
1684 .await?;
1685 anyhow::Ok(())
1686 })
1687 .boxed()
1688 }
1689
1690 fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1691 let git_binary_path = self.any_git_binary_path.clone();
1692 let working_directory = self.working_directory();
1693 let executor = self.executor.clone();
1694
1695 self.executor
1696 .spawn(async move {
1697 GitBinary::new(git_binary_path, working_directory?, executor)
1698 .run(&["branch", "-d", &name])
1699 .await?;
1700 anyhow::Ok(())
1701 })
1702 .boxed()
1703 }
1704
1705 fn blame(
1706 &self,
1707 path: RepoPath,
1708 content: Rope,
1709 line_ending: LineEnding,
1710 ) -> BoxFuture<'_, Result<crate::blame::Blame>> {
1711 let working_directory = self.working_directory();
1712 let git_binary_path = self.any_git_binary_path.clone();
1713 let executor = self.executor.clone();
1714
1715 executor
1716 .spawn(async move {
1717 crate::blame::Blame::for_path(
1718 &git_binary_path,
1719 &working_directory?,
1720 &path,
1721 &content,
1722 line_ending,
1723 )
1724 .await
1725 })
1726 .boxed()
1727 }
1728
1729 fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>> {
1730 self.file_history_paginated(path, 0, None)
1731 }
1732
1733 fn file_history_paginated(
1734 &self,
1735 path: RepoPath,
1736 skip: usize,
1737 limit: Option<usize>,
1738 ) -> BoxFuture<'_, Result<FileHistory>> {
1739 let working_directory = self.working_directory();
1740 let git_binary_path = self.any_git_binary_path.clone();
1741 self.executor
1742 .spawn(async move {
1743 let working_directory = working_directory?;
1744 // Use a unique delimiter with a hardcoded UUID to separate commits
1745 // This essentially eliminates any chance of encountering the delimiter in actual commit data
1746 let commit_delimiter =
1747 concat!("<<COMMIT_END-", "3f8a9c2e-7d4b-4e1a-9f6c-8b5d2a1e4c3f>>",);
1748
1749 let format_string = format!(
1750 "--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}",
1751 commit_delimiter
1752 );
1753
1754 let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string];
1755
1756 let skip_str;
1757 let limit_str;
1758 if skip > 0 {
1759 skip_str = skip.to_string();
1760 args.push("--skip");
1761 args.push(&skip_str);
1762 }
1763 if let Some(n) = limit {
1764 limit_str = n.to_string();
1765 args.push("-n");
1766 args.push(&limit_str);
1767 }
1768
1769 args.push("--");
1770
1771 let output = new_smol_command(&git_binary_path)
1772 .current_dir(&working_directory)
1773 .args(&args)
1774 .arg(path.as_unix_str())
1775 .output()
1776 .await?;
1777
1778 if !output.status.success() {
1779 let stderr = String::from_utf8_lossy(&output.stderr);
1780 bail!("git log failed: {stderr}");
1781 }
1782
1783 let stdout = std::str::from_utf8(&output.stdout)?;
1784 let mut entries = Vec::new();
1785
1786 for commit_block in stdout.split(commit_delimiter) {
1787 let commit_block = commit_block.trim();
1788 if commit_block.is_empty() {
1789 continue;
1790 }
1791
1792 let fields: Vec<&str> = commit_block.split('\0').collect();
1793 if fields.len() >= 6 {
1794 let sha = fields[0].trim().to_string().into();
1795 let subject = fields[1].trim().to_string().into();
1796 let message = fields[2].trim().to_string().into();
1797 let commit_timestamp = fields[3].trim().parse().unwrap_or(0);
1798 let author_name = fields[4].trim().to_string().into();
1799 let author_email = fields[5].trim().to_string().into();
1800
1801 entries.push(FileHistoryEntry {
1802 sha,
1803 subject,
1804 message,
1805 commit_timestamp,
1806 author_name,
1807 author_email,
1808 });
1809 }
1810 }
1811
1812 Ok(FileHistory { entries, path })
1813 })
1814 .boxed()
1815 }
1816
1817 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
1818 let working_directory = self.working_directory();
1819 let git_binary_path = self.any_git_binary_path.clone();
1820 self.executor
1821 .spawn(async move {
1822 let args = match diff {
1823 DiffType::HeadToIndex => Some("--staged"),
1824 DiffType::HeadToWorktree => None,
1825 };
1826
1827 let output = new_smol_command(&git_binary_path)
1828 .current_dir(&working_directory?)
1829 .args(["diff"])
1830 .args(args)
1831 .output()
1832 .await?;
1833
1834 anyhow::ensure!(
1835 output.status.success(),
1836 "Failed to run git diff:\n{}",
1837 String::from_utf8_lossy(&output.stderr)
1838 );
1839 Ok(String::from_utf8_lossy(&output.stdout).to_string())
1840 })
1841 .boxed()
1842 }
1843
1844 fn stage_paths(
1845 &self,
1846 paths: Vec<RepoPath>,
1847 env: Arc<HashMap<String, String>>,
1848 ) -> BoxFuture<'_, Result<()>> {
1849 let working_directory = self.working_directory();
1850 let git_binary_path = self.any_git_binary_path.clone();
1851 self.executor
1852 .spawn(async move {
1853 if !paths.is_empty() {
1854 let output = new_smol_command(&git_binary_path)
1855 .current_dir(&working_directory?)
1856 .envs(env.iter())
1857 .args(["update-index", "--add", "--remove", "--"])
1858 .args(paths.iter().map(|p| p.as_unix_str()))
1859 .output()
1860 .await?;
1861 anyhow::ensure!(
1862 output.status.success(),
1863 "Failed to stage paths:\n{}",
1864 String::from_utf8_lossy(&output.stderr),
1865 );
1866 }
1867 Ok(())
1868 })
1869 .boxed()
1870 }
1871
1872 fn unstage_paths(
1873 &self,
1874 paths: Vec<RepoPath>,
1875 env: Arc<HashMap<String, String>>,
1876 ) -> BoxFuture<'_, Result<()>> {
1877 let working_directory = self.working_directory();
1878 let git_binary_path = self.any_git_binary_path.clone();
1879
1880 self.executor
1881 .spawn(async move {
1882 if !paths.is_empty() {
1883 let output = new_smol_command(&git_binary_path)
1884 .current_dir(&working_directory?)
1885 .envs(env.iter())
1886 .args(["reset", "--quiet", "--"])
1887 .args(paths.iter().map(|p| p.as_std_path()))
1888 .output()
1889 .await?;
1890
1891 anyhow::ensure!(
1892 output.status.success(),
1893 "Failed to unstage:\n{}",
1894 String::from_utf8_lossy(&output.stderr),
1895 );
1896 }
1897 Ok(())
1898 })
1899 .boxed()
1900 }
1901
1902 fn stash_paths(
1903 &self,
1904 paths: Vec<RepoPath>,
1905 env: Arc<HashMap<String, String>>,
1906 ) -> BoxFuture<'_, Result<()>> {
1907 let working_directory = self.working_directory();
1908 let git_binary_path = self.any_git_binary_path.clone();
1909 self.executor
1910 .spawn(async move {
1911 let mut cmd = new_smol_command(&git_binary_path);
1912 cmd.current_dir(&working_directory?)
1913 .envs(env.iter())
1914 .args(["stash", "push", "--quiet"])
1915 .arg("--include-untracked");
1916
1917 cmd.args(paths.iter().map(|p| p.as_unix_str()));
1918
1919 let output = cmd.output().await?;
1920
1921 anyhow::ensure!(
1922 output.status.success(),
1923 "Failed to stash:\n{}",
1924 String::from_utf8_lossy(&output.stderr)
1925 );
1926 Ok(())
1927 })
1928 .boxed()
1929 }
1930
1931 fn stash_pop(
1932 &self,
1933 index: Option<usize>,
1934 env: Arc<HashMap<String, String>>,
1935 ) -> BoxFuture<'_, Result<()>> {
1936 let working_directory = self.working_directory();
1937 let git_binary_path = self.any_git_binary_path.clone();
1938 self.executor
1939 .spawn(async move {
1940 let mut cmd = new_smol_command(git_binary_path);
1941 let mut args = vec!["stash".to_string(), "pop".to_string()];
1942 if let Some(index) = index {
1943 args.push(format!("stash@{{{}}}", index));
1944 }
1945 cmd.current_dir(&working_directory?)
1946 .envs(env.iter())
1947 .args(args);
1948
1949 let output = cmd.output().await?;
1950
1951 anyhow::ensure!(
1952 output.status.success(),
1953 "Failed to stash pop:\n{}",
1954 String::from_utf8_lossy(&output.stderr)
1955 );
1956 Ok(())
1957 })
1958 .boxed()
1959 }
1960
1961 fn stash_apply(
1962 &self,
1963 index: Option<usize>,
1964 env: Arc<HashMap<String, String>>,
1965 ) -> BoxFuture<'_, Result<()>> {
1966 let working_directory = self.working_directory();
1967 let git_binary_path = self.any_git_binary_path.clone();
1968 self.executor
1969 .spawn(async move {
1970 let mut cmd = new_smol_command(git_binary_path);
1971 let mut args = vec!["stash".to_string(), "apply".to_string()];
1972 if let Some(index) = index {
1973 args.push(format!("stash@{{{}}}", index));
1974 }
1975 cmd.current_dir(&working_directory?)
1976 .envs(env.iter())
1977 .args(args);
1978
1979 let output = cmd.output().await?;
1980
1981 anyhow::ensure!(
1982 output.status.success(),
1983 "Failed to apply stash:\n{}",
1984 String::from_utf8_lossy(&output.stderr)
1985 );
1986 Ok(())
1987 })
1988 .boxed()
1989 }
1990
1991 fn stash_drop(
1992 &self,
1993 index: Option<usize>,
1994 env: Arc<HashMap<String, String>>,
1995 ) -> BoxFuture<'_, Result<()>> {
1996 let working_directory = self.working_directory();
1997 let git_binary_path = self.any_git_binary_path.clone();
1998 self.executor
1999 .spawn(async move {
2000 let mut cmd = new_smol_command(git_binary_path);
2001 let mut args = vec!["stash".to_string(), "drop".to_string()];
2002 if let Some(index) = index {
2003 args.push(format!("stash@{{{}}}", index));
2004 }
2005 cmd.current_dir(&working_directory?)
2006 .envs(env.iter())
2007 .args(args);
2008
2009 let output = cmd.output().await?;
2010
2011 anyhow::ensure!(
2012 output.status.success(),
2013 "Failed to stash drop:\n{}",
2014 String::from_utf8_lossy(&output.stderr)
2015 );
2016 Ok(())
2017 })
2018 .boxed()
2019 }
2020
2021 fn commit(
2022 &self,
2023 message: SharedString,
2024 name_and_email: Option<(SharedString, SharedString)>,
2025 options: CommitOptions,
2026 ask_pass: AskPassDelegate,
2027 env: Arc<HashMap<String, String>>,
2028 ) -> BoxFuture<'_, Result<()>> {
2029 let working_directory = self.working_directory();
2030 let git_binary_path = self.any_git_binary_path.clone();
2031 let executor = self.executor.clone();
2032 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2033 // which we want to block on.
2034 async move {
2035 let mut cmd = new_smol_command(git_binary_path);
2036 cmd.current_dir(&working_directory?)
2037 .envs(env.iter())
2038 .args(["commit", "--quiet", "-m"])
2039 .arg(&message.to_string())
2040 .arg("--cleanup=strip")
2041 .arg("--no-verify")
2042 .stdout(smol::process::Stdio::piped())
2043 .stderr(smol::process::Stdio::piped());
2044
2045 if options.amend {
2046 cmd.arg("--amend");
2047 }
2048
2049 if options.signoff {
2050 cmd.arg("--signoff");
2051 }
2052
2053 if let Some((name, email)) = name_and_email {
2054 cmd.arg("--author").arg(&format!("{name} <{email}>"));
2055 }
2056
2057 run_git_command(env, ask_pass, cmd, executor).await?;
2058
2059 Ok(())
2060 }
2061 .boxed()
2062 }
2063
2064 fn push(
2065 &self,
2066 branch_name: String,
2067 remote_branch_name: String,
2068 remote_name: String,
2069 options: Option<PushOptions>,
2070 ask_pass: AskPassDelegate,
2071 env: Arc<HashMap<String, String>>,
2072 cx: AsyncApp,
2073 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2074 let working_directory = self.working_directory();
2075 let executor = cx.background_executor().clone();
2076 let git_binary_path = self.system_git_binary_path.clone();
2077 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2078 // which we want to block on.
2079 async move {
2080 let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?;
2081 let working_directory = working_directory?;
2082 let mut command = new_smol_command(git_binary_path);
2083 command
2084 .envs(env.iter())
2085 .current_dir(&working_directory)
2086 .args(["push"])
2087 .args(options.map(|option| match option {
2088 PushOptions::SetUpstream => "--set-upstream",
2089 PushOptions::Force => "--force-with-lease",
2090 }))
2091 .arg(remote_name)
2092 .arg(format!("{}:{}", branch_name, remote_branch_name))
2093 .stdin(smol::process::Stdio::null())
2094 .stdout(smol::process::Stdio::piped())
2095 .stderr(smol::process::Stdio::piped());
2096
2097 run_git_command(env, ask_pass, command, executor).await
2098 }
2099 .boxed()
2100 }
2101
2102 fn pull(
2103 &self,
2104 branch_name: Option<String>,
2105 remote_name: String,
2106 rebase: bool,
2107 ask_pass: AskPassDelegate,
2108 env: Arc<HashMap<String, String>>,
2109 cx: AsyncApp,
2110 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2111 let working_directory = self.working_directory();
2112 let executor = cx.background_executor().clone();
2113 let git_binary_path = self.system_git_binary_path.clone();
2114 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2115 // which we want to block on.
2116 async move {
2117 let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?;
2118 let mut command = new_smol_command(git_binary_path);
2119 command
2120 .envs(env.iter())
2121 .current_dir(&working_directory?)
2122 .arg("pull");
2123
2124 if rebase {
2125 command.arg("--rebase");
2126 }
2127
2128 command
2129 .arg(remote_name)
2130 .args(branch_name)
2131 .stdout(smol::process::Stdio::piped())
2132 .stderr(smol::process::Stdio::piped());
2133
2134 run_git_command(env, ask_pass, command, executor).await
2135 }
2136 .boxed()
2137 }
2138
2139 fn fetch(
2140 &self,
2141 fetch_options: FetchOptions,
2142 ask_pass: AskPassDelegate,
2143 env: Arc<HashMap<String, String>>,
2144 cx: AsyncApp,
2145 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2146 let working_directory = self.working_directory();
2147 let remote_name = format!("{}", fetch_options);
2148 let git_binary_path = self.system_git_binary_path.clone();
2149 let executor = cx.background_executor().clone();
2150 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2151 // which we want to block on.
2152 async move {
2153 let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?;
2154 let mut command = new_smol_command(git_binary_path);
2155 command
2156 .envs(env.iter())
2157 .current_dir(&working_directory?)
2158 .args(["fetch", &remote_name])
2159 .stdout(smol::process::Stdio::piped())
2160 .stderr(smol::process::Stdio::piped());
2161
2162 run_git_command(env, ask_pass, command, executor).await
2163 }
2164 .boxed()
2165 }
2166
2167 fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2168 let working_directory = self.working_directory();
2169 let git_binary_path = self.any_git_binary_path.clone();
2170 self.executor
2171 .spawn(async move {
2172 let working_directory = working_directory?;
2173 let output = new_smol_command(&git_binary_path)
2174 .current_dir(&working_directory)
2175 .args(["rev-parse", "--abbrev-ref"])
2176 .arg(format!("{branch}@{{push}}"))
2177 .output()
2178 .await?;
2179 if !output.status.success() {
2180 return Ok(None);
2181 }
2182 let remote_name = String::from_utf8_lossy(&output.stdout)
2183 .split('/')
2184 .next()
2185 .map(|name| Remote {
2186 name: name.trim().to_string().into(),
2187 });
2188
2189 Ok(remote_name)
2190 })
2191 .boxed()
2192 }
2193
2194 fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2195 let working_directory = self.working_directory();
2196 let git_binary_path = self.any_git_binary_path.clone();
2197 self.executor
2198 .spawn(async move {
2199 let working_directory = working_directory?;
2200 let output = new_smol_command(&git_binary_path)
2201 .current_dir(&working_directory)
2202 .args(["config", "--get"])
2203 .arg(format!("branch.{branch}.remote"))
2204 .output()
2205 .await?;
2206 if !output.status.success() {
2207 return Ok(None);
2208 }
2209
2210 let remote_name = String::from_utf8_lossy(&output.stdout);
2211 return Ok(Some(Remote {
2212 name: remote_name.trim().to_string().into(),
2213 }));
2214 })
2215 .boxed()
2216 }
2217
2218 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
2219 let working_directory = self.working_directory();
2220 let git_binary_path = self.any_git_binary_path.clone();
2221 self.executor
2222 .spawn(async move {
2223 let working_directory = working_directory?;
2224 let output = new_smol_command(&git_binary_path)
2225 .current_dir(&working_directory)
2226 .args(["remote", "-v"])
2227 .output()
2228 .await?;
2229
2230 anyhow::ensure!(
2231 output.status.success(),
2232 "Failed to get all remotes:\n{}",
2233 String::from_utf8_lossy(&output.stderr)
2234 );
2235 let remote_names: HashSet<Remote> = String::from_utf8_lossy(&output.stdout)
2236 .lines()
2237 .filter(|line| !line.is_empty())
2238 .filter_map(|line| {
2239 let mut split_line = line.split_whitespace();
2240 let remote_name = split_line.next()?;
2241
2242 Some(Remote {
2243 name: remote_name.trim().to_string().into(),
2244 })
2245 })
2246 .collect();
2247
2248 Ok(remote_names.into_iter().collect())
2249 })
2250 .boxed()
2251 }
2252
2253 fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> {
2254 let repo = self.repository.clone();
2255 self.executor
2256 .spawn(async move {
2257 let repo = repo.lock();
2258 repo.remote_delete(&name)?;
2259
2260 Ok(())
2261 })
2262 .boxed()
2263 }
2264
2265 fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> {
2266 let repo = self.repository.clone();
2267 self.executor
2268 .spawn(async move {
2269 let repo = repo.lock();
2270 repo.remote(&name, url.as_ref())?;
2271 Ok(())
2272 })
2273 .boxed()
2274 }
2275
2276 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
2277 let working_directory = self.working_directory();
2278 let git_binary_path = self.any_git_binary_path.clone();
2279 self.executor
2280 .spawn(async move {
2281 let working_directory = working_directory?;
2282 let git_cmd = async |args: &[&str]| -> Result<String> {
2283 let output = new_smol_command(&git_binary_path)
2284 .current_dir(&working_directory)
2285 .args(args)
2286 .output()
2287 .await?;
2288 anyhow::ensure!(
2289 output.status.success(),
2290 String::from_utf8_lossy(&output.stderr).to_string()
2291 );
2292 Ok(String::from_utf8(output.stdout)?)
2293 };
2294
2295 let head = git_cmd(&["rev-parse", "HEAD"])
2296 .await
2297 .context("Failed to get HEAD")?
2298 .trim()
2299 .to_owned();
2300
2301 let mut remote_branches = vec![];
2302 let mut add_if_matching = async |remote_head: &str| {
2303 if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
2304 && merge_base.trim() == head
2305 && let Some(s) = remote_head.strip_prefix("refs/remotes/")
2306 {
2307 remote_branches.push(s.to_owned().into());
2308 }
2309 };
2310
2311 // check the main branch of each remote
2312 let remotes = git_cmd(&["remote"])
2313 .await
2314 .context("Failed to get remotes")?;
2315 for remote in remotes.lines() {
2316 if let Ok(remote_head) =
2317 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
2318 {
2319 add_if_matching(remote_head.trim()).await;
2320 }
2321 }
2322
2323 // ... and the remote branch that the checked-out one is tracking
2324 if let Ok(remote_head) =
2325 git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
2326 {
2327 add_if_matching(remote_head.trim()).await;
2328 }
2329
2330 Ok(remote_branches)
2331 })
2332 .boxed()
2333 }
2334
2335 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
2336 let working_directory = self.working_directory();
2337 let git_binary_path = self.any_git_binary_path.clone();
2338 let executor = self.executor.clone();
2339 self.executor
2340 .spawn(async move {
2341 let working_directory = working_directory?;
2342 let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
2343 .envs(checkpoint_author_envs());
2344 git.with_temp_index(async |git| {
2345 let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
2346 let mut excludes = exclude_files(git).await?;
2347
2348 git.run(&["add", "--all"]).await?;
2349 let tree = git.run(&["write-tree"]).await?;
2350 let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
2351 git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
2352 .await?
2353 } else {
2354 git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
2355 };
2356
2357 excludes.restore_original().await?;
2358
2359 Ok(GitRepositoryCheckpoint {
2360 commit_sha: checkpoint_sha.parse()?,
2361 })
2362 })
2363 .await
2364 })
2365 .boxed()
2366 }
2367
2368 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
2369 let working_directory = self.working_directory();
2370 let git_binary_path = self.any_git_binary_path.clone();
2371
2372 let executor = self.executor.clone();
2373 self.executor
2374 .spawn(async move {
2375 let working_directory = working_directory?;
2376
2377 let git = GitBinary::new(git_binary_path, working_directory, executor);
2378 git.run(&[
2379 "restore",
2380 "--source",
2381 &checkpoint.commit_sha.to_string(),
2382 "--worktree",
2383 ".",
2384 ])
2385 .await?;
2386
2387 // TODO: We don't track binary and large files anymore,
2388 // so the following call would delete them.
2389 // Implement an alternative way to track files added by agent.
2390 //
2391 // git.with_temp_index(async move |git| {
2392 // git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
2393 // .await?;
2394 // git.run(&["clean", "-d", "--force"]).await
2395 // })
2396 // .await?;
2397
2398 Ok(())
2399 })
2400 .boxed()
2401 }
2402
2403 fn compare_checkpoints(
2404 &self,
2405 left: GitRepositoryCheckpoint,
2406 right: GitRepositoryCheckpoint,
2407 ) -> BoxFuture<'_, Result<bool>> {
2408 let working_directory = self.working_directory();
2409 let git_binary_path = self.any_git_binary_path.clone();
2410
2411 let executor = self.executor.clone();
2412 self.executor
2413 .spawn(async move {
2414 let working_directory = working_directory?;
2415 let git = GitBinary::new(git_binary_path, working_directory, executor);
2416 let result = git
2417 .run(&[
2418 "diff-tree",
2419 "--quiet",
2420 &left.commit_sha.to_string(),
2421 &right.commit_sha.to_string(),
2422 ])
2423 .await;
2424 match result {
2425 Ok(_) => Ok(true),
2426 Err(error) => {
2427 if let Some(GitBinaryCommandError { status, .. }) =
2428 error.downcast_ref::<GitBinaryCommandError>()
2429 && status.code() == Some(1)
2430 {
2431 return Ok(false);
2432 }
2433
2434 Err(error)
2435 }
2436 }
2437 })
2438 .boxed()
2439 }
2440
2441 fn diff_checkpoints(
2442 &self,
2443 base_checkpoint: GitRepositoryCheckpoint,
2444 target_checkpoint: GitRepositoryCheckpoint,
2445 ) -> BoxFuture<'_, Result<String>> {
2446 let working_directory = self.working_directory();
2447 let git_binary_path = self.any_git_binary_path.clone();
2448
2449 let executor = self.executor.clone();
2450 self.executor
2451 .spawn(async move {
2452 let working_directory = working_directory?;
2453 let git = GitBinary::new(git_binary_path, working_directory, executor);
2454 git.run(&[
2455 "diff",
2456 "--find-renames",
2457 "--patch",
2458 &base_checkpoint.commit_sha.to_string(),
2459 &target_checkpoint.commit_sha.to_string(),
2460 ])
2461 .await
2462 })
2463 .boxed()
2464 }
2465
2466 fn default_branch(
2467 &self,
2468 include_remote_name: bool,
2469 ) -> BoxFuture<'_, Result<Option<SharedString>>> {
2470 let working_directory = self.working_directory();
2471 let git_binary_path = self.any_git_binary_path.clone();
2472
2473 let executor = self.executor.clone();
2474 self.executor
2475 .spawn(async move {
2476 let working_directory = working_directory?;
2477 let git = GitBinary::new(git_binary_path, working_directory, executor);
2478
2479 let strip_prefix = if include_remote_name {
2480 "refs/remotes/"
2481 } else {
2482 "refs/remotes/upstream/"
2483 };
2484
2485 if let Ok(output) = git
2486 .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
2487 .await
2488 {
2489 let output = output
2490 .strip_prefix(strip_prefix)
2491 .map(|s| SharedString::from(s.to_owned()));
2492 return Ok(output);
2493 }
2494
2495 let strip_prefix = if include_remote_name {
2496 "refs/remotes/"
2497 } else {
2498 "refs/remotes/origin/"
2499 };
2500
2501 if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
2502 return Ok(output
2503 .strip_prefix(strip_prefix)
2504 .map(|s| SharedString::from(s.to_owned())));
2505 }
2506
2507 if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
2508 if git.run(&["rev-parse", &default_branch]).await.is_ok() {
2509 return Ok(Some(default_branch.into()));
2510 }
2511 }
2512
2513 if git.run(&["rev-parse", "master"]).await.is_ok() {
2514 return Ok(Some("master".into()));
2515 }
2516
2517 Ok(None)
2518 })
2519 .boxed()
2520 }
2521
2522 fn run_hook(
2523 &self,
2524 hook: RunHook,
2525 env: Arc<HashMap<String, String>>,
2526 ) -> BoxFuture<'_, Result<()>> {
2527 let working_directory = self.working_directory();
2528 let repository = self.repository.clone();
2529 let git_binary_path = self.any_git_binary_path.clone();
2530 let executor = self.executor.clone();
2531 let help_output = self.any_git_binary_help_output();
2532
2533 // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang.
2534 async move {
2535 let working_directory = working_directory?;
2536 if !help_output
2537 .await
2538 .lines()
2539 .any(|line| line.trim().starts_with("hook "))
2540 {
2541 let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str());
2542 if hook_abs_path.is_file() {
2543 let output = new_smol_command(&hook_abs_path)
2544 .envs(env.iter())
2545 .current_dir(&working_directory)
2546 .output()
2547 .await?;
2548
2549 if !output.status.success() {
2550 return Err(GitBinaryCommandError {
2551 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2552 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2553 status: output.status,
2554 }
2555 .into());
2556 }
2557 }
2558
2559 return Ok(());
2560 }
2561
2562 let git = GitBinary::new(git_binary_path, working_directory, executor)
2563 .envs(HashMap::clone(&env));
2564 git.run(&["hook", "run", "--ignore-missing", hook.as_str()])
2565 .await?;
2566 Ok(())
2567 }
2568 .boxed()
2569 }
2570
2571 fn initial_graph_data(
2572 &self,
2573 log_source: LogSource,
2574 log_order: LogOrder,
2575 request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
2576 ) -> BoxFuture<'_, Result<()>> {
2577 let git_binary_path = self.any_git_binary_path.clone();
2578 let working_directory = self.working_directory();
2579 let executor = self.executor.clone();
2580
2581 async move {
2582 let working_directory = working_directory?;
2583 let git = GitBinary::new(git_binary_path, working_directory, executor);
2584
2585 let mut command = git.build_command([
2586 "log",
2587 GRAPH_COMMIT_FORMAT,
2588 log_order.as_arg(),
2589 log_source.get_arg()?,
2590 ]);
2591 command.stdout(Stdio::piped());
2592 command.stderr(Stdio::null());
2593
2594 let mut child = command.spawn()?;
2595 let stdout = child.stdout.take().context("failed to get stdout")?;
2596 let mut reader = BufReader::new(stdout);
2597
2598 let mut line_buffer = String::new();
2599 let mut lines: Vec<String> = Vec::with_capacity(GRAPH_CHUNK_SIZE);
2600
2601 loop {
2602 line_buffer.clear();
2603 let bytes_read = reader.read_line(&mut line_buffer).await?;
2604
2605 if bytes_read == 0 {
2606 if !lines.is_empty() {
2607 let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
2608 if request_tx.send(commits).await.is_err() {
2609 log::warn!(
2610 "initial_graph_data: receiver dropped while sending commits"
2611 );
2612 }
2613 }
2614 break;
2615 }
2616
2617 let line = line_buffer.trim_end_matches('\n').to_string();
2618 lines.push(line);
2619
2620 if lines.len() >= GRAPH_CHUNK_SIZE {
2621 let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
2622 if request_tx.send(commits).await.is_err() {
2623 log::warn!("initial_graph_data: receiver dropped while streaming commits");
2624 break;
2625 }
2626 lines.clear();
2627 }
2628 }
2629
2630 child.status().await?;
2631 Ok(())
2632 }
2633 .boxed()
2634 }
2635
2636 fn commit_data_reader(&self) -> Result<CommitDataReader> {
2637 let git_binary_path = self.any_git_binary_path.clone();
2638 let working_directory = self
2639 .working_directory()
2640 .map_err(|_| anyhow!("no working directory"))?;
2641 let executor = self.executor.clone();
2642
2643 let (request_tx, request_rx) = smol::channel::bounded::<CommitDataRequest>(64);
2644
2645 let task = self.executor.spawn(async move {
2646 if let Err(error) =
2647 run_commit_data_reader(git_binary_path, working_directory, executor, request_rx)
2648 .await
2649 {
2650 log::error!("commit data reader failed: {error:?}");
2651 }
2652 });
2653
2654 Ok(CommitDataReader {
2655 request_tx,
2656 _task: task,
2657 })
2658 }
2659}
2660
2661async fn run_commit_data_reader(
2662 git_binary_path: PathBuf,
2663 working_directory: PathBuf,
2664 executor: BackgroundExecutor,
2665 request_rx: smol::channel::Receiver<CommitDataRequest>,
2666) -> Result<()> {
2667 let git = GitBinary::new(git_binary_path, working_directory, executor);
2668 let mut process = git
2669 .build_command(["--no-optional-locks", "cat-file", "--batch"])
2670 .stdin(Stdio::piped())
2671 .stdout(Stdio::piped())
2672 .stderr(Stdio::piped())
2673 .spawn()
2674 .context("starting git cat-file --batch process")?;
2675
2676 let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
2677 let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
2678
2679 const MAX_BATCH_SIZE: usize = 64;
2680
2681 while let Ok(first_request) = request_rx.recv().await {
2682 let mut pending_requests = vec![first_request];
2683
2684 while pending_requests.len() < MAX_BATCH_SIZE {
2685 match request_rx.try_recv() {
2686 Ok(request) => pending_requests.push(request),
2687 Err(_) => break,
2688 }
2689 }
2690
2691 for request in &pending_requests {
2692 stdin.write_all(request.sha.to_string().as_bytes()).await?;
2693 stdin.write_all(b"\n").await?;
2694 }
2695 stdin.flush().await?;
2696
2697 for request in pending_requests {
2698 let result = read_single_commit_response(&mut stdout, &request.sha).await;
2699 request.response_tx.send(result).ok();
2700 }
2701 }
2702
2703 drop(stdin);
2704 process.kill().ok();
2705
2706 Ok(())
2707}
2708
2709async fn read_single_commit_response(
2710 stdout: &mut BufReader<smol::process::ChildStdout>,
2711 sha: &Oid,
2712) -> Result<GraphCommitData> {
2713 let mut header_bytes = Vec::new();
2714 stdout.read_until(b'\n', &mut header_bytes).await?;
2715 let header_line = String::from_utf8_lossy(&header_bytes);
2716
2717 let parts: Vec<&str> = header_line.trim().split(' ').collect();
2718 if parts.len() < 3 {
2719 bail!("invalid cat-file header: {header_line}");
2720 }
2721
2722 let object_type = parts[1];
2723 if object_type == "missing" {
2724 bail!("object not found: {}", sha);
2725 }
2726
2727 if object_type != "commit" {
2728 bail!("expected commit object, got {object_type}");
2729 }
2730
2731 let size: usize = parts[2]
2732 .parse()
2733 .with_context(|| format!("invalid object size: {}", parts[2]))?;
2734
2735 let mut content = vec![0u8; size];
2736 stdout.read_exact(&mut content).await?;
2737
2738 let mut newline = [0u8; 1];
2739 stdout.read_exact(&mut newline).await?;
2740
2741 let content_str = String::from_utf8_lossy(&content);
2742 parse_cat_file_commit(*sha, &content_str)
2743 .ok_or_else(|| anyhow!("failed to parse commit {}", sha))
2744}
2745
2746fn parse_initial_graph_output<'a>(
2747 lines: impl Iterator<Item = &'a str>,
2748) -> Vec<Arc<InitialGraphCommitData>> {
2749 lines
2750 .filter(|line| !line.is_empty())
2751 .filter_map(|line| {
2752 // Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..."
2753 let mut parts = line.split('\x00');
2754
2755 let sha = Oid::from_str(parts.next()?).ok()?;
2756 let parents_str = parts.next()?;
2757 let parents = parents_str
2758 .split_whitespace()
2759 .filter_map(|p| Oid::from_str(p).ok())
2760 .collect();
2761
2762 let ref_names_str = parts.next().unwrap_or("");
2763 let ref_names = if ref_names_str.is_empty() {
2764 Vec::new()
2765 } else {
2766 ref_names_str
2767 .split(", ")
2768 .map(|s| SharedString::from(s.to_string()))
2769 .collect()
2770 };
2771
2772 Some(Arc::new(InitialGraphCommitData {
2773 sha,
2774 parents,
2775 ref_names,
2776 }))
2777 })
2778 .collect()
2779}
2780
2781fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
2782 let mut args = vec![
2783 OsString::from("--no-optional-locks"),
2784 OsString::from("status"),
2785 OsString::from("--porcelain=v1"),
2786 OsString::from("--untracked-files=all"),
2787 OsString::from("--no-renames"),
2788 OsString::from("-z"),
2789 ];
2790 args.extend(
2791 path_prefixes
2792 .iter()
2793 .map(|path_prefix| path_prefix.as_std_path().into()),
2794 );
2795 args.extend(path_prefixes.iter().map(|path_prefix| {
2796 if path_prefix.is_empty() {
2797 Path::new(".").into()
2798 } else {
2799 path_prefix.as_std_path().into()
2800 }
2801 }));
2802 args
2803}
2804
2805/// Temporarily git-ignore commonly ignored files and files over 2MB
2806async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
2807 const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
2808 let mut excludes = git.with_exclude_overrides().await?;
2809 excludes
2810 .add_excludes(include_str!("./checkpoint.gitignore"))
2811 .await?;
2812
2813 let working_directory = git.working_directory.clone();
2814 let untracked_files = git.list_untracked_files().await?;
2815 let excluded_paths = untracked_files.into_iter().map(|path| {
2816 let working_directory = working_directory.clone();
2817 smol::spawn(async move {
2818 let full_path = working_directory.join(path.clone());
2819 match smol::fs::metadata(&full_path).await {
2820 Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
2821 Some(PathBuf::from("/").join(path.clone()))
2822 }
2823 _ => None,
2824 }
2825 })
2826 });
2827
2828 let excluded_paths = futures::future::join_all(excluded_paths).await;
2829 let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
2830
2831 if !excluded_paths.is_empty() {
2832 let exclude_patterns = excluded_paths
2833 .into_iter()
2834 .map(|path| path.to_string_lossy().into_owned())
2835 .collect::<Vec<_>>()
2836 .join("\n");
2837 excludes.add_excludes(&exclude_patterns).await?;
2838 }
2839
2840 Ok(excludes)
2841}
2842
2843struct GitBinary {
2844 git_binary_path: PathBuf,
2845 working_directory: PathBuf,
2846 executor: BackgroundExecutor,
2847 index_file_path: Option<PathBuf>,
2848 envs: HashMap<String, String>,
2849}
2850
2851impl GitBinary {
2852 fn new(
2853 git_binary_path: PathBuf,
2854 working_directory: PathBuf,
2855 executor: BackgroundExecutor,
2856 ) -> Self {
2857 Self {
2858 git_binary_path,
2859 working_directory,
2860 executor,
2861 index_file_path: None,
2862 envs: HashMap::default(),
2863 }
2864 }
2865
2866 async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
2867 let status_output = self
2868 .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
2869 .await?;
2870
2871 let paths = status_output
2872 .split('\0')
2873 .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
2874 .map(|entry| PathBuf::from(&entry[3..]))
2875 .collect::<Vec<_>>();
2876 Ok(paths)
2877 }
2878
2879 fn envs(mut self, envs: HashMap<String, String>) -> Self {
2880 self.envs = envs;
2881 self
2882 }
2883
2884 pub async fn with_temp_index<R>(
2885 &mut self,
2886 f: impl AsyncFnOnce(&Self) -> Result<R>,
2887 ) -> Result<R> {
2888 let index_file_path = self.path_for_index_id(Uuid::new_v4());
2889
2890 let delete_temp_index = util::defer({
2891 let index_file_path = index_file_path.clone();
2892 let executor = self.executor.clone();
2893 move || {
2894 executor
2895 .spawn(async move {
2896 smol::fs::remove_file(index_file_path).await.log_err();
2897 })
2898 .detach();
2899 }
2900 });
2901
2902 // Copy the default index file so that Git doesn't have to rebuild the
2903 // whole index from scratch. This might fail if this is an empty repository.
2904 smol::fs::copy(
2905 self.working_directory.join(".git").join("index"),
2906 &index_file_path,
2907 )
2908 .await
2909 .ok();
2910
2911 self.index_file_path = Some(index_file_path.clone());
2912 let result = f(self).await;
2913 self.index_file_path = None;
2914 let result = result?;
2915
2916 smol::fs::remove_file(index_file_path).await.ok();
2917 delete_temp_index.abort();
2918
2919 Ok(result)
2920 }
2921
2922 pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
2923 let path = self
2924 .working_directory
2925 .join(".git")
2926 .join("info")
2927 .join("exclude");
2928
2929 GitExcludeOverride::new(path).await
2930 }
2931
2932 fn path_for_index_id(&self, id: Uuid) -> PathBuf {
2933 self.working_directory
2934 .join(".git")
2935 .join(format!("index-{}.tmp", id))
2936 }
2937
2938 pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
2939 where
2940 S: AsRef<OsStr>,
2941 {
2942 let mut stdout = self.run_raw(args).await?;
2943 if stdout.chars().last() == Some('\n') {
2944 stdout.pop();
2945 }
2946 Ok(stdout)
2947 }
2948
2949 /// Returns the result of the command without trimming the trailing newline.
2950 pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
2951 where
2952 S: AsRef<OsStr>,
2953 {
2954 let mut command = self.build_command(args);
2955 let output = command.output().await?;
2956 anyhow::ensure!(
2957 output.status.success(),
2958 GitBinaryCommandError {
2959 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
2960 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
2961 status: output.status,
2962 }
2963 );
2964 Ok(String::from_utf8(output.stdout)?)
2965 }
2966
2967 fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
2968 where
2969 S: AsRef<OsStr>,
2970 {
2971 let mut command = new_smol_command(&self.git_binary_path);
2972 command.current_dir(&self.working_directory);
2973 command.args(args);
2974 if let Some(index_file_path) = self.index_file_path.as_ref() {
2975 command.env("GIT_INDEX_FILE", index_file_path);
2976 }
2977 command.envs(&self.envs);
2978 command
2979 }
2980}
2981
2982#[derive(Error, Debug)]
2983#[error("Git command failed:\n{stdout}{stderr}\n")]
2984struct GitBinaryCommandError {
2985 stdout: String,
2986 stderr: String,
2987 status: ExitStatus,
2988}
2989
2990async fn run_git_command(
2991 env: Arc<HashMap<String, String>>,
2992 ask_pass: AskPassDelegate,
2993 mut command: smol::process::Command,
2994 executor: BackgroundExecutor,
2995) -> Result<RemoteCommandOutput> {
2996 if env.contains_key("GIT_ASKPASS") {
2997 let git_process = command.spawn()?;
2998 let output = git_process.output().await?;
2999 anyhow::ensure!(
3000 output.status.success(),
3001 "{}",
3002 String::from_utf8_lossy(&output.stderr)
3003 );
3004 Ok(RemoteCommandOutput {
3005 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3006 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3007 })
3008 } else {
3009 let ask_pass = AskPassSession::new(executor, ask_pass).await?;
3010 command
3011 .env("GIT_ASKPASS", ask_pass.script_path())
3012 .env("SSH_ASKPASS", ask_pass.script_path())
3013 .env("SSH_ASKPASS_REQUIRE", "force");
3014 let git_process = command.spawn()?;
3015
3016 run_askpass_command(ask_pass, git_process).await
3017 }
3018}
3019
3020async fn run_askpass_command(
3021 mut ask_pass: AskPassSession,
3022 git_process: smol::process::Child,
3023) -> anyhow::Result<RemoteCommandOutput> {
3024 select_biased! {
3025 result = ask_pass.run().fuse() => {
3026 match result {
3027 AskPassResult::CancelledByUser => {
3028 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
3029 }
3030 AskPassResult::Timedout => {
3031 Err(anyhow!("Connecting to host timed out"))?
3032 }
3033 }
3034 }
3035 output = git_process.output().fuse() => {
3036 let output = output?;
3037 anyhow::ensure!(
3038 output.status.success(),
3039 "{}",
3040 String::from_utf8_lossy(&output.stderr)
3041 );
3042 Ok(RemoteCommandOutput {
3043 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3044 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3045 })
3046 }
3047 }
3048}
3049
3050#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
3051pub struct RepoPath(Arc<RelPath>);
3052
3053impl std::fmt::Debug for RepoPath {
3054 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3055 self.0.fmt(f)
3056 }
3057}
3058
3059impl RepoPath {
3060 pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
3061 let rel_path = RelPath::unix(s.as_ref())?;
3062 Ok(Self::from_rel_path(rel_path))
3063 }
3064
3065 pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
3066 let rel_path = RelPath::new(path, path_style)?;
3067 Ok(Self::from_rel_path(&rel_path))
3068 }
3069
3070 pub fn from_proto(proto: &str) -> Result<Self> {
3071 let rel_path = RelPath::from_proto(proto)?;
3072 Ok(Self(rel_path))
3073 }
3074
3075 pub fn from_rel_path(path: &RelPath) -> RepoPath {
3076 Self(Arc::from(path))
3077 }
3078
3079 pub fn as_std_path(&self) -> &Path {
3080 // git2 does not like empty paths and our RelPath infra turns `.` into ``
3081 // so undo that here
3082 if self.is_empty() {
3083 Path::new(".")
3084 } else {
3085 self.0.as_std_path()
3086 }
3087 }
3088}
3089
3090#[cfg(any(test, feature = "test-support"))]
3091pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
3092 RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
3093}
3094
3095impl AsRef<Arc<RelPath>> for RepoPath {
3096 fn as_ref(&self) -> &Arc<RelPath> {
3097 &self.0
3098 }
3099}
3100
3101impl std::ops::Deref for RepoPath {
3102 type Target = RelPath;
3103
3104 fn deref(&self) -> &Self::Target {
3105 &self.0
3106 }
3107}
3108
3109#[derive(Debug)]
3110pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
3111
3112impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
3113 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
3114 if key.starts_with(self.0) {
3115 Ordering::Greater
3116 } else {
3117 self.0.cmp(key)
3118 }
3119 }
3120}
3121
3122fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
3123 let mut branches = Vec::new();
3124 for line in input.split('\n') {
3125 if line.is_empty() {
3126 continue;
3127 }
3128 let mut fields = line.split('\x00');
3129 let Some(head) = fields.next() else {
3130 continue;
3131 };
3132 let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
3133 continue;
3134 };
3135 let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
3136 continue;
3137 };
3138 let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
3139 continue;
3140 };
3141 let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
3142 continue;
3143 };
3144 let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
3145 else {
3146 continue;
3147 };
3148 let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
3149 continue;
3150 };
3151 let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
3152 continue;
3153 };
3154 let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
3155 continue;
3156 };
3157
3158 branches.push(Branch {
3159 is_head: head == "*",
3160 ref_name,
3161 most_recent_commit: Some(CommitSummary {
3162 sha: head_sha,
3163 subject,
3164 commit_timestamp: commiterdate,
3165 author_name: author_name,
3166 has_parent: !parent_sha.is_empty(),
3167 }),
3168 upstream: if upstream_name.is_empty() {
3169 None
3170 } else {
3171 Some(Upstream {
3172 ref_name: upstream_name.into(),
3173 tracking: upstream_tracking,
3174 })
3175 },
3176 })
3177 }
3178
3179 Ok(branches)
3180}
3181
3182fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
3183 if upstream_track.is_empty() {
3184 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3185 ahead: 0,
3186 behind: 0,
3187 }));
3188 }
3189
3190 let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
3191 let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
3192 let mut ahead: u32 = 0;
3193 let mut behind: u32 = 0;
3194 for component in upstream_track.split(", ") {
3195 if component == "gone" {
3196 return Ok(UpstreamTracking::Gone);
3197 }
3198 if let Some(ahead_num) = component.strip_prefix("ahead ") {
3199 ahead = ahead_num.parse::<u32>()?;
3200 }
3201 if let Some(behind_num) = component.strip_prefix("behind ") {
3202 behind = behind_num.parse::<u32>()?;
3203 }
3204 }
3205 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3206 ahead,
3207 behind,
3208 }))
3209}
3210
3211fn checkpoint_author_envs() -> HashMap<String, String> {
3212 HashMap::from_iter([
3213 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
3214 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
3215 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
3216 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
3217 ])
3218}
3219
3220#[cfg(test)]
3221mod tests {
3222 use super::*;
3223 use gpui::TestAppContext;
3224
3225 fn disable_git_global_config() {
3226 unsafe {
3227 std::env::set_var("GIT_CONFIG_GLOBAL", "");
3228 std::env::set_var("GIT_CONFIG_SYSTEM", "");
3229 }
3230 }
3231
3232 #[gpui::test]
3233 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
3234 disable_git_global_config();
3235
3236 cx.executor().allow_parking();
3237
3238 let repo_dir = tempfile::tempdir().unwrap();
3239
3240 git2::Repository::init(repo_dir.path()).unwrap();
3241 let file_path = repo_dir.path().join("file");
3242 smol::fs::write(&file_path, "initial").await.unwrap();
3243
3244 let repo = RealGitRepository::new(
3245 &repo_dir.path().join(".git"),
3246 None,
3247 Some("git".into()),
3248 cx.executor(),
3249 )
3250 .unwrap();
3251
3252 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3253 .await
3254 .unwrap();
3255 repo.commit(
3256 "Initial commit".into(),
3257 None,
3258 CommitOptions::default(),
3259 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3260 Arc::new(checkpoint_author_envs()),
3261 )
3262 .await
3263 .unwrap();
3264
3265 smol::fs::write(&file_path, "modified before checkpoint")
3266 .await
3267 .unwrap();
3268 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
3269 .await
3270 .unwrap();
3271 let checkpoint = repo.checkpoint().await.unwrap();
3272
3273 // Ensure the user can't see any branches after creating a checkpoint.
3274 assert_eq!(repo.branches().await.unwrap().len(), 1);
3275
3276 smol::fs::write(&file_path, "modified after checkpoint")
3277 .await
3278 .unwrap();
3279 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3280 .await
3281 .unwrap();
3282 repo.commit(
3283 "Commit after checkpoint".into(),
3284 None,
3285 CommitOptions::default(),
3286 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3287 Arc::new(checkpoint_author_envs()),
3288 )
3289 .await
3290 .unwrap();
3291
3292 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
3293 .await
3294 .unwrap();
3295 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
3296 .await
3297 .unwrap();
3298
3299 // Ensure checkpoint stays alive even after a Git GC.
3300 repo.gc().await.unwrap();
3301 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
3302
3303 assert_eq!(
3304 smol::fs::read_to_string(&file_path).await.unwrap(),
3305 "modified before checkpoint"
3306 );
3307 assert_eq!(
3308 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
3309 .await
3310 .unwrap(),
3311 "1"
3312 );
3313 // See TODO above
3314 // assert_eq!(
3315 // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
3316 // .await
3317 // .ok(),
3318 // None
3319 // );
3320 }
3321
3322 #[gpui::test]
3323 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
3324 disable_git_global_config();
3325
3326 cx.executor().allow_parking();
3327
3328 let repo_dir = tempfile::tempdir().unwrap();
3329 git2::Repository::init(repo_dir.path()).unwrap();
3330 let repo = RealGitRepository::new(
3331 &repo_dir.path().join(".git"),
3332 None,
3333 Some("git".into()),
3334 cx.executor(),
3335 )
3336 .unwrap();
3337
3338 smol::fs::write(repo_dir.path().join("foo"), "foo")
3339 .await
3340 .unwrap();
3341 let checkpoint_sha = repo.checkpoint().await.unwrap();
3342
3343 // Ensure the user can't see any branches after creating a checkpoint.
3344 assert_eq!(repo.branches().await.unwrap().len(), 1);
3345
3346 smol::fs::write(repo_dir.path().join("foo"), "bar")
3347 .await
3348 .unwrap();
3349 smol::fs::write(repo_dir.path().join("baz"), "qux")
3350 .await
3351 .unwrap();
3352 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
3353 assert_eq!(
3354 smol::fs::read_to_string(repo_dir.path().join("foo"))
3355 .await
3356 .unwrap(),
3357 "foo"
3358 );
3359 // See TODOs above
3360 // assert_eq!(
3361 // smol::fs::read_to_string(repo_dir.path().join("baz"))
3362 // .await
3363 // .ok(),
3364 // None
3365 // );
3366 }
3367
3368 #[gpui::test]
3369 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
3370 disable_git_global_config();
3371
3372 cx.executor().allow_parking();
3373
3374 let repo_dir = tempfile::tempdir().unwrap();
3375 git2::Repository::init(repo_dir.path()).unwrap();
3376 let repo = RealGitRepository::new(
3377 &repo_dir.path().join(".git"),
3378 None,
3379 Some("git".into()),
3380 cx.executor(),
3381 )
3382 .unwrap();
3383
3384 smol::fs::write(repo_dir.path().join("file1"), "content1")
3385 .await
3386 .unwrap();
3387 let checkpoint1 = repo.checkpoint().await.unwrap();
3388
3389 smol::fs::write(repo_dir.path().join("file2"), "content2")
3390 .await
3391 .unwrap();
3392 let checkpoint2 = repo.checkpoint().await.unwrap();
3393
3394 assert!(
3395 !repo
3396 .compare_checkpoints(checkpoint1, checkpoint2.clone())
3397 .await
3398 .unwrap()
3399 );
3400
3401 let checkpoint3 = repo.checkpoint().await.unwrap();
3402 assert!(
3403 repo.compare_checkpoints(checkpoint2, checkpoint3)
3404 .await
3405 .unwrap()
3406 );
3407 }
3408
3409 #[gpui::test]
3410 async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
3411 disable_git_global_config();
3412
3413 cx.executor().allow_parking();
3414
3415 let repo_dir = tempfile::tempdir().unwrap();
3416 let text_path = repo_dir.path().join("main.rs");
3417 let bin_path = repo_dir.path().join("binary.o");
3418
3419 git2::Repository::init(repo_dir.path()).unwrap();
3420
3421 smol::fs::write(&text_path, "fn main() {}").await.unwrap();
3422
3423 smol::fs::write(&bin_path, "some binary file here")
3424 .await
3425 .unwrap();
3426
3427 let repo = RealGitRepository::new(
3428 &repo_dir.path().join(".git"),
3429 None,
3430 Some("git".into()),
3431 cx.executor(),
3432 )
3433 .unwrap();
3434
3435 // initial commit
3436 repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
3437 .await
3438 .unwrap();
3439 repo.commit(
3440 "Initial commit".into(),
3441 None,
3442 CommitOptions::default(),
3443 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3444 Arc::new(checkpoint_author_envs()),
3445 )
3446 .await
3447 .unwrap();
3448
3449 let checkpoint = repo.checkpoint().await.unwrap();
3450
3451 smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
3452 .await
3453 .unwrap();
3454 smol::fs::write(&bin_path, "Modified binary file")
3455 .await
3456 .unwrap();
3457
3458 repo.restore_checkpoint(checkpoint).await.unwrap();
3459
3460 // Text files should be restored to checkpoint state,
3461 // but binaries should not (they aren't tracked)
3462 assert_eq!(
3463 smol::fs::read_to_string(&text_path).await.unwrap(),
3464 "fn main() {}"
3465 );
3466
3467 assert_eq!(
3468 smol::fs::read_to_string(&bin_path).await.unwrap(),
3469 "Modified binary file"
3470 );
3471 }
3472
3473 #[test]
3474 fn test_branches_parsing() {
3475 // suppress "help: octal escapes are not supported, `\0` is always null"
3476 #[allow(clippy::octal_escapes)]
3477 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
3478 assert_eq!(
3479 parse_branch_input(input).unwrap(),
3480 vec![Branch {
3481 is_head: true,
3482 ref_name: "refs/heads/zed-patches".into(),
3483 upstream: Some(Upstream {
3484 ref_name: "refs/remotes/origin/zed-patches".into(),
3485 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3486 ahead: 0,
3487 behind: 0
3488 })
3489 }),
3490 most_recent_commit: Some(CommitSummary {
3491 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
3492 subject: "generated protobuf".into(),
3493 commit_timestamp: 1733187470,
3494 author_name: SharedString::new("John Doe"),
3495 has_parent: false,
3496 })
3497 }]
3498 )
3499 }
3500
3501 #[test]
3502 fn test_branches_parsing_containing_refs_with_missing_fields() {
3503 #[allow(clippy::octal_escapes)]
3504 let input = " \090012116c03db04344ab10d50348553aa94f1ea0\0refs/heads/broken\n \0eb0cae33272689bd11030822939dd2701c52f81e\0895951d681e5561478c0acdd6905e8aacdfd2249\0refs/heads/dev\0\0\01762948725\0Zed\0Add feature\n*\0895951d681e5561478c0acdd6905e8aacdfd2249\0\0refs/heads/main\0\0\01762948695\0Zed\0Initial commit\n";
3505
3506 let branches = parse_branch_input(input).unwrap();
3507 assert_eq!(branches.len(), 2);
3508 assert_eq!(
3509 branches,
3510 vec![
3511 Branch {
3512 is_head: false,
3513 ref_name: "refs/heads/dev".into(),
3514 upstream: None,
3515 most_recent_commit: Some(CommitSummary {
3516 sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
3517 subject: "Add feature".into(),
3518 commit_timestamp: 1762948725,
3519 author_name: SharedString::new("Zed"),
3520 has_parent: true,
3521 })
3522 },
3523 Branch {
3524 is_head: true,
3525 ref_name: "refs/heads/main".into(),
3526 upstream: None,
3527 most_recent_commit: Some(CommitSummary {
3528 sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
3529 subject: "Initial commit".into(),
3530 commit_timestamp: 1762948695,
3531 author_name: SharedString::new("Zed"),
3532 has_parent: false,
3533 })
3534 }
3535 ]
3536 )
3537 }
3538
3539 #[test]
3540 fn test_upstream_branch_name() {
3541 let upstream = Upstream {
3542 ref_name: "refs/remotes/origin/feature/branch".into(),
3543 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3544 ahead: 0,
3545 behind: 0,
3546 }),
3547 };
3548 assert_eq!(upstream.branch_name(), Some("feature/branch"));
3549
3550 let upstream = Upstream {
3551 ref_name: "refs/remotes/upstream/main".into(),
3552 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3553 ahead: 0,
3554 behind: 0,
3555 }),
3556 };
3557 assert_eq!(upstream.branch_name(), Some("main"));
3558
3559 let upstream = Upstream {
3560 ref_name: "refs/heads/local".into(),
3561 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3562 ahead: 0,
3563 behind: 0,
3564 }),
3565 };
3566 assert_eq!(upstream.branch_name(), None);
3567
3568 // Test case where upstream branch name differs from what might be the local branch name
3569 let upstream = Upstream {
3570 ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
3571 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3572 ahead: 0,
3573 behind: 0,
3574 }),
3575 };
3576 assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
3577 }
3578
3579 impl RealGitRepository {
3580 /// Force a Git garbage collection on the repository.
3581 fn gc(&self) -> BoxFuture<'_, Result<()>> {
3582 let working_directory = self.working_directory();
3583 let git_binary_path = self.any_git_binary_path.clone();
3584 let executor = self.executor.clone();
3585 self.executor
3586 .spawn(async move {
3587 let git_binary_path = git_binary_path.clone();
3588 let working_directory = working_directory?;
3589 let git = GitBinary::new(git_binary_path, working_directory, executor);
3590 git.run(&["gc", "--prune"]).await?;
3591 Ok(())
3592 })
3593 .boxed()
3594 }
3595 }
3596}