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