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