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::piped());
2788
2789 let mut child = command.spawn()?;
2790 let stdout = child.stdout.take().context("failed to get stdout")?;
2791 let stderr = child.stderr.take().context("failed to get stderr")?;
2792 let mut reader = BufReader::new(stdout);
2793
2794 let mut line_buffer = String::new();
2795 let mut lines: Vec<String> = Vec::with_capacity(GRAPH_CHUNK_SIZE);
2796
2797 loop {
2798 line_buffer.clear();
2799 let bytes_read = reader.read_line(&mut line_buffer).await?;
2800
2801 if bytes_read == 0 {
2802 if !lines.is_empty() {
2803 let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
2804 if request_tx.send(commits).await.is_err() {
2805 log::warn!(
2806 "initial_graph_data: receiver dropped while sending commits"
2807 );
2808 }
2809 }
2810 break;
2811 }
2812
2813 let line = line_buffer.trim_end_matches('\n').to_string();
2814 lines.push(line);
2815
2816 if lines.len() >= GRAPH_CHUNK_SIZE {
2817 let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
2818 if request_tx.send(commits).await.is_err() {
2819 log::warn!("initial_graph_data: receiver dropped while streaming commits");
2820 break;
2821 }
2822 lines.clear();
2823 }
2824 }
2825
2826 let status = child.status().await?;
2827 if !status.success() {
2828 let mut stderr_output = String::new();
2829 BufReader::new(stderr)
2830 .read_to_string(&mut stderr_output)
2831 .await
2832 .log_err();
2833
2834 if stderr_output.is_empty() {
2835 anyhow::bail!("git log command failed with {}", status);
2836 } else {
2837 anyhow::bail!("git log command failed with {}: {}", status, stderr_output);
2838 }
2839 }
2840 Ok(())
2841 }
2842 .boxed()
2843 }
2844
2845 fn search_commits(
2846 &self,
2847 log_source: LogSource,
2848 search_args: SearchCommitArgs,
2849 request_tx: Sender<Oid>,
2850 ) -> BoxFuture<'_, Result<()>> {
2851 let git_binary = self.git_binary();
2852
2853 async move {
2854 let git = git_binary?;
2855
2856 let mut args = vec!["log", SEARCH_COMMIT_FORMAT, log_source.get_arg()?];
2857
2858 args.push("--fixed-strings");
2859
2860 if !search_args.case_sensitive {
2861 args.push("--regexp-ignore-case");
2862 }
2863
2864 args.push("--grep");
2865 args.push(search_args.query.as_str());
2866
2867 let mut command = git.build_command(&args);
2868 command.stdout(Stdio::piped());
2869 command.stderr(Stdio::null());
2870
2871 let mut child = command.spawn()?;
2872 let stdout = child.stdout.take().context("failed to get stdout")?;
2873 let mut reader = BufReader::new(stdout);
2874
2875 let mut line_buffer = String::new();
2876
2877 loop {
2878 line_buffer.clear();
2879 let bytes_read = reader.read_line(&mut line_buffer).await?;
2880
2881 if bytes_read == 0 {
2882 break;
2883 }
2884
2885 let sha = line_buffer.trim_end_matches('\n');
2886
2887 if let Ok(oid) = Oid::from_str(sha)
2888 && request_tx.send(oid).await.is_err()
2889 {
2890 break;
2891 }
2892 }
2893
2894 child.status().await?;
2895 Ok(())
2896 }
2897 .boxed()
2898 }
2899
2900 fn commit_data_reader(&self) -> Result<CommitDataReader> {
2901 let git_binary = self.git_binary()?;
2902
2903 let (request_tx, request_rx) = smol::channel::bounded::<CommitDataRequest>(64);
2904
2905 let task = self.executor.spawn(async move {
2906 if let Err(error) = run_commit_data_reader(git_binary, request_rx).await {
2907 log::error!("commit data reader failed: {error:?}");
2908 }
2909 });
2910
2911 Ok(CommitDataReader {
2912 request_tx,
2913 _task: task,
2914 })
2915 }
2916
2917 fn set_trusted(&self, trusted: bool) {
2918 self.is_trusted
2919 .store(trusted, std::sync::atomic::Ordering::Release);
2920 }
2921
2922 fn is_trusted(&self) -> bool {
2923 self.is_trusted.load(std::sync::atomic::Ordering::Acquire)
2924 }
2925}
2926
2927async fn run_commit_data_reader(
2928 git: GitBinary,
2929 request_rx: smol::channel::Receiver<CommitDataRequest>,
2930) -> Result<()> {
2931 let mut process = git
2932 .build_command(&["cat-file", "--batch"])
2933 .stdin(Stdio::piped())
2934 .stdout(Stdio::piped())
2935 .stderr(Stdio::piped())
2936 .spawn()
2937 .context("starting git cat-file --batch process")?;
2938
2939 let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
2940 let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
2941
2942 const MAX_BATCH_SIZE: usize = 64;
2943
2944 while let Ok(first_request) = request_rx.recv().await {
2945 let mut pending_requests = vec![first_request];
2946
2947 while pending_requests.len() < MAX_BATCH_SIZE {
2948 match request_rx.try_recv() {
2949 Ok(request) => pending_requests.push(request),
2950 Err(_) => break,
2951 }
2952 }
2953
2954 for request in &pending_requests {
2955 stdin.write_all(request.sha.to_string().as_bytes()).await?;
2956 stdin.write_all(b"\n").await?;
2957 }
2958 stdin.flush().await?;
2959
2960 for request in pending_requests {
2961 let result = read_single_commit_response(&mut stdout, &request.sha).await;
2962 request.response_tx.send(result).ok();
2963 }
2964 }
2965
2966 drop(stdin);
2967 process.kill().ok();
2968
2969 Ok(())
2970}
2971
2972async fn read_single_commit_response<R: smol::io::AsyncBufRead + Unpin>(
2973 stdout: &mut R,
2974 sha: &Oid,
2975) -> Result<GraphCommitData> {
2976 let mut header_bytes = Vec::new();
2977 stdout.read_until(b'\n', &mut header_bytes).await?;
2978 let header_line = String::from_utf8_lossy(&header_bytes);
2979
2980 let parts: Vec<&str> = header_line.trim().split(' ').collect();
2981 if parts.len() < 3 {
2982 bail!("invalid cat-file header: {header_line}");
2983 }
2984
2985 let object_type = parts[1];
2986 if object_type == "missing" {
2987 bail!("object not found: {}", sha);
2988 }
2989
2990 if object_type != "commit" {
2991 bail!("expected commit object, got {object_type}");
2992 }
2993
2994 let size: usize = parts[2]
2995 .parse()
2996 .with_context(|| format!("invalid object size: {}", parts[2]))?;
2997
2998 let mut content = vec![0u8; size];
2999 stdout.read_exact(&mut content).await?;
3000
3001 let mut newline = [0u8; 1];
3002 stdout.read_exact(&mut newline).await?;
3003
3004 let content_str = String::from_utf8_lossy(&content);
3005 parse_cat_file_commit(*sha, &content_str)
3006 .ok_or_else(|| anyhow!("failed to parse commit {}", sha))
3007}
3008
3009fn parse_initial_graph_output<'a>(
3010 lines: impl Iterator<Item = &'a str>,
3011) -> Vec<Arc<InitialGraphCommitData>> {
3012 lines
3013 .filter(|line| !line.is_empty())
3014 .filter_map(|line| {
3015 // Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..."
3016 let mut parts = line.split('\x00');
3017
3018 let sha = Oid::from_str(parts.next()?).ok()?;
3019 let parents_str = parts.next()?;
3020 let parents = parents_str
3021 .split_whitespace()
3022 .filter_map(|p| Oid::from_str(p).ok())
3023 .collect();
3024
3025 let ref_names_str = parts.next().unwrap_or("");
3026 let ref_names = if ref_names_str.is_empty() {
3027 Vec::new()
3028 } else {
3029 ref_names_str
3030 .split(", ")
3031 .map(|s| SharedString::from(s.to_string()))
3032 .collect()
3033 };
3034
3035 Some(Arc::new(InitialGraphCommitData {
3036 sha,
3037 parents,
3038 ref_names,
3039 }))
3040 })
3041 .collect()
3042}
3043
3044fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
3045 let mut args = vec![
3046 OsString::from("status"),
3047 OsString::from("--porcelain=v1"),
3048 OsString::from("--untracked-files=all"),
3049 OsString::from("--no-renames"),
3050 OsString::from("-z"),
3051 ];
3052 args.extend(path_prefixes.iter().map(|path_prefix| {
3053 if path_prefix.is_empty() {
3054 Path::new(".").into()
3055 } else {
3056 path_prefix.as_std_path().into()
3057 }
3058 }));
3059 args
3060}
3061
3062/// Temporarily git-ignore commonly ignored files and files over 2MB
3063async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
3064 const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
3065 let mut excludes = git.with_exclude_overrides().await?;
3066 excludes
3067 .add_excludes(include_str!("./checkpoint.gitignore"))
3068 .await?;
3069
3070 let working_directory = git.working_directory.clone();
3071 let untracked_files = git.list_untracked_files().await?;
3072 let excluded_paths = untracked_files.into_iter().map(|path| {
3073 let working_directory = working_directory.clone();
3074 smol::spawn(async move {
3075 let full_path = working_directory.join(path.clone());
3076 match smol::fs::metadata(&full_path).await {
3077 Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
3078 Some(PathBuf::from("/").join(path.clone()))
3079 }
3080 _ => None,
3081 }
3082 })
3083 });
3084
3085 let excluded_paths = futures::future::join_all(excluded_paths).await;
3086 let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
3087
3088 if !excluded_paths.is_empty() {
3089 let exclude_patterns = excluded_paths
3090 .into_iter()
3091 .map(|path| path.to_string_lossy().into_owned())
3092 .collect::<Vec<_>>()
3093 .join("\n");
3094 excludes.add_excludes(&exclude_patterns).await?;
3095 }
3096
3097 Ok(excludes)
3098}
3099
3100pub(crate) struct GitBinary {
3101 git_binary_path: PathBuf,
3102 working_directory: PathBuf,
3103 git_directory: PathBuf,
3104 executor: BackgroundExecutor,
3105 index_file_path: Option<PathBuf>,
3106 envs: HashMap<String, String>,
3107 is_trusted: bool,
3108}
3109
3110impl GitBinary {
3111 pub(crate) fn new(
3112 git_binary_path: PathBuf,
3113 working_directory: PathBuf,
3114 git_directory: PathBuf,
3115 executor: BackgroundExecutor,
3116 is_trusted: bool,
3117 ) -> Self {
3118 Self {
3119 git_binary_path,
3120 working_directory,
3121 git_directory,
3122 executor,
3123 index_file_path: None,
3124 envs: HashMap::default(),
3125 is_trusted,
3126 }
3127 }
3128
3129 async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
3130 let status_output = self
3131 .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
3132 .await?;
3133
3134 let paths = status_output
3135 .split('\0')
3136 .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
3137 .map(|entry| PathBuf::from(&entry[3..]))
3138 .collect::<Vec<_>>();
3139 Ok(paths)
3140 }
3141
3142 fn envs(mut self, envs: HashMap<String, String>) -> Self {
3143 self.envs = envs;
3144 self
3145 }
3146
3147 pub async fn with_temp_index<R>(
3148 &mut self,
3149 f: impl AsyncFnOnce(&Self) -> Result<R>,
3150 ) -> Result<R> {
3151 let index_file_path = self.path_for_index_id(Uuid::new_v4());
3152
3153 let delete_temp_index = util::defer({
3154 let index_file_path = index_file_path.clone();
3155 let executor = self.executor.clone();
3156 move || {
3157 executor
3158 .spawn(async move {
3159 smol::fs::remove_file(index_file_path).await.log_err();
3160 })
3161 .detach();
3162 }
3163 });
3164
3165 // Copy the default index file so that Git doesn't have to rebuild the
3166 // whole index from scratch. This might fail if this is an empty repository.
3167 smol::fs::copy(self.git_directory.join("index"), &index_file_path)
3168 .await
3169 .ok();
3170
3171 self.index_file_path = Some(index_file_path.clone());
3172 let result = f(self).await;
3173 self.index_file_path = None;
3174 let result = result?;
3175
3176 smol::fs::remove_file(index_file_path).await.ok();
3177 delete_temp_index.abort();
3178
3179 Ok(result)
3180 }
3181
3182 pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
3183 let path = self.git_directory.join("info").join("exclude");
3184
3185 GitExcludeOverride::new(path).await
3186 }
3187
3188 fn path_for_index_id(&self, id: Uuid) -> PathBuf {
3189 self.git_directory.join(format!("index-{}.tmp", id))
3190 }
3191
3192 pub async fn run<S>(&self, args: &[S]) -> Result<String>
3193 where
3194 S: AsRef<OsStr>,
3195 {
3196 let mut stdout = self.run_raw(args).await?;
3197 if stdout.chars().last() == Some('\n') {
3198 stdout.pop();
3199 }
3200 Ok(stdout)
3201 }
3202
3203 /// Returns the result of the command without trimming the trailing newline.
3204 pub async fn run_raw<S>(&self, args: &[S]) -> Result<String>
3205 where
3206 S: AsRef<OsStr>,
3207 {
3208 let mut command = self.build_command(args);
3209 let output = command.output().await?;
3210 anyhow::ensure!(
3211 output.status.success(),
3212 GitBinaryCommandError {
3213 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3214 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3215 status: output.status,
3216 }
3217 );
3218 Ok(String::from_utf8(output.stdout)?)
3219 }
3220
3221 #[allow(clippy::disallowed_methods)]
3222 pub(crate) fn build_command<S>(&self, args: &[S]) -> util::command::Command
3223 where
3224 S: AsRef<OsStr>,
3225 {
3226 let mut command = new_command(&self.git_binary_path);
3227 command.current_dir(&self.working_directory);
3228 command.args(["-c", "core.fsmonitor=false"]);
3229 command.arg("--no-optional-locks");
3230 command.arg("--no-pager");
3231
3232 if !self.is_trusted {
3233 command.args(["-c", "core.hooksPath=/dev/null"]);
3234 command.args(["-c", "core.sshCommand=ssh"]);
3235 command.args(["-c", "credential.helper="]);
3236 command.args(["-c", "protocol.ext.allow=never"]);
3237 command.args(["-c", "diff.external="]);
3238 }
3239 command.args(args);
3240
3241 // If the `diff` command is being used, we'll want to add the
3242 // `--no-ext-diff` flag when working on an untrusted repository,
3243 // preventing any external diff programs from being invoked.
3244 if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") {
3245 command.arg("--no-ext-diff");
3246 }
3247
3248 if let Some(index_file_path) = self.index_file_path.as_ref() {
3249 command.env("GIT_INDEX_FILE", index_file_path);
3250 }
3251 command.envs(&self.envs);
3252 command
3253 }
3254}
3255
3256#[derive(Error, Debug)]
3257#[error("Git command failed:\n{stdout}{stderr}\n")]
3258struct GitBinaryCommandError {
3259 stdout: String,
3260 stderr: String,
3261 status: ExitStatus,
3262}
3263
3264async fn run_git_command(
3265 env: Arc<HashMap<String, String>>,
3266 ask_pass: AskPassDelegate,
3267 mut command: util::command::Command,
3268 executor: BackgroundExecutor,
3269) -> Result<RemoteCommandOutput> {
3270 if env.contains_key("GIT_ASKPASS") {
3271 let git_process = command.spawn()?;
3272 let output = git_process.output().await?;
3273 anyhow::ensure!(
3274 output.status.success(),
3275 "{}",
3276 String::from_utf8_lossy(&output.stderr)
3277 );
3278 Ok(RemoteCommandOutput {
3279 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3280 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3281 })
3282 } else {
3283 let ask_pass = AskPassSession::new(executor, ask_pass).await?;
3284 command
3285 .env("GIT_ASKPASS", ask_pass.script_path())
3286 .env("SSH_ASKPASS", ask_pass.script_path())
3287 .env("SSH_ASKPASS_REQUIRE", "force");
3288 let git_process = command.spawn()?;
3289
3290 run_askpass_command(ask_pass, git_process).await
3291 }
3292}
3293
3294async fn run_askpass_command(
3295 mut ask_pass: AskPassSession,
3296 git_process: util::command::Child,
3297) -> anyhow::Result<RemoteCommandOutput> {
3298 select_biased! {
3299 result = ask_pass.run().fuse() => {
3300 match result {
3301 AskPassResult::CancelledByUser => {
3302 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
3303 }
3304 AskPassResult::Timedout => {
3305 Err(anyhow!("Connecting to host timed out"))?
3306 }
3307 }
3308 }
3309 output = git_process.output().fuse() => {
3310 let output = output?;
3311 anyhow::ensure!(
3312 output.status.success(),
3313 "{}",
3314 String::from_utf8_lossy(&output.stderr)
3315 );
3316 Ok(RemoteCommandOutput {
3317 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3318 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3319 })
3320 }
3321 }
3322}
3323
3324#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
3325pub struct RepoPath(Arc<RelPath>);
3326
3327impl std::fmt::Debug for RepoPath {
3328 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3329 self.0.fmt(f)
3330 }
3331}
3332
3333impl RepoPath {
3334 pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
3335 let rel_path = RelPath::unix(s.as_ref())?;
3336 Ok(Self::from_rel_path(rel_path))
3337 }
3338
3339 pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
3340 let rel_path = RelPath::new(path, path_style)?;
3341 Ok(Self::from_rel_path(&rel_path))
3342 }
3343
3344 pub fn from_proto(proto: &str) -> Result<Self> {
3345 let rel_path = RelPath::from_proto(proto)?;
3346 Ok(Self(rel_path))
3347 }
3348
3349 pub fn from_rel_path(path: &RelPath) -> RepoPath {
3350 Self(Arc::from(path))
3351 }
3352
3353 pub fn as_std_path(&self) -> &Path {
3354 // git2 does not like empty paths and our RelPath infra turns `.` into ``
3355 // so undo that here
3356 if self.is_empty() {
3357 Path::new(".")
3358 } else {
3359 self.0.as_std_path()
3360 }
3361 }
3362}
3363
3364#[cfg(any(test, feature = "test-support"))]
3365pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
3366 RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
3367}
3368
3369impl AsRef<Arc<RelPath>> for RepoPath {
3370 fn as_ref(&self) -> &Arc<RelPath> {
3371 &self.0
3372 }
3373}
3374
3375impl std::ops::Deref for RepoPath {
3376 type Target = RelPath;
3377
3378 fn deref(&self) -> &Self::Target {
3379 &self.0
3380 }
3381}
3382
3383#[derive(Debug)]
3384pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
3385
3386impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
3387 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
3388 if key.starts_with(self.0) {
3389 Ordering::Greater
3390 } else {
3391 self.0.cmp(key)
3392 }
3393 }
3394}
3395
3396fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
3397 let mut branches = Vec::new();
3398 for line in input.split('\n') {
3399 if line.is_empty() {
3400 continue;
3401 }
3402 let mut fields = line.split('\x00');
3403 let Some(head) = fields.next() else {
3404 continue;
3405 };
3406 let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
3407 continue;
3408 };
3409 let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
3410 continue;
3411 };
3412 let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
3413 continue;
3414 };
3415 let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
3416 continue;
3417 };
3418 let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
3419 else {
3420 continue;
3421 };
3422 let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
3423 continue;
3424 };
3425 let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
3426 continue;
3427 };
3428 let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
3429 continue;
3430 };
3431
3432 branches.push(Branch {
3433 is_head: head == "*",
3434 ref_name,
3435 most_recent_commit: Some(CommitSummary {
3436 sha: head_sha,
3437 subject,
3438 commit_timestamp: commiterdate,
3439 author_name: author_name,
3440 has_parent: !parent_sha.is_empty(),
3441 }),
3442 upstream: if upstream_name.is_empty() {
3443 None
3444 } else {
3445 Some(Upstream {
3446 ref_name: upstream_name.into(),
3447 tracking: upstream_tracking,
3448 })
3449 },
3450 })
3451 }
3452
3453 Ok(branches)
3454}
3455
3456fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
3457 if upstream_track.is_empty() {
3458 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3459 ahead: 0,
3460 behind: 0,
3461 }));
3462 }
3463
3464 let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
3465 let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
3466 let mut ahead: u32 = 0;
3467 let mut behind: u32 = 0;
3468 for component in upstream_track.split(", ") {
3469 if component == "gone" {
3470 return Ok(UpstreamTracking::Gone);
3471 }
3472 if let Some(ahead_num) = component.strip_prefix("ahead ") {
3473 ahead = ahead_num.parse::<u32>()?;
3474 }
3475 if let Some(behind_num) = component.strip_prefix("behind ") {
3476 behind = behind_num.parse::<u32>()?;
3477 }
3478 }
3479 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3480 ahead,
3481 behind,
3482 }))
3483}
3484
3485fn checkpoint_author_envs() -> HashMap<String, String> {
3486 HashMap::from_iter([
3487 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
3488 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
3489 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
3490 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
3491 ])
3492}
3493
3494#[cfg(test)]
3495mod tests {
3496 use std::fs;
3497
3498 use super::*;
3499 use gpui::TestAppContext;
3500
3501 fn disable_git_global_config() {
3502 unsafe {
3503 std::env::set_var("GIT_CONFIG_GLOBAL", "");
3504 std::env::set_var("GIT_CONFIG_SYSTEM", "");
3505 }
3506 }
3507
3508 #[gpui::test]
3509 async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
3510 cx.executor().allow_parking();
3511 let dir = tempfile::tempdir().unwrap();
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(&["version"])
3521 .output()
3522 .await
3523 .expect("git version should succeed");
3524 assert!(output.status.success());
3525
3526 let git = GitBinary::new(
3527 PathBuf::from("git"),
3528 dir.path().to_path_buf(),
3529 dir.path().join(".git"),
3530 cx.executor(),
3531 false,
3532 );
3533 let output = git
3534 .build_command(&["config", "--get", "core.fsmonitor"])
3535 .output()
3536 .await
3537 .expect("git config should run");
3538 let stdout = String::from_utf8_lossy(&output.stdout);
3539 assert_eq!(
3540 stdout.trim(),
3541 "false",
3542 "fsmonitor should be disabled for untrusted repos"
3543 );
3544
3545 git2::Repository::init(dir.path()).unwrap();
3546 let git = GitBinary::new(
3547 PathBuf::from("git"),
3548 dir.path().to_path_buf(),
3549 dir.path().join(".git"),
3550 cx.executor(),
3551 false,
3552 );
3553 let output = git
3554 .build_command(&["config", "--get", "core.hooksPath"])
3555 .output()
3556 .await
3557 .expect("git config should run");
3558 let stdout = String::from_utf8_lossy(&output.stdout);
3559 assert_eq!(
3560 stdout.trim(),
3561 "/dev/null",
3562 "hooksPath should be /dev/null for untrusted repos"
3563 );
3564 }
3565
3566 #[gpui::test]
3567 async fn test_build_command_trusted_only_disables_fsmonitor(cx: &mut TestAppContext) {
3568 cx.executor().allow_parking();
3569 let dir = tempfile::tempdir().unwrap();
3570 git2::Repository::init(dir.path()).unwrap();
3571
3572 let git = GitBinary::new(
3573 PathBuf::from("git"),
3574 dir.path().to_path_buf(),
3575 dir.path().join(".git"),
3576 cx.executor(),
3577 true,
3578 );
3579 let output = git
3580 .build_command(&["config", "--get", "core.fsmonitor"])
3581 .output()
3582 .await
3583 .expect("git config should run");
3584 let stdout = String::from_utf8_lossy(&output.stdout);
3585 assert_eq!(
3586 stdout.trim(),
3587 "false",
3588 "fsmonitor should be disabled even for trusted repos"
3589 );
3590
3591 let git = GitBinary::new(
3592 PathBuf::from("git"),
3593 dir.path().to_path_buf(),
3594 dir.path().join(".git"),
3595 cx.executor(),
3596 true,
3597 );
3598 let output = git
3599 .build_command(&["config", "--get", "core.hooksPath"])
3600 .output()
3601 .await
3602 .expect("git config should run");
3603 assert!(
3604 !output.status.success(),
3605 "hooksPath should NOT be overridden for trusted repos"
3606 );
3607 }
3608
3609 #[gpui::test]
3610 async fn test_path_for_index_id_uses_real_git_directory(cx: &mut TestAppContext) {
3611 cx.executor().allow_parking();
3612 let working_directory = PathBuf::from("/code/worktree");
3613 let git_directory = PathBuf::from("/code/repo/.git/modules/worktree");
3614 let git = GitBinary::new(
3615 PathBuf::from("git"),
3616 working_directory,
3617 git_directory.clone(),
3618 cx.executor(),
3619 false,
3620 );
3621
3622 let path = git.path_for_index_id(Uuid::nil());
3623
3624 assert_eq!(
3625 path,
3626 git_directory.join(format!("index-{}.tmp", Uuid::nil()))
3627 );
3628 }
3629
3630 #[gpui::test]
3631 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
3632 disable_git_global_config();
3633
3634 cx.executor().allow_parking();
3635
3636 let repo_dir = tempfile::tempdir().unwrap();
3637
3638 git2::Repository::init(repo_dir.path()).unwrap();
3639 let file_path = repo_dir.path().join("file");
3640 smol::fs::write(&file_path, "initial").await.unwrap();
3641
3642 let repo = RealGitRepository::new(
3643 &repo_dir.path().join(".git"),
3644 None,
3645 Some("git".into()),
3646 cx.executor(),
3647 )
3648 .unwrap();
3649
3650 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3651 .await
3652 .unwrap();
3653 repo.commit(
3654 "Initial commit".into(),
3655 None,
3656 CommitOptions::default(),
3657 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3658 Arc::new(checkpoint_author_envs()),
3659 )
3660 .await
3661 .unwrap();
3662
3663 smol::fs::write(&file_path, "modified before checkpoint")
3664 .await
3665 .unwrap();
3666 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
3667 .await
3668 .unwrap();
3669 let checkpoint = repo.checkpoint().await.unwrap();
3670
3671 // Ensure the user can't see any branches after creating a checkpoint.
3672 assert_eq!(repo.branches().await.unwrap().len(), 1);
3673
3674 smol::fs::write(&file_path, "modified after checkpoint")
3675 .await
3676 .unwrap();
3677 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3678 .await
3679 .unwrap();
3680 repo.commit(
3681 "Commit after checkpoint".into(),
3682 None,
3683 CommitOptions::default(),
3684 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3685 Arc::new(checkpoint_author_envs()),
3686 )
3687 .await
3688 .unwrap();
3689
3690 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
3691 .await
3692 .unwrap();
3693 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
3694 .await
3695 .unwrap();
3696
3697 // Ensure checkpoint stays alive even after a Git GC.
3698 repo.gc().await.unwrap();
3699 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
3700
3701 assert_eq!(
3702 smol::fs::read_to_string(&file_path).await.unwrap(),
3703 "modified before checkpoint"
3704 );
3705 assert_eq!(
3706 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
3707 .await
3708 .unwrap(),
3709 "1"
3710 );
3711 // See TODO above
3712 // assert_eq!(
3713 // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
3714 // .await
3715 // .ok(),
3716 // None
3717 // );
3718 }
3719
3720 #[gpui::test]
3721 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
3722 disable_git_global_config();
3723
3724 cx.executor().allow_parking();
3725
3726 let repo_dir = tempfile::tempdir().unwrap();
3727 git2::Repository::init(repo_dir.path()).unwrap();
3728 let repo = RealGitRepository::new(
3729 &repo_dir.path().join(".git"),
3730 None,
3731 Some("git".into()),
3732 cx.executor(),
3733 )
3734 .unwrap();
3735
3736 smol::fs::write(repo_dir.path().join("foo"), "foo")
3737 .await
3738 .unwrap();
3739 let checkpoint_sha = repo.checkpoint().await.unwrap();
3740
3741 // Ensure the user can't see any branches after creating a checkpoint.
3742 assert_eq!(repo.branches().await.unwrap().len(), 1);
3743
3744 smol::fs::write(repo_dir.path().join("foo"), "bar")
3745 .await
3746 .unwrap();
3747 smol::fs::write(repo_dir.path().join("baz"), "qux")
3748 .await
3749 .unwrap();
3750 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
3751 assert_eq!(
3752 smol::fs::read_to_string(repo_dir.path().join("foo"))
3753 .await
3754 .unwrap(),
3755 "foo"
3756 );
3757 // See TODOs above
3758 // assert_eq!(
3759 // smol::fs::read_to_string(repo_dir.path().join("baz"))
3760 // .await
3761 // .ok(),
3762 // None
3763 // );
3764 }
3765
3766 #[gpui::test]
3767 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
3768 disable_git_global_config();
3769
3770 cx.executor().allow_parking();
3771
3772 let repo_dir = tempfile::tempdir().unwrap();
3773 git2::Repository::init(repo_dir.path()).unwrap();
3774 let repo = RealGitRepository::new(
3775 &repo_dir.path().join(".git"),
3776 None,
3777 Some("git".into()),
3778 cx.executor(),
3779 )
3780 .unwrap();
3781
3782 smol::fs::write(repo_dir.path().join("file1"), "content1")
3783 .await
3784 .unwrap();
3785 let checkpoint1 = repo.checkpoint().await.unwrap();
3786
3787 smol::fs::write(repo_dir.path().join("file2"), "content2")
3788 .await
3789 .unwrap();
3790 let checkpoint2 = repo.checkpoint().await.unwrap();
3791
3792 assert!(
3793 !repo
3794 .compare_checkpoints(checkpoint1, checkpoint2.clone())
3795 .await
3796 .unwrap()
3797 );
3798
3799 let checkpoint3 = repo.checkpoint().await.unwrap();
3800 assert!(
3801 repo.compare_checkpoints(checkpoint2, checkpoint3)
3802 .await
3803 .unwrap()
3804 );
3805 }
3806
3807 #[gpui::test]
3808 async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
3809 disable_git_global_config();
3810
3811 cx.executor().allow_parking();
3812
3813 let repo_dir = tempfile::tempdir().unwrap();
3814 let text_path = repo_dir.path().join("main.rs");
3815 let bin_path = repo_dir.path().join("binary.o");
3816
3817 git2::Repository::init(repo_dir.path()).unwrap();
3818
3819 smol::fs::write(&text_path, "fn main() {}").await.unwrap();
3820
3821 smol::fs::write(&bin_path, "some binary file here")
3822 .await
3823 .unwrap();
3824
3825 let repo = RealGitRepository::new(
3826 &repo_dir.path().join(".git"),
3827 None,
3828 Some("git".into()),
3829 cx.executor(),
3830 )
3831 .unwrap();
3832
3833 // initial commit
3834 repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
3835 .await
3836 .unwrap();
3837 repo.commit(
3838 "Initial commit".into(),
3839 None,
3840 CommitOptions::default(),
3841 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3842 Arc::new(checkpoint_author_envs()),
3843 )
3844 .await
3845 .unwrap();
3846
3847 let checkpoint = repo.checkpoint().await.unwrap();
3848
3849 smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
3850 .await
3851 .unwrap();
3852 smol::fs::write(&bin_path, "Modified binary file")
3853 .await
3854 .unwrap();
3855
3856 repo.restore_checkpoint(checkpoint).await.unwrap();
3857
3858 // Text files should be restored to checkpoint state,
3859 // but binaries should not (they aren't tracked)
3860 assert_eq!(
3861 smol::fs::read_to_string(&text_path).await.unwrap(),
3862 "fn main() {}"
3863 );
3864
3865 assert_eq!(
3866 smol::fs::read_to_string(&bin_path).await.unwrap(),
3867 "Modified binary file"
3868 );
3869 }
3870
3871 #[test]
3872 fn test_branches_parsing() {
3873 // suppress "help: octal escapes are not supported, `\0` is always null"
3874 #[allow(clippy::octal_escapes)]
3875 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
3876 assert_eq!(
3877 parse_branch_input(input).unwrap(),
3878 vec![Branch {
3879 is_head: true,
3880 ref_name: "refs/heads/zed-patches".into(),
3881 upstream: Some(Upstream {
3882 ref_name: "refs/remotes/origin/zed-patches".into(),
3883 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3884 ahead: 0,
3885 behind: 0
3886 })
3887 }),
3888 most_recent_commit: Some(CommitSummary {
3889 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
3890 subject: "generated protobuf".into(),
3891 commit_timestamp: 1733187470,
3892 author_name: SharedString::new_static("John Doe"),
3893 has_parent: false,
3894 })
3895 }]
3896 )
3897 }
3898
3899 #[test]
3900 fn test_branches_parsing_containing_refs_with_missing_fields() {
3901 #[allow(clippy::octal_escapes)]
3902 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";
3903
3904 let branches = parse_branch_input(input).unwrap();
3905 assert_eq!(branches.len(), 2);
3906 assert_eq!(
3907 branches,
3908 vec![
3909 Branch {
3910 is_head: false,
3911 ref_name: "refs/heads/dev".into(),
3912 upstream: None,
3913 most_recent_commit: Some(CommitSummary {
3914 sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
3915 subject: "Add feature".into(),
3916 commit_timestamp: 1762948725,
3917 author_name: SharedString::new_static("Zed"),
3918 has_parent: true,
3919 })
3920 },
3921 Branch {
3922 is_head: true,
3923 ref_name: "refs/heads/main".into(),
3924 upstream: None,
3925 most_recent_commit: Some(CommitSummary {
3926 sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
3927 subject: "Initial commit".into(),
3928 commit_timestamp: 1762948695,
3929 author_name: SharedString::new_static("Zed"),
3930 has_parent: false,
3931 })
3932 }
3933 ]
3934 )
3935 }
3936
3937 #[test]
3938 fn test_upstream_branch_name() {
3939 let upstream = Upstream {
3940 ref_name: "refs/remotes/origin/feature/branch".into(),
3941 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3942 ahead: 0,
3943 behind: 0,
3944 }),
3945 };
3946 assert_eq!(upstream.branch_name(), Some("feature/branch"));
3947
3948 let upstream = Upstream {
3949 ref_name: "refs/remotes/upstream/main".into(),
3950 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3951 ahead: 0,
3952 behind: 0,
3953 }),
3954 };
3955 assert_eq!(upstream.branch_name(), Some("main"));
3956
3957 let upstream = Upstream {
3958 ref_name: "refs/heads/local".into(),
3959 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3960 ahead: 0,
3961 behind: 0,
3962 }),
3963 };
3964 assert_eq!(upstream.branch_name(), None);
3965
3966 // Test case where upstream branch name differs from what might be the local branch name
3967 let upstream = Upstream {
3968 ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
3969 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3970 ahead: 0,
3971 behind: 0,
3972 }),
3973 };
3974 assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
3975 }
3976
3977 #[test]
3978 fn test_parse_worktrees_from_str() {
3979 // Empty input
3980 let result = parse_worktrees_from_str("");
3981 assert!(result.is_empty());
3982
3983 // Single worktree (main)
3984 let input = "worktree /home/user/project\nHEAD abc123def\nbranch refs/heads/main\n\n";
3985 let result = parse_worktrees_from_str(input);
3986 assert_eq!(result.len(), 1);
3987 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
3988 assert_eq!(result[0].sha.as_ref(), "abc123def");
3989 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
3990 assert!(result[0].is_main);
3991
3992 // Multiple worktrees
3993 let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
3994 worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n";
3995 let result = parse_worktrees_from_str(input);
3996 assert_eq!(result.len(), 2);
3997 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
3998 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
3999 assert!(result[0].is_main);
4000 assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt"));
4001 assert_eq!(result[1].ref_name, Some("refs/heads/feature".into()));
4002 assert!(!result[1].is_main);
4003
4004 // Detached HEAD entry (included with ref_name: None)
4005 let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
4006 worktree /home/user/detached\nHEAD def456\ndetached\n\n";
4007 let result = parse_worktrees_from_str(input);
4008 assert_eq!(result.len(), 2);
4009 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4010 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4011 assert!(result[0].is_main);
4012 assert_eq!(result[1].path, PathBuf::from("/home/user/detached"));
4013 assert_eq!(result[1].ref_name, None);
4014 assert_eq!(result[1].sha.as_ref(), "def456");
4015 assert!(!result[1].is_main);
4016
4017 // Bare repo entry (included with ref_name: None)
4018 let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
4019 worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n";
4020 let result = parse_worktrees_from_str(input);
4021 assert_eq!(result.len(), 2);
4022 assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git"));
4023 assert_eq!(result[0].ref_name, None);
4024 assert!(result[0].is_main);
4025 assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
4026 assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
4027 assert!(!result[1].is_main);
4028
4029 // Extra porcelain lines (locked, prunable) should be ignored
4030 let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
4031 worktree /home/user/locked-wt\nHEAD def456\nbranch refs/heads/locked-branch\nlocked\n\n\
4032 worktree /home/user/prunable-wt\nHEAD 789aaa\nbranch refs/heads/prunable-branch\nprunable\n\n";
4033 let result = parse_worktrees_from_str(input);
4034 assert_eq!(result.len(), 3);
4035 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4036 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4037 assert!(result[0].is_main);
4038 assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt"));
4039 assert_eq!(result[1].ref_name, Some("refs/heads/locked-branch".into()));
4040 assert!(!result[1].is_main);
4041 assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt"));
4042 assert_eq!(
4043 result[2].ref_name,
4044 Some("refs/heads/prunable-branch".into())
4045 );
4046 assert!(!result[2].is_main);
4047
4048 // Leading/trailing whitespace on lines should be tolerated
4049 let input =
4050 " worktree /home/user/project \n HEAD abc123 \n branch refs/heads/main \n\n";
4051 let result = parse_worktrees_from_str(input);
4052 assert_eq!(result.len(), 1);
4053 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4054 assert_eq!(result[0].sha.as_ref(), "abc123");
4055 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4056 assert!(result[0].is_main);
4057
4058 // Windows-style line endings should be handled
4059 let input = "worktree /home/user/project\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\n";
4060 let result = parse_worktrees_from_str(input);
4061 assert_eq!(result.len(), 1);
4062 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4063 assert_eq!(result[0].sha.as_ref(), "abc123");
4064 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4065 assert!(result[0].is_main);
4066 }
4067
4068 #[gpui::test]
4069 async fn test_create_and_list_worktrees(cx: &mut TestAppContext) {
4070 disable_git_global_config();
4071 cx.executor().allow_parking();
4072
4073 let temp_dir = tempfile::tempdir().unwrap();
4074 let repo_dir = temp_dir.path().join("repo");
4075 let worktrees_dir = temp_dir.path().join("worktrees");
4076
4077 fs::create_dir_all(&repo_dir).unwrap();
4078 fs::create_dir_all(&worktrees_dir).unwrap();
4079
4080 git2::Repository::init(&repo_dir).unwrap();
4081
4082 let repo = RealGitRepository::new(
4083 &repo_dir.join(".git"),
4084 None,
4085 Some("git".into()),
4086 cx.executor(),
4087 )
4088 .unwrap();
4089
4090 // Create an initial commit (required for worktrees)
4091 smol::fs::write(repo_dir.join("file.txt"), "content")
4092 .await
4093 .unwrap();
4094 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
4095 .await
4096 .unwrap();
4097 repo.commit(
4098 "Initial commit".into(),
4099 None,
4100 CommitOptions::default(),
4101 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4102 Arc::new(checkpoint_author_envs()),
4103 )
4104 .await
4105 .unwrap();
4106
4107 // List worktrees — should have just the main one
4108 let worktrees = repo.worktrees().await.unwrap();
4109 assert_eq!(worktrees.len(), 1);
4110 assert_eq!(
4111 worktrees[0].path.canonicalize().unwrap(),
4112 repo_dir.canonicalize().unwrap()
4113 );
4114
4115 let worktree_path = worktrees_dir.join("some-worktree");
4116
4117 // Create a new worktree
4118 repo.create_worktree(
4119 CreateWorktreeTarget::NewBranch {
4120 branch_name: "test-branch".to_string(),
4121 base_sha: Some("HEAD".to_string()),
4122 },
4123 worktree_path.clone(),
4124 )
4125 .await
4126 .unwrap();
4127
4128 // List worktrees — should have two
4129 let worktrees = repo.worktrees().await.unwrap();
4130 assert_eq!(worktrees.len(), 2);
4131
4132 let new_worktree = worktrees
4133 .iter()
4134 .find(|w| w.display_name() == "test-branch")
4135 .expect("should find worktree with test-branch");
4136 assert_eq!(
4137 new_worktree.path.canonicalize().unwrap(),
4138 worktree_path.canonicalize().unwrap(),
4139 );
4140 }
4141
4142 #[gpui::test]
4143 async fn test_remove_worktree(cx: &mut TestAppContext) {
4144 disable_git_global_config();
4145 cx.executor().allow_parking();
4146
4147 let temp_dir = tempfile::tempdir().unwrap();
4148 let repo_dir = temp_dir.path().join("repo");
4149 let worktrees_dir = temp_dir.path().join("worktrees");
4150 git2::Repository::init(&repo_dir).unwrap();
4151
4152 let repo = RealGitRepository::new(
4153 &repo_dir.join(".git"),
4154 None,
4155 Some("git".into()),
4156 cx.executor(),
4157 )
4158 .unwrap();
4159
4160 // Create an initial commit
4161 smol::fs::write(repo_dir.join("file.txt"), "content")
4162 .await
4163 .unwrap();
4164 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
4165 .await
4166 .unwrap();
4167 repo.commit(
4168 "Initial commit".into(),
4169 None,
4170 CommitOptions::default(),
4171 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4172 Arc::new(checkpoint_author_envs()),
4173 )
4174 .await
4175 .unwrap();
4176
4177 // Create a worktree
4178 let worktree_path = worktrees_dir.join("worktree-to-remove");
4179 repo.create_worktree(
4180 CreateWorktreeTarget::NewBranch {
4181 branch_name: "to-remove".to_string(),
4182 base_sha: Some("HEAD".to_string()),
4183 },
4184 worktree_path.clone(),
4185 )
4186 .await
4187 .unwrap();
4188
4189 // Remove the worktree
4190 repo.remove_worktree(worktree_path.clone(), false)
4191 .await
4192 .unwrap();
4193
4194 // Verify the directory is removed
4195 let worktrees = repo.worktrees().await.unwrap();
4196 assert_eq!(worktrees.len(), 1);
4197 assert!(
4198 worktrees.iter().all(|w| w.display_name() != "to-remove"),
4199 "removed worktree should not appear in list"
4200 );
4201 assert!(!worktree_path.exists());
4202
4203 // Create a worktree
4204 let worktree_path = worktrees_dir.join("dirty-wt");
4205 repo.create_worktree(
4206 CreateWorktreeTarget::NewBranch {
4207 branch_name: "dirty-wt".to_string(),
4208 base_sha: Some("HEAD".to_string()),
4209 },
4210 worktree_path.clone(),
4211 )
4212 .await
4213 .unwrap();
4214
4215 assert!(worktree_path.exists());
4216
4217 // Add uncommitted changes in the worktree
4218 smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted")
4219 .await
4220 .unwrap();
4221
4222 // Non-force removal should fail with dirty worktree
4223 let result = repo.remove_worktree(worktree_path.clone(), false).await;
4224 assert!(
4225 result.is_err(),
4226 "non-force removal of dirty worktree should fail"
4227 );
4228
4229 // Force removal should succeed
4230 repo.remove_worktree(worktree_path.clone(), true)
4231 .await
4232 .unwrap();
4233
4234 let worktrees = repo.worktrees().await.unwrap();
4235 assert_eq!(worktrees.len(), 1);
4236 assert!(!worktree_path.exists());
4237 }
4238
4239 #[gpui::test]
4240 async fn test_rename_worktree(cx: &mut TestAppContext) {
4241 disable_git_global_config();
4242 cx.executor().allow_parking();
4243
4244 let temp_dir = tempfile::tempdir().unwrap();
4245 let repo_dir = temp_dir.path().join("repo");
4246 let worktrees_dir = temp_dir.path().join("worktrees");
4247
4248 git2::Repository::init(&repo_dir).unwrap();
4249
4250 let repo = RealGitRepository::new(
4251 &repo_dir.join(".git"),
4252 None,
4253 Some("git".into()),
4254 cx.executor(),
4255 )
4256 .unwrap();
4257
4258 // Create an initial commit
4259 smol::fs::write(repo_dir.join("file.txt"), "content")
4260 .await
4261 .unwrap();
4262 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
4263 .await
4264 .unwrap();
4265 repo.commit(
4266 "Initial commit".into(),
4267 None,
4268 CommitOptions::default(),
4269 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4270 Arc::new(checkpoint_author_envs()),
4271 )
4272 .await
4273 .unwrap();
4274
4275 // Create a worktree
4276 let old_path = worktrees_dir.join("old-worktree-name");
4277 repo.create_worktree(
4278 CreateWorktreeTarget::NewBranch {
4279 branch_name: "old-name".to_string(),
4280 base_sha: Some("HEAD".to_string()),
4281 },
4282 old_path.clone(),
4283 )
4284 .await
4285 .unwrap();
4286
4287 assert!(old_path.exists());
4288
4289 // Move the worktree to a new path
4290 let new_path = worktrees_dir.join("new-worktree-name");
4291 repo.rename_worktree(old_path.clone(), new_path.clone())
4292 .await
4293 .unwrap();
4294
4295 // Verify the old path is gone and new path exists
4296 assert!(!old_path.exists());
4297 assert!(new_path.exists());
4298
4299 // Verify it shows up in worktree list at the new path
4300 let worktrees = repo.worktrees().await.unwrap();
4301 assert_eq!(worktrees.len(), 2);
4302 let moved_worktree = worktrees
4303 .iter()
4304 .find(|w| w.display_name() == "old-name")
4305 .expect("should find worktree by branch name");
4306 assert_eq!(
4307 moved_worktree.path.canonicalize().unwrap(),
4308 new_path.canonicalize().unwrap()
4309 );
4310 }
4311
4312 #[test]
4313 fn test_original_repo_path_from_common_dir() {
4314 // Normal repo: common_dir is <work_dir>/.git
4315 assert_eq!(
4316 original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
4317 PathBuf::from("/code/zed5")
4318 );
4319
4320 // Worktree: common_dir is the main repo's .git
4321 // (same result — that's the point, it always traces back to the original)
4322 assert_eq!(
4323 original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
4324 PathBuf::from("/code/zed5")
4325 );
4326
4327 // Bare repo: no .git suffix, returns as-is
4328 assert_eq!(
4329 original_repo_path_from_common_dir(Path::new("/code/zed5.git")),
4330 PathBuf::from("/code/zed5.git")
4331 );
4332
4333 // Root-level .git directory
4334 assert_eq!(
4335 original_repo_path_from_common_dir(Path::new("/.git")),
4336 PathBuf::from("/")
4337 );
4338 }
4339
4340 impl RealGitRepository {
4341 /// Force a Git garbage collection on the repository.
4342 fn gc(&self) -> BoxFuture<'_, Result<()>> {
4343 let working_directory = self.working_directory();
4344 let git_directory = self.path();
4345 let git_binary_path = self.any_git_binary_path.clone();
4346 let executor = self.executor.clone();
4347 self.executor
4348 .spawn(async move {
4349 let git_binary_path = git_binary_path.clone();
4350 let working_directory = working_directory?;
4351 let git = GitBinary::new(
4352 git_binary_path,
4353 working_directory,
4354 git_directory,
4355 executor,
4356 true,
4357 );
4358 git.run(&["gc", "--prune"]).await?;
4359 Ok(())
4360 })
4361 .boxed()
4362 }
4363 }
4364}