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