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