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 ];
3150 args.extend(path_prefixes.iter().map(|path_prefix| {
3151 if path_prefix.is_empty() {
3152 Path::new(".").into()
3153 } else {
3154 path_prefix.as_std_path().into()
3155 }
3156 }));
3157 args
3158}
3159
3160/// Temporarily git-ignore commonly ignored files and files over 2MB
3161async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
3162 const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
3163 let mut excludes = git.with_exclude_overrides().await?;
3164 excludes
3165 .add_excludes(include_str!("./checkpoint.gitignore"))
3166 .await?;
3167
3168 let working_directory = git.working_directory.clone();
3169 let untracked_files = git.list_untracked_files().await?;
3170 let excluded_paths = untracked_files.into_iter().map(|path| {
3171 let working_directory = working_directory.clone();
3172 smol::spawn(async move {
3173 let full_path = working_directory.join(path.clone());
3174 match smol::fs::metadata(&full_path).await {
3175 Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
3176 Some(PathBuf::from("/").join(path.clone()))
3177 }
3178 _ => None,
3179 }
3180 })
3181 });
3182
3183 let excluded_paths = futures::future::join_all(excluded_paths).await;
3184 let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
3185
3186 if !excluded_paths.is_empty() {
3187 let exclude_patterns = excluded_paths
3188 .into_iter()
3189 .map(|path| path.to_string_lossy().into_owned())
3190 .collect::<Vec<_>>()
3191 .join("\n");
3192 excludes.add_excludes(&exclude_patterns).await?;
3193 }
3194
3195 Ok(excludes)
3196}
3197
3198pub(crate) struct GitBinary {
3199 git_binary_path: PathBuf,
3200 working_directory: PathBuf,
3201 git_directory: PathBuf,
3202 executor: BackgroundExecutor,
3203 index_file_path: Option<PathBuf>,
3204 envs: HashMap<String, String>,
3205 is_trusted: bool,
3206}
3207
3208impl GitBinary {
3209 pub(crate) fn new(
3210 git_binary_path: PathBuf,
3211 working_directory: PathBuf,
3212 git_directory: PathBuf,
3213 executor: BackgroundExecutor,
3214 is_trusted: bool,
3215 ) -> Self {
3216 Self {
3217 git_binary_path,
3218 working_directory,
3219 git_directory,
3220 executor,
3221 index_file_path: None,
3222 envs: HashMap::default(),
3223 is_trusted,
3224 }
3225 }
3226
3227 async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
3228 let status_output = self
3229 .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
3230 .await?;
3231
3232 let paths = status_output
3233 .split('\0')
3234 .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
3235 .map(|entry| PathBuf::from(&entry[3..]))
3236 .collect::<Vec<_>>();
3237 Ok(paths)
3238 }
3239
3240 fn envs(mut self, envs: HashMap<String, String>) -> Self {
3241 self.envs = envs;
3242 self
3243 }
3244
3245 pub async fn with_temp_index<R>(
3246 &mut self,
3247 f: impl AsyncFnOnce(&Self) -> Result<R>,
3248 ) -> Result<R> {
3249 let index_file_path = self.path_for_index_id(Uuid::new_v4());
3250
3251 let delete_temp_index = util::defer({
3252 let index_file_path = index_file_path.clone();
3253 let executor = self.executor.clone();
3254 move || {
3255 executor
3256 .spawn(async move {
3257 smol::fs::remove_file(index_file_path).await.log_err();
3258 })
3259 .detach();
3260 }
3261 });
3262
3263 // Copy the default index file so that Git doesn't have to rebuild the
3264 // whole index from scratch. This might fail if this is an empty repository.
3265 smol::fs::copy(self.git_directory.join("index"), &index_file_path)
3266 .await
3267 .ok();
3268
3269 self.index_file_path = Some(index_file_path.clone());
3270 let result = f(self).await;
3271 self.index_file_path = None;
3272 let result = result?;
3273
3274 smol::fs::remove_file(index_file_path).await.ok();
3275 delete_temp_index.abort();
3276
3277 Ok(result)
3278 }
3279
3280 pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
3281 let path = self.git_directory.join("info").join("exclude");
3282
3283 GitExcludeOverride::new(path).await
3284 }
3285
3286 fn path_for_index_id(&self, id: Uuid) -> PathBuf {
3287 self.git_directory.join(format!("index-{}.tmp", id))
3288 }
3289
3290 pub async fn run<S>(&self, args: &[S]) -> Result<String>
3291 where
3292 S: AsRef<OsStr>,
3293 {
3294 let mut stdout = self.run_raw(args).await?;
3295 if stdout.chars().last() == Some('\n') {
3296 stdout.pop();
3297 }
3298 Ok(stdout)
3299 }
3300
3301 /// Returns the result of the command without trimming the trailing newline.
3302 pub async fn run_raw<S>(&self, args: &[S]) -> Result<String>
3303 where
3304 S: AsRef<OsStr>,
3305 {
3306 let mut command = self.build_command(args);
3307 let output = command.output().await?;
3308 anyhow::ensure!(
3309 output.status.success(),
3310 GitBinaryCommandError {
3311 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3312 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3313 status: output.status,
3314 }
3315 );
3316 Ok(String::from_utf8(output.stdout)?)
3317 }
3318
3319 #[allow(clippy::disallowed_methods)]
3320 pub(crate) fn build_command<S>(&self, args: &[S]) -> util::command::Command
3321 where
3322 S: AsRef<OsStr>,
3323 {
3324 let mut command = new_command(&self.git_binary_path);
3325 command.current_dir(&self.working_directory);
3326 command.args(["-c", "core.fsmonitor=false"]);
3327 command.arg("--no-optional-locks");
3328 command.arg("--no-pager");
3329
3330 if !self.is_trusted {
3331 command.args(["-c", "core.hooksPath=/dev/null"]);
3332 command.args(["-c", "core.sshCommand=ssh"]);
3333 command.args(["-c", "credential.helper="]);
3334 command.args(["-c", "protocol.ext.allow=never"]);
3335 command.args(["-c", "diff.external="]);
3336 }
3337 command.args(args);
3338
3339 // If the `diff` command is being used, we'll want to add the
3340 // `--no-ext-diff` flag when working on an untrusted repository,
3341 // preventing any external diff programs from being invoked.
3342 if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") {
3343 command.arg("--no-ext-diff");
3344 }
3345
3346 if let Some(index_file_path) = self.index_file_path.as_ref() {
3347 command.env("GIT_INDEX_FILE", index_file_path);
3348 }
3349 command.envs(&self.envs);
3350 command
3351 }
3352}
3353
3354#[derive(Error, Debug)]
3355#[error("Git command failed:\n{stdout}{stderr}\n")]
3356struct GitBinaryCommandError {
3357 stdout: String,
3358 stderr: String,
3359 status: ExitStatus,
3360}
3361
3362async fn run_git_command(
3363 env: Arc<HashMap<String, String>>,
3364 ask_pass: AskPassDelegate,
3365 mut command: util::command::Command,
3366 executor: BackgroundExecutor,
3367) -> Result<RemoteCommandOutput> {
3368 if env.contains_key("GIT_ASKPASS") {
3369 let git_process = command.spawn()?;
3370 let output = git_process.output().await?;
3371 anyhow::ensure!(
3372 output.status.success(),
3373 "{}",
3374 String::from_utf8_lossy(&output.stderr)
3375 );
3376 Ok(RemoteCommandOutput {
3377 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3378 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3379 })
3380 } else {
3381 let ask_pass = AskPassSession::new(executor, ask_pass).await?;
3382 command
3383 .env("GIT_ASKPASS", ask_pass.script_path())
3384 .env("SSH_ASKPASS", ask_pass.script_path())
3385 .env("SSH_ASKPASS_REQUIRE", "force");
3386 let git_process = command.spawn()?;
3387
3388 run_askpass_command(ask_pass, git_process).await
3389 }
3390}
3391
3392async fn run_askpass_command(
3393 mut ask_pass: AskPassSession,
3394 git_process: util::command::Child,
3395) -> anyhow::Result<RemoteCommandOutput> {
3396 select_biased! {
3397 result = ask_pass.run().fuse() => {
3398 match result {
3399 AskPassResult::CancelledByUser => {
3400 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
3401 }
3402 AskPassResult::Timedout => {
3403 Err(anyhow!("Connecting to host timed out"))?
3404 }
3405 }
3406 }
3407 output = git_process.output().fuse() => {
3408 let output = output?;
3409 anyhow::ensure!(
3410 output.status.success(),
3411 "{}",
3412 String::from_utf8_lossy(&output.stderr)
3413 );
3414 Ok(RemoteCommandOutput {
3415 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3416 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3417 })
3418 }
3419 }
3420}
3421
3422#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
3423pub struct RepoPath(Arc<RelPath>);
3424
3425impl std::fmt::Debug for RepoPath {
3426 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3427 self.0.fmt(f)
3428 }
3429}
3430
3431impl RepoPath {
3432 pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
3433 let rel_path = RelPath::unix(s.as_ref())?;
3434 Ok(Self::from_rel_path(rel_path))
3435 }
3436
3437 pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
3438 let rel_path = RelPath::new(path, path_style)?;
3439 Ok(Self::from_rel_path(&rel_path))
3440 }
3441
3442 pub fn from_proto(proto: &str) -> Result<Self> {
3443 let rel_path = RelPath::from_proto(proto)?;
3444 Ok(Self(rel_path))
3445 }
3446
3447 pub fn from_rel_path(path: &RelPath) -> RepoPath {
3448 Self(Arc::from(path))
3449 }
3450
3451 pub fn as_std_path(&self) -> &Path {
3452 // git2 does not like empty paths and our RelPath infra turns `.` into ``
3453 // so undo that here
3454 if self.is_empty() {
3455 Path::new(".")
3456 } else {
3457 self.0.as_std_path()
3458 }
3459 }
3460}
3461
3462#[cfg(any(test, feature = "test-support"))]
3463pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
3464 RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
3465}
3466
3467impl AsRef<Arc<RelPath>> for RepoPath {
3468 fn as_ref(&self) -> &Arc<RelPath> {
3469 &self.0
3470 }
3471}
3472
3473impl std::ops::Deref for RepoPath {
3474 type Target = RelPath;
3475
3476 fn deref(&self) -> &Self::Target {
3477 &self.0
3478 }
3479}
3480
3481#[derive(Debug)]
3482pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
3483
3484impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
3485 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
3486 if key.starts_with(self.0) {
3487 Ordering::Greater
3488 } else {
3489 self.0.cmp(key)
3490 }
3491 }
3492}
3493
3494fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
3495 let mut branches = Vec::new();
3496 for line in input.split('\n') {
3497 if line.is_empty() {
3498 continue;
3499 }
3500 let mut fields = line.split('\x00');
3501 let Some(head) = fields.next() else {
3502 continue;
3503 };
3504 let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
3505 continue;
3506 };
3507 let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
3508 continue;
3509 };
3510 let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
3511 continue;
3512 };
3513 let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
3514 continue;
3515 };
3516 let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
3517 else {
3518 continue;
3519 };
3520 let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
3521 continue;
3522 };
3523 let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
3524 continue;
3525 };
3526 let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
3527 continue;
3528 };
3529
3530 branches.push(Branch {
3531 is_head: head == "*",
3532 ref_name,
3533 most_recent_commit: Some(CommitSummary {
3534 sha: head_sha,
3535 subject,
3536 commit_timestamp: commiterdate,
3537 author_name: author_name,
3538 has_parent: !parent_sha.is_empty(),
3539 }),
3540 upstream: if upstream_name.is_empty() {
3541 None
3542 } else {
3543 Some(Upstream {
3544 ref_name: upstream_name.into(),
3545 tracking: upstream_tracking,
3546 })
3547 },
3548 })
3549 }
3550
3551 Ok(branches)
3552}
3553
3554fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
3555 if upstream_track.is_empty() {
3556 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3557 ahead: 0,
3558 behind: 0,
3559 }));
3560 }
3561
3562 let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
3563 let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
3564 let mut ahead: u32 = 0;
3565 let mut behind: u32 = 0;
3566 for component in upstream_track.split(", ") {
3567 if component == "gone" {
3568 return Ok(UpstreamTracking::Gone);
3569 }
3570 if let Some(ahead_num) = component.strip_prefix("ahead ") {
3571 ahead = ahead_num.parse::<u32>()?;
3572 }
3573 if let Some(behind_num) = component.strip_prefix("behind ") {
3574 behind = behind_num.parse::<u32>()?;
3575 }
3576 }
3577 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3578 ahead,
3579 behind,
3580 }))
3581}
3582
3583fn checkpoint_author_envs() -> HashMap<String, String> {
3584 HashMap::from_iter([
3585 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
3586 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
3587 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
3588 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
3589 ])
3590}
3591
3592#[cfg(test)]
3593mod tests {
3594 use std::fs;
3595
3596 use super::*;
3597 use gpui::TestAppContext;
3598
3599 fn disable_git_global_config() {
3600 unsafe {
3601 std::env::set_var("GIT_CONFIG_GLOBAL", "");
3602 std::env::set_var("GIT_CONFIG_SYSTEM", "");
3603 }
3604 }
3605
3606 #[gpui::test]
3607 async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
3608 cx.executor().allow_parking();
3609 let dir = tempfile::tempdir().unwrap();
3610 let git = GitBinary::new(
3611 PathBuf::from("git"),
3612 dir.path().to_path_buf(),
3613 dir.path().join(".git"),
3614 cx.executor(),
3615 false,
3616 );
3617 let output = git
3618 .build_command(&["version"])
3619 .output()
3620 .await
3621 .expect("git version should succeed");
3622 assert!(output.status.success());
3623
3624 let git = GitBinary::new(
3625 PathBuf::from("git"),
3626 dir.path().to_path_buf(),
3627 dir.path().join(".git"),
3628 cx.executor(),
3629 false,
3630 );
3631 let output = git
3632 .build_command(&["config", "--get", "core.fsmonitor"])
3633 .output()
3634 .await
3635 .expect("git config should run");
3636 let stdout = String::from_utf8_lossy(&output.stdout);
3637 assert_eq!(
3638 stdout.trim(),
3639 "false",
3640 "fsmonitor should be disabled for untrusted repos"
3641 );
3642
3643 git2::Repository::init(dir.path()).unwrap();
3644 let git = GitBinary::new(
3645 PathBuf::from("git"),
3646 dir.path().to_path_buf(),
3647 dir.path().join(".git"),
3648 cx.executor(),
3649 false,
3650 );
3651 let output = git
3652 .build_command(&["config", "--get", "core.hooksPath"])
3653 .output()
3654 .await
3655 .expect("git config should run");
3656 let stdout = String::from_utf8_lossy(&output.stdout);
3657 assert_eq!(
3658 stdout.trim(),
3659 "/dev/null",
3660 "hooksPath should be /dev/null for untrusted repos"
3661 );
3662 }
3663
3664 #[gpui::test]
3665 async fn test_build_command_trusted_only_disables_fsmonitor(cx: &mut TestAppContext) {
3666 cx.executor().allow_parking();
3667 let dir = tempfile::tempdir().unwrap();
3668 git2::Repository::init(dir.path()).unwrap();
3669
3670 let git = GitBinary::new(
3671 PathBuf::from("git"),
3672 dir.path().to_path_buf(),
3673 dir.path().join(".git"),
3674 cx.executor(),
3675 true,
3676 );
3677 let output = git
3678 .build_command(&["config", "--get", "core.fsmonitor"])
3679 .output()
3680 .await
3681 .expect("git config should run");
3682 let stdout = String::from_utf8_lossy(&output.stdout);
3683 assert_eq!(
3684 stdout.trim(),
3685 "false",
3686 "fsmonitor should be disabled even for trusted repos"
3687 );
3688
3689 let git = GitBinary::new(
3690 PathBuf::from("git"),
3691 dir.path().to_path_buf(),
3692 dir.path().join(".git"),
3693 cx.executor(),
3694 true,
3695 );
3696 let output = git
3697 .build_command(&["config", "--get", "core.hooksPath"])
3698 .output()
3699 .await
3700 .expect("git config should run");
3701 assert!(
3702 !output.status.success(),
3703 "hooksPath should NOT be overridden for trusted repos"
3704 );
3705 }
3706
3707 #[gpui::test]
3708 async fn test_path_for_index_id_uses_real_git_directory(cx: &mut TestAppContext) {
3709 cx.executor().allow_parking();
3710 let working_directory = PathBuf::from("/code/worktree");
3711 let git_directory = PathBuf::from("/code/repo/.git/modules/worktree");
3712 let git = GitBinary::new(
3713 PathBuf::from("git"),
3714 working_directory,
3715 git_directory.clone(),
3716 cx.executor(),
3717 false,
3718 );
3719
3720 let path = git.path_for_index_id(Uuid::nil());
3721
3722 assert_eq!(
3723 path,
3724 git_directory.join(format!("index-{}.tmp", Uuid::nil()))
3725 );
3726 }
3727
3728 #[gpui::test]
3729 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
3730 disable_git_global_config();
3731
3732 cx.executor().allow_parking();
3733
3734 let repo_dir = tempfile::tempdir().unwrap();
3735
3736 git2::Repository::init(repo_dir.path()).unwrap();
3737 let file_path = repo_dir.path().join("file");
3738 smol::fs::write(&file_path, "initial").await.unwrap();
3739
3740 let repo = RealGitRepository::new(
3741 &repo_dir.path().join(".git"),
3742 None,
3743 Some("git".into()),
3744 cx.executor(),
3745 )
3746 .unwrap();
3747
3748 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3749 .await
3750 .unwrap();
3751 repo.commit(
3752 "Initial commit".into(),
3753 None,
3754 CommitOptions::default(),
3755 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3756 Arc::new(checkpoint_author_envs()),
3757 )
3758 .await
3759 .unwrap();
3760
3761 smol::fs::write(&file_path, "modified before checkpoint")
3762 .await
3763 .unwrap();
3764 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
3765 .await
3766 .unwrap();
3767 let checkpoint = repo.checkpoint().await.unwrap();
3768
3769 // Ensure the user can't see any branches after creating a checkpoint.
3770 assert_eq!(repo.branches().await.unwrap().len(), 1);
3771
3772 smol::fs::write(&file_path, "modified after checkpoint")
3773 .await
3774 .unwrap();
3775 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3776 .await
3777 .unwrap();
3778 repo.commit(
3779 "Commit after checkpoint".into(),
3780 None,
3781 CommitOptions::default(),
3782 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3783 Arc::new(checkpoint_author_envs()),
3784 )
3785 .await
3786 .unwrap();
3787
3788 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
3789 .await
3790 .unwrap();
3791 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
3792 .await
3793 .unwrap();
3794
3795 // Ensure checkpoint stays alive even after a Git GC.
3796 repo.gc().await.unwrap();
3797 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
3798
3799 assert_eq!(
3800 smol::fs::read_to_string(&file_path).await.unwrap(),
3801 "modified before checkpoint"
3802 );
3803 assert_eq!(
3804 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
3805 .await
3806 .unwrap(),
3807 "1"
3808 );
3809 // See TODO above
3810 // assert_eq!(
3811 // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
3812 // .await
3813 // .ok(),
3814 // None
3815 // );
3816 }
3817
3818 #[gpui::test]
3819 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
3820 disable_git_global_config();
3821
3822 cx.executor().allow_parking();
3823
3824 let repo_dir = tempfile::tempdir().unwrap();
3825 git2::Repository::init(repo_dir.path()).unwrap();
3826 let repo = RealGitRepository::new(
3827 &repo_dir.path().join(".git"),
3828 None,
3829 Some("git".into()),
3830 cx.executor(),
3831 )
3832 .unwrap();
3833
3834 smol::fs::write(repo_dir.path().join("foo"), "foo")
3835 .await
3836 .unwrap();
3837 let checkpoint_sha = repo.checkpoint().await.unwrap();
3838
3839 // Ensure the user can't see any branches after creating a checkpoint.
3840 assert_eq!(repo.branches().await.unwrap().len(), 1);
3841
3842 smol::fs::write(repo_dir.path().join("foo"), "bar")
3843 .await
3844 .unwrap();
3845 smol::fs::write(repo_dir.path().join("baz"), "qux")
3846 .await
3847 .unwrap();
3848 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
3849 assert_eq!(
3850 smol::fs::read_to_string(repo_dir.path().join("foo"))
3851 .await
3852 .unwrap(),
3853 "foo"
3854 );
3855 // See TODOs above
3856 // assert_eq!(
3857 // smol::fs::read_to_string(repo_dir.path().join("baz"))
3858 // .await
3859 // .ok(),
3860 // None
3861 // );
3862 }
3863
3864 #[gpui::test]
3865 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
3866 disable_git_global_config();
3867
3868 cx.executor().allow_parking();
3869
3870 let repo_dir = tempfile::tempdir().unwrap();
3871 git2::Repository::init(repo_dir.path()).unwrap();
3872 let repo = RealGitRepository::new(
3873 &repo_dir.path().join(".git"),
3874 None,
3875 Some("git".into()),
3876 cx.executor(),
3877 )
3878 .unwrap();
3879
3880 smol::fs::write(repo_dir.path().join("file1"), "content1")
3881 .await
3882 .unwrap();
3883 let checkpoint1 = repo.checkpoint().await.unwrap();
3884
3885 smol::fs::write(repo_dir.path().join("file2"), "content2")
3886 .await
3887 .unwrap();
3888 let checkpoint2 = repo.checkpoint().await.unwrap();
3889
3890 assert!(
3891 !repo
3892 .compare_checkpoints(checkpoint1, checkpoint2.clone())
3893 .await
3894 .unwrap()
3895 );
3896
3897 let checkpoint3 = repo.checkpoint().await.unwrap();
3898 assert!(
3899 repo.compare_checkpoints(checkpoint2, checkpoint3)
3900 .await
3901 .unwrap()
3902 );
3903 }
3904
3905 #[gpui::test]
3906 async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
3907 disable_git_global_config();
3908
3909 cx.executor().allow_parking();
3910
3911 let repo_dir = tempfile::tempdir().unwrap();
3912 let text_path = repo_dir.path().join("main.rs");
3913 let bin_path = repo_dir.path().join("binary.o");
3914
3915 git2::Repository::init(repo_dir.path()).unwrap();
3916
3917 smol::fs::write(&text_path, "fn main() {}").await.unwrap();
3918
3919 smol::fs::write(&bin_path, "some binary file here")
3920 .await
3921 .unwrap();
3922
3923 let repo = RealGitRepository::new(
3924 &repo_dir.path().join(".git"),
3925 None,
3926 Some("git".into()),
3927 cx.executor(),
3928 )
3929 .unwrap();
3930
3931 // initial commit
3932 repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
3933 .await
3934 .unwrap();
3935 repo.commit(
3936 "Initial commit".into(),
3937 None,
3938 CommitOptions::default(),
3939 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3940 Arc::new(checkpoint_author_envs()),
3941 )
3942 .await
3943 .unwrap();
3944
3945 let checkpoint = repo.checkpoint().await.unwrap();
3946
3947 smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
3948 .await
3949 .unwrap();
3950 smol::fs::write(&bin_path, "Modified binary file")
3951 .await
3952 .unwrap();
3953
3954 repo.restore_checkpoint(checkpoint).await.unwrap();
3955
3956 // Text files should be restored to checkpoint state,
3957 // but binaries should not (they aren't tracked)
3958 assert_eq!(
3959 smol::fs::read_to_string(&text_path).await.unwrap(),
3960 "fn main() {}"
3961 );
3962
3963 assert_eq!(
3964 smol::fs::read_to_string(&bin_path).await.unwrap(),
3965 "Modified binary file"
3966 );
3967 }
3968
3969 #[test]
3970 fn test_branches_parsing() {
3971 // suppress "help: octal escapes are not supported, `\0` is always null"
3972 #[allow(clippy::octal_escapes)]
3973 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
3974 assert_eq!(
3975 parse_branch_input(input).unwrap(),
3976 vec![Branch {
3977 is_head: true,
3978 ref_name: "refs/heads/zed-patches".into(),
3979 upstream: Some(Upstream {
3980 ref_name: "refs/remotes/origin/zed-patches".into(),
3981 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3982 ahead: 0,
3983 behind: 0
3984 })
3985 }),
3986 most_recent_commit: Some(CommitSummary {
3987 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
3988 subject: "generated protobuf".into(),
3989 commit_timestamp: 1733187470,
3990 author_name: SharedString::new_static("John Doe"),
3991 has_parent: false,
3992 })
3993 }]
3994 )
3995 }
3996
3997 #[test]
3998 fn test_branches_parsing_containing_refs_with_missing_fields() {
3999 #[allow(clippy::octal_escapes)]
4000 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";
4001
4002 let branches = parse_branch_input(input).unwrap();
4003 assert_eq!(branches.len(), 2);
4004 assert_eq!(
4005 branches,
4006 vec![
4007 Branch {
4008 is_head: false,
4009 ref_name: "refs/heads/dev".into(),
4010 upstream: None,
4011 most_recent_commit: Some(CommitSummary {
4012 sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
4013 subject: "Add feature".into(),
4014 commit_timestamp: 1762948725,
4015 author_name: SharedString::new_static("Zed"),
4016 has_parent: true,
4017 })
4018 },
4019 Branch {
4020 is_head: true,
4021 ref_name: "refs/heads/main".into(),
4022 upstream: None,
4023 most_recent_commit: Some(CommitSummary {
4024 sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
4025 subject: "Initial commit".into(),
4026 commit_timestamp: 1762948695,
4027 author_name: SharedString::new_static("Zed"),
4028 has_parent: false,
4029 })
4030 }
4031 ]
4032 )
4033 }
4034
4035 #[test]
4036 fn test_upstream_branch_name() {
4037 let upstream = Upstream {
4038 ref_name: "refs/remotes/origin/feature/branch".into(),
4039 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
4040 ahead: 0,
4041 behind: 0,
4042 }),
4043 };
4044 assert_eq!(upstream.branch_name(), Some("feature/branch"));
4045
4046 let upstream = Upstream {
4047 ref_name: "refs/remotes/upstream/main".into(),
4048 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
4049 ahead: 0,
4050 behind: 0,
4051 }),
4052 };
4053 assert_eq!(upstream.branch_name(), Some("main"));
4054
4055 let upstream = Upstream {
4056 ref_name: "refs/heads/local".into(),
4057 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
4058 ahead: 0,
4059 behind: 0,
4060 }),
4061 };
4062 assert_eq!(upstream.branch_name(), None);
4063
4064 // Test case where upstream branch name differs from what might be the local branch name
4065 let upstream = Upstream {
4066 ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
4067 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
4068 ahead: 0,
4069 behind: 0,
4070 }),
4071 };
4072 assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
4073 }
4074
4075 #[test]
4076 fn test_parse_worktrees_from_str() {
4077 // Empty input
4078 let result = parse_worktrees_from_str("");
4079 assert!(result.is_empty());
4080
4081 // Single worktree (main)
4082 let input = "worktree /home/user/project\nHEAD abc123def\nbranch refs/heads/main\n\n";
4083 let result = parse_worktrees_from_str(input);
4084 assert_eq!(result.len(), 1);
4085 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4086 assert_eq!(result[0].sha.as_ref(), "abc123def");
4087 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4088 assert!(result[0].is_main);
4089
4090 // Multiple worktrees
4091 let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
4092 worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n";
4093 let result = parse_worktrees_from_str(input);
4094 assert_eq!(result.len(), 2);
4095 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4096 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4097 assert!(result[0].is_main);
4098 assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt"));
4099 assert_eq!(result[1].ref_name, Some("refs/heads/feature".into()));
4100 assert!(!result[1].is_main);
4101
4102 // Detached HEAD entry (included with ref_name: None)
4103 let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
4104 worktree /home/user/detached\nHEAD def456\ndetached\n\n";
4105 let result = parse_worktrees_from_str(input);
4106 assert_eq!(result.len(), 2);
4107 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4108 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4109 assert!(result[0].is_main);
4110 assert_eq!(result[1].path, PathBuf::from("/home/user/detached"));
4111 assert_eq!(result[1].ref_name, None);
4112 assert_eq!(result[1].sha.as_ref(), "def456");
4113 assert!(!result[1].is_main);
4114
4115 // Bare repo entry (included with ref_name: None)
4116 let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
4117 worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n";
4118 let result = parse_worktrees_from_str(input);
4119 assert_eq!(result.len(), 2);
4120 assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git"));
4121 assert_eq!(result[0].ref_name, None);
4122 assert!(result[0].is_main);
4123 assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
4124 assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
4125 assert!(!result[1].is_main);
4126
4127 // Extra porcelain lines (locked, prunable) should be ignored
4128 let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
4129 worktree /home/user/locked-wt\nHEAD def456\nbranch refs/heads/locked-branch\nlocked\n\n\
4130 worktree /home/user/prunable-wt\nHEAD 789aaa\nbranch refs/heads/prunable-branch\nprunable\n\n";
4131 let result = parse_worktrees_from_str(input);
4132 assert_eq!(result.len(), 3);
4133 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4134 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4135 assert!(result[0].is_main);
4136 assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt"));
4137 assert_eq!(result[1].ref_name, Some("refs/heads/locked-branch".into()));
4138 assert!(!result[1].is_main);
4139 assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt"));
4140 assert_eq!(
4141 result[2].ref_name,
4142 Some("refs/heads/prunable-branch".into())
4143 );
4144 assert!(!result[2].is_main);
4145
4146 // Leading/trailing whitespace on lines should be tolerated
4147 let input =
4148 " worktree /home/user/project \n HEAD abc123 \n branch refs/heads/main \n\n";
4149 let result = parse_worktrees_from_str(input);
4150 assert_eq!(result.len(), 1);
4151 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4152 assert_eq!(result[0].sha.as_ref(), "abc123");
4153 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4154 assert!(result[0].is_main);
4155
4156 // Windows-style line endings should be handled
4157 let input = "worktree /home/user/project\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\n";
4158 let result = parse_worktrees_from_str(input);
4159 assert_eq!(result.len(), 1);
4160 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
4161 assert_eq!(result[0].sha.as_ref(), "abc123");
4162 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
4163 assert!(result[0].is_main);
4164 }
4165
4166 #[gpui::test]
4167 async fn test_create_and_list_worktrees(cx: &mut TestAppContext) {
4168 disable_git_global_config();
4169 cx.executor().allow_parking();
4170
4171 let temp_dir = tempfile::tempdir().unwrap();
4172 let repo_dir = temp_dir.path().join("repo");
4173 let worktrees_dir = temp_dir.path().join("worktrees");
4174
4175 fs::create_dir_all(&repo_dir).unwrap();
4176 fs::create_dir_all(&worktrees_dir).unwrap();
4177
4178 git2::Repository::init(&repo_dir).unwrap();
4179
4180 let repo = RealGitRepository::new(
4181 &repo_dir.join(".git"),
4182 None,
4183 Some("git".into()),
4184 cx.executor(),
4185 )
4186 .unwrap();
4187
4188 // Create an initial commit (required for worktrees)
4189 smol::fs::write(repo_dir.join("file.txt"), "content")
4190 .await
4191 .unwrap();
4192 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
4193 .await
4194 .unwrap();
4195 repo.commit(
4196 "Initial commit".into(),
4197 None,
4198 CommitOptions::default(),
4199 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4200 Arc::new(checkpoint_author_envs()),
4201 )
4202 .await
4203 .unwrap();
4204
4205 // List worktrees — should have just the main one
4206 let worktrees = repo.worktrees().await.unwrap();
4207 assert_eq!(worktrees.len(), 1);
4208 assert_eq!(
4209 worktrees[0].path.canonicalize().unwrap(),
4210 repo_dir.canonicalize().unwrap()
4211 );
4212
4213 let worktree_path = worktrees_dir.join("some-worktree");
4214
4215 // Create a new worktree
4216 repo.create_worktree(
4217 CreateWorktreeTarget::NewBranch {
4218 branch_name: "test-branch".to_string(),
4219 base_sha: Some("HEAD".to_string()),
4220 },
4221 worktree_path.clone(),
4222 )
4223 .await
4224 .unwrap();
4225
4226 // List worktrees — should have two
4227 let worktrees = repo.worktrees().await.unwrap();
4228 assert_eq!(worktrees.len(), 2);
4229
4230 let new_worktree = worktrees
4231 .iter()
4232 .find(|w| w.display_name() == "test-branch")
4233 .expect("should find worktree with test-branch");
4234 assert_eq!(
4235 new_worktree.path.canonicalize().unwrap(),
4236 worktree_path.canonicalize().unwrap(),
4237 );
4238 }
4239
4240 #[gpui::test]
4241 async fn test_remove_worktree(cx: &mut TestAppContext) {
4242 disable_git_global_config();
4243 cx.executor().allow_parking();
4244
4245 let temp_dir = tempfile::tempdir().unwrap();
4246 let repo_dir = temp_dir.path().join("repo");
4247 let worktrees_dir = temp_dir.path().join("worktrees");
4248 git2::Repository::init(&repo_dir).unwrap();
4249
4250 let repo = RealGitRepository::new(
4251 &repo_dir.join(".git"),
4252 None,
4253 Some("git".into()),
4254 cx.executor(),
4255 )
4256 .unwrap();
4257
4258 // Create an initial commit
4259 smol::fs::write(repo_dir.join("file.txt"), "content")
4260 .await
4261 .unwrap();
4262 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
4263 .await
4264 .unwrap();
4265 repo.commit(
4266 "Initial commit".into(),
4267 None,
4268 CommitOptions::default(),
4269 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4270 Arc::new(checkpoint_author_envs()),
4271 )
4272 .await
4273 .unwrap();
4274
4275 // Create a worktree
4276 let worktree_path = worktrees_dir.join("worktree-to-remove");
4277 repo.create_worktree(
4278 CreateWorktreeTarget::NewBranch {
4279 branch_name: "to-remove".to_string(),
4280 base_sha: Some("HEAD".to_string()),
4281 },
4282 worktree_path.clone(),
4283 )
4284 .await
4285 .unwrap();
4286
4287 // Remove the worktree
4288 repo.remove_worktree(worktree_path.clone(), false)
4289 .await
4290 .unwrap();
4291
4292 // Verify the directory is removed
4293 let worktrees = repo.worktrees().await.unwrap();
4294 assert_eq!(worktrees.len(), 1);
4295 assert!(
4296 worktrees.iter().all(|w| w.display_name() != "to-remove"),
4297 "removed worktree should not appear in list"
4298 );
4299 assert!(!worktree_path.exists());
4300
4301 // Create a worktree
4302 let worktree_path = worktrees_dir.join("dirty-wt");
4303 repo.create_worktree(
4304 CreateWorktreeTarget::NewBranch {
4305 branch_name: "dirty-wt".to_string(),
4306 base_sha: Some("HEAD".to_string()),
4307 },
4308 worktree_path.clone(),
4309 )
4310 .await
4311 .unwrap();
4312
4313 assert!(worktree_path.exists());
4314
4315 // Add uncommitted changes in the worktree
4316 smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted")
4317 .await
4318 .unwrap();
4319
4320 // Non-force removal should fail with dirty worktree
4321 let result = repo.remove_worktree(worktree_path.clone(), false).await;
4322 assert!(
4323 result.is_err(),
4324 "non-force removal of dirty worktree should fail"
4325 );
4326
4327 // Force removal should succeed
4328 repo.remove_worktree(worktree_path.clone(), true)
4329 .await
4330 .unwrap();
4331
4332 let worktrees = repo.worktrees().await.unwrap();
4333 assert_eq!(worktrees.len(), 1);
4334 assert!(!worktree_path.exists());
4335 }
4336
4337 #[gpui::test]
4338 async fn test_rename_worktree(cx: &mut TestAppContext) {
4339 disable_git_global_config();
4340 cx.executor().allow_parking();
4341
4342 let temp_dir = tempfile::tempdir().unwrap();
4343 let repo_dir = temp_dir.path().join("repo");
4344 let worktrees_dir = temp_dir.path().join("worktrees");
4345
4346 git2::Repository::init(&repo_dir).unwrap();
4347
4348 let repo = RealGitRepository::new(
4349 &repo_dir.join(".git"),
4350 None,
4351 Some("git".into()),
4352 cx.executor(),
4353 )
4354 .unwrap();
4355
4356 // Create an initial commit
4357 smol::fs::write(repo_dir.join("file.txt"), "content")
4358 .await
4359 .unwrap();
4360 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
4361 .await
4362 .unwrap();
4363 repo.commit(
4364 "Initial commit".into(),
4365 None,
4366 CommitOptions::default(),
4367 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4368 Arc::new(checkpoint_author_envs()),
4369 )
4370 .await
4371 .unwrap();
4372
4373 // Create a worktree
4374 let old_path = worktrees_dir.join("old-worktree-name");
4375 repo.create_worktree(
4376 CreateWorktreeTarget::NewBranch {
4377 branch_name: "old-name".to_string(),
4378 base_sha: Some("HEAD".to_string()),
4379 },
4380 old_path.clone(),
4381 )
4382 .await
4383 .unwrap();
4384
4385 assert!(old_path.exists());
4386
4387 // Move the worktree to a new path
4388 let new_path = worktrees_dir.join("new-worktree-name");
4389 repo.rename_worktree(old_path.clone(), new_path.clone())
4390 .await
4391 .unwrap();
4392
4393 // Verify the old path is gone and new path exists
4394 assert!(!old_path.exists());
4395 assert!(new_path.exists());
4396
4397 // Verify it shows up in worktree list at the new path
4398 let worktrees = repo.worktrees().await.unwrap();
4399 assert_eq!(worktrees.len(), 2);
4400 let moved_worktree = worktrees
4401 .iter()
4402 .find(|w| w.display_name() == "old-name")
4403 .expect("should find worktree by branch name");
4404 assert_eq!(
4405 moved_worktree.path.canonicalize().unwrap(),
4406 new_path.canonicalize().unwrap()
4407 );
4408 }
4409
4410 #[test]
4411 fn test_original_repo_path_from_common_dir() {
4412 // Normal repo: common_dir is <work_dir>/.git
4413 assert_eq!(
4414 original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
4415 PathBuf::from("/code/zed5")
4416 );
4417
4418 // Worktree: common_dir is the main repo's .git
4419 // (same result — that's the point, it always traces back to the original)
4420 assert_eq!(
4421 original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
4422 PathBuf::from("/code/zed5")
4423 );
4424
4425 // Bare repo: no .git suffix, returns as-is
4426 assert_eq!(
4427 original_repo_path_from_common_dir(Path::new("/code/zed5.git")),
4428 PathBuf::from("/code/zed5.git")
4429 );
4430
4431 // Root-level .git directory
4432 assert_eq!(
4433 original_repo_path_from_common_dir(Path::new("/.git")),
4434 PathBuf::from("/")
4435 );
4436 }
4437
4438 impl RealGitRepository {
4439 /// Force a Git garbage collection on the repository.
4440 fn gc(&self) -> BoxFuture<'_, Result<()>> {
4441 let working_directory = self.working_directory();
4442 let git_directory = self.path();
4443 let git_binary_path = self.any_git_binary_path.clone();
4444 let executor = self.executor.clone();
4445 self.executor
4446 .spawn(async move {
4447 let git_binary_path = git_binary_path.clone();
4448 let working_directory = working_directory?;
4449 let git = GitBinary::new(
4450 git_binary_path,
4451 working_directory,
4452 git_directory,
4453 executor,
4454 true,
4455 );
4456 git.run(&["gc", "--prune"]).await?;
4457 Ok(())
4458 })
4459 .boxed()
4460 }
4461 }
4462}