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