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