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