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