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