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