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