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 stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>>;
963
964 fn set_trusted(&self, trusted: bool);
965 fn is_trusted(&self) -> bool;
966}
967
968pub enum DiffType {
969 HeadToIndex,
970 HeadToWorktree,
971 MergeBase { base_ref: SharedString },
972}
973
974#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
975pub enum PushOptions {
976 SetUpstream,
977 Force,
978}
979
980impl std::fmt::Debug for dyn GitRepository {
981 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
982 f.debug_struct("dyn GitRepository<...>").finish()
983 }
984}
985
986pub struct RealGitRepository {
987 pub repository: Arc<Mutex<git2::Repository>>,
988 pub system_git_binary_path: Option<PathBuf>,
989 pub any_git_binary_path: PathBuf,
990 any_git_binary_help_output: Arc<Mutex<Option<SharedString>>>,
991 executor: BackgroundExecutor,
992 is_trusted: Arc<AtomicBool>,
993}
994
995impl RealGitRepository {
996 pub fn new(
997 dotgit_path: &Path,
998 bundled_git_binary_path: Option<PathBuf>,
999 system_git_binary_path: Option<PathBuf>,
1000 executor: BackgroundExecutor,
1001 ) -> Result<Self> {
1002 let any_git_binary_path = system_git_binary_path
1003 .clone()
1004 .or(bundled_git_binary_path)
1005 .context("no git binary available")?;
1006 log::info!(
1007 "opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}"
1008 );
1009 let workdir_root = dotgit_path.parent().context(".git has no parent")?;
1010 let repository =
1011 git2::Repository::open(workdir_root).context("creating libgit2 repository")?;
1012 Ok(Self {
1013 repository: Arc::new(Mutex::new(repository)),
1014 system_git_binary_path,
1015 any_git_binary_path,
1016 executor,
1017 any_git_binary_help_output: Arc::new(Mutex::new(None)),
1018 is_trusted: Arc::new(AtomicBool::new(false)),
1019 })
1020 }
1021
1022 fn working_directory(&self) -> Result<PathBuf> {
1023 self.repository
1024 .lock()
1025 .workdir()
1026 .context("failed to read git work directory")
1027 .map(Path::to_path_buf)
1028 }
1029
1030 fn git_binary(&self) -> Result<GitBinary> {
1031 Ok(GitBinary::new(
1032 self.any_git_binary_path.clone(),
1033 self.working_directory()
1034 .with_context(|| "Can't run git commands without a working directory")?,
1035 self.path(),
1036 self.executor.clone(),
1037 self.is_trusted(),
1038 ))
1039 }
1040
1041 async fn any_git_binary_help_output(&self) -> SharedString {
1042 if let Some(output) = self.any_git_binary_help_output.lock().clone() {
1043 return output;
1044 }
1045 let git_binary = self.git_binary();
1046 let output: SharedString = self
1047 .executor
1048 .spawn(async move { git_binary?.run(&["help", "-a"]).await })
1049 .await
1050 .unwrap_or_default()
1051 .into();
1052 *self.any_git_binary_help_output.lock() = Some(output.clone());
1053 output
1054 }
1055}
1056
1057#[derive(Clone, Debug)]
1058pub struct GitRepositoryCheckpoint {
1059 pub commit_sha: Oid,
1060}
1061
1062#[derive(Debug)]
1063pub struct GitCommitter {
1064 pub name: Option<String>,
1065 pub email: Option<String>,
1066}
1067
1068pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
1069 if cfg!(any(feature = "test-support", test)) {
1070 return GitCommitter {
1071 name: None,
1072 email: None,
1073 };
1074 }
1075
1076 let git_binary_path =
1077 if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
1078 cx.update(|cx| {
1079 cx.path_for_auxiliary_executable("git")
1080 .context("could not find git binary path")
1081 .log_err()
1082 })
1083 } else {
1084 None
1085 };
1086
1087 let git = GitBinary::new(
1088 git_binary_path.unwrap_or(PathBuf::from("git")),
1089 paths::home_dir().clone(),
1090 paths::home_dir().join(".git"),
1091 cx.background_executor().clone(),
1092 true,
1093 );
1094
1095 cx.background_spawn(async move {
1096 let name = git
1097 .run(&["config", "--global", "user.name"])
1098 .await
1099 .log_err();
1100 let email = git
1101 .run(&["config", "--global", "user.email"])
1102 .await
1103 .log_err();
1104 GitCommitter { name, email }
1105 })
1106 .await
1107}
1108
1109impl GitRepository for RealGitRepository {
1110 fn reload_index(&self) {
1111 if let Ok(mut index) = self.repository.lock().index() {
1112 _ = index.read(false);
1113 }
1114 }
1115
1116 fn path(&self) -> PathBuf {
1117 let repo = self.repository.lock();
1118 repo.path().into()
1119 }
1120
1121 fn main_repository_path(&self) -> PathBuf {
1122 let repo = self.repository.lock();
1123 repo.commondir().into()
1124 }
1125
1126 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
1127 let git_binary = self.git_binary();
1128 self.executor
1129 .spawn(async move {
1130 let git = git_binary?;
1131 let output = git
1132 .build_command(&[
1133 "show",
1134 "--no-patch",
1135 "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
1136 &commit,
1137 ])
1138 .output()
1139 .await?;
1140 let output = std::str::from_utf8(&output.stdout)?;
1141 let fields = output.split('\0').collect::<Vec<_>>();
1142 if fields.len() != 6 {
1143 bail!("unexpected git-show output for {commit:?}: {output:?}")
1144 }
1145 let sha = fields[0].to_string().into();
1146 let message = fields[1].to_string().into();
1147 let commit_timestamp = fields[2].parse()?;
1148 let author_email = fields[3].to_string().into();
1149 let author_name = fields[4].to_string().into();
1150 Ok(CommitDetails {
1151 sha,
1152 message,
1153 commit_timestamp,
1154 author_email,
1155 author_name,
1156 })
1157 })
1158 .boxed()
1159 }
1160
1161 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
1162 if self.repository.lock().workdir().is_none() {
1163 return future::ready(Err(anyhow!("no working directory"))).boxed();
1164 }
1165 let git_binary = self.git_binary();
1166 cx.background_spawn(async move {
1167 let git = git_binary?;
1168 let show_output = git
1169 .build_command(&[
1170 "show",
1171 "--format=",
1172 "-z",
1173 "--no-renames",
1174 "--name-status",
1175 "--first-parent",
1176 ])
1177 .arg(&commit)
1178 .stdin(Stdio::null())
1179 .stdout(Stdio::piped())
1180 .stderr(Stdio::piped())
1181 .output()
1182 .await
1183 .context("starting git show process")?;
1184
1185 let show_stdout = String::from_utf8_lossy(&show_output.stdout);
1186 let changes = parse_git_diff_name_status(&show_stdout);
1187 let parent_sha = format!("{}^", commit);
1188
1189 let mut cat_file_process = git
1190 .build_command(&["cat-file", "--batch=%(objectsize)"])
1191 .stdin(Stdio::piped())
1192 .stdout(Stdio::piped())
1193 .stderr(Stdio::piped())
1194 .spawn()
1195 .context("starting git cat-file process")?;
1196
1197 let mut files = Vec::<CommitFile>::new();
1198 let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
1199 let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
1200 let mut info_line = String::new();
1201 let mut newline = [b'\0'];
1202 for (path, status_code) in changes {
1203 // git-show outputs `/`-delimited paths even on Windows.
1204 let Some(rel_path) = RelPath::unix(path).log_err() else {
1205 continue;
1206 };
1207
1208 match status_code {
1209 StatusCode::Modified => {
1210 stdin.write_all(commit.as_bytes()).await?;
1211 stdin.write_all(b":").await?;
1212 stdin.write_all(path.as_bytes()).await?;
1213 stdin.write_all(b"\n").await?;
1214 stdin.write_all(parent_sha.as_bytes()).await?;
1215 stdin.write_all(b":").await?;
1216 stdin.write_all(path.as_bytes()).await?;
1217 stdin.write_all(b"\n").await?;
1218 }
1219 StatusCode::Added => {
1220 stdin.write_all(commit.as_bytes()).await?;
1221 stdin.write_all(b":").await?;
1222 stdin.write_all(path.as_bytes()).await?;
1223 stdin.write_all(b"\n").await?;
1224 }
1225 StatusCode::Deleted => {
1226 stdin.write_all(parent_sha.as_bytes()).await?;
1227 stdin.write_all(b":").await?;
1228 stdin.write_all(path.as_bytes()).await?;
1229 stdin.write_all(b"\n").await?;
1230 }
1231 _ => continue,
1232 }
1233 stdin.flush().await?;
1234
1235 info_line.clear();
1236 stdout.read_line(&mut info_line).await?;
1237
1238 let len = info_line.trim_end().parse().with_context(|| {
1239 format!("invalid object size output from cat-file {info_line}")
1240 })?;
1241 let mut text_bytes = vec![0; len];
1242 stdout.read_exact(&mut text_bytes).await?;
1243 stdout.read_exact(&mut newline).await?;
1244
1245 let mut old_text = None;
1246 let mut new_text = None;
1247 let mut is_binary = is_binary_content(&text_bytes);
1248 let text = if is_binary {
1249 String::new()
1250 } else {
1251 String::from_utf8_lossy(&text_bytes).to_string()
1252 };
1253
1254 match status_code {
1255 StatusCode::Modified => {
1256 info_line.clear();
1257 stdout.read_line(&mut info_line).await?;
1258 let len = info_line.trim_end().parse().with_context(|| {
1259 format!("invalid object size output from cat-file {}", info_line)
1260 })?;
1261 let mut parent_bytes = vec![0; len];
1262 stdout.read_exact(&mut parent_bytes).await?;
1263 stdout.read_exact(&mut newline).await?;
1264 is_binary = is_binary || is_binary_content(&parent_bytes);
1265 if is_binary {
1266 old_text = Some(String::new());
1267 new_text = Some(String::new());
1268 } else {
1269 old_text = Some(String::from_utf8_lossy(&parent_bytes).to_string());
1270 new_text = Some(text);
1271 }
1272 }
1273 StatusCode::Added => new_text = Some(text),
1274 StatusCode::Deleted => old_text = Some(text),
1275 _ => continue,
1276 }
1277
1278 files.push(CommitFile {
1279 path: RepoPath(Arc::from(rel_path)),
1280 old_text,
1281 new_text,
1282 is_binary,
1283 })
1284 }
1285
1286 Ok(CommitDiff { files })
1287 })
1288 .boxed()
1289 }
1290
1291 fn reset(
1292 &self,
1293 commit: String,
1294 mode: ResetMode,
1295 env: Arc<HashMap<String, String>>,
1296 ) -> BoxFuture<'_, Result<()>> {
1297 let git_binary = self.git_binary();
1298 async move {
1299 let mode_flag = match mode {
1300 ResetMode::Mixed => "--mixed",
1301 ResetMode::Soft => "--soft",
1302 };
1303
1304 let git = git_binary?;
1305 let output = git
1306 .build_command(&["reset", mode_flag, &commit])
1307 .envs(env.iter())
1308 .output()
1309 .await?;
1310 anyhow::ensure!(
1311 output.status.success(),
1312 "Failed to reset:\n{}",
1313 String::from_utf8_lossy(&output.stderr),
1314 );
1315 Ok(())
1316 }
1317 .boxed()
1318 }
1319
1320 fn checkout_files(
1321 &self,
1322 commit: String,
1323 paths: Vec<RepoPath>,
1324 env: Arc<HashMap<String, String>>,
1325 ) -> BoxFuture<'_, Result<()>> {
1326 let git_binary = self.git_binary();
1327 async move {
1328 if paths.is_empty() {
1329 return Ok(());
1330 }
1331
1332 let git = git_binary?;
1333 let output = git
1334 .build_command(&["checkout", &commit, "--"])
1335 .envs(env.iter())
1336 .args(paths.iter().map(|path| path.as_unix_str()))
1337 .output()
1338 .await?;
1339 anyhow::ensure!(
1340 output.status.success(),
1341 "Failed to checkout files:\n{}",
1342 String::from_utf8_lossy(&output.stderr),
1343 );
1344 Ok(())
1345 }
1346 .boxed()
1347 }
1348
1349 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
1350 // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
1351 const GIT_MODE_SYMLINK: u32 = 0o120000;
1352
1353 let repo = self.repository.clone();
1354 self.executor
1355 .spawn(async move {
1356 fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
1357 let mut index = repo.index()?;
1358 index.read(false)?;
1359
1360 const STAGE_NORMAL: i32 = 0;
1361 // git2 unwraps internally on empty paths or `.`
1362 if path.is_empty() {
1363 bail!("empty path has no index text");
1364 }
1365 let Some(entry) = index.get_path(path.as_std_path(), STAGE_NORMAL) else {
1366 return Ok(None);
1367 };
1368 if entry.mode == GIT_MODE_SYMLINK {
1369 return Ok(None);
1370 }
1371
1372 let content = repo.find_blob(entry.id)?.content().to_owned();
1373 Ok(String::from_utf8(content).ok())
1374 }
1375
1376 logic(&repo.lock(), &path)
1377 .context("loading index text")
1378 .log_err()
1379 .flatten()
1380 })
1381 .boxed()
1382 }
1383
1384 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
1385 let repo = self.repository.clone();
1386 self.executor
1387 .spawn(async move {
1388 fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
1389 let head = repo.head()?.peel_to_tree()?;
1390 // git2 unwraps internally on empty paths or `.`
1391 if path.is_empty() {
1392 return Err(anyhow!("empty path has no committed text"));
1393 }
1394 let Some(entry) = head.get_path(path.as_std_path()).ok() else {
1395 return Ok(None);
1396 };
1397 if entry.filemode() == i32::from(git2::FileMode::Link) {
1398 return Ok(None);
1399 }
1400 let content = repo.find_blob(entry.id())?.content().to_owned();
1401 Ok(String::from_utf8(content).ok())
1402 }
1403
1404 logic(&repo.lock(), &path)
1405 .context("loading committed text")
1406 .log_err()
1407 .flatten()
1408 })
1409 .boxed()
1410 }
1411
1412 fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
1413 let repo = self.repository.clone();
1414 self.executor
1415 .spawn(async move {
1416 let repo = repo.lock();
1417 let content = repo.find_blob(oid.0)?.content().to_owned();
1418 Ok(String::from_utf8(content)?)
1419 })
1420 .boxed()
1421 }
1422
1423 fn set_index_text(
1424 &self,
1425 path: RepoPath,
1426 content: Option<String>,
1427 env: Arc<HashMap<String, String>>,
1428 is_executable: bool,
1429 ) -> BoxFuture<'_, anyhow::Result<()>> {
1430 let git_binary = self.git_binary();
1431 self.executor
1432 .spawn(async move {
1433 let git = git_binary?;
1434 let mode = if is_executable { "100755" } else { "100644" };
1435
1436 if let Some(content) = content {
1437 let mut child = git
1438 .build_command(&["hash-object", "-w", "--stdin"])
1439 .envs(env.iter())
1440 .stdin(Stdio::piped())
1441 .stdout(Stdio::piped())
1442 .spawn()?;
1443 let mut stdin = child.stdin.take().unwrap();
1444 stdin.write_all(content.as_bytes()).await?;
1445 stdin.flush().await?;
1446 drop(stdin);
1447 let output = child.output().await?.stdout;
1448 let sha = str::from_utf8(&output)?.trim();
1449
1450 log::debug!("indexing SHA: {sha}, path {path:?}");
1451
1452 let output = git
1453 .build_command(&["update-index", "--add", "--cacheinfo", mode, sha])
1454 .envs(env.iter())
1455 .arg(path.as_unix_str())
1456 .output()
1457 .await?;
1458
1459 anyhow::ensure!(
1460 output.status.success(),
1461 "Failed to stage:\n{}",
1462 String::from_utf8_lossy(&output.stderr)
1463 );
1464 } else {
1465 log::debug!("removing path {path:?} from the index");
1466 let output = git
1467 .build_command(&["update-index", "--force-remove"])
1468 .envs(env.iter())
1469 .arg(path.as_unix_str())
1470 .output()
1471 .await?;
1472 anyhow::ensure!(
1473 output.status.success(),
1474 "Failed to unstage:\n{}",
1475 String::from_utf8_lossy(&output.stderr)
1476 );
1477 }
1478
1479 Ok(())
1480 })
1481 .boxed()
1482 }
1483
1484 fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
1485 let repo = self.repository.clone();
1486 let name = name.to_owned();
1487 self.executor
1488 .spawn(async move {
1489 let repo = repo.lock();
1490 let remote = repo.find_remote(&name).ok()?;
1491 remote.url().map(|url| url.to_string())
1492 })
1493 .boxed()
1494 }
1495
1496 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
1497 let git_binary = self.git_binary();
1498 self.executor
1499 .spawn(async move {
1500 let git = git_binary?;
1501 let mut process = git
1502 .build_command(&["cat-file", "--batch-check=%(objectname)"])
1503 .stdin(Stdio::piped())
1504 .stdout(Stdio::piped())
1505 .stderr(Stdio::piped())
1506 .spawn()?;
1507
1508 let stdin = process
1509 .stdin
1510 .take()
1511 .context("no stdin for git cat-file subprocess")?;
1512 let mut stdin = BufWriter::new(stdin);
1513 for rev in &revs {
1514 stdin.write_all(rev.as_bytes()).await?;
1515 stdin.write_all(b"\n").await?;
1516 }
1517 stdin.flush().await?;
1518 drop(stdin);
1519
1520 let output = process.output().await?;
1521 let output = std::str::from_utf8(&output.stdout)?;
1522 let shas = output
1523 .lines()
1524 .map(|line| {
1525 if line.ends_with("missing") {
1526 None
1527 } else {
1528 Some(line.to_string())
1529 }
1530 })
1531 .collect::<Vec<_>>();
1532
1533 if shas.len() != revs.len() {
1534 // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
1535 bail!("unexpected number of shas")
1536 }
1537
1538 Ok(shas)
1539 })
1540 .boxed()
1541 }
1542
1543 fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
1544 let path = self.path().join("MERGE_MSG");
1545 self.executor
1546 .spawn(async move { std::fs::read_to_string(&path).ok() })
1547 .boxed()
1548 }
1549
1550 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
1551 let git = match self.git_binary() {
1552 Ok(git) => git,
1553 Err(e) => return Task::ready(Err(e)),
1554 };
1555 let args = git_status_args(path_prefixes);
1556 log::debug!("Checking for git status in {path_prefixes:?}");
1557 self.executor.spawn(async move {
1558 let output = git.build_command(&args).output().await?;
1559 if output.status.success() {
1560 let stdout = String::from_utf8_lossy(&output.stdout);
1561 stdout.parse()
1562 } else {
1563 let stderr = String::from_utf8_lossy(&output.stderr);
1564 anyhow::bail!("git status failed: {stderr}");
1565 }
1566 })
1567 }
1568
1569 fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
1570 let git = match self.git_binary() {
1571 Ok(git) => git,
1572 Err(e) => return Task::ready(Err(e)).boxed(),
1573 };
1574
1575 let mut args = vec![
1576 OsString::from("diff-tree"),
1577 OsString::from("-r"),
1578 OsString::from("-z"),
1579 OsString::from("--no-renames"),
1580 ];
1581 match request {
1582 DiffTreeType::MergeBase { base, head } => {
1583 args.push("--merge-base".into());
1584 args.push(OsString::from(base.as_str()));
1585 args.push(OsString::from(head.as_str()));
1586 }
1587 DiffTreeType::Since { base, head } => {
1588 args.push(OsString::from(base.as_str()));
1589 args.push(OsString::from(head.as_str()));
1590 }
1591 }
1592
1593 self.executor
1594 .spawn(async move {
1595 let output = git.build_command(&args).output().await?;
1596 if output.status.success() {
1597 let stdout = String::from_utf8_lossy(&output.stdout);
1598 stdout.parse()
1599 } else {
1600 let stderr = String::from_utf8_lossy(&output.stderr);
1601 anyhow::bail!("git status failed: {stderr}");
1602 }
1603 })
1604 .boxed()
1605 }
1606
1607 fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
1608 let git_binary = self.git_binary();
1609 self.executor
1610 .spawn(async move {
1611 let git = git_binary?;
1612 let output = git
1613 .build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
1614 .output()
1615 .await?;
1616 if output.status.success() {
1617 let stdout = String::from_utf8_lossy(&output.stdout);
1618 stdout.parse()
1619 } else {
1620 let stderr = String::from_utf8_lossy(&output.stderr);
1621 anyhow::bail!("git status failed: {stderr}");
1622 }
1623 })
1624 .boxed()
1625 }
1626
1627 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
1628 let git_binary = self.git_binary();
1629 self.executor
1630 .spawn(async move {
1631 let fields = [
1632 "%(HEAD)",
1633 "%(objectname)",
1634 "%(parent)",
1635 "%(refname)",
1636 "%(upstream)",
1637 "%(upstream:track)",
1638 "%(committerdate:unix)",
1639 "%(authorname)",
1640 "%(contents:subject)",
1641 ]
1642 .join("%00");
1643 let args = vec![
1644 "for-each-ref",
1645 "refs/heads/**/*",
1646 "refs/remotes/**/*",
1647 "--format",
1648 &fields,
1649 ];
1650 let git = git_binary?;
1651 let output = git.build_command(&args).output().await?;
1652
1653 anyhow::ensure!(
1654 output.status.success(),
1655 "Failed to git git branches:\n{}",
1656 String::from_utf8_lossy(&output.stderr)
1657 );
1658
1659 let input = String::from_utf8_lossy(&output.stdout);
1660
1661 let mut branches = parse_branch_input(&input)?;
1662 if branches.is_empty() {
1663 let args = vec!["symbolic-ref", "--quiet", "HEAD"];
1664
1665 let output = git.build_command(&args).output().await?;
1666
1667 // git symbolic-ref returns a non-0 exit code if HEAD points
1668 // to something other than a branch
1669 if output.status.success() {
1670 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
1671
1672 branches.push(Branch {
1673 ref_name: name.into(),
1674 is_head: true,
1675 upstream: None,
1676 most_recent_commit: None,
1677 });
1678 }
1679 }
1680
1681 Ok(branches)
1682 })
1683 .boxed()
1684 }
1685
1686 fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
1687 let git_binary = self.git_binary();
1688 self.executor
1689 .spawn(async move {
1690 let git = git_binary?;
1691 let output = git
1692 .build_command(&["worktree", "list", "--porcelain"])
1693 .output()
1694 .await?;
1695 if output.status.success() {
1696 let stdout = String::from_utf8_lossy(&output.stdout);
1697 Ok(parse_worktrees_from_str(&stdout))
1698 } else {
1699 let stderr = String::from_utf8_lossy(&output.stderr);
1700 anyhow::bail!("git worktree list failed: {stderr}");
1701 }
1702 })
1703 .boxed()
1704 }
1705
1706 fn create_worktree(
1707 &self,
1708 target: CreateWorktreeTarget,
1709 path: PathBuf,
1710 ) -> BoxFuture<'_, Result<()>> {
1711 let git_binary = self.git_binary();
1712 let mut args = vec![OsString::from("worktree"), OsString::from("add")];
1713
1714 match &target {
1715 CreateWorktreeTarget::ExistingBranch { branch_name } => {
1716 args.push(OsString::from("--"));
1717 args.push(OsString::from(path.as_os_str()));
1718 args.push(OsString::from(branch_name));
1719 }
1720 CreateWorktreeTarget::NewBranch {
1721 branch_name,
1722 base_sha: start_point,
1723 } => {
1724 args.push(OsString::from("-b"));
1725 args.push(OsString::from(branch_name));
1726 args.push(OsString::from("--"));
1727 args.push(OsString::from(path.as_os_str()));
1728 args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD")));
1729 }
1730 CreateWorktreeTarget::Detached {
1731 base_sha: start_point,
1732 } => {
1733 args.push(OsString::from("--detach"));
1734 args.push(OsString::from("--"));
1735 args.push(OsString::from(path.as_os_str()));
1736 args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD")));
1737 }
1738 }
1739
1740 self.executor
1741 .spawn(async move {
1742 std::fs::create_dir_all(path.parent().unwrap_or(&path))?;
1743 let git = git_binary?;
1744 let output = git.build_command(&args).output().await?;
1745 if output.status.success() {
1746 Ok(())
1747 } else {
1748 let stderr = String::from_utf8_lossy(&output.stderr);
1749 anyhow::bail!("git worktree add failed: {stderr}");
1750 }
1751 })
1752 .boxed()
1753 }
1754
1755 fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> {
1756 let git_binary = self.git_binary();
1757
1758 self.executor
1759 .spawn(async move {
1760 let mut args: Vec<OsString> = vec!["worktree".into(), "remove".into()];
1761 if force {
1762 args.push("--force".into());
1763 }
1764 args.push("--".into());
1765 args.push(path.as_os_str().into());
1766 git_binary?.run(&args).await?;
1767 anyhow::Ok(())
1768 })
1769 .boxed()
1770 }
1771
1772 fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> {
1773 let git_binary = self.git_binary();
1774
1775 self.executor
1776 .spawn(async move {
1777 let args: Vec<OsString> = vec![
1778 "worktree".into(),
1779 "move".into(),
1780 "--".into(),
1781 old_path.as_os_str().into(),
1782 new_path.as_os_str().into(),
1783 ];
1784 git_binary?.run(&args).await?;
1785 anyhow::Ok(())
1786 })
1787 .boxed()
1788 }
1789
1790 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1791 let repo = self.repository.clone();
1792 let git_binary = self.git_binary();
1793 let branch = self.executor.spawn(async move {
1794 let repo = repo.lock();
1795 let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
1796 branch
1797 } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
1798 let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
1799
1800 let revision = revision.get();
1801 let branch_commit = revision.peel_to_commit()?;
1802 let mut branch = match repo.branch(&branch_name, &branch_commit, false) {
1803 Ok(branch) => branch,
1804 Err(err) if err.code() == ErrorCode::Exists => {
1805 repo.find_branch(&branch_name, BranchType::Local)?
1806 }
1807 Err(err) => {
1808 return Err(err.into());
1809 }
1810 };
1811
1812 branch.set_upstream(Some(&name))?;
1813 branch
1814 } else {
1815 anyhow::bail!("Branch '{}' not found", name);
1816 };
1817
1818 Ok(branch
1819 .name()?
1820 .context("cannot checkout anonymous branch")?
1821 .to_string())
1822 });
1823
1824 self.executor
1825 .spawn(async move {
1826 let branch = branch.await?;
1827 git_binary?.run(&["checkout", &branch]).await?;
1828 anyhow::Ok(())
1829 })
1830 .boxed()
1831 }
1832
1833 fn create_branch(
1834 &self,
1835 name: String,
1836 base_branch: Option<String>,
1837 ) -> BoxFuture<'_, Result<()>> {
1838 let git_binary = self.git_binary();
1839
1840 self.executor
1841 .spawn(async move {
1842 let mut args = vec!["switch", "-c", &name];
1843 let base_branch_str;
1844 if let Some(ref base) = base_branch {
1845 base_branch_str = base.clone();
1846 args.push(&base_branch_str);
1847 }
1848
1849 git_binary?.run(&args).await?;
1850 anyhow::Ok(())
1851 })
1852 .boxed()
1853 }
1854
1855 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
1856 let git_binary = self.git_binary();
1857
1858 self.executor
1859 .spawn(async move {
1860 git_binary?
1861 .run(&["branch", "-m", &branch, &new_name])
1862 .await?;
1863 anyhow::Ok(())
1864 })
1865 .boxed()
1866 }
1867
1868 fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> {
1869 let git_binary = self.git_binary();
1870
1871 self.executor
1872 .spawn(async move {
1873 git_binary?
1874 .run(&["branch", if is_remote { "-dr" } else { "-d" }, &name])
1875 .await?;
1876 anyhow::Ok(())
1877 })
1878 .boxed()
1879 }
1880
1881 fn blame(
1882 &self,
1883 path: RepoPath,
1884 content: Rope,
1885 line_ending: LineEnding,
1886 ) -> BoxFuture<'_, Result<crate::blame::Blame>> {
1887 let git = self.git_binary();
1888
1889 self.executor
1890 .spawn(async move {
1891 crate::blame::Blame::for_path(&git?, &path, &content, line_ending).await
1892 })
1893 .boxed()
1894 }
1895
1896 fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>> {
1897 self.file_history_paginated(path, 0, None)
1898 }
1899
1900 fn file_history_paginated(
1901 &self,
1902 path: RepoPath,
1903 skip: usize,
1904 limit: Option<usize>,
1905 ) -> BoxFuture<'_, Result<FileHistory>> {
1906 let git_binary = self.git_binary();
1907 self.executor
1908 .spawn(async move {
1909 let git = git_binary?;
1910 // Use a unique delimiter with a hardcoded UUID to separate commits
1911 // This essentially eliminates any chance of encountering the delimiter in actual commit data
1912 let commit_delimiter =
1913 concat!("<<COMMIT_END-", "3f8a9c2e-7d4b-4e1a-9f6c-8b5d2a1e4c3f>>",);
1914
1915 let format_string = format!(
1916 "--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}",
1917 commit_delimiter
1918 );
1919
1920 let mut args = vec!["log", "--follow", &format_string];
1921
1922 let skip_str;
1923 let limit_str;
1924 if skip > 0 {
1925 skip_str = skip.to_string();
1926 args.push("--skip");
1927 args.push(&skip_str);
1928 }
1929 if let Some(n) = limit {
1930 limit_str = n.to_string();
1931 args.push("-n");
1932 args.push(&limit_str);
1933 }
1934
1935 args.push("--");
1936
1937 let output = git
1938 .build_command(&args)
1939 .arg(path.as_unix_str())
1940 .output()
1941 .await?;
1942
1943 if !output.status.success() {
1944 let stderr = String::from_utf8_lossy(&output.stderr);
1945 bail!("git log failed: {stderr}");
1946 }
1947
1948 let stdout = std::str::from_utf8(&output.stdout)?;
1949 let mut entries = Vec::new();
1950
1951 for commit_block in stdout.split(commit_delimiter) {
1952 let commit_block = commit_block.trim();
1953 if commit_block.is_empty() {
1954 continue;
1955 }
1956
1957 let fields: Vec<&str> = commit_block.split('\0').collect();
1958 if fields.len() >= 6 {
1959 let sha = fields[0].trim().to_string().into();
1960 let subject = fields[1].trim().to_string().into();
1961 let message = fields[2].trim().to_string().into();
1962 let commit_timestamp = fields[3].trim().parse().unwrap_or(0);
1963 let author_name = fields[4].trim().to_string().into();
1964 let author_email = fields[5].trim().to_string().into();
1965
1966 entries.push(FileHistoryEntry {
1967 sha,
1968 subject,
1969 message,
1970 commit_timestamp,
1971 author_name,
1972 author_email,
1973 });
1974 }
1975 }
1976
1977 Ok(FileHistory { entries, path })
1978 })
1979 .boxed()
1980 }
1981
1982 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
1983 let git_binary = self.git_binary();
1984 self.executor
1985 .spawn(async move {
1986 let git = git_binary?;
1987 let output = match diff {
1988 DiffType::HeadToIndex => {
1989 git.build_command(&["diff", "--staged"]).output().await?
1990 }
1991 DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?,
1992 DiffType::MergeBase { base_ref } => {
1993 git.build_command(&["diff", "--merge-base", base_ref.as_ref()])
1994 .output()
1995 .await?
1996 }
1997 };
1998
1999 anyhow::ensure!(
2000 output.status.success(),
2001 "Failed to run git diff:\n{}",
2002 String::from_utf8_lossy(&output.stderr)
2003 );
2004 Ok(String::from_utf8_lossy(&output.stdout).to_string())
2005 })
2006 .boxed()
2007 }
2008
2009 fn diff_stat(
2010 &self,
2011 path_prefixes: &[RepoPath],
2012 ) -> BoxFuture<'_, Result<crate::status::GitDiffStat>> {
2013 let path_prefixes = path_prefixes.to_vec();
2014 let git_binary = self.git_binary();
2015
2016 self.executor
2017 .spawn(async move {
2018 let git_binary = git_binary?;
2019 let mut args: Vec<String> = vec![
2020 "diff".into(),
2021 "--numstat".into(),
2022 "--no-renames".into(),
2023 "HEAD".into(),
2024 ];
2025 if !path_prefixes.is_empty() {
2026 args.push("--".into());
2027 args.extend(
2028 path_prefixes
2029 .iter()
2030 .map(|p| p.as_std_path().to_string_lossy().into_owned()),
2031 );
2032 }
2033 let output = git_binary.run(&args).await?;
2034 Ok(crate::status::parse_numstat(&output))
2035 })
2036 .boxed()
2037 }
2038
2039 fn stage_paths(
2040 &self,
2041 paths: Vec<RepoPath>,
2042 env: Arc<HashMap<String, String>>,
2043 ) -> BoxFuture<'_, Result<()>> {
2044 let git_binary = self.git_binary();
2045 self.executor
2046 .spawn(async move {
2047 if !paths.is_empty() {
2048 let git = git_binary?;
2049 let output = git
2050 .build_command(&["update-index", "--add", "--remove", "--"])
2051 .envs(env.iter())
2052 .args(paths.iter().map(|p| p.as_unix_str()))
2053 .output()
2054 .await?;
2055 anyhow::ensure!(
2056 output.status.success(),
2057 "Failed to stage paths:\n{}",
2058 String::from_utf8_lossy(&output.stderr),
2059 );
2060 }
2061 Ok(())
2062 })
2063 .boxed()
2064 }
2065
2066 fn unstage_paths(
2067 &self,
2068 paths: Vec<RepoPath>,
2069 env: Arc<HashMap<String, String>>,
2070 ) -> BoxFuture<'_, Result<()>> {
2071 let git_binary = self.git_binary();
2072
2073 self.executor
2074 .spawn(async move {
2075 if !paths.is_empty() {
2076 let git = git_binary?;
2077 let output = git
2078 .build_command(&["reset", "--quiet", "--"])
2079 .envs(env.iter())
2080 .args(paths.iter().map(|p| p.as_std_path()))
2081 .output()
2082 .await?;
2083
2084 anyhow::ensure!(
2085 output.status.success(),
2086 "Failed to unstage:\n{}",
2087 String::from_utf8_lossy(&output.stderr),
2088 );
2089 }
2090 Ok(())
2091 })
2092 .boxed()
2093 }
2094
2095 fn stash_paths(
2096 &self,
2097 paths: Vec<RepoPath>,
2098 env: Arc<HashMap<String, String>>,
2099 ) -> BoxFuture<'_, Result<()>> {
2100 let git_binary = self.git_binary();
2101 self.executor
2102 .spawn(async move {
2103 let git = git_binary?;
2104 let output = git
2105 .build_command(&["stash", "push", "--quiet", "--include-untracked"])
2106 .envs(env.iter())
2107 .args(paths.iter().map(|p| p.as_unix_str()))
2108 .output()
2109 .await?;
2110
2111 anyhow::ensure!(
2112 output.status.success(),
2113 "Failed to stash:\n{}",
2114 String::from_utf8_lossy(&output.stderr)
2115 );
2116 Ok(())
2117 })
2118 .boxed()
2119 }
2120
2121 fn stash_pop(
2122 &self,
2123 index: Option<usize>,
2124 env: Arc<HashMap<String, String>>,
2125 ) -> BoxFuture<'_, Result<()>> {
2126 let git_binary = self.git_binary();
2127 self.executor
2128 .spawn(async move {
2129 let git = git_binary?;
2130 let mut args = vec!["stash".to_string(), "pop".to_string()];
2131 if let Some(index) = index {
2132 args.push(format!("stash@{{{}}}", index));
2133 }
2134 let output = git.build_command(&args).envs(env.iter()).output().await?;
2135
2136 anyhow::ensure!(
2137 output.status.success(),
2138 "Failed to stash pop:\n{}",
2139 String::from_utf8_lossy(&output.stderr)
2140 );
2141 Ok(())
2142 })
2143 .boxed()
2144 }
2145
2146 fn stash_apply(
2147 &self,
2148 index: Option<usize>,
2149 env: Arc<HashMap<String, String>>,
2150 ) -> BoxFuture<'_, Result<()>> {
2151 let git_binary = self.git_binary();
2152 self.executor
2153 .spawn(async move {
2154 let git = git_binary?;
2155 let mut args = vec!["stash".to_string(), "apply".to_string()];
2156 if let Some(index) = index {
2157 args.push(format!("stash@{{{}}}", index));
2158 }
2159 let output = git.build_command(&args).envs(env.iter()).output().await?;
2160
2161 anyhow::ensure!(
2162 output.status.success(),
2163 "Failed to apply stash:\n{}",
2164 String::from_utf8_lossy(&output.stderr)
2165 );
2166 Ok(())
2167 })
2168 .boxed()
2169 }
2170
2171 fn stash_drop(
2172 &self,
2173 index: Option<usize>,
2174 env: Arc<HashMap<String, String>>,
2175 ) -> BoxFuture<'_, Result<()>> {
2176 let git_binary = self.git_binary();
2177 self.executor
2178 .spawn(async move {
2179 let git = git_binary?;
2180 let mut args = vec!["stash".to_string(), "drop".to_string()];
2181 if let Some(index) = index {
2182 args.push(format!("stash@{{{}}}", index));
2183 }
2184 let output = git.build_command(&args).envs(env.iter()).output().await?;
2185
2186 anyhow::ensure!(
2187 output.status.success(),
2188 "Failed to stash drop:\n{}",
2189 String::from_utf8_lossy(&output.stderr)
2190 );
2191 Ok(())
2192 })
2193 .boxed()
2194 }
2195
2196 fn commit(
2197 &self,
2198 message: SharedString,
2199 name_and_email: Option<(SharedString, SharedString)>,
2200 options: CommitOptions,
2201 ask_pass: AskPassDelegate,
2202 env: Arc<HashMap<String, String>>,
2203 ) -> BoxFuture<'_, Result<()>> {
2204 let git_binary = self.git_binary();
2205 let executor = self.executor.clone();
2206 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2207 // which we want to block on.
2208 async move {
2209 let git = git_binary?;
2210 let mut cmd = git.build_command(&["commit", "--quiet", "-m"]);
2211 cmd.envs(env.iter())
2212 .arg(&message.to_string())
2213 .arg("--cleanup=strip")
2214 .arg("--no-verify")
2215 .stdout(Stdio::piped())
2216 .stderr(Stdio::piped());
2217
2218 if options.amend {
2219 cmd.arg("--amend");
2220 }
2221
2222 if options.signoff {
2223 cmd.arg("--signoff");
2224 }
2225
2226 if options.allow_empty {
2227 cmd.arg("--allow-empty");
2228 }
2229
2230 if let Some((name, email)) = name_and_email {
2231 cmd.arg("--author").arg(&format!("{name} <{email}>"));
2232 }
2233
2234 run_git_command(env, ask_pass, cmd, executor).await?;
2235
2236 Ok(())
2237 }
2238 .boxed()
2239 }
2240
2241 fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> {
2242 let git_binary = self.git_binary();
2243 self.executor
2244 .spawn(async move {
2245 let args: Vec<OsString> = vec!["update-ref".into(), ref_name.into(), commit.into()];
2246 git_binary?.run(&args).await?;
2247 Ok(())
2248 })
2249 .boxed()
2250 }
2251
2252 fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
2253 let git_binary = self.git_binary();
2254 self.executor
2255 .spawn(async move {
2256 let args: Vec<OsString> = vec!["update-ref".into(), "-d".into(), ref_name.into()];
2257 git_binary?.run(&args).await?;
2258 Ok(())
2259 })
2260 .boxed()
2261 }
2262
2263 fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> {
2264 let git_binary = self.git_binary();
2265 self.executor
2266 .spawn(async move {
2267 let args: Vec<OsString> = vec!["worktree".into(), "repair".into()];
2268 git_binary?.run(&args).await?;
2269 Ok(())
2270 })
2271 .boxed()
2272 }
2273
2274 fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>> {
2275 let git_binary = self.git_binary();
2276 self.executor
2277 .spawn(async move {
2278 let args: Vec<OsString> =
2279 vec!["--no-optional-locks".into(), "add".into(), "-A".into()];
2280 git_binary?.run(&args).await?;
2281 Ok(())
2282 })
2283 .boxed()
2284 }
2285
2286 fn push(
2287 &self,
2288 branch_name: String,
2289 remote_branch_name: String,
2290 remote_name: String,
2291 options: Option<PushOptions>,
2292 ask_pass: AskPassDelegate,
2293 env: Arc<HashMap<String, String>>,
2294 cx: AsyncApp,
2295 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2296 let working_directory = self.working_directory();
2297 let git_directory = self.path();
2298 let executor = cx.background_executor().clone();
2299 let git_binary_path = self.system_git_binary_path.clone();
2300 let is_trusted = self.is_trusted();
2301 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2302 // which we want to block on.
2303 async move {
2304 let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?;
2305 let working_directory = working_directory?;
2306 let git = GitBinary::new(
2307 git_binary_path,
2308 working_directory,
2309 git_directory,
2310 executor.clone(),
2311 is_trusted,
2312 );
2313 let mut command = git.build_command(&["push"]);
2314 command
2315 .envs(env.iter())
2316 .args(options.map(|option| match option {
2317 PushOptions::SetUpstream => "--set-upstream",
2318 PushOptions::Force => "--force-with-lease",
2319 }))
2320 .arg(remote_name)
2321 .arg(format!("{}:{}", branch_name, remote_branch_name))
2322 .stdin(Stdio::null())
2323 .stdout(Stdio::piped())
2324 .stderr(Stdio::piped());
2325
2326 run_git_command(env, ask_pass, command, executor).await
2327 }
2328 .boxed()
2329 }
2330
2331 fn pull(
2332 &self,
2333 branch_name: Option<String>,
2334 remote_name: String,
2335 rebase: bool,
2336 ask_pass: AskPassDelegate,
2337 env: Arc<HashMap<String, String>>,
2338 cx: AsyncApp,
2339 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2340 let working_directory = self.working_directory();
2341 let git_directory = self.path();
2342 let executor = cx.background_executor().clone();
2343 let git_binary_path = self.system_git_binary_path.clone();
2344 let is_trusted = self.is_trusted();
2345 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2346 // which we want to block on.
2347 async move {
2348 let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?;
2349 let working_directory = working_directory?;
2350 let git = GitBinary::new(
2351 git_binary_path,
2352 working_directory,
2353 git_directory,
2354 executor.clone(),
2355 is_trusted,
2356 );
2357 let mut command = git.build_command(&["pull"]);
2358 command.envs(env.iter());
2359
2360 if rebase {
2361 command.arg("--rebase");
2362 }
2363
2364 command
2365 .arg(remote_name)
2366 .args(branch_name)
2367 .stdout(Stdio::piped())
2368 .stderr(Stdio::piped());
2369
2370 run_git_command(env, ask_pass, command, executor).await
2371 }
2372 .boxed()
2373 }
2374
2375 fn fetch(
2376 &self,
2377 fetch_options: FetchOptions,
2378 ask_pass: AskPassDelegate,
2379 env: Arc<HashMap<String, String>>,
2380 cx: AsyncApp,
2381 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2382 let working_directory = self.working_directory();
2383 let git_directory = self.path();
2384 let remote_name = format!("{}", fetch_options);
2385 let git_binary_path = self.system_git_binary_path.clone();
2386 let executor = cx.background_executor().clone();
2387 let is_trusted = self.is_trusted();
2388 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2389 // which we want to block on.
2390 async move {
2391 let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?;
2392 let working_directory = working_directory?;
2393 let git = GitBinary::new(
2394 git_binary_path,
2395 working_directory,
2396 git_directory,
2397 executor.clone(),
2398 is_trusted,
2399 );
2400 let mut command = git.build_command(&["fetch", &remote_name]);
2401 command
2402 .envs(env.iter())
2403 .stdout(Stdio::piped())
2404 .stderr(Stdio::piped());
2405
2406 run_git_command(env, ask_pass, command, executor).await
2407 }
2408 .boxed()
2409 }
2410
2411 fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2412 let git_binary = self.git_binary();
2413 self.executor
2414 .spawn(async move {
2415 let git = git_binary?;
2416 let output = git
2417 .build_command(&["rev-parse", "--abbrev-ref"])
2418 .arg(format!("{branch}@{{push}}"))
2419 .output()
2420 .await?;
2421 if !output.status.success() {
2422 return Ok(None);
2423 }
2424 let remote_name = String::from_utf8_lossy(&output.stdout)
2425 .split('/')
2426 .next()
2427 .map(|name| Remote {
2428 name: name.trim().to_string().into(),
2429 });
2430
2431 Ok(remote_name)
2432 })
2433 .boxed()
2434 }
2435
2436 fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2437 let git_binary = self.git_binary();
2438 self.executor
2439 .spawn(async move {
2440 let git = git_binary?;
2441 let output = git
2442 .build_command(&["config", "--get"])
2443 .arg(format!("branch.{branch}.remote"))
2444 .output()
2445 .await?;
2446 if !output.status.success() {
2447 return Ok(None);
2448 }
2449
2450 let remote_name = String::from_utf8_lossy(&output.stdout);
2451 return Ok(Some(Remote {
2452 name: remote_name.trim().to_string().into(),
2453 }));
2454 })
2455 .boxed()
2456 }
2457
2458 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
2459 let git_binary = self.git_binary();
2460 self.executor
2461 .spawn(async move {
2462 let git = git_binary?;
2463 let output = git.build_command(&["remote", "-v"]).output().await?;
2464
2465 anyhow::ensure!(
2466 output.status.success(),
2467 "Failed to get all remotes:\n{}",
2468 String::from_utf8_lossy(&output.stderr)
2469 );
2470 let remote_names: HashSet<Remote> = String::from_utf8_lossy(&output.stdout)
2471 .lines()
2472 .filter(|line| !line.is_empty())
2473 .filter_map(|line| {
2474 let mut split_line = line.split_whitespace();
2475 let remote_name = split_line.next()?;
2476
2477 Some(Remote {
2478 name: remote_name.trim().to_string().into(),
2479 })
2480 })
2481 .collect();
2482
2483 Ok(remote_names.into_iter().collect())
2484 })
2485 .boxed()
2486 }
2487
2488 fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> {
2489 let repo = self.repository.clone();
2490 self.executor
2491 .spawn(async move {
2492 let repo = repo.lock();
2493 repo.remote_delete(&name)?;
2494
2495 Ok(())
2496 })
2497 .boxed()
2498 }
2499
2500 fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> {
2501 let repo = self.repository.clone();
2502 self.executor
2503 .spawn(async move {
2504 let repo = repo.lock();
2505 repo.remote(&name, url.as_ref())?;
2506 Ok(())
2507 })
2508 .boxed()
2509 }
2510
2511 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
2512 let git_binary = self.git_binary();
2513 self.executor
2514 .spawn(async move {
2515 let git = git_binary?;
2516 let git_cmd = async |args: &[&str]| -> Result<String> {
2517 let output = git.build_command(args).output().await?;
2518 anyhow::ensure!(
2519 output.status.success(),
2520 String::from_utf8_lossy(&output.stderr).to_string()
2521 );
2522 Ok(String::from_utf8(output.stdout)?)
2523 };
2524
2525 let head = git_cmd(&["rev-parse", "HEAD"])
2526 .await
2527 .context("Failed to get HEAD")?
2528 .trim()
2529 .to_owned();
2530
2531 let mut remote_branches = vec![];
2532 let mut add_if_matching = async |remote_head: &str| {
2533 if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
2534 && merge_base.trim() == head
2535 && let Some(s) = remote_head.strip_prefix("refs/remotes/")
2536 {
2537 remote_branches.push(s.to_owned().into());
2538 }
2539 };
2540
2541 // check the main branch of each remote
2542 let remotes = git_cmd(&["remote"])
2543 .await
2544 .context("Failed to get remotes")?;
2545 for remote in remotes.lines() {
2546 if let Ok(remote_head) =
2547 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
2548 {
2549 add_if_matching(remote_head.trim()).await;
2550 }
2551 }
2552
2553 // ... and the remote branch that the checked-out one is tracking
2554 if let Ok(remote_head) =
2555 git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
2556 {
2557 add_if_matching(remote_head.trim()).await;
2558 }
2559
2560 Ok(remote_branches)
2561 })
2562 .boxed()
2563 }
2564
2565 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
2566 let git_binary = self.git_binary();
2567 self.executor
2568 .spawn(async move {
2569 let mut git = git_binary?.envs(checkpoint_author_envs());
2570 git.with_temp_index(async |git| {
2571 let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
2572 let mut excludes = exclude_files(git).await?;
2573
2574 git.run(&["add", "--all"]).await?;
2575 let tree = git.run(&["write-tree"]).await?;
2576 let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
2577 git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
2578 .await?
2579 } else {
2580 git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
2581 };
2582
2583 excludes.restore_original().await?;
2584
2585 Ok(GitRepositoryCheckpoint {
2586 commit_sha: checkpoint_sha.parse()?,
2587 })
2588 })
2589 .await
2590 })
2591 .boxed()
2592 }
2593
2594 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
2595 let git_binary = self.git_binary();
2596 self.executor
2597 .spawn(async move {
2598 let git = git_binary?;
2599 git.run(&[
2600 "restore",
2601 "--source",
2602 &checkpoint.commit_sha.to_string(),
2603 "--worktree",
2604 ".",
2605 ])
2606 .await?;
2607
2608 // TODO: We don't track binary and large files anymore,
2609 // so the following call would delete them.
2610 // Implement an alternative way to track files added by agent.
2611 //
2612 // git.with_temp_index(async move |git| {
2613 // git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
2614 // .await?;
2615 // git.run(&["clean", "-d", "--force"]).await
2616 // })
2617 // .await?;
2618
2619 Ok(())
2620 })
2621 .boxed()
2622 }
2623
2624 fn compare_checkpoints(
2625 &self,
2626 left: GitRepositoryCheckpoint,
2627 right: GitRepositoryCheckpoint,
2628 ) -> BoxFuture<'_, Result<bool>> {
2629 let git_binary = self.git_binary();
2630 self.executor
2631 .spawn(async move {
2632 let git = git_binary?;
2633 let result = git
2634 .run(&[
2635 "diff-tree",
2636 "--quiet",
2637 &left.commit_sha.to_string(),
2638 &right.commit_sha.to_string(),
2639 ])
2640 .await;
2641 match result {
2642 Ok(_) => Ok(true),
2643 Err(error) => {
2644 if let Some(GitBinaryCommandError { status, .. }) =
2645 error.downcast_ref::<GitBinaryCommandError>()
2646 && status.code() == Some(1)
2647 {
2648 return Ok(false);
2649 }
2650
2651 Err(error)
2652 }
2653 }
2654 })
2655 .boxed()
2656 }
2657
2658 fn diff_checkpoints(
2659 &self,
2660 base_checkpoint: GitRepositoryCheckpoint,
2661 target_checkpoint: GitRepositoryCheckpoint,
2662 ) -> BoxFuture<'_, Result<String>> {
2663 let git_binary = self.git_binary();
2664 self.executor
2665 .spawn(async move {
2666 let git = git_binary?;
2667 git.run(&[
2668 "diff",
2669 "--find-renames",
2670 "--patch",
2671 &base_checkpoint.commit_sha.to_string(),
2672 &target_checkpoint.commit_sha.to_string(),
2673 ])
2674 .await
2675 })
2676 .boxed()
2677 }
2678
2679 fn default_branch(
2680 &self,
2681 include_remote_name: bool,
2682 ) -> BoxFuture<'_, Result<Option<SharedString>>> {
2683 let git_binary = self.git_binary();
2684 self.executor
2685 .spawn(async move {
2686 let git = git_binary?;
2687
2688 let strip_prefix = if include_remote_name {
2689 "refs/remotes/"
2690 } else {
2691 "refs/remotes/upstream/"
2692 };
2693
2694 if let Ok(output) = git
2695 .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
2696 .await
2697 {
2698 let output = output
2699 .strip_prefix(strip_prefix)
2700 .map(|s| SharedString::from(s.to_owned()));
2701 return Ok(output);
2702 }
2703
2704 let strip_prefix = if include_remote_name {
2705 "refs/remotes/"
2706 } else {
2707 "refs/remotes/origin/"
2708 };
2709
2710 if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
2711 return Ok(output
2712 .strip_prefix(strip_prefix)
2713 .map(|s| SharedString::from(s.to_owned())));
2714 }
2715
2716 if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
2717 if git.run(&["rev-parse", &default_branch]).await.is_ok() {
2718 return Ok(Some(default_branch.into()));
2719 }
2720 }
2721
2722 if git.run(&["rev-parse", "master"]).await.is_ok() {
2723 return Ok(Some("master".into()));
2724 }
2725
2726 Ok(None)
2727 })
2728 .boxed()
2729 }
2730
2731 fn run_hook(
2732 &self,
2733 hook: RunHook,
2734 env: Arc<HashMap<String, String>>,
2735 ) -> BoxFuture<'_, Result<()>> {
2736 let git_binary = self.git_binary();
2737 let repository = self.repository.clone();
2738 let help_output = self.any_git_binary_help_output();
2739
2740 // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang.
2741 async move {
2742 let git_binary = git_binary?;
2743
2744 let working_directory = git_binary.working_directory.clone();
2745 if !help_output
2746 .await
2747 .lines()
2748 .any(|line| line.trim().starts_with("hook "))
2749 {
2750 let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str());
2751 if hook_abs_path.is_file() && git_binary.is_trusted {
2752 #[allow(clippy::disallowed_methods)]
2753 let output = new_command(&hook_abs_path)
2754 .envs(env.iter())
2755 .current_dir(&working_directory)
2756 .output()
2757 .await?;
2758
2759 if !output.status.success() {
2760 return Err(GitBinaryCommandError {
2761 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2762 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2763 status: output.status,
2764 }
2765 .into());
2766 }
2767 }
2768
2769 return Ok(());
2770 }
2771
2772 if git_binary.is_trusted {
2773 let git_binary = git_binary.envs(HashMap::clone(&env));
2774 git_binary
2775 .run(&["hook", "run", "--ignore-missing", hook.as_str()])
2776 .await?;
2777 }
2778 Ok(())
2779 }
2780 .boxed()
2781 }
2782
2783 fn initial_graph_data(
2784 &self,
2785 log_source: LogSource,
2786 log_order: LogOrder,
2787 request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
2788 ) -> BoxFuture<'_, Result<()>> {
2789 let git_binary = self.git_binary();
2790
2791 async move {
2792 let git = git_binary?;
2793
2794 let mut command = git.build_command(&[
2795 "log",
2796 GRAPH_COMMIT_FORMAT,
2797 log_order.as_arg(),
2798 log_source.get_arg()?,
2799 ]);
2800 command.stdout(Stdio::piped());
2801 command.stderr(Stdio::piped());
2802
2803 let mut child = command.spawn()?;
2804 let stdout = child.stdout.take().context("failed to get stdout")?;
2805 let stderr = child.stderr.take().context("failed to get stderr")?;
2806 let mut reader = BufReader::new(stdout);
2807
2808 let mut line_buffer = String::new();
2809 let mut lines: Vec<String> = Vec::with_capacity(GRAPH_CHUNK_SIZE);
2810
2811 loop {
2812 line_buffer.clear();
2813 let bytes_read = reader.read_line(&mut line_buffer).await?;
2814
2815 if bytes_read == 0 {
2816 if !lines.is_empty() {
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!(
2820 "initial_graph_data: receiver dropped while sending commits"
2821 );
2822 }
2823 }
2824 break;
2825 }
2826
2827 let line = line_buffer.trim_end_matches('\n').to_string();
2828 lines.push(line);
2829
2830 if lines.len() >= GRAPH_CHUNK_SIZE {
2831 let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
2832 if request_tx.send(commits).await.is_err() {
2833 log::warn!("initial_graph_data: receiver dropped while streaming commits");
2834 break;
2835 }
2836 lines.clear();
2837 }
2838 }
2839
2840 let status = child.status().await?;
2841 if !status.success() {
2842 let mut stderr_output = String::new();
2843 BufReader::new(stderr)
2844 .read_to_string(&mut stderr_output)
2845 .await
2846 .log_err();
2847
2848 if stderr_output.is_empty() {
2849 anyhow::bail!("git log command failed with {}", status);
2850 } else {
2851 anyhow::bail!("git log command failed with {}: {}", status, stderr_output);
2852 }
2853 }
2854 Ok(())
2855 }
2856 .boxed()
2857 }
2858
2859 fn search_commits(
2860 &self,
2861 log_source: LogSource,
2862 search_args: SearchCommitArgs,
2863 request_tx: Sender<Oid>,
2864 ) -> BoxFuture<'_, Result<()>> {
2865 let git_binary = self.git_binary();
2866
2867 async move {
2868 let git = git_binary?;
2869
2870 let mut args = vec!["log", SEARCH_COMMIT_FORMAT, log_source.get_arg()?];
2871
2872 args.push("--fixed-strings");
2873
2874 if !search_args.case_sensitive {
2875 args.push("--regexp-ignore-case");
2876 }
2877
2878 args.push("--grep");
2879 args.push(search_args.query.as_str());
2880
2881 let mut command = git.build_command(&args);
2882 command.stdout(Stdio::piped());
2883 command.stderr(Stdio::null());
2884
2885 let mut child = command.spawn()?;
2886 let stdout = child.stdout.take().context("failed to get stdout")?;
2887 let mut reader = BufReader::new(stdout);
2888
2889 let mut line_buffer = String::new();
2890
2891 loop {
2892 line_buffer.clear();
2893 let bytes_read = reader.read_line(&mut line_buffer).await?;
2894
2895 if bytes_read == 0 {
2896 break;
2897 }
2898
2899 let sha = line_buffer.trim_end_matches('\n');
2900
2901 if let Ok(oid) = Oid::from_str(sha)
2902 && request_tx.send(oid).await.is_err()
2903 {
2904 break;
2905 }
2906 }
2907
2908 child.status().await?;
2909 Ok(())
2910 }
2911 .boxed()
2912 }
2913
2914 fn commit_data_reader(&self) -> Result<CommitDataReader> {
2915 let git_binary = self.git_binary()?;
2916
2917 let (request_tx, request_rx) = smol::channel::bounded::<CommitDataRequest>(64);
2918
2919 let task = self.executor.spawn(async move {
2920 if let Err(error) = run_commit_data_reader(git_binary, request_rx).await {
2921 log::error!("commit data reader failed: {error:?}");
2922 }
2923 });
2924
2925 Ok(CommitDataReader {
2926 request_tx,
2927 _task: task,
2928 })
2929 }
2930
2931 fn set_trusted(&self, trusted: bool) {
2932 self.is_trusted
2933 .store(trusted, std::sync::atomic::Ordering::Release);
2934 }
2935
2936 fn is_trusted(&self) -> bool {
2937 self.is_trusted.load(std::sync::atomic::Ordering::Acquire)
2938 }
2939}
2940
2941async fn run_commit_data_reader(
2942 git: GitBinary,
2943 request_rx: smol::channel::Receiver<CommitDataRequest>,
2944) -> Result<()> {
2945 let mut process = git
2946 .build_command(&["cat-file", "--batch"])
2947 .stdin(Stdio::piped())
2948 .stdout(Stdio::piped())
2949 .stderr(Stdio::piped())
2950 .spawn()
2951 .context("starting git cat-file --batch process")?;
2952
2953 let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
2954 let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
2955
2956 const MAX_BATCH_SIZE: usize = 64;
2957
2958 while let Ok(first_request) = request_rx.recv().await {
2959 let mut pending_requests = vec![first_request];
2960
2961 while pending_requests.len() < MAX_BATCH_SIZE {
2962 match request_rx.try_recv() {
2963 Ok(request) => pending_requests.push(request),
2964 Err(_) => break,
2965 }
2966 }
2967
2968 for request in &pending_requests {
2969 stdin.write_all(request.sha.to_string().as_bytes()).await?;
2970 stdin.write_all(b"\n").await?;
2971 }
2972 stdin.flush().await?;
2973
2974 for request in pending_requests {
2975 let result = read_single_commit_response(&mut stdout, &request.sha).await;
2976 request.response_tx.send(result).ok();
2977 }
2978 }
2979
2980 drop(stdin);
2981 process.kill().ok();
2982
2983 Ok(())
2984}
2985
2986async fn read_single_commit_response<R: smol::io::AsyncBufRead + Unpin>(
2987 stdout: &mut R,
2988 sha: &Oid,
2989) -> Result<GraphCommitData> {
2990 let mut header_bytes = Vec::new();
2991 stdout.read_until(b'\n', &mut header_bytes).await?;
2992 let header_line = String::from_utf8_lossy(&header_bytes);
2993
2994 let parts: Vec<&str> = header_line.trim().split(' ').collect();
2995 if parts.len() < 3 {
2996 bail!("invalid cat-file header: {header_line}");
2997 }
2998
2999 let object_type = parts[1];
3000 if object_type == "missing" {
3001 bail!("object not found: {}", sha);
3002 }
3003
3004 if object_type != "commit" {
3005 bail!("expected commit object, got {object_type}");
3006 }
3007
3008 let size: usize = parts[2]
3009 .parse()
3010 .with_context(|| format!("invalid object size: {}", parts[2]))?;
3011
3012 let mut content = vec![0u8; size];
3013 stdout.read_exact(&mut content).await?;
3014
3015 let mut newline = [0u8; 1];
3016 stdout.read_exact(&mut newline).await?;
3017
3018 let content_str = String::from_utf8_lossy(&content);
3019 parse_cat_file_commit(*sha, &content_str)
3020 .ok_or_else(|| anyhow!("failed to parse commit {}", sha))
3021}
3022
3023fn parse_initial_graph_output<'a>(
3024 lines: impl Iterator<Item = &'a str>,
3025) -> Vec<Arc<InitialGraphCommitData>> {
3026 lines
3027 .filter(|line| !line.is_empty())
3028 .filter_map(|line| {
3029 // Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..."
3030 let mut parts = line.split('\x00');
3031
3032 let sha = Oid::from_str(parts.next()?).ok()?;
3033 let parents_str = parts.next()?;
3034 let parents = parents_str
3035 .split_whitespace()
3036 .filter_map(|p| Oid::from_str(p).ok())
3037 .collect();
3038
3039 let ref_names_str = parts.next().unwrap_or("");
3040 let ref_names = if ref_names_str.is_empty() {
3041 Vec::new()
3042 } else {
3043 ref_names_str
3044 .split(", ")
3045 .map(|s| SharedString::from(s.to_string()))
3046 .collect()
3047 };
3048
3049 Some(Arc::new(InitialGraphCommitData {
3050 sha,
3051 parents,
3052 ref_names,
3053 }))
3054 })
3055 .collect()
3056}
3057
3058fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
3059 let mut args = vec![
3060 OsString::from("status"),
3061 OsString::from("--porcelain=v1"),
3062 OsString::from("--untracked-files=all"),
3063 OsString::from("--no-renames"),
3064 OsString::from("-z"),
3065 ];
3066 args.extend(path_prefixes.iter().map(|path_prefix| {
3067 if path_prefix.is_empty() {
3068 Path::new(".").into()
3069 } else {
3070 path_prefix.as_std_path().into()
3071 }
3072 }));
3073 args
3074}
3075
3076/// Temporarily git-ignore commonly ignored files and files over 2MB
3077async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
3078 const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
3079 let mut excludes = git.with_exclude_overrides().await?;
3080 excludes
3081 .add_excludes(include_str!("./checkpoint.gitignore"))
3082 .await?;
3083
3084 let working_directory = git.working_directory.clone();
3085 let untracked_files = git.list_untracked_files().await?;
3086 let excluded_paths = untracked_files.into_iter().map(|path| {
3087 let working_directory = working_directory.clone();
3088 smol::spawn(async move {
3089 let full_path = working_directory.join(path.clone());
3090 match smol::fs::metadata(&full_path).await {
3091 Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
3092 Some(PathBuf::from("/").join(path.clone()))
3093 }
3094 _ => None,
3095 }
3096 })
3097 });
3098
3099 let excluded_paths = futures::future::join_all(excluded_paths).await;
3100 let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
3101
3102 if !excluded_paths.is_empty() {
3103 let exclude_patterns = excluded_paths
3104 .into_iter()
3105 .map(|path| path.to_string_lossy().into_owned())
3106 .collect::<Vec<_>>()
3107 .join("\n");
3108 excludes.add_excludes(&exclude_patterns).await?;
3109 }
3110
3111 Ok(excludes)
3112}
3113
3114pub(crate) struct GitBinary {
3115 git_binary_path: PathBuf,
3116 working_directory: PathBuf,
3117 git_directory: PathBuf,
3118 executor: BackgroundExecutor,
3119 index_file_path: Option<PathBuf>,
3120 envs: HashMap<String, String>,
3121 is_trusted: bool,
3122}
3123
3124impl GitBinary {
3125 pub(crate) fn new(
3126 git_binary_path: PathBuf,
3127 working_directory: PathBuf,
3128 git_directory: PathBuf,
3129 executor: BackgroundExecutor,
3130 is_trusted: bool,
3131 ) -> Self {
3132 Self {
3133 git_binary_path,
3134 working_directory,
3135 git_directory,
3136 executor,
3137 index_file_path: None,
3138 envs: HashMap::default(),
3139 is_trusted,
3140 }
3141 }
3142
3143 async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
3144 let status_output = self
3145 .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
3146 .await?;
3147
3148 let paths = status_output
3149 .split('\0')
3150 .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
3151 .map(|entry| PathBuf::from(&entry[3..]))
3152 .collect::<Vec<_>>();
3153 Ok(paths)
3154 }
3155
3156 fn envs(mut self, envs: HashMap<String, String>) -> Self {
3157 self.envs = envs;
3158 self
3159 }
3160
3161 pub async fn with_temp_index<R>(
3162 &mut self,
3163 f: impl AsyncFnOnce(&Self) -> Result<R>,
3164 ) -> Result<R> {
3165 let index_file_path = self.path_for_index_id(Uuid::new_v4());
3166
3167 let delete_temp_index = util::defer({
3168 let index_file_path = index_file_path.clone();
3169 let executor = self.executor.clone();
3170 move || {
3171 executor
3172 .spawn(async move {
3173 smol::fs::remove_file(index_file_path).await.log_err();
3174 })
3175 .detach();
3176 }
3177 });
3178
3179 // Copy the default index file so that Git doesn't have to rebuild the
3180 // whole index from scratch. This might fail if this is an empty repository.
3181 smol::fs::copy(self.git_directory.join("index"), &index_file_path)
3182 .await
3183 .ok();
3184
3185 self.index_file_path = Some(index_file_path.clone());
3186 let result = f(self).await;
3187 self.index_file_path = None;
3188 let result = result?;
3189
3190 smol::fs::remove_file(index_file_path).await.ok();
3191 delete_temp_index.abort();
3192
3193 Ok(result)
3194 }
3195
3196 pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
3197 let path = self.git_directory.join("info").join("exclude");
3198
3199 GitExcludeOverride::new(path).await
3200 }
3201
3202 fn path_for_index_id(&self, id: Uuid) -> PathBuf {
3203 self.git_directory.join(format!("index-{}.tmp", id))
3204 }
3205
3206 pub async fn run<S>(&self, args: &[S]) -> Result<String>
3207 where
3208 S: AsRef<OsStr>,
3209 {
3210 let mut stdout = self.run_raw(args).await?;
3211 if stdout.chars().last() == Some('\n') {
3212 stdout.pop();
3213 }
3214 Ok(stdout)
3215 }
3216
3217 /// Returns the result of the command without trimming the trailing newline.
3218 pub async fn run_raw<S>(&self, args: &[S]) -> Result<String>
3219 where
3220 S: AsRef<OsStr>,
3221 {
3222 let mut command = self.build_command(args);
3223 let output = command.output().await?;
3224 anyhow::ensure!(
3225 output.status.success(),
3226 GitBinaryCommandError {
3227 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3228 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3229 status: output.status,
3230 }
3231 );
3232 Ok(String::from_utf8(output.stdout)?)
3233 }
3234
3235 #[allow(clippy::disallowed_methods)]
3236 pub(crate) fn build_command<S>(&self, args: &[S]) -> util::command::Command
3237 where
3238 S: AsRef<OsStr>,
3239 {
3240 let mut command = new_command(&self.git_binary_path);
3241 command.current_dir(&self.working_directory);
3242 command.args(["-c", "core.fsmonitor=false"]);
3243 command.arg("--no-optional-locks");
3244 command.arg("--no-pager");
3245
3246 if !self.is_trusted {
3247 command.args(["-c", "core.hooksPath=/dev/null"]);
3248 command.args(["-c", "core.sshCommand=ssh"]);
3249 command.args(["-c", "credential.helper="]);
3250 command.args(["-c", "protocol.ext.allow=never"]);
3251 command.args(["-c", "diff.external="]);
3252 }
3253 command.args(args);
3254
3255 // If the `diff` command is being used, we'll want to add the
3256 // `--no-ext-diff` flag when working on an untrusted repository,
3257 // preventing any external diff programs from being invoked.
3258 if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") {
3259 command.arg("--no-ext-diff");
3260 }
3261
3262 if let Some(index_file_path) = self.index_file_path.as_ref() {
3263 command.env("GIT_INDEX_FILE", index_file_path);
3264 }
3265 command.envs(&self.envs);
3266 command
3267 }
3268}
3269
3270#[derive(Error, Debug)]
3271#[error("Git command failed:\n{stdout}{stderr}\n")]
3272struct GitBinaryCommandError {
3273 stdout: String,
3274 stderr: String,
3275 status: ExitStatus,
3276}
3277
3278async fn run_git_command(
3279 env: Arc<HashMap<String, String>>,
3280 ask_pass: AskPassDelegate,
3281 mut command: util::command::Command,
3282 executor: BackgroundExecutor,
3283) -> Result<RemoteCommandOutput> {
3284 if env.contains_key("GIT_ASKPASS") {
3285 let git_process = command.spawn()?;
3286 let output = git_process.output().await?;
3287 anyhow::ensure!(
3288 output.status.success(),
3289 "{}",
3290 String::from_utf8_lossy(&output.stderr)
3291 );
3292 Ok(RemoteCommandOutput {
3293 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3294 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3295 })
3296 } else {
3297 let ask_pass = AskPassSession::new(executor, ask_pass).await?;
3298 command
3299 .env("GIT_ASKPASS", ask_pass.script_path())
3300 .env("SSH_ASKPASS", ask_pass.script_path())
3301 .env("SSH_ASKPASS_REQUIRE", "force");
3302 let git_process = command.spawn()?;
3303
3304 run_askpass_command(ask_pass, git_process).await
3305 }
3306}
3307
3308async fn run_askpass_command(
3309 mut ask_pass: AskPassSession,
3310 git_process: util::command::Child,
3311) -> anyhow::Result<RemoteCommandOutput> {
3312 select_biased! {
3313 result = ask_pass.run().fuse() => {
3314 match result {
3315 AskPassResult::CancelledByUser => {
3316 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
3317 }
3318 AskPassResult::Timedout => {
3319 Err(anyhow!("Connecting to host timed out"))?
3320 }
3321 }
3322 }
3323 output = git_process.output().fuse() => {
3324 let output = output?;
3325 anyhow::ensure!(
3326 output.status.success(),
3327 "{}",
3328 String::from_utf8_lossy(&output.stderr)
3329 );
3330 Ok(RemoteCommandOutput {
3331 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3332 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3333 })
3334 }
3335 }
3336}
3337
3338#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
3339pub struct RepoPath(Arc<RelPath>);
3340
3341impl std::fmt::Debug for RepoPath {
3342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3343 self.0.fmt(f)
3344 }
3345}
3346
3347impl RepoPath {
3348 pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
3349 let rel_path = RelPath::unix(s.as_ref())?;
3350 Ok(Self::from_rel_path(rel_path))
3351 }
3352
3353 pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
3354 let rel_path = RelPath::new(path, path_style)?;
3355 Ok(Self::from_rel_path(&rel_path))
3356 }
3357
3358 pub fn from_proto(proto: &str) -> Result<Self> {
3359 let rel_path = RelPath::from_proto(proto)?;
3360 Ok(Self(rel_path))
3361 }
3362
3363 pub fn from_rel_path(path: &RelPath) -> RepoPath {
3364 Self(Arc::from(path))
3365 }
3366
3367 pub fn as_std_path(&self) -> &Path {
3368 // git2 does not like empty paths and our RelPath infra turns `.` into ``
3369 // so undo that here
3370 if self.is_empty() {
3371 Path::new(".")
3372 } else {
3373 self.0.as_std_path()
3374 }
3375 }
3376}
3377
3378#[cfg(any(test, feature = "test-support"))]
3379pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
3380 RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
3381}
3382
3383impl AsRef<Arc<RelPath>> for RepoPath {
3384 fn as_ref(&self) -> &Arc<RelPath> {
3385 &self.0
3386 }
3387}
3388
3389impl std::ops::Deref for RepoPath {
3390 type Target = RelPath;
3391
3392 fn deref(&self) -> &Self::Target {
3393 &self.0
3394 }
3395}
3396
3397#[derive(Debug)]
3398pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
3399
3400impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
3401 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
3402 if key.starts_with(self.0) {
3403 Ordering::Greater
3404 } else {
3405 self.0.cmp(key)
3406 }
3407 }
3408}
3409
3410fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
3411 let mut branches = Vec::new();
3412 for line in input.split('\n') {
3413 if line.is_empty() {
3414 continue;
3415 }
3416 let mut fields = line.split('\x00');
3417 let Some(head) = fields.next() else {
3418 continue;
3419 };
3420 let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
3421 continue;
3422 };
3423 let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
3424 continue;
3425 };
3426 let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
3427 continue;
3428 };
3429 let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
3430 continue;
3431 };
3432 let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
3433 else {
3434 continue;
3435 };
3436 let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
3437 continue;
3438 };
3439 let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
3440 continue;
3441 };
3442 let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
3443 continue;
3444 };
3445
3446 branches.push(Branch {
3447 is_head: head == "*",
3448 ref_name,
3449 most_recent_commit: Some(CommitSummary {
3450 sha: head_sha,
3451 subject,
3452 commit_timestamp: commiterdate,
3453 author_name: author_name,
3454 has_parent: !parent_sha.is_empty(),
3455 }),
3456 upstream: if upstream_name.is_empty() {
3457 None
3458 } else {
3459 Some(Upstream {
3460 ref_name: upstream_name.into(),
3461 tracking: upstream_tracking,
3462 })
3463 },
3464 })
3465 }
3466
3467 Ok(branches)
3468}
3469
3470fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
3471 if upstream_track.is_empty() {
3472 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3473 ahead: 0,
3474 behind: 0,
3475 }));
3476 }
3477
3478 let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
3479 let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
3480 let mut ahead: u32 = 0;
3481 let mut behind: u32 = 0;
3482 for component in upstream_track.split(", ") {
3483 if component == "gone" {
3484 return Ok(UpstreamTracking::Gone);
3485 }
3486 if let Some(ahead_num) = component.strip_prefix("ahead ") {
3487 ahead = ahead_num.parse::<u32>()?;
3488 }
3489 if let Some(behind_num) = component.strip_prefix("behind ") {
3490 behind = behind_num.parse::<u32>()?;
3491 }
3492 }
3493 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3494 ahead,
3495 behind,
3496 }))
3497}
3498
3499fn checkpoint_author_envs() -> HashMap<String, String> {
3500 HashMap::from_iter([
3501 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
3502 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
3503 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
3504 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
3505 ])
3506}
3507
3508#[cfg(test)]
3509mod tests {
3510 use std::fs;
3511
3512 use super::*;
3513 use gpui::TestAppContext;
3514
3515 fn disable_git_global_config() {
3516 unsafe {
3517 std::env::set_var("GIT_CONFIG_GLOBAL", "");
3518 std::env::set_var("GIT_CONFIG_SYSTEM", "");
3519 }
3520 }
3521
3522 #[gpui::test]
3523 async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
3524 cx.executor().allow_parking();
3525 let dir = tempfile::tempdir().unwrap();
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(&["version"])
3535 .output()
3536 .await
3537 .expect("git version should succeed");
3538 assert!(output.status.success());
3539
3540 let git = GitBinary::new(
3541 PathBuf::from("git"),
3542 dir.path().to_path_buf(),
3543 dir.path().join(".git"),
3544 cx.executor(),
3545 false,
3546 );
3547 let output = git
3548 .build_command(&["config", "--get", "core.fsmonitor"])
3549 .output()
3550 .await
3551 .expect("git config should run");
3552 let stdout = String::from_utf8_lossy(&output.stdout);
3553 assert_eq!(
3554 stdout.trim(),
3555 "false",
3556 "fsmonitor should be disabled for untrusted repos"
3557 );
3558
3559 git2::Repository::init(dir.path()).unwrap();
3560 let git = GitBinary::new(
3561 PathBuf::from("git"),
3562 dir.path().to_path_buf(),
3563 dir.path().join(".git"),
3564 cx.executor(),
3565 false,
3566 );
3567 let output = git
3568 .build_command(&["config", "--get", "core.hooksPath"])
3569 .output()
3570 .await
3571 .expect("git config should run");
3572 let stdout = String::from_utf8_lossy(&output.stdout);
3573 assert_eq!(
3574 stdout.trim(),
3575 "/dev/null",
3576 "hooksPath should be /dev/null for untrusted repos"
3577 );
3578 }
3579
3580 #[gpui::test]
3581 async fn test_build_command_trusted_only_disables_fsmonitor(cx: &mut TestAppContext) {
3582 cx.executor().allow_parking();
3583 let dir = tempfile::tempdir().unwrap();
3584 git2::Repository::init(dir.path()).unwrap();
3585
3586 let git = GitBinary::new(
3587 PathBuf::from("git"),
3588 dir.path().to_path_buf(),
3589 dir.path().join(".git"),
3590 cx.executor(),
3591 true,
3592 );
3593 let output = git
3594 .build_command(&["config", "--get", "core.fsmonitor"])
3595 .output()
3596 .await
3597 .expect("git config should run");
3598 let stdout = String::from_utf8_lossy(&output.stdout);
3599 assert_eq!(
3600 stdout.trim(),
3601 "false",
3602 "fsmonitor should be disabled even for trusted repos"
3603 );
3604
3605 let git = GitBinary::new(
3606 PathBuf::from("git"),
3607 dir.path().to_path_buf(),
3608 dir.path().join(".git"),
3609 cx.executor(),
3610 true,
3611 );
3612 let output = git
3613 .build_command(&["config", "--get", "core.hooksPath"])
3614 .output()
3615 .await
3616 .expect("git config should run");
3617 assert!(
3618 !output.status.success(),
3619 "hooksPath should NOT be overridden for trusted repos"
3620 );
3621 }
3622
3623 #[gpui::test]
3624 async fn test_path_for_index_id_uses_real_git_directory(cx: &mut TestAppContext) {
3625 cx.executor().allow_parking();
3626 let working_directory = PathBuf::from("/code/worktree");
3627 let git_directory = PathBuf::from("/code/repo/.git/modules/worktree");
3628 let git = GitBinary::new(
3629 PathBuf::from("git"),
3630 working_directory,
3631 git_directory.clone(),
3632 cx.executor(),
3633 false,
3634 );
3635
3636 let path = git.path_for_index_id(Uuid::nil());
3637
3638 assert_eq!(
3639 path,
3640 git_directory.join(format!("index-{}.tmp", Uuid::nil()))
3641 );
3642 }
3643
3644 #[gpui::test]
3645 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
3646 disable_git_global_config();
3647
3648 cx.executor().allow_parking();
3649
3650 let repo_dir = tempfile::tempdir().unwrap();
3651
3652 git2::Repository::init(repo_dir.path()).unwrap();
3653 let file_path = repo_dir.path().join("file");
3654 smol::fs::write(&file_path, "initial").await.unwrap();
3655
3656 let repo = RealGitRepository::new(
3657 &repo_dir.path().join(".git"),
3658 None,
3659 Some("git".into()),
3660 cx.executor(),
3661 )
3662 .unwrap();
3663
3664 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3665 .await
3666 .unwrap();
3667 repo.commit(
3668 "Initial commit".into(),
3669 None,
3670 CommitOptions::default(),
3671 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3672 Arc::new(checkpoint_author_envs()),
3673 )
3674 .await
3675 .unwrap();
3676
3677 smol::fs::write(&file_path, "modified before checkpoint")
3678 .await
3679 .unwrap();
3680 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
3681 .await
3682 .unwrap();
3683 let checkpoint = repo.checkpoint().await.unwrap();
3684
3685 // Ensure the user can't see any branches after creating a checkpoint.
3686 assert_eq!(repo.branches().await.unwrap().len(), 1);
3687
3688 smol::fs::write(&file_path, "modified after checkpoint")
3689 .await
3690 .unwrap();
3691 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3692 .await
3693 .unwrap();
3694 repo.commit(
3695 "Commit after checkpoint".into(),
3696 None,
3697 CommitOptions::default(),
3698 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3699 Arc::new(checkpoint_author_envs()),
3700 )
3701 .await
3702 .unwrap();
3703
3704 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
3705 .await
3706 .unwrap();
3707 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
3708 .await
3709 .unwrap();
3710
3711 // Ensure checkpoint stays alive even after a Git GC.
3712 repo.gc().await.unwrap();
3713 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
3714
3715 assert_eq!(
3716 smol::fs::read_to_string(&file_path).await.unwrap(),
3717 "modified before checkpoint"
3718 );
3719 assert_eq!(
3720 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
3721 .await
3722 .unwrap(),
3723 "1"
3724 );
3725 // See TODO above
3726 // assert_eq!(
3727 // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
3728 // .await
3729 // .ok(),
3730 // None
3731 // );
3732 }
3733
3734 #[gpui::test]
3735 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
3736 disable_git_global_config();
3737
3738 cx.executor().allow_parking();
3739
3740 let repo_dir = tempfile::tempdir().unwrap();
3741 git2::Repository::init(repo_dir.path()).unwrap();
3742 let repo = RealGitRepository::new(
3743 &repo_dir.path().join(".git"),
3744 None,
3745 Some("git".into()),
3746 cx.executor(),
3747 )
3748 .unwrap();
3749
3750 smol::fs::write(repo_dir.path().join("foo"), "foo")
3751 .await
3752 .unwrap();
3753 let checkpoint_sha = repo.checkpoint().await.unwrap();
3754
3755 // Ensure the user can't see any branches after creating a checkpoint.
3756 assert_eq!(repo.branches().await.unwrap().len(), 1);
3757
3758 smol::fs::write(repo_dir.path().join("foo"), "bar")
3759 .await
3760 .unwrap();
3761 smol::fs::write(repo_dir.path().join("baz"), "qux")
3762 .await
3763 .unwrap();
3764 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
3765 assert_eq!(
3766 smol::fs::read_to_string(repo_dir.path().join("foo"))
3767 .await
3768 .unwrap(),
3769 "foo"
3770 );
3771 // See TODOs above
3772 // assert_eq!(
3773 // smol::fs::read_to_string(repo_dir.path().join("baz"))
3774 // .await
3775 // .ok(),
3776 // None
3777 // );
3778 }
3779
3780 #[gpui::test]
3781 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
3782 disable_git_global_config();
3783
3784 cx.executor().allow_parking();
3785
3786 let repo_dir = tempfile::tempdir().unwrap();
3787 git2::Repository::init(repo_dir.path()).unwrap();
3788 let repo = RealGitRepository::new(
3789 &repo_dir.path().join(".git"),
3790 None,
3791 Some("git".into()),
3792 cx.executor(),
3793 )
3794 .unwrap();
3795
3796 smol::fs::write(repo_dir.path().join("file1"), "content1")
3797 .await
3798 .unwrap();
3799 let checkpoint1 = repo.checkpoint().await.unwrap();
3800
3801 smol::fs::write(repo_dir.path().join("file2"), "content2")
3802 .await
3803 .unwrap();
3804 let checkpoint2 = repo.checkpoint().await.unwrap();
3805
3806 assert!(
3807 !repo
3808 .compare_checkpoints(checkpoint1, checkpoint2.clone())
3809 .await
3810 .unwrap()
3811 );
3812
3813 let checkpoint3 = repo.checkpoint().await.unwrap();
3814 assert!(
3815 repo.compare_checkpoints(checkpoint2, checkpoint3)
3816 .await
3817 .unwrap()
3818 );
3819 }
3820
3821 #[gpui::test]
3822 async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
3823 disable_git_global_config();
3824
3825 cx.executor().allow_parking();
3826
3827 let repo_dir = tempfile::tempdir().unwrap();
3828 let text_path = repo_dir.path().join("main.rs");
3829 let bin_path = repo_dir.path().join("binary.o");
3830
3831 git2::Repository::init(repo_dir.path()).unwrap();
3832
3833 smol::fs::write(&text_path, "fn main() {}").await.unwrap();
3834
3835 smol::fs::write(&bin_path, "some binary file here")
3836 .await
3837 .unwrap();
3838
3839 let repo = RealGitRepository::new(
3840 &repo_dir.path().join(".git"),
3841 None,
3842 Some("git".into()),
3843 cx.executor(),
3844 )
3845 .unwrap();
3846
3847 // initial commit
3848 repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
3849 .await
3850 .unwrap();
3851 repo.commit(
3852 "Initial commit".into(),
3853 None,
3854 CommitOptions::default(),
3855 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3856 Arc::new(checkpoint_author_envs()),
3857 )
3858 .await
3859 .unwrap();
3860
3861 let checkpoint = repo.checkpoint().await.unwrap();
3862
3863 smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
3864 .await
3865 .unwrap();
3866 smol::fs::write(&bin_path, "Modified binary file")
3867 .await
3868 .unwrap();
3869
3870 repo.restore_checkpoint(checkpoint).await.unwrap();
3871
3872 // Text files should be restored to checkpoint state,
3873 // but binaries should not (they aren't tracked)
3874 assert_eq!(
3875 smol::fs::read_to_string(&text_path).await.unwrap(),
3876 "fn main() {}"
3877 );
3878
3879 assert_eq!(
3880 smol::fs::read_to_string(&bin_path).await.unwrap(),
3881 "Modified binary file"
3882 );
3883 }
3884
3885 #[test]
3886 fn test_branches_parsing() {
3887 // suppress "help: octal escapes are not supported, `\0` is always null"
3888 #[allow(clippy::octal_escapes)]
3889 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
3890 assert_eq!(
3891 parse_branch_input(input).unwrap(),
3892 vec![Branch {
3893 is_head: true,
3894 ref_name: "refs/heads/zed-patches".into(),
3895 upstream: Some(Upstream {
3896 ref_name: "refs/remotes/origin/zed-patches".into(),
3897 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3898 ahead: 0,
3899 behind: 0
3900 })
3901 }),
3902 most_recent_commit: Some(CommitSummary {
3903 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
3904 subject: "generated protobuf".into(),
3905 commit_timestamp: 1733187470,
3906 author_name: SharedString::new_static("John Doe"),
3907 has_parent: false,
3908 })
3909 }]
3910 )
3911 }
3912
3913 #[test]
3914 fn test_branches_parsing_containing_refs_with_missing_fields() {
3915 #[allow(clippy::octal_escapes)]
3916 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";
3917
3918 let branches = parse_branch_input(input).unwrap();
3919 assert_eq!(branches.len(), 2);
3920 assert_eq!(
3921 branches,
3922 vec![
3923 Branch {
3924 is_head: false,
3925 ref_name: "refs/heads/dev".into(),
3926 upstream: None,
3927 most_recent_commit: Some(CommitSummary {
3928 sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
3929 subject: "Add feature".into(),
3930 commit_timestamp: 1762948725,
3931 author_name: SharedString::new_static("Zed"),
3932 has_parent: true,
3933 })
3934 },
3935 Branch {
3936 is_head: true,
3937 ref_name: "refs/heads/main".into(),
3938 upstream: None,
3939 most_recent_commit: Some(CommitSummary {
3940 sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
3941 subject: "Initial commit".into(),
3942 commit_timestamp: 1762948695,
3943 author_name: SharedString::new_static("Zed"),
3944 has_parent: false,
3945 })
3946 }
3947 ]
3948 )
3949 }
3950
3951 #[test]
3952 fn test_upstream_branch_name() {
3953 let upstream = Upstream {
3954 ref_name: "refs/remotes/origin/feature/branch".into(),
3955 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3956 ahead: 0,
3957 behind: 0,
3958 }),
3959 };
3960 assert_eq!(upstream.branch_name(), Some("feature/branch"));
3961
3962 let upstream = Upstream {
3963 ref_name: "refs/remotes/upstream/main".into(),
3964 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3965 ahead: 0,
3966 behind: 0,
3967 }),
3968 };
3969 assert_eq!(upstream.branch_name(), Some("main"));
3970
3971 let upstream = Upstream {
3972 ref_name: "refs/heads/local".into(),
3973 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3974 ahead: 0,
3975 behind: 0,
3976 }),
3977 };
3978 assert_eq!(upstream.branch_name(), None);
3979
3980 // Test case where upstream branch name differs from what might be the local branch name
3981 let upstream = Upstream {
3982 ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
3983 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3984 ahead: 0,
3985 behind: 0,
3986 }),
3987 };
3988 assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
3989 }
3990
3991 #[test]
3992 fn test_parse_worktrees_from_str() {
3993 // Empty input
3994 let result = parse_worktrees_from_str("");
3995 assert!(result.is_empty());
3996
3997 // Single worktree (main)
3998 let input = "worktree /home/user/project\nHEAD abc123def\nbranch refs/heads/main\n\n";
3999 let result = parse_worktrees_from_str(input);
4000 assert_eq!(result.len(), 1);
4001 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4002 assert_eq!(result[0].sha.as_ref(), "abc123def");
4003 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4004 assert!(result[0].is_main);
4005
4006 // Multiple worktrees
4007 let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
4008 worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n";
4009 let result = parse_worktrees_from_str(input);
4010 assert_eq!(result.len(), 2);
4011 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4012 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4013 assert!(result[0].is_main);
4014 assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt"));
4015 assert_eq!(result[1].ref_name, Some("refs/heads/feature".into()));
4016 assert!(!result[1].is_main);
4017
4018 // Detached HEAD entry (included with ref_name: None)
4019 let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
4020 worktree /home/user/detached\nHEAD def456\ndetached\n\n";
4021 let result = parse_worktrees_from_str(input);
4022 assert_eq!(result.len(), 2);
4023 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4024 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4025 assert!(result[0].is_main);
4026 assert_eq!(result[1].path, PathBuf::from("/home/user/detached"));
4027 assert_eq!(result[1].ref_name, None);
4028 assert_eq!(result[1].sha.as_ref(), "def456");
4029 assert!(!result[1].is_main);
4030
4031 // Bare repo entry (included with ref_name: None)
4032 let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
4033 worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n";
4034 let result = parse_worktrees_from_str(input);
4035 assert_eq!(result.len(), 2);
4036 assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git"));
4037 assert_eq!(result[0].ref_name, None);
4038 assert!(result[0].is_main);
4039 assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
4040 assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
4041 assert!(!result[1].is_main);
4042
4043 // Extra porcelain lines (locked, prunable) should be ignored
4044 let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
4045 worktree /home/user/locked-wt\nHEAD def456\nbranch refs/heads/locked-branch\nlocked\n\n\
4046 worktree /home/user/prunable-wt\nHEAD 789aaa\nbranch refs/heads/prunable-branch\nprunable\n\n";
4047 let result = parse_worktrees_from_str(input);
4048 assert_eq!(result.len(), 3);
4049 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4050 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4051 assert!(result[0].is_main);
4052 assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt"));
4053 assert_eq!(result[1].ref_name, Some("refs/heads/locked-branch".into()));
4054 assert!(!result[1].is_main);
4055 assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt"));
4056 assert_eq!(
4057 result[2].ref_name,
4058 Some("refs/heads/prunable-branch".into())
4059 );
4060 assert!(!result[2].is_main);
4061
4062 // Leading/trailing whitespace on lines should be tolerated
4063 let input =
4064 " worktree /home/user/project \n HEAD abc123 \n branch refs/heads/main \n\n";
4065 let result = parse_worktrees_from_str(input);
4066 assert_eq!(result.len(), 1);
4067 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4068 assert_eq!(result[0].sha.as_ref(), "abc123");
4069 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4070 assert!(result[0].is_main);
4071
4072 // Windows-style line endings should be handled
4073 let input = "worktree /home/user/project\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\n";
4074 let result = parse_worktrees_from_str(input);
4075 assert_eq!(result.len(), 1);
4076 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4077 assert_eq!(result[0].sha.as_ref(), "abc123");
4078 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4079 assert!(result[0].is_main);
4080 }
4081
4082 #[gpui::test]
4083 async fn test_create_and_list_worktrees(cx: &mut TestAppContext) {
4084 disable_git_global_config();
4085 cx.executor().allow_parking();
4086
4087 let temp_dir = tempfile::tempdir().unwrap();
4088 let repo_dir = temp_dir.path().join("repo");
4089 let worktrees_dir = temp_dir.path().join("worktrees");
4090
4091 fs::create_dir_all(&repo_dir).unwrap();
4092 fs::create_dir_all(&worktrees_dir).unwrap();
4093
4094 git2::Repository::init(&repo_dir).unwrap();
4095
4096 let repo = RealGitRepository::new(
4097 &repo_dir.join(".git"),
4098 None,
4099 Some("git".into()),
4100 cx.executor(),
4101 )
4102 .unwrap();
4103
4104 // Create an initial commit (required for worktrees)
4105 smol::fs::write(repo_dir.join("file.txt"), "content")
4106 .await
4107 .unwrap();
4108 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
4109 .await
4110 .unwrap();
4111 repo.commit(
4112 "Initial commit".into(),
4113 None,
4114 CommitOptions::default(),
4115 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4116 Arc::new(checkpoint_author_envs()),
4117 )
4118 .await
4119 .unwrap();
4120
4121 // List worktrees — should have just the main one
4122 let worktrees = repo.worktrees().await.unwrap();
4123 assert_eq!(worktrees.len(), 1);
4124 assert_eq!(
4125 worktrees[0].path.canonicalize().unwrap(),
4126 repo_dir.canonicalize().unwrap()
4127 );
4128
4129 let worktree_path = worktrees_dir.join("some-worktree");
4130
4131 // Create a new worktree
4132 repo.create_worktree(
4133 CreateWorktreeTarget::NewBranch {
4134 branch_name: "test-branch".to_string(),
4135 base_sha: Some("HEAD".to_string()),
4136 },
4137 worktree_path.clone(),
4138 )
4139 .await
4140 .unwrap();
4141
4142 // List worktrees — should have two
4143 let worktrees = repo.worktrees().await.unwrap();
4144 assert_eq!(worktrees.len(), 2);
4145
4146 let new_worktree = worktrees
4147 .iter()
4148 .find(|w| w.display_name() == "test-branch")
4149 .expect("should find worktree with test-branch");
4150 assert_eq!(
4151 new_worktree.path.canonicalize().unwrap(),
4152 worktree_path.canonicalize().unwrap(),
4153 );
4154 }
4155
4156 #[gpui::test]
4157 async fn test_remove_worktree(cx: &mut TestAppContext) {
4158 disable_git_global_config();
4159 cx.executor().allow_parking();
4160
4161 let temp_dir = tempfile::tempdir().unwrap();
4162 let repo_dir = temp_dir.path().join("repo");
4163 let worktrees_dir = temp_dir.path().join("worktrees");
4164 git2::Repository::init(&repo_dir).unwrap();
4165
4166 let repo = RealGitRepository::new(
4167 &repo_dir.join(".git"),
4168 None,
4169 Some("git".into()),
4170 cx.executor(),
4171 )
4172 .unwrap();
4173
4174 // Create an initial commit
4175 smol::fs::write(repo_dir.join("file.txt"), "content")
4176 .await
4177 .unwrap();
4178 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
4179 .await
4180 .unwrap();
4181 repo.commit(
4182 "Initial commit".into(),
4183 None,
4184 CommitOptions::default(),
4185 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4186 Arc::new(checkpoint_author_envs()),
4187 )
4188 .await
4189 .unwrap();
4190
4191 // Create a worktree
4192 let worktree_path = worktrees_dir.join("worktree-to-remove");
4193 repo.create_worktree(
4194 CreateWorktreeTarget::NewBranch {
4195 branch_name: "to-remove".to_string(),
4196 base_sha: Some("HEAD".to_string()),
4197 },
4198 worktree_path.clone(),
4199 )
4200 .await
4201 .unwrap();
4202
4203 // Remove the worktree
4204 repo.remove_worktree(worktree_path.clone(), false)
4205 .await
4206 .unwrap();
4207
4208 // Verify the directory is removed
4209 let worktrees = repo.worktrees().await.unwrap();
4210 assert_eq!(worktrees.len(), 1);
4211 assert!(
4212 worktrees.iter().all(|w| w.display_name() != "to-remove"),
4213 "removed worktree should not appear in list"
4214 );
4215 assert!(!worktree_path.exists());
4216
4217 // Create a worktree
4218 let worktree_path = worktrees_dir.join("dirty-wt");
4219 repo.create_worktree(
4220 CreateWorktreeTarget::NewBranch {
4221 branch_name: "dirty-wt".to_string(),
4222 base_sha: Some("HEAD".to_string()),
4223 },
4224 worktree_path.clone(),
4225 )
4226 .await
4227 .unwrap();
4228
4229 assert!(worktree_path.exists());
4230
4231 // Add uncommitted changes in the worktree
4232 smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted")
4233 .await
4234 .unwrap();
4235
4236 // Non-force removal should fail with dirty worktree
4237 let result = repo.remove_worktree(worktree_path.clone(), false).await;
4238 assert!(
4239 result.is_err(),
4240 "non-force removal of dirty worktree should fail"
4241 );
4242
4243 // Force removal should succeed
4244 repo.remove_worktree(worktree_path.clone(), true)
4245 .await
4246 .unwrap();
4247
4248 let worktrees = repo.worktrees().await.unwrap();
4249 assert_eq!(worktrees.len(), 1);
4250 assert!(!worktree_path.exists());
4251 }
4252
4253 #[gpui::test]
4254 async fn test_rename_worktree(cx: &mut TestAppContext) {
4255 disable_git_global_config();
4256 cx.executor().allow_parking();
4257
4258 let temp_dir = tempfile::tempdir().unwrap();
4259 let repo_dir = temp_dir.path().join("repo");
4260 let worktrees_dir = temp_dir.path().join("worktrees");
4261
4262 git2::Repository::init(&repo_dir).unwrap();
4263
4264 let repo = RealGitRepository::new(
4265 &repo_dir.join(".git"),
4266 None,
4267 Some("git".into()),
4268 cx.executor(),
4269 )
4270 .unwrap();
4271
4272 // Create an initial commit
4273 smol::fs::write(repo_dir.join("file.txt"), "content")
4274 .await
4275 .unwrap();
4276 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
4277 .await
4278 .unwrap();
4279 repo.commit(
4280 "Initial commit".into(),
4281 None,
4282 CommitOptions::default(),
4283 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4284 Arc::new(checkpoint_author_envs()),
4285 )
4286 .await
4287 .unwrap();
4288
4289 // Create a worktree
4290 let old_path = worktrees_dir.join("old-worktree-name");
4291 repo.create_worktree(
4292 CreateWorktreeTarget::NewBranch {
4293 branch_name: "old-name".to_string(),
4294 base_sha: Some("HEAD".to_string()),
4295 },
4296 old_path.clone(),
4297 )
4298 .await
4299 .unwrap();
4300
4301 assert!(old_path.exists());
4302
4303 // Move the worktree to a new path
4304 let new_path = worktrees_dir.join("new-worktree-name");
4305 repo.rename_worktree(old_path.clone(), new_path.clone())
4306 .await
4307 .unwrap();
4308
4309 // Verify the old path is gone and new path exists
4310 assert!(!old_path.exists());
4311 assert!(new_path.exists());
4312
4313 // Verify it shows up in worktree list at the new path
4314 let worktrees = repo.worktrees().await.unwrap();
4315 assert_eq!(worktrees.len(), 2);
4316 let moved_worktree = worktrees
4317 .iter()
4318 .find(|w| w.display_name() == "old-name")
4319 .expect("should find worktree by branch name");
4320 assert_eq!(
4321 moved_worktree.path.canonicalize().unwrap(),
4322 new_path.canonicalize().unwrap()
4323 );
4324 }
4325
4326 #[test]
4327 fn test_original_repo_path_from_common_dir() {
4328 // Normal repo: common_dir is <work_dir>/.git
4329 assert_eq!(
4330 original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
4331 PathBuf::from("/code/zed5")
4332 );
4333
4334 // Worktree: common_dir is the main repo's .git
4335 // (same result — that's the point, it always traces back to the original)
4336 assert_eq!(
4337 original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
4338 PathBuf::from("/code/zed5")
4339 );
4340
4341 // Bare repo: no .git suffix, returns as-is
4342 assert_eq!(
4343 original_repo_path_from_common_dir(Path::new("/code/zed5.git")),
4344 PathBuf::from("/code/zed5.git")
4345 );
4346
4347 // Root-level .git directory
4348 assert_eq!(
4349 original_repo_path_from_common_dir(Path::new("/.git")),
4350 PathBuf::from("/")
4351 );
4352 }
4353
4354 impl RealGitRepository {
4355 /// Force a Git garbage collection on the repository.
4356 fn gc(&self) -> BoxFuture<'_, Result<()>> {
4357 let working_directory = self.working_directory();
4358 let git_directory = self.path();
4359 let git_binary_path = self.any_git_binary_path.clone();
4360 let executor = self.executor.clone();
4361 self.executor
4362 .spawn(async move {
4363 let git_binary_path = git_binary_path.clone();
4364 let working_directory = working_directory?;
4365 let git = GitBinary::new(
4366 git_binary_path,
4367 working_directory,
4368 git_directory,
4369 executor,
4370 true,
4371 );
4372 git.run(&["gc", "--prune"]).await?;
4373 Ok(())
4374 })
4375 .boxed()
4376 }
4377 }
4378}