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