vcs_core/lib.rs
1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-core` — write code against "the repository" without caring whether it's
4//! git or jj.
5//!
6//! You hold one handle, [`Repo`], that auto-detects whether a directory is a git or
7//! a jj checkout and runs whatever operations *both* tools support — handing back
8//! plain result types ([`RepoSnapshot`], [`FileChange`], [`MergeProbe`], …) that
9//! don't mention the backend (whether the repo is git or jj). Async, structured
10//! errors, and every subprocess
11//! inherits the underlying client's OS-**job** containment (an OS-level container
12//! that kills the whole process tree if your program exits, via [`processkit`]) so
13//! no `git`/`jj` tree is orphaned.
14//!
15//! # What you can do
16//!
17//! From one [`Repo`] handle: read the current branch and a batched status
18//! [`snapshot`](Repo::snapshot) · list & diff changed files · commit paths · fetch
19//! / push / checkout / rebase · probe a merge for conflicts
20//! ([`try_merge`](Repo::try_merge)) · drive in-progress merge/rebase state · manage
21//! worktrees. Open one and read a prompt line:
22//!
23//! ```no_run
24//! use vcs_core::Repo;
25//! # async fn demo() -> vcs_core::Result<()> {
26//! let repo = Repo::discover(".")?; // walks up, detects git vs jj
27//! let s = repo.snapshot().await?; // a few spawns, not a call per field
28//! let branch = s.branch.as_deref().unwrap_or("(detached)");
29//! println!("{branch} {}", if s.dirty { "*" } else { "" });
30//! # Ok(()) }
31//! ```
32//!
33//! **It's a thin common layer, not a god-object.** The shared surface carries only
34//! what unifies *without lying*; the few operations the two tools model too
35//! differently (a full `merge`, jj's `op restore`, range/revset queries) stay on
36//! the raw `git`/`jj` handle rather than being faked (see
37//! [below](#whats-deliberately-not-unified)). Reach for the unified handle when code
38//! must work on both backends; drop to the raw client when you need power only one
39//! of them offers.
40//!
41//! # Mental model (engineering reference)
42//!
43//! The surface is three layers, narrowing from "which tool is this?" to "do the
44//! thing":
45//!
46//! - **[`discover`]** — walk up from a directory to the filesystem root for a
47//! `.git`/`.jj` repo (jj wins when colocated — it's the tool driving the working
48//! copy). Pure filesystem probing, no subprocess; yields a [`Located`]
49//! ([`BackendKind`] + worktree root).
50//! - **[`Repo`]** — the cwd-bound facade handle, the thing you hold. Open one with
51//! [`Repo::discover`] (walks up to find the repo; real job-backed runner) or
52//! [`Repo::open`] (strict — exactly `dir`, no walking up), or build it over an
53//! explicit client with [`Repo::from_git`] / [`Repo::from_jj`] (the test seam).
54//! Re-anchor it to another directory cheaply with [`Repo::at`] — the backend is
55//! shared behind an `Arc`, so threading work across worktrees never re-detects
56//! or rebuilds the client. Inspect it with [`kind`](Repo::kind) /
57//! [`root`](Repo::root) / [`cwd`](Repo::cwd).
58//! - **[`VcsRepo`]** — the same common surface as an object-safe trait, so a
59//! consumer can hold a `Box<dyn VcsRepo>` / `&dyn VcsRepo` without naming the
60//! [`ProcessRunner`] generic. Every method mirrors the like-named inherent method
61//! on [`Repo`]; it adds nothing but the abstraction boundary.
62//!
63//! ## The common operations
64//!
65//! All on [`Repo`] (and [`VcsRepo`]), dir-free, dispatched per backend:
66//!
67//! - **Refs** — [`current_branch`](Repo::current_branch),
68//! [`trunk`](Repo::trunk), [`local_branches`](Repo::local_branches),
69//! [`branch_exists`](Repo::branch_exists),
70//! [`create_branch`](Repo::create_branch),
71//! [`delete_branch`](Repo::delete_branch),
72//! [`rename_branch`](Repo::rename_branch) (branch on git, bookmark on jj).
73//! - **Status** — [`changed_files`](Repo::changed_files),
74//! [`diff_stat`](Repo::diff_stat), [`diff`](Repo::diff),
75//! [`has_uncommitted_changes`](Repo::has_uncommitted_changes),
76//! [`has_tracked_changes`](Repo::has_tracked_changes),
77//! [`conflicted_files`](Repo::conflicted_files), and
78//! [`snapshot`](Repo::snapshot) — a **batched** prompt/status-bar read of the
79//! lot in one or two spawns.
80//! - **Mutations** — [`commit_paths`](Repo::commit_paths) (partial commit),
81//! [`fetch`](Repo::fetch) / [`fetch_from`](Repo::fetch_from) /
82//! [`fetch_branch`](Repo::fetch_branch) /
83//! [`push`](Repo::push), [`checkout`](Repo::checkout),
84//! [`rebase`](Repo::rebase).
85//! - **Merge & operation state** — [`try_merge`](Repo::try_merge) (a
86//! trace-free conflict probe → [`MergeProbe`]),
87//! [`in_progress_state`](Repo::in_progress_state) /
88//! [`abort_in_progress`](Repo::abort_in_progress) /
89//! [`continue_in_progress`](Repo::continue_in_progress) → [`OperationState`].
90//! - **Worktrees / workspaces** — [`list_worktrees`](Repo::list_worktrees),
91//! [`create_worktree`](Repo::create_worktree),
92//! [`remove_worktree`](Repo::remove_worktree), and the **synchronous**
93//! [`cleanup_worktree_blocking`](Repo::cleanup_worktree_blocking) for a `Drop`
94//! guard that cannot `.await`.
95//!
96//! Because the backends genuinely diverge in places, several common methods carry
97//! a documented asymmetry (e.g. `upstream`/`ahead`/`behind` are always `None` on
98//! jj; [`diff_stat`](Repo::diff_stat) and [`diff`](Repo::diff) exclude untracked
99//! files on git but not jj;
100//! [`in_progress_state`](Repo::in_progress_state) never returns `Conflict` on git).
101//! The method docs spell each one out — the facade unifies the *shape*, not away
102//! the truth.
103//!
104//! ## The escape hatches
105//!
106//! Tool-specific work reaches the underlying typed clients without adding
107//! `vcs-git`/`vcs-jj` as separate dependencies (both are re-exported):
108//! [`git_at`](Repo::git_at) / [`jj_at`](Repo::jj_at) hand out a cwd-bound view
109//! ([`GitAt`] / [`JjAt`], `dir` dropped); the raw
110//! [`git`](Repo::git) / [`jj`](Repo::jj) hand out a borrow of the client itself.
111//! Each returns `None` for the other backend.
112//!
113//! ## What's deliberately *not* unified
114//!
115//! Three families stay off the common surface because no honest single shape
116//! exists — reach them through the bound handles:
117//!
118//! - **Full `merge`** — jj composes `new` + `squash` + bookmark moves; git runs a
119//! single command. Only the *conflict probe* unifies, as
120//! [`try_merge`](Repo::try_merge).
121//! - **Operation rollback** — jj's `op restore` has no faithful git analogue; use
122//! [`Jj::transaction`](vcs_jj::Jj::transaction) on the jj client.
123//! - **Range / revset queries** — commit counts and diff stats over a range: git's
124//! `a..b` and jj's revsets aren't interchangeable, so neither is forced onto a
125//! shared signature.
126//!
127//! # Recipes
128//!
129//! Probe a merge for conflicts (trace-free), or spin up a worktree:
130//!
131//! ```no_run
132//! use std::path::Path;
133//! use vcs_core::{MergeProbe, Repo, WorktreeCreate};
134//! # async fn demo(repo: &Repo) -> vcs_core::Result<()> {
135//! match repo.try_merge("feature").await? {
136//! MergeProbe::Clean => println!("merges cleanly"),
137//! MergeProbe::Conflicts(paths) => println!("would conflict in {paths:?}"),
138//! _ => {} // #[non_exhaustive]
139//! }
140//! let wt = repo
141//! .create_worktree(WorktreeCreate::new(Path::new("/tmp/feat"), "feature").base("main"))
142//! .await?;
143//! # let _ = wt;
144//! # Ok(()) }
145//! ```
146//!
147//! # Testing
148//!
149//! There is **no mock feature** on the facade traits — the runner is the seam.
150//! Build a [`Repo`] over a fake [`ProcessRunner`] with [`Repo::from_git`] /
151//! [`Repo::from_jj`] (e.g. a [`ScriptedRunner`](processkit::testing::ScriptedRunner)
152//! replying to canned argv), so the *real* per-backend dispatch, argv-building and
153//! parsing run against canned output — exactly what a mocked `VcsRepo` would skip.
154//! The cross-cutting patterns live in
155//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
156//!
157//! ```no_run
158//! use processkit::testing::{Reply, ScriptedRunner};
159//! use vcs_core::{vcs_git::Git, Repo};
160//! # async fn demo() -> vcs_core::Result<()> {
161//! let runner = ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0"));
162//! let repo = Repo::from_git("/repo", "/repo", Git::with_runner(runner));
163//! assert!(repo.has_uncommitted_changes().await?);
164//! # Ok(()) }
165//! ```
166//!
167//! # In-depth guide
168//!
169//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
170//! from `docs/`. See the [`guide`] module, which walks every operation in depth
171//! and hosts the cross-cutting sub-guides: a [`cookbook`](guide::cookbook) of
172//! end-to-end flows, the [`process_model`](guide::process_model) (job containment,
173//! errors, cancellation), [`positioning`](guide::positioning) (facade-vs-raw-client
174//! and the three call shapes), and the [`stability`](guide::stability) contract.
175
176use std::fmt::{self, Debug, Formatter};
177use std::path::{Path, PathBuf};
178use std::sync::Arc;
179
180use processkit::{JobRunner, ProcessRunner};
181use vcs_git::{Git, GitAt};
182use vcs_jj::{Jj, JjAt};
183
184mod dto;
185mod error;
186mod git_backend;
187mod jj_backend;
188
189pub use dto::{
190 AnnotationLine, BackendKind, BranchDelete, ChangeKind, Commit, CreateOutcome, DiffStat,
191 FileChange, FileDiff, MergeProbe, OperationState, RepoSnapshot, UpstreamTracking,
192 WorktreeCreate, WorktreeCreatePartial, WorktreeInfo, WorktreeRemove,
193};
194pub use error::{Error, Result};
195// The shared output-budget knob (from the CLI-support plumbing, via `vcs-git`): a
196// per-client default ([`Repo::from_git`]/[`from_jj`] over a client built with
197// `default_output_budget`) or a per-call override
198// ([`Repo::show_file_within`](Repo::show_file_within)) for the content read this
199// facade exposes. `vcs-git` and `vcs-jj` re-export the same type.
200pub use vcs_git::OutputBudget;
201
202// Re-export the underlying typed clients so a consumer depending only on
203// `vcs-core` can still reach raw, tool-specific operations — and their types
204// (`GitApi`, `JjApi`, `WorktreeAdd`, `JjFileset`, …) — without adding `vcs-git`
205// / `vcs-jj` as separate dependencies. [`Repo::git`] / [`Repo::jj`] hand out
206// borrows of these clients; the consumer decides, per call, whether to go
207// through the facade or straight to the tool.
208pub use vcs_git;
209pub use vcs_jj;
210// Re-export `processkit` itself so a `vcs-core`-only consumer can name the
211// wrapped error directly — `match err { Error::Vcs(vcs_core::processkit::Error::
212// Timeout { .. }) => … }` — and reach `Outcome`/`CancellationToken`/… without
213// adding `processkit` as a separate dependency. (`Error::Vcs` carries a
214// `processkit::Error`; the classifiers below cover the common branches.)
215pub use processkit;
216// Also surfaced at the crate root so the token a `default_cancel_on` client takes
217// (built via `Git`/`Jj`, then passed to `Repo::from_git`/`from_jj`) is one name
218// away. (Cancellation is core in processkit 0.10 — always available, no feature.)
219pub use processkit::CancellationToken;
220
221/// The result of [`discover`]: which backend, and the repository root it was
222/// found at.
223#[derive(Debug, Clone, PartialEq, Eq)]
224#[non_exhaustive]
225pub struct Located {
226 /// The detected backend.
227 pub kind: BackendKind,
228 /// The directory holding `.git`/`.jj` — the worktree root.
229 pub root: PathBuf,
230}
231
232/// Walk up from `start` to the filesystem root looking for a repository. A `.jj`
233/// directory wins over `.git` (colocated repos are driven through jj); `.git` may
234/// be a directory or a gitlink file (a linked worktree/submodule). Pure
235/// filesystem probing — no subprocess.
236///
237/// `start` is walked exactly as given via [`Path::parent`], so pass an **absolute**
238/// path to search ancestors — a relative path like `"."` has no ancestor chain
239/// and only its own directory is checked. ([`Repo::discover`] absolutises for
240/// you.) See [`Repo::open`] for a strict, non-walking check of exactly one
241/// directory.
242pub fn discover(start: &Path) -> Option<Located> {
243 let mut current = Some(start);
244 while let Some(dir) = current {
245 if is_jj_marker(&dir.join(".jj")) {
246 return Some(Located {
247 kind: BackendKind::Jj,
248 root: dir.to_path_buf(),
249 });
250 }
251 if is_git_marker(&dir.join(".git")) {
252 return Some(Located {
253 kind: BackendKind::Git,
254 root: dir.to_path_buf(),
255 });
256 }
257 current = dir.parent();
258 }
259 None
260}
261
262/// Whether `path` (a candidate `.jj`) is a real jj repository marker — a `.jj`
263/// **directory** that contains a **`repo`** entry (the store: a *directory* in a
264/// repo's main workspace / a colocated repo, a *file* pointer in a secondary
265/// workspace). A stray/empty directory merely *named* `.jj` (e.g. a leftover
266/// `mkdir .jj`) has no `repo` entry, so it can't shadow a healthy `.git` repo in the
267/// same or a higher directory (M19). Symmetric with [`is_git_marker`]: both require a
268/// *valid* marker, not mere existence.
269fn is_jj_marker(path: &Path) -> bool {
270 path.is_dir() && path.join("repo").exists()
271}
272
273/// Whether `path` (a candidate `.git`) is a real git repository marker — a `.git`
274/// **directory**, or a **gitlink file** (a linked worktree / submodule) whose
275/// content starts with `gitdir:`. A stray/garbage file merely *named* `.git` is
276/// rejected, so it can't shadow a real repository higher up the tree, and a binary
277/// or unreadable file is rejected too (the read fails → `false`). Symmetric with
278/// [`is_jj_marker`]: both require a *valid* marker, not mere existence.
279fn is_git_marker(path: &Path) -> bool {
280 use std::io::Read;
281 match std::fs::metadata(path) {
282 Ok(meta) if meta.is_dir() => true,
283 Ok(meta) if meta.is_file() => {
284 // A gitlink file is tiny (`gitdir: <path>\n`), so read only a small
285 // prefix: `discover` walks *up to the filesystem root*, so a huge/garbage
286 // file merely named `.git` in an ancestor we don't own must not force an
287 // unbounded read. `read_to_end` loops over short reads (unlike a single
288 // `read`, which the `Read` contract lets return fewer bytes), and
289 // `from_utf8_lossy` tolerates a binary file or a multibyte char split at
290 // the cap — the `gitdir:` marker is ASCII and within the first bytes.
291 let Ok(file) = std::fs::File::open(path) else {
292 return false;
293 };
294 let mut buf = Vec::new();
295 let _ = file.take(32).read_to_end(&mut buf);
296 String::from_utf8_lossy(&buf)
297 .trim_start()
298 .starts_with("gitdir:")
299 }
300 _ => false,
301 }
302}
303
304/// Whether `dir` (the candidate itself, not a `.git` beneath it) is a **bare**
305/// git repository — created with `git init --bare` (or an equivalent bare
306/// clone): `HEAD`/`config`/`objects`/`refs` sit directly in `dir`, with no
307/// `.git` subdirectory. Requires all four markers together (`HEAD` a file,
308/// `config` a file, `objects`/`refs` directories) so a directory that merely
309/// happens to contain one or two similarly-named entries isn't misdetected —
310/// symmetric with [`is_jj_marker`]/[`is_git_marker`]: a *valid* marker, not
311/// mere partial name overlap. Used to give bare repositories their own
312/// [`Error::BareRepository`](crate::Error::BareRepository) instead of the
313/// generic [`Error::NotARepository`](crate::Error::NotARepository) (issue #6).
314fn is_bare_git_repo_marker(dir: &Path) -> bool {
315 dir.join("HEAD").is_file()
316 && dir.join("config").is_file()
317 && dir.join("objects").is_dir()
318 && dir.join("refs").is_dir()
319}
320
321/// Walk up from `start` to the filesystem root looking for a **bare** git
322/// repository marker (see [`is_bare_git_repo_marker`]). Only called after
323/// [`discover`] has already walked the same chain and found no `.jj`/`.git`, so
324/// any hit here is unambiguous — no real (non-bare) repository intervenes
325/// between `start` and the bare repository root.
326fn find_bare_git_repo(start: &Path) -> Option<PathBuf> {
327 let mut current = Some(start);
328 while let Some(dir) = current {
329 if is_bare_git_repo_marker(dir) {
330 return Some(dir.to_path_buf());
331 }
332 current = dir.parent();
333 }
334 None
335}
336
337/// The per-tool client behind a [`Repo`]. Shared via `Arc` so [`Repo::at`] can
338/// re-anchor the cwd cheaply without rebuilding the client.
339enum Backend<R: ProcessRunner> {
340 Git(Arc<Git<R>>),
341 Jj(Arc<Jj<R>>),
342}
343
344impl<R: ProcessRunner> Debug for Backend<R> {
345 fn fmt(&self, f: &mut Formatter) -> fmt::Result {
346 let variant_name = match self {
347 Backend::Git(_) => "Git",
348 Backend::Jj(_) => "Jj",
349 };
350 f.debug_tuple(variant_name).finish_non_exhaustive()
351 }
352}
353
354impl<R: ProcessRunner> Backend<R> {
355 fn shared(&self) -> Self {
356 match self {
357 Backend::Git(g) => Backend::Git(Arc::clone(g)),
358 Backend::Jj(j) => Backend::Jj(Arc::clone(j)),
359 }
360 }
361}
362
363/// A cwd-bound, backend-agnostic VCS handle. Operations run against the bound
364/// directory ([`cwd`](Repo::cwd)); use [`at`](Repo::at) to get a sibling handle
365/// bound elsewhere.
366pub struct Repo<R: ProcessRunner = JobRunner> {
367 root: PathBuf,
368 cwd: PathBuf,
369 backend: Backend<R>,
370}
371// need a manual impl to avoid `R: Debug` bound.
372impl<R: ProcessRunner> Debug for Repo<R> {
373 fn fmt(&self, f: &mut Formatter) -> fmt::Result {
374 let Repo { root, cwd, backend } = self;
375 f.debug_struct("Repo")
376 .field("root", root)
377 .field("cwd", cwd)
378 .field("backend", backend)
379 .finish()
380 }
381}
382
383impl Repo<JobRunner> {
384 /// Discover the repository at or above `dir` and open a handle bound to
385 /// `dir`, using the real job-backed runner. Walks up from `dir` toward the
386 /// filesystem root — see [`discover`] — so it finds a repository whose root
387 /// is `dir` itself or any ancestor. Errors with [`Error::NotARepository`]
388 /// when no `.git`/`.jj` is found, or with [`Error::BareRepository`] when the
389 /// walk instead reaches a **bare** git repository (`git init --bare`) before
390 /// any `.jj`/`.git` — a bare repo has no working tree for this facade to
391 /// drive (issue #6).
392 ///
393 /// For a strict check of exactly `dir` — no walking up — see [`Repo::open`].
394 pub fn discover(dir: impl AsRef<Path>) -> Result<Self> {
395 // Absolutise first: `discover` walks parents, and a relative path like "."
396 // has no real ancestor chain (`Path::new(".").parent()` is `""`, then
397 // `None`), so a relative input would never find a repo above the cwd.
398 let dir = std::path::absolute(dir.as_ref())?;
399 let located = match discover(&dir) {
400 Some(located) => located,
401 None => {
402 // `discover` already walked the full chain and found nothing — a
403 // second, cheap walk tells us whether the reason is "no
404 // repository at all" or "a bare git repository sits in the
405 // way", so the caller gets the more precise error.
406 return Err(match find_bare_git_repo(&dir) {
407 Some(bare_root) => Error::BareRepository(bare_root),
408 None => Error::NotARepository(dir),
409 });
410 }
411 };
412 let backend = match located.kind {
413 BackendKind::Git => Backend::Git(Arc::new(Git::new())),
414 BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
415 };
416 Ok(Repo {
417 root: located.root,
418 cwd: dir,
419 backend,
420 })
421 }
422
423 /// Open the repository at **exactly** `dir` — unlike [`Repo::discover`],
424 /// this does **not** walk up through parent directories: `dir` itself must
425 /// hold the `.jj`/`.git` marker (a `.jj` directory with a `repo` entry, or a
426 /// `.git` directory / gitlink file — the same validated markers [`discover`]
427 /// uses), or this errors with
428 /// [`Error::NotARepository(dir)`](Error::NotARepository)
429 /// even if a repository exists somewhere above `dir`. Mirrors the
430 /// discover-vs-open split in gitoxide (`gix::discover` vs `gix::open`) and
431 /// libgit2 (`git_repository_discover` vs `git_repository_open`) — see
432 /// issue #8.
433 ///
434 /// If `dir` itself is a **bare** git repository (`git init --bare`: no
435 /// `.git` subdirectory, just `HEAD`/`config`/`objects`/`refs` directly in
436 /// `dir` — see `is_bare_git_repo_marker`), this errors with
437 /// [`Error::BareRepository(dir)`](Error::BareRepository) instead of the
438 /// generic `NotARepository`, matching what [`Repo::discover`] reports for
439 /// the same directory (issue #6) — `open` still never walks up, so this
440 /// only applies to `dir` itself, not an ancestor.
441 pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
442 // Absolutise so the bound `cwd`/`root` are consistent with `discover`'s
443 // and so a relative "." names the actual directory, not an empty path.
444 let dir = std::path::absolute(dir.as_ref())?;
445 let kind = if is_jj_marker(&dir.join(".jj")) {
446 BackendKind::Jj
447 } else if is_git_marker(&dir.join(".git")) {
448 BackendKind::Git
449 } else if is_bare_git_repo_marker(&dir) {
450 return Err(Error::BareRepository(dir));
451 } else {
452 return Err(Error::NotARepository(dir));
453 };
454 let backend = match kind {
455 BackendKind::Git => Backend::Git(Arc::new(Git::new())),
456 BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
457 };
458 Ok(Repo {
459 root: dir.clone(),
460 cwd: dir,
461 backend,
462 })
463 }
464}
465
466impl<R: ProcessRunner> Repo<R> {
467 /// Build a git-backed handle from an explicit client — for a custom runner
468 /// (e.g. a test seam) or a pre-configured [`Git`].
469 pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self {
470 Repo {
471 root: root.into(),
472 cwd: cwd.into(),
473 backend: Backend::Git(Arc::new(client)),
474 }
475 }
476
477 /// Build a jj-backed handle from an explicit client.
478 pub fn from_jj(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>) -> Self {
479 Repo {
480 root: root.into(),
481 cwd: cwd.into(),
482 backend: Backend::Jj(Arc::new(client)),
483 }
484 }
485
486 /// Discover the repository at or above `dir` — exactly as [`Repo::discover`] —
487 /// but build the handle from a **caller-injected** client instead of the plain
488 /// default one. `dir` is absolutised and walked toward the filesystem root (see
489 /// [`discover`]), then the factory for the **detected** backend is invoked:
490 /// `git` for a `.git` repository, `jj` for a `.jj` one. Only the matching
491 /// closure runs — the other is never called, so neither client is built
492 /// speculatively.
493 ///
494 /// This is the injected-client counterpart of [`Repo::discover`]: reach for it
495 /// when the handle needs a pre-configured client — a hardened [`Git`], a
496 /// per-command timeout, a custom [`ProcessRunner`] test seam — rather than the
497 /// default `Git::new` / `Jj::new` that [`Repo::discover`] uses. It shares
498 /// [`Repo::discover`]'s exact detection and error classification, so a consumer
499 /// no longer has to re-implement the [`discover`] walk, match [`BackendKind`],
500 /// and assemble [`from_git`](Repo::from_git) / [`from_jj`](Repo::from_jj) by
501 /// hand just to inject clients.
502 ///
503 /// Because the match on [`BackendKind`] lives **here**, inside the crate that
504 /// declares the enum `#[non_exhaustive]`, adding a future backend variant is a
505 /// change to this one method — callers need no wildcard/catch-all arm of their
506 /// own to keep in sync.
507 ///
508 /// # Errors
509 /// The same as [`Repo::discover`]: [`Error::NotARepository`] when no `.git`/`.jj`
510 /// marker is found from `dir` up to the filesystem root, or
511 /// [`Error::BareRepository`] when the walk instead reaches a **bare** git
512 /// repository (`git init --bare`) first. It reuses the very same private
513 /// `find_bare_git_repo` diagnostic path as [`Repo::discover`], so the bare-repo
514 /// distinction is reported identically no matter which entry point opened the
515 /// repository.
516 ///
517 /// ```no_run
518 /// # use std::time::Duration;
519 /// # use vcs_core::{Repo, vcs_git::Git, vcs_jj::Jj};
520 /// # fn f() -> vcs_core::Result<()> {
521 /// // A hardened git client / timeout-bound jj client, injected lazily — only
522 /// // the one matching the detected backend is ever built.
523 /// let repo = Repo::discover_with(
524 /// ".",
525 /// || Git::hardened().default_timeout(Duration::from_secs(120)),
526 /// || Jj::new().default_timeout(Duration::from_secs(120)),
527 /// )?;
528 /// # let _ = repo;
529 /// # Ok(()) }
530 /// ```
531 pub fn discover_with<G, J>(dir: impl AsRef<Path>, git: G, jj: J) -> Result<Self>
532 where
533 G: FnOnce() -> Git<R>,
534 J: FnOnce() -> Jj<R>,
535 {
536 // Absolutise first, for the same reason `Repo::discover` does: `discover`
537 // walks parents, and a relative path like "." has no real ancestor chain.
538 let dir = std::path::absolute(dir.as_ref())?;
539 let located = match discover(&dir) {
540 Some(located) => located,
541 None => {
542 // Identical bare-vs-nothing diagnostic to `Repo::discover`: the
543 // second, cheap walk distinguishes "no repository at all" from "a
544 // bare git repository sits in the way", so the injected-client path
545 // reports the precise error rather than a stringly-typed blob.
546 return Err(match find_bare_git_repo(&dir) {
547 Some(bare_root) => Error::BareRepository(bare_root),
548 None => Error::NotARepository(dir),
549 });
550 }
551 };
552 // The match on `BackendKind` lives inside `vcs-core`, where the enum is
553 // declared, so a new `#[non_exhaustive]` variant is handled here (a
554 // compile error to add without a client) rather than by a caller's
555 // catch-all arm.
556 Ok(match located.kind {
557 BackendKind::Git => Repo::from_git(located.root, dir, git()),
558 BackendKind::Jj => Repo::from_jj(located.root, dir, jj()),
559 })
560 }
561
562 /// Which backend drives this handle.
563 pub fn kind(&self) -> BackendKind {
564 match &self.backend {
565 Backend::Git(_) => BackendKind::Git,
566 Backend::Jj(_) => BackendKind::Jj,
567 }
568 }
569
570 /// The repository root detected at open time.
571 pub fn root(&self) -> &Path {
572 &self.root
573 }
574
575 /// The directory operations run against.
576 pub fn cwd(&self) -> &Path {
577 &self.cwd
578 }
579
580 /// A sibling handle bound to `dir`, sharing this handle's client and root.
581 pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
582 Repo {
583 root: self.root.clone(),
584 cwd: dir.into(),
585 backend: self.backend.shared(),
586 }
587 }
588
589 /// The underlying [`Git`] client, or `None` when jj-backed — an escape hatch
590 /// to git-only operations not on the common surface.
591 pub fn git(&self) -> Option<&Git<R>> {
592 match &self.backend {
593 Backend::Git(g) => Some(g.as_ref()),
594 Backend::Jj(_) => None,
595 }
596 }
597
598 /// The underlying [`Jj`] client, or `None` when git-backed.
599 pub fn jj(&self) -> Option<&Jj<R>> {
600 match &self.backend {
601 Backend::Jj(j) => Some(j.as_ref()),
602 Backend::Git(_) => None,
603 }
604 }
605
606 /// The git client bound to this handle's [`cwd`](Repo::cwd) — a [`GitAt`] whose
607 /// methods omit the `dir` argument — or `None` when jj-backed. The dir-free
608 /// counterpart of [`git`](Repo::git): `repo.git_at()?.merge_continue().await?`.
609 ///
610 /// The returned view borrows `self`. To work in another worktree, **bind the
611 /// re-anchored handle first** (the view can't outlive a temporary
612 /// [`at`](Repo::at)):
613 ///
614 /// ```no_run
615 /// # async fn f(repo: vcs_core::Repo, wt: &std::path::Path) -> vcs_core::Result<()> {
616 /// let wt = repo.at(wt); // owns the re-anchored handle
617 /// let git = wt.git_at().unwrap();
618 /// git.fetch().await?;
619 /// # Ok(()) }
620 /// ```
621 pub fn git_at(&self) -> Option<GitAt<'_, R>> {
622 match &self.backend {
623 Backend::Git(g) => Some(g.at(&self.cwd)),
624 Backend::Jj(_) => None,
625 }
626 }
627
628 /// The jj client bound to this handle's [`cwd`](Repo::cwd) — a [`JjAt`] whose
629 /// methods omit the `dir` argument — or `None` when git-backed. The dir-free
630 /// counterpart of [`jj`](Repo::jj). For another workspace, bind the re-anchored
631 /// handle first (`let ws = repo.at(path); ws.jj_at()…`) — see [`git_at`](Repo::git_at).
632 pub fn jj_at(&self) -> Option<JjAt<'_, R>> {
633 match &self.backend {
634 Backend::Jj(j) => Some(j.at(&self.cwd)),
635 Backend::Git(_) => None,
636 }
637 }
638
639 /// The current branch (git) or bookmark (jj). On jj this is the nearest
640 /// bookmark reachable from the working copy (`heads(::@ & bookmarks())`),
641 /// so it stays set across a `jj describe`/`jj new`/`jj commit` — which leave
642 /// the bookmark on the described parent while the new change carries none —
643 /// matching git's "still on my branch" reporting. When several bookmarks are
644 /// equally near `@`, the lexicographically-smallest name is returned
645 /// (deterministic). `None` only when detached / no bookmark on or above `@`.
646 pub async fn current_branch(&self) -> Result<Option<String>> {
647 match &self.backend {
648 Backend::Git(g) => git_backend::current_branch(g, &self.cwd).await,
649 Backend::Jj(j) => jj_backend::current_branch(j, &self.cwd).await,
650 }
651 }
652
653 /// The trunk branch/bookmark. Resolution order: the backend's own notion
654 /// (git's `origin/HEAD`, jj's `trunk()` revset), then a fallback to a local
655 /// `main`, then `master`; `None` when none of those resolve.
656 pub async fn trunk(&self) -> Result<Option<String>> {
657 let native = match &self.backend {
658 Backend::Git(g) => git_backend::trunk(g, &self.cwd).await?,
659 Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await?,
660 };
661 if native.is_some() {
662 return Ok(native);
663 }
664 for candidate in ["main", "master"] {
665 if self.branch_exists(candidate).await? {
666 return Ok(Some(candidate.to_string()));
667 }
668 }
669 Ok(None)
670 }
671
672 /// Local branch (git) / bookmark (jj) names.
673 ///
674 /// Backend divergence: on **jj**, a bookmark deleted locally but still **tracked**
675 /// on a remote renders as a *tombstone* row (jj keeps it internally so the
676 /// deletion can be propagated) until the deletion is pushed. This tombstone is
677 /// filtered out here — a name a `delete_branch` just removed does **not**
678 /// reappear in the result — while a *conflicted* bookmark (present, but with no
679 /// single normal target) is still reported as existing, since dropping it too
680 /// would hide a real, live bookmark that merely has a conflict (T-041; was M21).
681 pub async fn local_branches(&self) -> Result<Vec<String>> {
682 match &self.backend {
683 Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
684 Backend::Jj(j) => jj_backend::local_branches(j, &self.cwd).await,
685 }
686 }
687
688 /// A **read-only** [`local_branches`](Repo::local_branches): the same result,
689 /// but on **jj** it passes `--ignore-working-copy`, so listing the bookmarks
690 /// records no jj operation and never moves `@`. On **git** it is exactly
691 /// [`local_branches`](Repo::local_branches) — git's branch listing records no
692 /// operation and moves no ref, so there is nothing to make read-only.
693 ///
694 /// Use it (with [`snapshot_readonly`](Repo::snapshot_readonly)) from an
695 /// *observer* — a watcher or a prompt refresh — that must not perturb the
696 /// state it reads. See [`snapshot_readonly`](Repo::snapshot_readonly) for the
697 /// jj working-copy trade-off this shares.
698 pub async fn local_branches_readonly(&self) -> Result<Vec<String>> {
699 match &self.backend {
700 Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
701 Backend::Jj(j) => jj_backend::local_branches_readonly(j, &self.cwd).await,
702 }
703 }
704
705 /// Whether a local branch/bookmark named `name` exists. See
706 /// [`local_branches`](Repo::local_branches) for the jj deleted-but-tracked
707 /// *tombstone* divergence: on jj, a bookmark just removed by `delete_branch` but
708 /// still tracked on a remote does **not** read as existing here (the tombstone is
709 /// filtered), while a *conflicted* bookmark still does.
710 pub async fn branch_exists(&self, name: &str) -> Result<bool> {
711 match &self.backend {
712 Backend::Git(g) => git_backend::branch_exists(g, &self.cwd, name).await,
713 Backend::Jj(j) => jj_backend::branch_exists(j, &self.cwd, name).await,
714 }
715 }
716
717 /// Whether the working copy has uncommitted changes (git: a non-empty
718 /// `status`; jj: a non-empty working-copy change `@`).
719 pub async fn has_uncommitted_changes(&self) -> Result<bool> {
720 match &self.backend {
721 Backend::Git(g) => git_backend::has_uncommitted_changes(g, &self.cwd).await,
722 Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
723 }
724 }
725
726 /// Whether the working copy has uncommitted changes to *tracked* files.
727 ///
728 /// Backend nuance: git ignores untracked files here
729 /// (`status --untracked-files=no`); jj auto-tracks new files, so there is no
730 /// untracked concept and this equals
731 /// [`has_uncommitted_changes`](Self::has_uncommitted_changes).
732 pub async fn has_tracked_changes(&self) -> Result<bool> {
733 match &self.backend {
734 Backend::Git(g) => git_backend::has_tracked_changes(g, &self.cwd).await,
735 Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
736 }
737 }
738
739 /// Paths with unresolved merge conflicts in the working copy, repo-relative
740 /// with `/` separators (git `diff --diff-filter=U` / jj `resolve --list -r @`).
741 /// Empty when there are none. Each path is a [`PathBuf`] carried losslessly from
742 /// the backend, so a non-UTF-8 conflicted filename (legal on Unix) is not
743 /// corrupted to `U+FFFD`.
744 pub async fn conflicted_files(&self) -> Result<Vec<PathBuf>> {
745 match &self.backend {
746 Backend::Git(g) => git_backend::conflicted_files(g, &self.cwd).await,
747 Backend::Jj(j) => jj_backend::conflicted_files(j, &self.cwd).await,
748 }
749 }
750
751 /// Create a local branch (git) / bookmark (jj) at the current head, without
752 /// switching the working copy (git `branch <name>`; jj `bookmark create <name>
753 /// -r @`).
754 pub async fn create_branch(&self, name: &str) -> Result<()> {
755 match &self.backend {
756 Backend::Git(g) => git_backend::create_branch(g, &self.cwd, name).await,
757 Backend::Jj(j) => jj_backend::create_branch(j, &self.cwd, name).await,
758 }
759 }
760
761 /// Delete a local branch (git) / bookmark (jj). The [`BranchDelete`] spec's
762 /// [`force`](BranchDelete::force) applies to git only (`branch -D` vs `-d`); jj
763 /// has no force and ignores it.
764 pub async fn delete_branch(&self, spec: BranchDelete) -> Result<()> {
765 match &self.backend {
766 Backend::Git(g) => {
767 git_backend::delete_branch(g, &self.cwd, &spec.name, spec.force).await
768 }
769 Backend::Jj(j) => jj_backend::delete_branch(j, &self.cwd, &spec.name).await,
770 }
771 }
772
773 /// Rename a local branch (git) / bookmark (jj).
774 pub async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
775 match &self.backend {
776 Backend::Git(g) => git_backend::rename_branch(g, &self.cwd, old, new).await,
777 Backend::Jj(j) => jj_backend::rename_branch(j, &self.cwd, old, new).await,
778 }
779 }
780
781 /// The working-copy changes (git `status` / jj `diff -r @ --summary`).
782 pub async fn changed_files(&self) -> Result<Vec<FileChange>> {
783 match &self.backend {
784 Backend::Git(g) => git_backend::changed_files(g, &self.cwd).await,
785 Backend::Jj(j) => jj_backend::changed_files(j, &self.cwd).await,
786 }
787 }
788
789 /// Aggregate insertion/deletion counts for the working copy.
790 ///
791 /// Backend nuance: git counts the working tree against `HEAD` (`git diff`,
792 /// which **excludes untracked files**), while jj counts the `@` change against
793 /// its parent (which **includes** newly-added files). So on git a brand-new
794 /// file shows in [`changed_files`](Self::changed_files) but not here, whereas
795 /// on jj it shows in both. On an unborn git repo (no commits yet) the count is
796 /// taken against the empty tree, so a pre-first-commit working tree stats
797 /// instead of erroring.
798 ///
799 /// jj snapshot caveat: like every other jj-backed read here (`status`,
800 /// `changed_files`, `snapshot`, `log`, …), this runs a plain `jj diff` — jj's
801 /// default mode, which first **snapshots the working copy** (imports any bare
802 /// filesystem edit into a fresh `@`) and **records a new operation** in the op
803 /// log. That is a bookkeeping side effect, not a content mutation (no tracked
804 /// file/ref changes, and it is transparently undoable via `jj op undo`), but it
805 /// is not a *no-op* read either. A genuinely non-recording read exists
806 /// (`--ignore-working-copy`, wired up as `vcs_jj`'s `_ignoring_working_copy`
807 /// client methods and this crate's [`snapshot_readonly`](Self::snapshot_readonly)
808 /// / `local_branches_readonly`, built for [`vcs-watch`](https://docs.rs/vcs-watch)'s
809 /// polling loop) but is deliberately **not** used here: it reports the state of
810 /// the *last recorded* operation rather than the live working tree, so a bare
811 /// edit no jj command has yet snapshotted would be silently invisible — wrong
812 /// for a method whose whole purpose is reporting the *current* working-copy
813 /// state.
814 pub async fn diff_stat(&self) -> Result<DiffStat> {
815 match &self.backend {
816 Backend::Git(g) => git_backend::diff_stat(g, &self.cwd).await,
817 Backend::Jj(j) => jj_backend::diff_stat(j, &self.cwd).await,
818 }
819 }
820
821 /// The full parsed diff for the working copy — the same scope as
822 /// [`diff_stat`](Self::diff_stat) (git: working tree vs `HEAD`, using the
823 /// empty-tree oid on an unborn repo; jj: `@` vs its parent), but returning the
824 /// per-file hunks/lines ([`FileDiff`]) rather than just the aggregate counts.
825 /// Dispatches to the already-existing `GitApi::diff`/`JjApi::diff` with
826 /// [`vcs_git::DiffSpec::WorkingTree`], so it inherits the backend client's
827 /// [`OutputBudget`] — an over-budget diff errors with
828 /// [`OutputTooLarge`](processkit::Error::OutputTooLarge) rather than
829 /// buffering (or silently truncating) an unbounded diff.
830 ///
831 /// Backend nuance (same as `diff_stat`): git diffs the working tree against
832 /// `HEAD`, which **excludes untracked files**; jj diffs `@` against its
833 /// parent, which **includes** newly-added files. So a brand-new file shows in
834 /// [`changed_files`](Self::changed_files) but *not* here on git, whereas on jj
835 /// it shows in both — don't assume the two backends return the same file set.
836 ///
837 /// Cross-backend revision-range diffs are deliberately **not** exposed here
838 /// (see the crate docs' "what's deliberately not unified" — range diffs stay
839 /// on the raw [`git`](Self::git)/[`jj`](Self::jj) client, via `GitApi::diff`/
840 /// `JjApi::diff` with `DiffSpec::Rev`).
841 ///
842 /// Same jj snapshot caveat as [`diff_stat`](Self::diff_stat): on jj this is a
843 /// plain `jj diff -r @ --git`, jj's default working-copy-snapshotting mode
844 /// (records an operation in the op log — a reversible bookkeeping side effect,
845 /// not a tracked-content mutation), deliberately not the non-recording
846 /// `--ignore-working-copy` mode, which would make this method blind to any not-
847 /// yet-snapshotted edit.
848 pub async fn diff(&self) -> Result<Vec<FileDiff>> {
849 match &self.backend {
850 Backend::Git(g) => git_backend::diff(g, &self.cwd).await,
851 Backend::Jj(j) => jj_backend::diff(j, &self.cwd).await,
852 }
853 }
854
855 /// Recent history: up to `max` commits reachable from `revspec_or_revset`
856 /// (git revspec / jj revset), most-recent-first (git `log`'s default order /
857 /// jj `log`'s topological order).
858 ///
859 /// Backend nuance: [`Commit::author`]/[`Commit::date`] are `Some` only on
860 /// git — jj's typed log doesn't currently surface authorship or a
861 /// timestamp, so they're `None` there rather than guessed (see the
862 /// [`Commit`] type docs).
863 pub async fn log(&self, revspec_or_revset: &str, max: usize) -> Result<Vec<Commit>> {
864 match &self.backend {
865 Backend::Git(g) => git_backend::log(g, &self.cwd, revspec_or_revset, max).await,
866 Backend::Jj(j) => jj_backend::log(j, &self.cwd, revspec_or_revset, max).await,
867 }
868 }
869
870 /// Per-line attribution for `path`, optionally at `rev` (a git revspec / jj
871 /// revset). `None` reads the current git `HEAD` / jj `@`; a supplied revision is
872 /// passed to the selected backend without facade-level interpretation.
873 ///
874 /// Backend nuance: [`AnnotationLine::author`]/[`AnnotationLine::date`] are
875 /// `Some` only on git — jj's typed annotation exposes only the introducing
876 /// change and line content, so the facade leaves them `None` rather than guessing
877 /// (see [`AnnotationLine`]).
878 pub async fn annotate(&self, path: &str, rev: Option<&str>) -> Result<Vec<AnnotationLine>> {
879 match &self.backend {
880 Backend::Git(g) => git_backend::annotate(g, &self.cwd, path, rev).await,
881 Backend::Jj(j) => jj_backend::annotate(j, &self.cwd, path, rev).await,
882 }
883 }
884
885 /// The content of `path` as it exists at `rev` (git revspec / jj revset), e.g.
886 /// `HEAD:src/lib.rs` on git or `@-` + a fileset on jj — both normalise
887 /// backslash path separators and return the file's bytes verbatim (including
888 /// any trailing newline).
889 pub async fn show_file(&self, rev: &str, path: &str) -> Result<String> {
890 match &self.backend {
891 Backend::Git(g) => git_backend::show_file(g, &self.cwd, rev, path).await,
892 Backend::Jj(j) => jj_backend::show_file(j, &self.cwd, rev, path).await,
893 }
894 }
895
896 /// [`show_file`](Repo::show_file) with an explicit per-call [`OutputBudget`],
897 /// instead of the budget the backend client was built with
898 /// ([`default_output_budget`](vcs_git::Git::default_output_budget), inherited
899 /// through [`from_git`](Repo::from_git)/[`from_jj`](Repo::from_jj)). Reads the
900 /// blob under `budget`: past the ceiling it errors with an
901 /// [`OutputTooLarge`](processkit::Error::OutputTooLarge)-carrying
902 /// [`Error::Vcs`] (actual and allowed sizes) rather than buffering an unbounded
903 /// file — use it to read a legitimately large file
904 /// ([`OutputBudget::unlimited`], or a higher cap) or to tighten the cap for one
905 /// call. A truncated blob is never returned as if complete.
906 pub async fn show_file_within(
907 &self,
908 rev: &str,
909 path: &str,
910 budget: OutputBudget,
911 ) -> Result<String> {
912 match &self.backend {
913 Backend::Git(g) => git_backend::show_file_within(g, &self.cwd, rev, path, budget).await,
914 Backend::Jj(j) => jj_backend::show_file_within(j, &self.cwd, rev, path, budget).await,
915 }
916 }
917
918 /// A batched [`RepoSnapshot`] of the common repo state — branch, upstream,
919 /// ahead/behind, dirtiness, change count, and operation state — in a **small
920 /// fixed** number of spawns instead of a call per field (git: `status
921 /// --porcelain=v2 --branch` + the in-progress probe; jj: a `log -r @`
922 /// template for head/empty/conflict, a `reachable_bookmarks` query for
923 /// `branch`, and a change count only when dirty). Built for prompt/status-bar/
924 /// TUI refreshes. Note the asymmetry: [`tracking`](RepoSnapshot::tracking)
925 /// (the upstream ref + ahead/behind) is always `None` on jj, which has no
926 /// git-style upstream tracking.
927 pub async fn snapshot(&self) -> Result<RepoSnapshot> {
928 match &self.backend {
929 Backend::Git(g) => git_backend::snapshot(g, &self.cwd).await,
930 Backend::Jj(j) => jj_backend::snapshot(j, &self.cwd).await,
931 }
932 }
933
934 /// A **read-only** [`snapshot`](Repo::snapshot): the same [`RepoSnapshot`],
935 /// but on **jj** it never snapshots the working copy — every underlying query
936 /// passes `--ignore-working-copy`, so the batched read records **no** jj
937 /// operation and never moves `@`. On **git** it is exactly
938 /// [`snapshot`](Repo::snapshot) (git's status query records no operation and
939 /// moves no ref).
940 ///
941 /// Use it for an *observer* — a repository watcher, a prompt/status-bar
942 /// refresh — that must not perturb the state it reports: an ordinary jj query
943 /// snapshots the working copy as a side effect (taking the lock, recording an
944 /// operation, possibly moving `@`), so the observer would otherwise *mutate*
945 /// the repo it merely means to read.
946 ///
947 /// **jj trade-off:** because the working copy isn't snapshotted, a bare
948 /// working-tree edit that no jj command has recorded yet is **not** reflected
949 /// — [`dirty`](RepoSnapshot::dirty)/[`head`](RepoSnapshot::head) are as of the
950 /// last recorded operation. To observe such unsnapshotted edits, accept the
951 /// mutation and call [`snapshot`](Repo::snapshot).
952 pub async fn snapshot_readonly(&self) -> Result<RepoSnapshot> {
953 match &self.backend {
954 Backend::Git(g) => git_backend::snapshot(g, &self.cwd).await,
955 Backend::Jj(j) => jj_backend::snapshot_readonly(j, &self.cwd).await,
956 }
957 }
958
959 /// Commit exactly `paths` with `message` (git `commit --only`, jj
960 /// `commit <filesets>`). Paths are repo-relative. `paths` must be non-empty:
961 /// an empty set is refused up front, because the backends would diverge
962 /// dangerously — git errors out, while jj's `commit` with no filesets would
963 /// silently commit the **entire** working copy.
964 ///
965 /// Takes [`PathBuf`]s so a path obtained from [`changed_files`](Self::changed_files)
966 /// / [`conflicted_files`](Self::conflicted_files) round-trips **losslessly** — on
967 /// git a non-UTF-8 path (legal on Unix) reaches the commit unchanged via the
968 /// NUL-safe pathspec transport; on jj the fileset language is text, so jj's own
969 /// (non-UTF-8-incapable) fileset handling applies.
970 pub async fn commit_paths(&self, paths: &[PathBuf], message: &str) -> Result<()> {
971 if paths.is_empty() {
972 return Err(Error::Io(std::io::Error::new(
973 std::io::ErrorKind::InvalidInput,
974 "commit_paths requires at least one path: an empty set would error \
975 on git but commit the entire working copy on jj",
976 )));
977 }
978 match &self.backend {
979 Backend::Git(g) => git_backend::commit_paths(g, &self.cwd, paths, message).await,
980 Backend::Jj(j) => jj_backend::commit_paths(j, &self.cwd, paths, message).await,
981 }
982 }
983
984 /// Fetch from the default remote (git `fetch` / jj `git fetch`).
985 pub async fn fetch(&self) -> Result<()> {
986 match &self.backend {
987 Backend::Git(g) => git_backend::fetch(g, &self.cwd).await,
988 Backend::Jj(j) => jj_backend::fetch(j, &self.cwd).await,
989 }
990 }
991
992 /// Fetch from a *named* remote (git `fetch <remote>` / jj
993 /// `git fetch --remote <remote>`). Transient network failures are retried by
994 /// the underlying client.
995 pub async fn fetch_from(&self, remote: &str) -> Result<()> {
996 match &self.backend {
997 Backend::Git(g) => git_backend::fetch_from(g, &self.cwd, remote).await,
998 Backend::Jj(j) => jj_backend::fetch_from(j, &self.cwd, remote).await,
999 }
1000 }
1001
1002 /// Fetch a single branch/bookmark from `origin` into its remote-tracking ref
1003 /// (git `fetch_branch` / jj `git fetch -b`). Transient network failures
1004 /// are retried by the underlying client.
1005 pub async fn fetch_branch(&self, branch: &str) -> Result<()> {
1006 match &self.backend {
1007 Backend::Git(g) => git_backend::fetch_branch(g, &self.cwd, branch).await,
1008 Backend::Jj(j) => jj_backend::fetch_branch(j, &self.cwd, branch).await,
1009 }
1010 }
1011
1012 /// Push `branch` to `origin` (git `push -u origin <branch>` / jj
1013 /// `git push -b <branch>`).
1014 ///
1015 /// The branch (jj: bookmark) must already exist locally. The two backends
1016 /// honestly differ in what "push" means: git pushes the *ref* and records
1017 /// the upstream (`-u`; idempotent on repeat pushes), while jj pushes the
1018 /// *bookmark's state* — including deleting the remote branch if the
1019 /// bookmark was deleted locally. Renamed refspecs (`local:remote`) and
1020 /// non-`origin` remotes are git-only concepts; use the
1021 /// [`git()`](Repo::git) escape hatch ([`vcs_git::GitPush`]) for those.
1022 pub async fn push(&self, branch: &str) -> Result<()> {
1023 match &self.backend {
1024 Backend::Git(g) => git_backend::push(g, &self.cwd, branch).await,
1025 Backend::Jj(j) => jj_backend::push(j, &self.cwd, branch).await,
1026 }
1027 }
1028
1029 /// Switch the working copy to `reference` (git `checkout` / jj `edit`).
1030 ///
1031 /// ⚠ **Backend divergence — this is not "detach and build on top" on jj.** On
1032 /// **git**, a subsequent commit *appends* on top of `reference` (its tip is
1033 /// untouched). On **jj**, `checkout` maps to `jj edit`, which makes `reference`'s
1034 /// commit *itself* the working-copy change — so a following
1035 /// [`commit_paths`](Repo::commit_paths) (or any edit) **rewrites that commit in
1036 /// place** (a new change-id, a replaced
1037 /// description), silently amending a possibly-already-pushed commit rather than
1038 /// adding a new one.
1039 ///
1040 /// So backend-agnostic "start fresh work on top of `main`" code must **not** rely
1041 /// on `checkout` alone. If you want git-like append-on-top semantics on both
1042 /// backends, use [`new_child`](Repo::new_child), which maps to `jj new
1043 /// <reference>` on jj and to `checkout <reference>` on git.
1044 pub async fn checkout(&self, reference: &str) -> Result<()> {
1045 match &self.backend {
1046 Backend::Git(g) => git_backend::checkout(g, &self.cwd, reference).await,
1047 Backend::Jj(j) => jj_backend::checkout(j, &self.cwd, reference).await,
1048 }
1049 }
1050
1051 /// Start new work on top of `reference` without modifying it.
1052 ///
1053 /// On git this checks out `reference`; the next commit naturally appends on top.
1054 /// On jj this runs `jj new <reference>`, creating an undescribed child change.
1055 pub async fn new_child(&self, reference: &str) -> Result<()> {
1056 match &self.backend {
1057 Backend::Git(g) => git_backend::new_child(g, &self.cwd, reference).await,
1058 Backend::Jj(j) => jj_backend::new_child(j, &self.cwd, reference).await,
1059 }
1060 }
1061
1062 /// Rebase the current line onto `onto`. The two backends **diverge** on
1063 /// non-linear layouts, so this is a documented least-common-denominator:
1064 /// - **git** (`rebase <onto>` = `merge-base(HEAD,onto)..HEAD`) moves only
1065 /// `HEAD`'s own ancestor line; commits stacked on `HEAD` stay put.
1066 /// - **jj** (`rebase -d <onto>` = the default `-b @` = `(onto..@)::`) moves
1067 /// that line *and its whole descendant closure* — anything stacked on `@`,
1068 /// and any sibling off an *intermediate* commit of the line, move too.
1069 ///
1070 /// They agree on a linear `HEAD`/`@`; on a **stacked or intermediate-fork**
1071 /// layout jj moves strictly more. A sibling that shares only the fork point is
1072 /// moved by neither. `onto` is a branch/bookmark name or revision the backend
1073 /// understands.
1074 pub async fn rebase(&self, onto: &str) -> Result<()> {
1075 match &self.backend {
1076 Backend::Git(g) => git_backend::rebase(g, &self.cwd, onto).await,
1077 Backend::Jj(j) => jj_backend::rebase(j, &self.cwd, onto).await,
1078 }
1079 }
1080
1081 /// Probe whether merging `source` into the current work would conflict,
1082 /// **without leaving any trace**: the probe is rolled back before returning
1083 /// (git: `merge --no-commit --no-ff` then `merge --abort`; jj: a merge
1084 /// change probed and undone via `op restore`).
1085 ///
1086 /// Preconditions/behaviour:
1087 /// - git: requires a clean-enough working tree — a dirty-tree refusal
1088 /// propagates as a plain error, not as [`MergeProbe::Conflicts`].
1089 /// - A failing rollback **propagates as an error** rather than returning a
1090 /// result that misdescribes the on-disk state.
1091 /// - **Cancellation-safe rollback:** on **both** backends the *whole* rollback
1092 /// path — the decision of whether to roll back **and** the command that
1093 /// performs it — runs on a fresh cancellation context with its own bounded
1094 /// deadline (git: `Git::is_merge_in_progress_detached` + `merge --abort` via
1095 /// `Git::merge_abort_detached`; jj: the op-log probe + `op restore` via
1096 /// `Jj::rollback_to`), so a `default_cancel_on` token (the `cancellation`
1097 /// feature) that fires during the probe no longer cancels the rollback too —
1098 /// not even by cancelling the "is a trial merge still staged?" check before
1099 /// the abort is reached. The trial merge is still undone rather than left
1100 /// staged, closing the gap where a cancelled probe abandoned it on git. (A
1101 /// rollback that fails for another reason still propagates per the bullet
1102 /// above.)
1103 pub async fn try_merge(&self, source: &str) -> Result<MergeProbe> {
1104 match &self.backend {
1105 Backend::Git(g) => git_backend::try_merge(g, &self.cwd, source).await,
1106 Backend::Jj(j) => jj_backend::try_merge(j, &self.cwd, source).await,
1107 }
1108 }
1109
1110 /// Abort the in-progress operation, if any (git: `merge --abort` /
1111 /// `rebase --abort`; jj: a no-op — there are no paused operations, roll back
1112 /// explicitly via `Jj::transaction` / `op_restore`). Returns the fresh
1113 /// *post-call* [`OperationState`]; `Clear` when nothing was (or remains) in
1114 /// progress.
1115 pub async fn abort_in_progress(&self) -> Result<OperationState> {
1116 match &self.backend {
1117 Backend::Git(g) => git_backend::abort_in_progress(g, &self.cwd).await,
1118 Backend::Jj(j) => jj_backend::abort_in_progress(j, &self.cwd).await,
1119 }
1120 }
1121
1122 /// Continue the in-progress operation after conflict resolution (git:
1123 /// `commit --no-edit` for a merge, or the matching `--continue` for a rebase /
1124 /// `am` / cherry-pick / revert; jj: a no-op — resolving the files *is* the
1125 /// continuation). A `git bisect` has no such step and is refused with
1126 /// [`Error::Unsupported`] rather than silently reported still in progress.
1127 /// Returns the fresh *post-call* [`OperationState`]:
1128 /// - `Conflict` when unresolved paths still block continuing (also on git —
1129 /// unlike [`in_progress_state`](Self::in_progress_state), this method
1130 /// *does* report `Conflict` for git), or when a continued rebase stops on
1131 /// the next patch's conflict.
1132 /// - `Clear` when the operation finished.
1133 pub async fn continue_in_progress(&self) -> Result<OperationState> {
1134 match &self.backend {
1135 Backend::Git(g) => git_backend::continue_in_progress(g, &self.cwd).await,
1136 Backend::Jj(j) => jj_backend::continue_in_progress(j, &self.cwd).await,
1137 }
1138 }
1139
1140 /// Whether the working copy is mid-operation or conflicted — see
1141 /// [`OperationState`]. Lets a caller decide between abort/continue without
1142 /// knowing the backend's model. Note the asymmetry: *this method* reports
1143 /// `Merge`/`Rebase` (never `Conflict`) on git — a git conflict *is* that
1144 /// paused state, and the conflict itself surfaces on the failed op via
1145 /// [`Error::is_merge_conflict`] (or as `Conflict` from
1146 /// [`continue_in_progress`](Self::continue_in_progress)) — while jj has no
1147 /// paused op and reports `Conflict` directly.
1148 pub async fn in_progress_state(&self) -> Result<OperationState> {
1149 match &self.backend {
1150 Backend::Git(g) => git_backend::in_progress_state(g, &self.cwd).await,
1151 Backend::Jj(j) => jj_backend::in_progress_state(j, &self.cwd).await,
1152 }
1153 }
1154
1155 /// List attached worktrees (git) / workspaces (jj).
1156 pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
1157 match &self.backend {
1158 Backend::Git(g) => git_backend::list_worktrees(g, &self.cwd).await,
1159 Backend::Jj(j) => jj_backend::list_worktrees(j, &self.cwd).await,
1160 }
1161 }
1162
1163 /// Create a worktree/workspace at `path` on a **new** `branch` based on
1164 /// `base`. Always [`CreateOutcome::Plain`]; a copy-on-write strategy stays in
1165 /// the consumer.
1166 ///
1167 /// `branch` must not already exist. The jj path is two steps (`workspace add`
1168 /// then `bookmark create`) and is not atomic, but a failed bookmark step
1169 /// **rolls back**: the workspace directory is removed only when `workspace add`
1170 /// created it (a pre-existing directory the caller already had is left intact),
1171 /// then the workspace is forgotten. Residue is no longer swallowed: if the
1172 /// rollback can't remove that directory or can't `forget` the workspace, the call
1173 /// fails with a composite [`Error::Io`] naming what still needs cleaning up (and
1174 /// is safe to re-run); a clean rollback instead surfaces the original
1175 /// bookmark-step error unchanged (its [`Error::Vcs`] classification) — so a failed
1176 /// call never silently leaks a half-made worktree.
1177 pub async fn create_worktree(&self, spec: WorktreeCreate) -> Result<CreateOutcome> {
1178 let WorktreeCreate { path, branch, base } = &spec;
1179 match &self.backend {
1180 Backend::Git(g) => git_backend::create_worktree(g, &self.cwd, path, branch, base).await,
1181 Backend::Jj(j) => jj_backend::create_worktree(j, &self.cwd, path, branch, base).await,
1182 }
1183 }
1184
1185 /// Remove the worktree/workspace at `path`. For jj this resolves the
1186 /// workspace name by matching `path`, deletes the directory, then forgets it;
1187 /// a `path` that matches none of the **resolvable** jj workspaces returns
1188 /// [`Error::WorktreeNotFound`], but when some registered workspace can't be
1189 /// resolved via `jj workspace root --name` the path's absence is unprovable, so a
1190 /// distinct diagnosable [`Error::Io`] (naming the unresolved workspaces;
1191 /// [`is_resource_not_found`](Error::is_resource_not_found) stays `false`) is
1192 /// returned instead. A directory that can't be deleted is likewise surfaced (an
1193 /// [`Error::Io`] naming the still-registered workspace, with the `forget` left for
1194 /// the retry). (For the short-lived, blocking `Drop`-path variant, see
1195 /// [`cleanup_worktree_blocking`](Self::cleanup_worktree_blocking).)
1196 ///
1197 /// The [`WorktreeRemove`] spec's [`force`](WorktreeRemove::force) mirrors git's
1198 /// `worktree remove`: without it a worktree that still has **uncommitted changes**
1199 /// is refused (`Err`) rather than deleted, so a stray edit isn't silently lost —
1200 /// build `WorktreeRemove::new(path).force()` to remove it anyway. On **jj** the
1201 /// changes are snapshotted into the op log before the check, so a refusal keeps
1202 /// them recoverable; note that checking spawns a jj command in the target
1203 /// workspace, so a genuinely stale working copy can surface an error without
1204 /// `force` (use `.force()` there). The repository's **main** workspace is always
1205 /// refused (it can't be removed without destroying the repo), regardless of `force`.
1206 pub async fn remove_worktree(&self, spec: WorktreeRemove) -> Result<()> {
1207 match &self.backend {
1208 Backend::Git(g) => {
1209 git_backend::remove_worktree(g, &self.cwd, &spec.path, spec.force).await
1210 }
1211 Backend::Jj(j) => {
1212 jj_backend::remove_worktree(j, &self.cwd, &spec.path, spec.force).await
1213 }
1214 }
1215 }
1216
1217 /// **Synchronous** worktree cleanup for a context that cannot `.await` —
1218 /// chiefly a `Drop` guard. Force-removes the worktree at `path` (git:
1219 /// `worktree remove --force`; jj: resolve the workspace name by `path`, delete
1220 /// the directory, then `workspace forget`). Short-lived and shells out directly
1221 /// (no job-containment), but not error-swallowing: a jj `path` that genuinely
1222 /// matches no workspace is an `Ok` no-op, yet a probe failure (the `workspace
1223 /// list`, or a registered workspace that won't resolve) and a `remove_dir_all`
1224 /// failure are surfaced as `Err` (the `forget` is skipped on a failed removal, so
1225 /// a surviving directory isn't orphaned). Like the async
1226 /// [`remove_worktree`](Self::remove_worktree), it **refuses the repository's
1227 /// main workspace** (whose directory is the main working copy) — deleting it
1228 /// would wipe the repo — even on this force-by-contract path.
1229 pub fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()> {
1230 match &self.backend {
1231 Backend::Git(_) => vcs_git::blocking::worktree_remove(
1232 &self.cwd,
1233 vcs_git::WorktreeRemove::new(path).force(),
1234 )
1235 .map_err(Error::Io),
1236 Backend::Jj(_) => {
1237 // jj resolves a relative worktree path against the repo dir (its
1238 // cwd), so resolve it the same way here — the lookup and the dir
1239 // removal must target the location jj used, not one under the process
1240 // cwd (which may differ from `self.cwd`).
1241 let abs_path = self.cwd.join(path);
1242 // Tell a genuine "no such workspace" (`Ok(None)` → nothing to clean
1243 // up, a no-op) apart from a probe failure (`Err` → surfaced, not
1244 // silently treated as a no-op): the blocking resolver no longer folds
1245 // both into `None`.
1246 match vcs_jj::blocking::workspace_name_for_path(&self.cwd, &abs_path)
1247 .map_err(Error::Io)?
1248 {
1249 Some(name) => {
1250 // Same main-workspace guard as the async `remove_worktree`
1251 // (jj_backend.rs): never `remove_dir_all` the repository's
1252 // main working copy — its directory owns the object store, so
1253 // deleting it wipes the whole repo. The `default` name and the
1254 // store-owning `.jj/repo` *directory* (a secondary's is a file
1255 // pointer) both flag it, so a `jj workspace rename` can't
1256 // bypass it. Force is implied on this Drop path, but this guard
1257 // is unconditional — a repo-wipe is never the intent.
1258 if name == "default" || abs_path.join(".jj").join("repo").is_dir() {
1259 return Err(Error::Io(std::io::Error::new(
1260 std::io::ErrorKind::InvalidInput,
1261 "refusing to remove the repository's main workspace",
1262 )));
1263 }
1264 // Delete the on-disk dir first (jj `forget` leaves it), then
1265 // drop jj's record of the workspace. A removal failure is
1266 // SURFACED (not swallowed with `let _ =`) and the forget is
1267 // skipped: forgetting a workspace whose directory survived
1268 // would orphan that dir — worse than a still-attached workspace
1269 // — and the reported error names what is still registered so
1270 // the cleanup can be safely re-run once the directory is free.
1271 if abs_path.exists() {
1272 std::fs::remove_dir_all(&abs_path).map_err(|e| {
1273 Error::Io(std::io::Error::new(
1274 e.kind(),
1275 format!(
1276 "failed to remove the worktree directory {} ({e}); the jj \
1277 workspace `{name}` is still registered — free the \
1278 directory and retry the cleanup",
1279 abs_path.display()
1280 ),
1281 ))
1282 })?;
1283 }
1284 vcs_jj::blocking::workspace_forget(&self.cwd, &name).map_err(Error::Io)
1285 }
1286 None => Ok(()),
1287 }
1288 }
1289 }
1290 }
1291}
1292
1293/// Generate a facade trait from one signature table: the `#[async_trait]` trait
1294/// declaration *and* the delegating `impl … for $Ty<R>`, so the two can never drift
1295/// out of sync (a hazard when each is hand-maintained). Every generated body is a
1296/// trivial delegation to the like-named inherent method — which method resolution
1297/// prefers, so this never recurses; the real backend-`match` dispatch stays
1298/// hand-written on the inherent `impl`. `async` methods doc-link to their inherent
1299/// twin; `sync` methods carry an explicit doc string (their docs aren't uniform).
1300///
1301/// `vcs-forge` used to carry a near-identical copy of this macro, kept
1302/// deliberately unshared (separate crates, ~40-line macro — duplication beats a
1303/// new dependency); it was removed there in v0.1.1 when new trait methods needed
1304/// default bodies the macro couldn't express, so `vcs-forge`'s facade trait and
1305/// impl are now hand-maintained (see the removal note in `vcs-forge`'s
1306/// `src/lib.rs`). This crate is still v0.x and doesn't need that, so the
1307/// original signature-table macro remains the right shape here.
1308///
1309/// Signatures only: each entry is a bare `&self` (or sync) method — no method-level
1310/// generics, no `&mut self`, no default bodies (a new method shaped that way needs a
1311/// grammar tweak, not just a table row).
1312///
1313/// No `mockall::automock`: a Wave-S spike proved it can't process a trait whose
1314/// signatures come from `macro_rules!`. Captured `$_:ty` fragments reach `automock`
1315/// as opaque nonterminal token groups; its `syn` parser rejects them ("unsupported
1316/// type in this position"), whereas `#[async_trait]` tolerates them. So the facade
1317/// traits stay test-seam-tested (build a handle over a fake runner — see the trait
1318/// docs), which is also what their docs already recommend over mocking.
1319macro_rules! facade_trait {
1320 (
1321 $(#[doc = $tdoc:expr])*
1322 trait $Trait:ident for $Ty:ident;
1323 sync {
1324 $( #[doc = $sdoc:expr] fn $sn:ident( $($sa:ident: $sat:ty),* $(,)? ) -> $sr:ty; )*
1325 }
1326 async {
1327 $( fn $an:ident( $($aa:ident: $aat:ty),* $(,)? ) -> $ar:ty; )*
1328 }
1329 ) => {
1330 $(#[doc = $tdoc])*
1331 #[async_trait::async_trait]
1332 pub trait $Trait: Send + Sync {
1333 $(
1334 #[doc = $sdoc]
1335 fn $sn(&self, $($sa: $sat),*) -> $sr;
1336 )*
1337 $(
1338 #[doc = concat!("See [`", stringify!($Ty), "::", stringify!($an), "`].")]
1339 async fn $an(&self, $($aa: $aat),*) -> $ar;
1340 )*
1341 }
1342
1343 // Delegates to the inherent methods, which method resolution prefers — so
1344 // these bodies dispatch through the concrete type's real implementations,
1345 // not back into the trait.
1346 #[async_trait::async_trait]
1347 impl<R: ProcessRunner> $Trait for $Ty<R> {
1348 $(
1349 fn $sn(&self, $($sa: $sat),*) -> $sr {
1350 self.$sn($($sa),*)
1351 }
1352 )*
1353 $(
1354 async fn $an(&self, $($aa: $aat),*) -> $ar {
1355 self.$an($($aa),*).await
1356 }
1357 )*
1358 }
1359 };
1360}
1361
1362facade_trait! {
1363 /// The backend-agnostic common surface of [`Repo`], as a trait — so a consumer can
1364 /// hold a `Box<dyn VcsRepo>` / `&dyn VcsRepo` and code against the operations
1365 /// without naming the [`ProcessRunner`] generic or wrapping `Repo` themselves.
1366 ///
1367 /// Every method mirrors the like-named inherent method on [`Repo`]; the trait adds
1368 /// nothing but the abstraction boundary. Tool-specific operations stay off it (see
1369 /// the crate docs) — reach those through the concrete [`Repo`] and its bound
1370 /// handles. For hermetic tests, build a `Repo` over a fake runner with
1371 /// [`Repo::from_git`] / [`Repo::from_jj`] rather than mocking this trait.
1372 trait VcsRepo for Repo;
1373 sync {
1374 #[doc = "Which backend drives this handle."]
1375 fn kind() -> BackendKind;
1376 #[doc = "The repository root detected at open time."]
1377 fn root() -> &Path;
1378 #[doc = "The directory operations run against."]
1379 fn cwd() -> &Path;
1380 #[doc = "See [`Repo::cleanup_worktree_blocking`]."]
1381 fn cleanup_worktree_blocking(path: &Path) -> Result<()>;
1382 }
1383 async {
1384 fn current_branch() -> Result<Option<String>>;
1385 fn trunk() -> Result<Option<String>>;
1386 fn local_branches() -> Result<Vec<String>>;
1387 fn local_branches_readonly() -> Result<Vec<String>>;
1388 fn branch_exists(name: &str) -> Result<bool>;
1389 fn has_uncommitted_changes() -> Result<bool>;
1390 fn has_tracked_changes() -> Result<bool>;
1391 fn conflicted_files() -> Result<Vec<PathBuf>>;
1392 fn create_branch(name: &str) -> Result<()>;
1393 fn delete_branch(spec: BranchDelete) -> Result<()>;
1394 fn rename_branch(old: &str, new: &str) -> Result<()>;
1395 fn changed_files() -> Result<Vec<FileChange>>;
1396 fn diff_stat() -> Result<DiffStat>;
1397 fn diff() -> Result<Vec<FileDiff>>;
1398 fn log(revspec_or_revset: &str, max: usize) -> Result<Vec<Commit>>;
1399 fn show_file(rev: &str, path: &str) -> Result<String>;
1400 fn annotate(path: &str, rev: Option<&str>) -> Result<Vec<AnnotationLine>>;
1401 fn show_file_within(rev: &str, path: &str, budget: OutputBudget) -> Result<String>;
1402 fn snapshot() -> Result<RepoSnapshot>;
1403 fn snapshot_readonly() -> Result<RepoSnapshot>;
1404 fn commit_paths(paths: &[PathBuf], message: &str) -> Result<()>;
1405 fn fetch() -> Result<()>;
1406 fn fetch_from(remote: &str) -> Result<()>;
1407 fn fetch_branch(branch: &str) -> Result<()>;
1408 fn push(branch: &str) -> Result<()>;
1409 fn checkout(reference: &str) -> Result<()>;
1410 fn new_child(reference: &str) -> Result<()>;
1411 fn rebase(onto: &str) -> Result<()>;
1412 fn try_merge(source: &str) -> Result<MergeProbe>;
1413 fn abort_in_progress() -> Result<OperationState>;
1414 fn continue_in_progress() -> Result<OperationState>;
1415 fn in_progress_state() -> Result<OperationState>;
1416 fn list_worktrees() -> Result<Vec<WorktreeInfo>>;
1417 fn create_worktree(spec: WorktreeCreate) -> Result<CreateOutcome>;
1418 fn remove_worktree(spec: WorktreeRemove) -> Result<()>;
1419 }
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424 use super::*;
1425 use processkit::testing::{Reply, ScriptedRunner};
1426 // The shared sandbox fixture — a unique temp dir removed on drop. Using the
1427 // testkit's one impl instead of a private copy means the wrappers/facades
1428 // don't each carry a fixture that could drift.
1429 use vcs_testkit::TempDir;
1430
1431 // --- discover ------------------------------------------------------------
1432
1433 #[test]
1434 fn discover_finds_git_and_jj_and_prefers_jj() {
1435 let tmp = TempDir::new("discover");
1436 let root = tmp.path();
1437
1438 // Plain git repo.
1439 std::fs::create_dir_all(root.join(".git")).unwrap();
1440 let located = discover(root).expect("git detected");
1441 assert_eq!(located.kind, BackendKind::Git);
1442 assert_eq!(located.root, root);
1443
1444 // Colocated: adding a *valid* .jj (with its `repo` store) makes jj win.
1445 std::fs::create_dir_all(root.join(".jj").join("repo")).unwrap();
1446 assert_eq!(discover(root).unwrap().kind, BackendKind::Jj);
1447 }
1448
1449 // M19: a stray/empty `.jj` directory (no `repo` store — e.g. a leftover
1450 // `mkdir .jj`) is NOT a jj marker and must not shadow a healthy `.git` repo in the
1451 // same directory. A valid `.jj` (with `repo`, dir or file) still wins.
1452 #[test]
1453 fn discover_ignores_a_dotjj_without_a_repo_store() {
1454 let tmp = TempDir::new("stray-jj");
1455 let root = tmp.path();
1456 std::fs::create_dir_all(root.join(".git")).unwrap();
1457 std::fs::create_dir_all(root.join(".jj")).unwrap(); // empty — no `repo`
1458 assert_eq!(
1459 discover(root).expect("git still detected").kind,
1460 BackendKind::Git,
1461 "an empty .jj must not shadow a real .git"
1462 );
1463
1464 // A secondary workspace's `.jj/repo` is a *file* pointer — still valid.
1465 let sec = TempDir::new("jj-secondary");
1466 std::fs::create_dir_all(sec.path().join(".jj")).unwrap();
1467 std::fs::write(sec.path().join(".jj").join("repo"), b"/path/to/store\n").unwrap();
1468 assert_eq!(discover(sec.path()).unwrap().kind, BackendKind::Jj);
1469 }
1470
1471 #[test]
1472 fn discover_walks_up_to_ancestor() {
1473 let tmp = TempDir::new("walkup");
1474 let root = tmp.path();
1475 std::fs::create_dir_all(root.join(".git")).unwrap();
1476 let nested = root.join("a").join("b");
1477 std::fs::create_dir_all(&nested).unwrap();
1478 let located = discover(&nested).expect("found via ancestor walk");
1479 assert_eq!(located.kind, BackendKind::Git);
1480 assert_eq!(located.root, root);
1481 }
1482
1483 #[test]
1484 fn discover_returns_none_outside_repo() {
1485 let tmp = TempDir::new("norepo");
1486 assert!(discover(tmp.path()).is_none());
1487 }
1488
1489 // A gitlink `.git` *file* (a linked worktree / submodule) is a valid git marker;
1490 // a stray file merely named `.git` is NOT — so it can't shadow a real repo above.
1491 #[test]
1492 fn discover_validates_dotgit_file_is_a_gitlink() {
1493 let tmp = TempDir::new("gitlink");
1494 let root = tmp.path();
1495
1496 // A gitlink file → detected as a git repo at this dir.
1497 std::fs::write(root.join(".git"), "gitdir: /somewhere/.git/worktrees/wt\n").unwrap();
1498 assert_eq!(
1499 discover(root).expect("gitlink detected").kind,
1500 BackendKind::Git
1501 );
1502
1503 // A garbage file named `.git` (not a gitlink) is rejected — and must NOT
1504 // shadow a real `.git` directory in the parent.
1505 let parent = TempDir::new("gitlink-parent");
1506 std::fs::create_dir_all(parent.path().join(".git")).unwrap();
1507 let child = parent.path().join("sub");
1508 std::fs::create_dir_all(&child).unwrap();
1509 std::fs::write(child.join(".git"), "not a gitlink, just noise\n").unwrap();
1510 let located = discover(&child).expect("walks up past the bogus .git file");
1511 assert_eq!(located.root, parent.path(), "the real repo is the parent");
1512
1513 // An empty `.git` file is not a marker.
1514 let empty = TempDir::new("gitlink-empty");
1515 std::fs::write(empty.path().join(".git"), "").unwrap();
1516 assert!(discover(empty.path()).is_none(), "empty .git is not a repo");
1517
1518 // Leading whitespace before `gitdir:` is tolerated (the `trim_start`).
1519 let spaced = TempDir::new("gitlink-spaced");
1520 std::fs::write(
1521 spaced.path().join(".git"),
1522 " gitdir: /x/.git/worktrees/w\n",
1523 )
1524 .unwrap();
1525 assert_eq!(
1526 discover(spaced.path())
1527 .expect("spaced gitlink detected")
1528 .kind,
1529 BackendKind::Git
1530 );
1531 }
1532
1533 // --- bare git repository (issue #6) -------------------------------------
1534
1535 // The issue #6 repro: a `git init --bare` directory (no `.git` subdir, just
1536 // `HEAD`/`config`/`objects`/`refs` in the root) must open as
1537 // `Error::BareRepository`, not the generic `Error::NotARepository` — matched
1538 // by variant, not by message substring, so the distinction can't silently
1539 // regress into the old generic error.
1540 #[test]
1541 fn discover_reports_bare_repository_not_generic_not_a_repository() {
1542 let tmp = TempDir::new("bare-repo");
1543 let root = tmp.path();
1544 std::fs::write(root.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1545 std::fs::write(root.join("config"), "[core]\n\tbare = true\n").unwrap();
1546 std::fs::create_dir_all(root.join("objects")).unwrap();
1547 std::fs::create_dir_all(root.join("refs")).unwrap();
1548
1549 match Repo::discover(root) {
1550 Err(Error::BareRepository(p)) => assert_eq!(p, root),
1551 other => panic!("expected Error::BareRepository, got {other:?}"),
1552 }
1553
1554 // The strict, non-walking `open`, called directly on the bare repo's own
1555 // root, also special-cases it via `is_bare_git_repo_marker` — mirroring
1556 // `discover`'s classification for this same directory (issue #6/#8
1557 // symmetry), even though `open` itself never walks up.
1558 match Repo::open(root) {
1559 Err(Error::BareRepository(p)) => assert_eq!(p, root),
1560 other => panic!("expected Error::BareRepository, got {other:?}"),
1561 }
1562 }
1563
1564 // A bare repository nested a few levels below `dir` is still found by
1565 // walking up — mirrors `discover_walks_up_to_ancestor` for the bare case.
1566 #[test]
1567 fn discover_finds_bare_repository_via_ancestor_walk() {
1568 let tmp = TempDir::new("bare-walkup");
1569 let root = tmp.path();
1570 std::fs::write(root.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1571 std::fs::write(root.join("config"), "[core]\n\tbare = true\n").unwrap();
1572 std::fs::create_dir_all(root.join("objects")).unwrap();
1573 std::fs::create_dir_all(root.join("refs")).unwrap();
1574 let nested = root.join("a").join("b");
1575 std::fs::create_dir_all(&nested).unwrap();
1576
1577 match Repo::discover(&nested) {
1578 Err(Error::BareRepository(p)) => assert_eq!(p, root),
1579 other => panic!("expected Error::BareRepository, got {other:?}"),
1580 }
1581
1582 // The strict `open` never walks up, so it reports `NotARepository` on
1583 // the nested dir regardless of what sits above it.
1584 match Repo::open(&nested) {
1585 Err(Error::NotARepository(p)) => assert_eq!(p, nested),
1586 other => panic!("expected Error::NotARepository, got {other:?}"),
1587 }
1588 }
1589
1590 // A directory that merely happens to hold some, but not all four, of the
1591 // bare-repo marker entries must NOT be misdetected as a bare repository —
1592 // it's just an ordinary non-repository directory.
1593 #[test]
1594 fn discover_does_not_misdetect_partial_bare_markers_as_bare_repository() {
1595 let tmp = TempDir::new("bare-partial");
1596 let root = tmp.path();
1597 // Only `HEAD` and `config` — no `objects`/`refs` directories.
1598 std::fs::write(root.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1599 std::fs::write(root.join("config"), "[core]\n\tbare = true\n").unwrap();
1600
1601 match Repo::discover(root) {
1602 Err(Error::NotARepository(p)) => assert_eq!(p, root),
1603 other => panic!("expected Error::NotARepository, got {other:?}"),
1604 }
1605 }
1606
1607 // A real (non-bare) git repository — `.git` subdirectory present — must
1608 // keep opening as before, not get swept up by the new bare-detection path.
1609 #[test]
1610 fn open_still_opens_a_normal_git_repository() {
1611 let tmp = TempDir::new("normal-git");
1612 let root = tmp.path();
1613 std::fs::create_dir_all(root.join(".git")).unwrap();
1614
1615 let repo = Repo::open(root).expect("normal git repo still opens");
1616 assert_eq!(repo.kind(), BackendKind::Git);
1617 assert_eq!(repo.root(), root);
1618 }
1619
1620 // A real jj repository must also keep opening as before.
1621 #[test]
1622 fn open_still_opens_a_normal_jj_repository() {
1623 let tmp = TempDir::new("normal-jj");
1624 let root = tmp.path();
1625 std::fs::create_dir_all(root.join(".jj").join("repo")).unwrap();
1626
1627 let repo = Repo::open(root).expect("normal jj repo still opens");
1628 assert_eq!(repo.kind(), BackendKind::Jj);
1629 assert_eq!(repo.root(), root);
1630 }
1631
1632 // A directory that is neither a repo nor a bare repo still reports the
1633 // generic `NotARepository`.
1634 #[test]
1635 fn open_reports_not_a_repository_when_nothing_found() {
1636 let tmp = TempDir::new("norepo-open");
1637 match Repo::open(tmp.path()) {
1638 Err(Error::NotARepository(p)) => assert_eq!(p, tmp.path()),
1639 other => panic!("expected Error::NotARepository, got {other:?}"),
1640 }
1641 }
1642
1643 // Unlike `discover`, the strict `open` never walks up — a repository at an
1644 // ancestor of `dir` must NOT make `open(dir)` succeed, even though
1645 // `discover(dir)` would find it.
1646 #[test]
1647 fn open_does_not_walk_up_even_though_discover_would() {
1648 let tmp = TempDir::new("open-no-walkup");
1649 let root = tmp.path();
1650 std::fs::create_dir_all(root.join(".git")).unwrap();
1651 let nested = root.join("a").join("b");
1652 std::fs::create_dir_all(&nested).unwrap();
1653
1654 match Repo::open(&nested) {
1655 Err(Error::NotARepository(p)) => assert_eq!(p, nested),
1656 other => panic!("expected Error::NotARepository, got {other:?}"),
1657 }
1658 // `discover` from the same nested dir finds the repo at `root`.
1659 assert_eq!(
1660 Repo::discover(&nested).expect("discover walks up").root(),
1661 root
1662 );
1663 }
1664
1665 // --- discover_with (injected clients) -----------------------------------
1666
1667 // `discover_with` runs the SAME detection as `Repo::discover`, then builds the
1668 // handle from the caller's client for the DETECTED backend only — the other
1669 // factory is never invoked (so no client is built speculatively). Both the git
1670 // and jj happy paths are covered here, over a hermetic `ScriptedRunner` client.
1671 #[test]
1672 fn discover_with_builds_only_the_detected_backends_client() {
1673 use std::cell::Cell;
1674
1675 // git: a `.git` dir → the git factory runs, the jj factory does not; the
1676 // handle is git-backed and bound to the absolutised discovery dir.
1677 let tmp = TempDir::new("discover-with-git");
1678 let root = tmp.path();
1679 std::fs::create_dir_all(root.join(".git")).unwrap();
1680 let git_built = Cell::new(false);
1681 let jj_built = Cell::new(false);
1682 let repo = Repo::discover_with(
1683 root,
1684 || {
1685 git_built.set(true);
1686 Git::with_runner(ScriptedRunner::new())
1687 },
1688 || {
1689 jj_built.set(true);
1690 Jj::with_runner(ScriptedRunner::new())
1691 },
1692 )
1693 .expect("git repo discovered");
1694 assert_eq!(repo.kind(), BackendKind::Git);
1695 assert_eq!(repo.root(), root);
1696 assert_eq!(repo.cwd(), root);
1697 assert!(git_built.get(), "the git factory must run for a .git repo");
1698 assert!(
1699 !jj_built.get(),
1700 "the jj factory must NOT run for a .git repo"
1701 );
1702
1703 // jj: a valid `.jj` (with its `repo` store) → symmetric, only the jj factory
1704 // runs. `.jj` wins over `.git` exactly as in `discover`.
1705 let tmp = TempDir::new("discover-with-jj");
1706 let root = tmp.path();
1707 std::fs::create_dir_all(root.join(".jj").join("repo")).unwrap();
1708 let git_built = Cell::new(false);
1709 let jj_built = Cell::new(false);
1710 let repo = Repo::discover_with(
1711 root,
1712 || {
1713 git_built.set(true);
1714 Git::with_runner(ScriptedRunner::new())
1715 },
1716 || {
1717 jj_built.set(true);
1718 Jj::with_runner(ScriptedRunner::new())
1719 },
1720 )
1721 .expect("jj repo discovered");
1722 assert_eq!(repo.kind(), BackendKind::Jj);
1723 assert_eq!(repo.root(), root);
1724 assert!(jj_built.get(), "the jj factory must run for a .jj repo");
1725 assert!(
1726 !git_built.get(),
1727 "the git factory must NOT run for a .jj repo"
1728 );
1729 }
1730
1731 // The injected client actually DRIVES the handle's operations (not merely
1732 // stored): a `Repo` opened via `discover_with` over a scripted git client
1733 // answers `current_branch` from that runner's scripted reply — proving the
1734 // caller-provided client, not a default one, is what backs the facade. The jj
1735 // factory panics if touched, pinning the "detected backend only" contract.
1736 #[tokio::test]
1737 async fn discover_with_injects_the_client_that_backs_operations() {
1738 let tmp = TempDir::new("discover-with-drives");
1739 let root = tmp.path();
1740 std::fs::create_dir_all(root.join(".git")).unwrap();
1741 let repo = Repo::discover_with(
1742 root,
1743 || {
1744 Git::with_runner(ScriptedRunner::new().on(
1745 ["git", "symbolic-ref", "--quiet", "--short", "HEAD"],
1746 Reply::ok("feature/x"),
1747 ))
1748 },
1749 || -> Jj<ScriptedRunner> { panic!("jj factory must not run for a .git repo") },
1750 )
1751 .expect("git repo discovered");
1752 assert_eq!(
1753 repo.current_branch().await.unwrap().as_deref(),
1754 Some("feature/x"),
1755 "the scripted, injected client must answer the facade call"
1756 );
1757 }
1758
1759 // The bare-repository diagnostic is shared with `Repo::discover`: opening a
1760 // `git init --bare` directory (no working tree) via the injected-client path
1761 // still yields `Error::BareRepository`, matched by variant — not the generic
1762 // `NotARepository`, and not a stringly-typed message. Neither client factory
1763 // runs, since discovery finds no working tree to back.
1764 #[test]
1765 fn discover_with_reports_bare_repository_on_injected_client() {
1766 use std::cell::Cell;
1767 let tmp = TempDir::new("discover-with-bare");
1768 let root = tmp.path();
1769 std::fs::write(root.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1770 std::fs::write(root.join("config"), "[core]\n\tbare = true\n").unwrap();
1771 std::fs::create_dir_all(root.join("objects")).unwrap();
1772 std::fs::create_dir_all(root.join("refs")).unwrap();
1773
1774 let git_built = Cell::new(false);
1775 let jj_built = Cell::new(false);
1776 let outcome = Repo::discover_with(
1777 root,
1778 || {
1779 git_built.set(true);
1780 Git::with_runner(ScriptedRunner::new())
1781 },
1782 || {
1783 jj_built.set(true);
1784 Jj::with_runner(ScriptedRunner::new())
1785 },
1786 );
1787 match outcome {
1788 Err(Error::BareRepository(p)) => assert_eq!(p, root),
1789 other => panic!("expected Error::BareRepository, got {other:?}"),
1790 }
1791 assert!(
1792 !git_built.get() && !jj_built.get(),
1793 "no client is built when discovery finds no working tree"
1794 );
1795
1796 // A directory that is neither a repo nor a bare repo still reports the
1797 // generic `NotARepository` through the same path.
1798 let empty = TempDir::new("discover-with-norepo");
1799 match Repo::discover_with(
1800 empty.path(),
1801 || Git::with_runner(ScriptedRunner::new()),
1802 || Jj::with_runner(ScriptedRunner::new()),
1803 ) {
1804 Err(Error::NotARepository(p)) => assert_eq!(p, empty.path()),
1805 other => panic!("expected Error::NotARepository, got {other:?}"),
1806 }
1807 }
1808
1809 // --- dispatch (hermetic, ScriptedRunner-backed) ------------------------
1810
1811 fn git_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
1812 Repo::from_git("/repo", "/repo", Git::with_runner(runner))
1813 }
1814
1815 fn jj_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
1816 Repo::from_jj("/repo", "/repo", Jj::with_runner(runner))
1817 }
1818
1819 // --- Debug -------------------------------------------------------------
1820 //
1821 // Regression tests for the `Repo`/`Backend` `Debug` impl (PR #7): formatting
1822 // the facade must show the elided shape (`Repo { .. }` with a `Git(..)`/
1823 // `Jj(..)` backend) but never expose the wrapped CLI client — and therefore
1824 // never a credential token that client might hold. These exist to catch a
1825 // future refactor that accidentally starts formatting the client (e.g.
1826 // deriving `Debug` on `Backend` directly, or dropping `finish_non_exhaustive`).
1827
1828 // A git-backed `Repo` built over a `Git` client holding a token via
1829 // `with_token` must format to the expected elided shape and must NOT leak
1830 // the token (or any other inner-client internal) through `{:?}`.
1831 #[test]
1832 fn debug_output_shows_elided_git_backend_and_never_leaks_the_token() {
1833 let repo = Repo::from_git(
1834 "/repo",
1835 "/repo",
1836 Git::with_runner(ScriptedRunner::new()).with_token("ghp_super_secret_token"),
1837 );
1838 let out = format!("{repo:?}");
1839 assert!(out.contains("Repo {"), "{out}");
1840 assert!(out.contains("root"), "{out}");
1841 assert!(out.contains("cwd"), "{out}");
1842 assert!(out.contains("Git(.."), "{out}");
1843 assert!(
1844 !out.contains("ghp_super_secret_token"),
1845 "token must not leak through Debug: {out}"
1846 );
1847 // Nothing from the inner `Git`/`ManagedClient`/`CliClient` internals
1848 // (e.g. its env-var bookkeeping) should surface either — the backend
1849 // must render as a bare, elided discriminant.
1850 assert!(!out.contains("ManagedClient"), "{out}");
1851 assert!(!out.contains("CliClient"), "{out}");
1852 }
1853
1854 // A jj-backed `Repo` (jj is ambient-auth-only — no `with_token`) must format
1855 // to the analogous elided shape, with the `Jj(..)` discriminant and no inner
1856 // client internals.
1857 #[test]
1858 fn debug_output_shows_elided_jj_backend() {
1859 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(ScriptedRunner::new()));
1860 let out = format!("{repo:?}");
1861 assert!(out.contains("Repo {"), "{out}");
1862 assert!(out.contains("Jj(.."), "{out}");
1863 assert!(!out.contains("ManagedClient"), "{out}");
1864 assert!(!out.contains("CliClient"), "{out}");
1865 }
1866
1867 // --- snapshot ----------------------------------------------------------
1868
1869 // git: one porcelain-v2 call + a git-dir probe → a combined RepoSnapshot.
1870 #[tokio::test]
1871 async fn git_snapshot_combines_v2_status_and_op_state() {
1872 let v2 = concat!(
1873 "# branch.oid abc123\0",
1874 "# branch.head main\0",
1875 "# branch.upstream origin/main\0",
1876 "# branch.ab +2 -0\0",
1877 "1 .M N... 100644 100644 100644 1 2 a.rs\0",
1878 "? new.txt\0",
1879 );
1880 // An empty git dir → no MERGE_HEAD / rebase dir → Clear.
1881 let gitdir = TempDir::new("snap-git");
1882 let repo = git_repo(
1883 ScriptedRunner::new()
1884 .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1885 .on(
1886 ["git", "rev-parse", "--git-dir"],
1887 Reply::ok(gitdir.path().to_str().unwrap()),
1888 ),
1889 );
1890 let s = repo.snapshot().await.unwrap();
1891 assert_eq!(s.branch.as_deref(), Some("main"));
1892 let tracking = s.tracking.as_ref().expect("upstream tracking");
1893 assert_eq!(tracking.branch, "origin/main");
1894 assert_eq!((tracking.ahead, tracking.behind), (Some(2), Some(0)));
1895 assert!(s.dirty);
1896 assert_eq!(s.change_count, 2, "1 tracked + 1 untracked");
1897 assert!(!s.conflicted);
1898 assert_eq!(s.operation, OperationState::Clear);
1899 }
1900
1901 // M20 (whole-solution): `snapshot()` has its OWN operation probe (separate from
1902 // `in_progress_state`); it too must report a `git am` as `ApplyMailbox`, not
1903 // `Rebase` — otherwise the new variant is dead on the snapshot → watch → mcp path.
1904 #[tokio::test]
1905 async fn git_snapshot_reports_git_am_as_apply_mailbox() {
1906 let v2 = concat!("# branch.oid abc\0", "# branch.head main\0");
1907 let gitdir = TempDir::new("snap-git-am");
1908 // A `git am` in progress: `rebase-apply/` WITH the `applying` marker.
1909 let apply = gitdir.path().join("rebase-apply");
1910 std::fs::create_dir_all(&apply).unwrap();
1911 std::fs::write(apply.join("applying"), b"").unwrap();
1912 let repo = git_repo(
1913 ScriptedRunner::new()
1914 .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1915 .on(
1916 ["git", "rev-parse", "--git-dir"],
1917 Reply::ok(gitdir.path().to_str().unwrap()),
1918 ),
1919 );
1920 let s = repo.snapshot().await.unwrap();
1921 assert_eq!(
1922 s.operation,
1923 OperationState::ApplyMailbox,
1924 "a git am must not read as Rebase in snapshot()"
1925 );
1926 }
1927
1928 // git with NO upstream configured: porcelain v2 omits the `# branch.upstream`
1929 // and `# branch.ab` lines, so `tracking` is None (the all-or-nothing invariant —
1930 // git is the only backend that can produce either) — mirrors the jj None case.
1931 #[tokio::test]
1932 async fn git_snapshot_without_upstream_has_no_tracking() {
1933 let v2 = concat!("# branch.oid abc123\0", "# branch.head main\0");
1934 let gitdir = TempDir::new("snap-git-noup");
1935 let repo = git_repo(
1936 ScriptedRunner::new()
1937 .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1938 .on(
1939 ["git", "rev-parse", "--git-dir"],
1940 Reply::ok(gitdir.path().to_str().unwrap()),
1941 ),
1942 );
1943 let s = repo.snapshot().await.unwrap();
1944 assert_eq!(s.branch.as_deref(), Some("main"));
1945 assert!(s.tracking.is_none(), "no upstream → no tracking");
1946 }
1947
1948 // M17: an upstream that is SET but GONE (deleted on the remote, or not yet
1949 // fetched) — porcelain v2 emits `# branch.upstream` but OMITS `# branch.ab`, so the
1950 // counts are uncountable. `tracking` must be `Some { branch, ahead: None, behind:
1951 // None }` (tracking configured but uncountable), NOT a fabricated in-sync `0`/`0`.
1952 #[tokio::test]
1953 async fn git_snapshot_upstream_set_but_gone_is_uncountable() {
1954 let v2 = concat!(
1955 "# branch.oid abc123\0",
1956 "# branch.head main\0",
1957 "# branch.upstream origin/main\0", // upstream named…
1958 // …but no `# branch.ab` line — it doesn't resolve.
1959 );
1960 let gitdir = TempDir::new("snap-git-gone");
1961 let repo = git_repo(
1962 ScriptedRunner::new()
1963 .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1964 .on(
1965 ["git", "rev-parse", "--git-dir"],
1966 Reply::ok(gitdir.path().to_str().unwrap()),
1967 ),
1968 );
1969 let s = repo.snapshot().await.unwrap();
1970 let tracking = s.tracking.as_ref().expect("upstream is set");
1971 assert_eq!(tracking.branch, "origin/main");
1972 assert_eq!(
1973 (tracking.ahead, tracking.behind),
1974 (None, None),
1975 "a gone upstream is uncountable, not in-sync 0/0"
1976 );
1977 }
1978
1979 // jj: one template row + a status count; a conflicted @ maps to Conflict; no
1980 // git-style upstream/ahead/behind.
1981 #[tokio::test]
1982 async fn jj_snapshot_dirty_with_change_count() {
1983 let repo = jj_repo(
1984 ScriptedRunner::new()
1985 // snapshot template (`jj log -r @`): commit_id \t empty \t conflict
1986 .on(["jj", "log", "-r", "@"], Reply::ok("deadbeef\t0\t1\n")) // empty=0 dirty, conflict=1
1987 // `branch` via `current_branch` → `reachable_bookmarks`
1988 // (`jj log -r heads(::@ & bookmarks())`): bookmarks \t commit
1989 .on(
1990 ["jj", "log", "-r", "heads(::@ & bookmarks())"],
1991 Reply::ok("\"main\"\tdeadbeef\n"),
1992 )
1993 .on(["jj", "root"], Reply::ok("/repo\n"))
1994 .on(["jj", "diff"], Reply::ok("M a.rs\nA b.rs\n")), // status -r @ --summary → 2
1995 );
1996 let s = repo.snapshot().await.unwrap();
1997 assert_eq!(s.head.as_deref(), Some("deadbeef"));
1998 assert_eq!(s.branch.as_deref(), Some("main"));
1999 assert!(s.dirty);
2000 assert_eq!(s.change_count, 2);
2001 assert!(s.conflicted);
2002 assert_eq!(s.operation, OperationState::Conflict);
2003 assert!(s.tracking.is_none(), "jj has no upstream tracking");
2004 }
2005
2006 // jj: a clean `@` (empty=1) skips the change-count spawn entirely — the test
2007 // scripts NO `diff` rule, so calling `status` would error.
2008 #[tokio::test]
2009 async fn jj_snapshot_clean_skips_change_count() {
2010 let repo = jj_repo(
2011 ScriptedRunner::new()
2012 .on(["jj", "log", "-r", "@"], Reply::ok("c0ffee\t1\t0\n"))
2013 .on(
2014 ["jj", "log", "-r", "heads(::@ & bookmarks())"],
2015 Reply::ok(""),
2016 ),
2017 );
2018 let s = repo.snapshot().await.unwrap();
2019 assert_eq!(s.head.as_deref(), Some("c0ffee"));
2020 assert_eq!(s.branch, None, "no bookmark");
2021 assert!(!s.dirty);
2022 assert_eq!(s.change_count, 0);
2023 assert!(!s.conflicted);
2024 assert_eq!(s.operation, OperationState::Clear);
2025 }
2026
2027 // jj: a conflicted `@` that jj marks `empty` (conflict but no net content change)
2028 // is still reported `dirty` — the conflict is uncommitted state needing
2029 // resolution — so the count runs and the snapshot is coherent (no
2030 // `conflicted: true` next to `dirty: false`), mirroring git's conflict handling.
2031 #[tokio::test]
2032 async fn jj_snapshot_conflicted_empty_change_is_dirty() {
2033 let repo = jj_repo(
2034 ScriptedRunner::new()
2035 .on(["jj", "log", "-r", "@"], Reply::ok("c0ffee\t1\t1\n")) // empty=1, conflict=1
2036 .on(
2037 ["jj", "log", "-r", "heads(::@ & bookmarks())"],
2038 Reply::ok(""),
2039 ) // no bookmark
2040 .on(["jj", "root"], Reply::ok("/repo\n"))
2041 .on(["jj", "diff"], Reply::ok("M conflicted.rs\n")), // status → 1
2042 );
2043 let s = repo.snapshot().await.unwrap();
2044 assert!(s.conflicted);
2045 assert!(s.dirty, "a conflicted change is a dirty working copy");
2046 assert_eq!(s.change_count, 1);
2047 assert_eq!(s.operation, OperationState::Conflict);
2048 }
2049
2050 // jj `list_worktrees` resolves each workspace's root via the batched
2051 // `workspace_roots` fan-out (one `workspace root --name <n>` per `workspace
2052 // list` row), then builds a `WorktreeInfo` per workspace. Hermetic: scripts the
2053 // template rows + the per-name root replies — the backend glue that the
2054 // `#[ignore]` integration tests otherwise cover only with a real `jj`.
2055 #[tokio::test]
2056 async fn jj_list_worktrees_batches_root_lookups() {
2057 let repo = jj_repo(
2058 ScriptedRunner::new()
2059 .on(
2060 ["jj", "workspace", "list"],
2061 Reply::ok("\"default\"\tc0ffee\t\"main\"\n\"ws1\"\tdecaf0\t\n"),
2062 )
2063 .on(
2064 [
2065 "jj",
2066 "--ignore-working-copy",
2067 "workspace",
2068 "root",
2069 "--name",
2070 "default",
2071 ],
2072 Reply::ok("/repo\n"),
2073 )
2074 .on(
2075 [
2076 "jj",
2077 "--ignore-working-copy",
2078 "workspace",
2079 "root",
2080 "--name",
2081 "ws1",
2082 ],
2083 Reply::ok("/repo/ws1\n"),
2084 ),
2085 );
2086 let worktrees = repo.list_worktrees().await.expect("list_worktrees");
2087 assert_eq!(worktrees.len(), 2);
2088 assert_eq!(worktrees[0].path, Path::new("/repo"));
2089 assert_eq!(worktrees[0].branch.as_deref(), Some("main"));
2090 assert_eq!(worktrees[1].path, Path::new("/repo/ws1"));
2091 assert_eq!(worktrees[1].branch, None);
2092 }
2093
2094 // A workspace whose `workspace root` lookup errors is skipped (no useful path),
2095 // mirroring the old sequential loop — the batch maps that slot to `Err`.
2096 #[tokio::test]
2097 async fn jj_list_worktrees_skips_unresolvable_root() {
2098 let repo = jj_repo(
2099 ScriptedRunner::new()
2100 .on(
2101 ["jj", "workspace", "list"],
2102 Reply::ok("\"default\"\tc0ffee\t\"main\"\n\"gone\"\tdecaf0\t\n"),
2103 )
2104 .on(
2105 [
2106 "jj",
2107 "--ignore-working-copy",
2108 "workspace",
2109 "root",
2110 "--name",
2111 "default",
2112 ],
2113 Reply::ok("/repo\n"),
2114 )
2115 .on(
2116 [
2117 "jj",
2118 "--ignore-working-copy",
2119 "workspace",
2120 "root",
2121 "--name",
2122 "gone",
2123 ],
2124 Reply::fail(1, "Error: No such workspace"),
2125 ),
2126 );
2127 let worktrees = repo.list_worktrees().await.expect("list_worktrees");
2128 assert_eq!(worktrees.len(), 1, "the unresolvable workspace is skipped");
2129 assert_eq!(worktrees[0].path, Path::new("/repo"));
2130 }
2131
2132 // remove_worktree surfaces a `workspace forget` failure rather than swallowing
2133 // it — name resolution already proved the workspace is registered, so a forget
2134 // error is a real dangling-registration the caller should see.
2135 #[tokio::test]
2136 async fn jj_remove_worktree_surfaces_forget_error() {
2137 let repo = jj_repo(
2138 ScriptedRunner::new()
2139 .on(
2140 ["jj", "workspace", "list"],
2141 Reply::ok("\"ws1\"\tc0ffee\t\n"),
2142 )
2143 .on(
2144 [
2145 "jj",
2146 "--ignore-working-copy",
2147 "workspace",
2148 "root",
2149 "--name",
2150 "ws1",
2151 ],
2152 Reply::ok("/repo/ws1\n"),
2153 )
2154 .on(
2155 ["jj", "workspace", "forget"],
2156 Reply::fail(1, "Error: cannot forget workspace"),
2157 ),
2158 );
2159 // `/repo/ws1` does not exist on disk, so the dir-removal step is skipped and
2160 // the forget error is the sole outcome.
2161 let res = repo.remove_worktree(WorktreeRemove::new("/repo/ws1")).await;
2162 assert!(res.is_err(), "a forget failure is surfaced, not swallowed");
2163 }
2164
2165 // Windows-like removal failure: `remove_worktree` surfaces a `remove_dir_all`
2166 // failure and names what remains (the still-registered workspace) rather than
2167 // swallowing it. A *file* sits where the workspace dir should be, so
2168 // `remove_dir_all` errors deterministically on every platform.
2169 #[tokio::test]
2170 async fn jj_remove_worktree_surfaces_dir_removal_failure() {
2171 let tmp = TempDir::new("rmw-rmdir-fail");
2172 let ws = tmp.path().join("ws1");
2173 std::fs::write(&ws, b"not a dir").expect("write file where the dir should be");
2174 let root = tmp.path().to_string_lossy().into_owned();
2175 let ws_str = ws.to_string_lossy().into_owned();
2176 let repo = Repo::from_jj(
2177 &root,
2178 &root,
2179 Jj::with_runner(
2180 ScriptedRunner::new()
2181 .on(
2182 ["jj", "workspace", "list"],
2183 Reply::ok("\"ws1\"\tc0ffee\t\n"),
2184 )
2185 .on(
2186 [
2187 "jj",
2188 "--ignore-working-copy",
2189 "workspace",
2190 "root",
2191 "--name",
2192 "ws1",
2193 ],
2194 Reply::ok(format!("{ws_str}\n")),
2195 ),
2196 ),
2197 );
2198 // force skips the dirty check, so the removal step is reached directly.
2199 let err = repo
2200 .remove_worktree(WorktreeRemove::new(ws.clone()).force())
2201 .await
2202 .expect_err("a dir-removal failure must be surfaced");
2203 let msg = err.to_string();
2204 assert!(
2205 msg.contains("still registered") && msg.contains("ws1"),
2206 "the failure must name what remains to clean up: {msg}"
2207 );
2208 assert!(
2209 ws.exists(),
2210 "the undeletable path must survive the failed removal"
2211 );
2212 }
2213
2214 // Compatible fallback / diagnosable error: when a registered workspace's root
2215 // can't be resolved via `workspace root --name`, a path matching none of the
2216 // resolvable ones is NOT reported as a clean `WorktreeNotFound` — absence can't be
2217 // proven, so a distinct diagnosable error naming the unresolved workspace is
2218 // raised instead (so a real-but-unresolvable workspace isn't misreported).
2219 #[tokio::test]
2220 async fn jj_remove_worktree_reports_unresolvable_workspaces() {
2221 let repo = jj_repo(
2222 ScriptedRunner::new()
2223 .on(
2224 ["jj", "workspace", "list"],
2225 Reply::ok("\"ws1\"\tc0ffee\t\n\"gone\"\tdecaf0\t\n"),
2226 )
2227 .on(
2228 [
2229 "jj",
2230 "--ignore-working-copy",
2231 "workspace",
2232 "root",
2233 "--name",
2234 "ws1",
2235 ],
2236 Reply::ok("/repo/ws1\n"),
2237 )
2238 .on(
2239 [
2240 "jj",
2241 "--ignore-working-copy",
2242 "workspace",
2243 "root",
2244 "--name",
2245 "gone",
2246 ],
2247 Reply::fail(1, "Error: No such workspace"),
2248 ),
2249 );
2250 let err = repo
2251 .remove_worktree(WorktreeRemove::new("/repo/missing"))
2252 .await
2253 .expect_err("an unresolvable workspace must not be reported as a clean not-found");
2254 assert!(
2255 !err.is_resource_not_found(),
2256 "a partial resolution is not a clean WorktreeNotFound: {err}"
2257 );
2258 let msg = err.to_string();
2259 assert!(
2260 msg.contains("could not resolve") && msg.contains("gone"),
2261 "the diagnosable error must name the unresolved workspace: {msg}"
2262 );
2263 }
2264
2265 // Repeated cleanup is idempotent: after a first pass removed the directory but its
2266 // `workspace forget` failed, a retry finds the dir already gone, re-resolves the
2267 // still-registered workspace by name, and completes the forget — no error.
2268 #[tokio::test]
2269 async fn jj_remove_worktree_retry_after_dir_gone_forgets_cleanly() {
2270 let repo = jj_repo(
2271 ScriptedRunner::new()
2272 .on(
2273 ["jj", "workspace", "list"],
2274 Reply::ok("\"ws1\"\tc0ffee\t\n"),
2275 )
2276 .on(
2277 [
2278 "jj",
2279 "--ignore-working-copy",
2280 "workspace",
2281 "root",
2282 "--name",
2283 "ws1",
2284 ],
2285 Reply::ok("/repo/ws1\n"),
2286 )
2287 .on(["jj", "workspace", "forget"], Reply::ok("")),
2288 );
2289 // `/repo/ws1` does not exist on disk (a prior pass removed it), so the removal
2290 // step is skipped and the forget clears the dangling registration.
2291 repo.remove_worktree(WorktreeRemove::new("/repo/ws1"))
2292 .await
2293 .expect("a retry with the dir already gone completes the forget");
2294 }
2295
2296 // C1: the default workspace resolves at the repo root; removing it would wipe
2297 // the whole repository, so it is refused even with force = true and WITHOUT
2298 // running `workspace forget` (no such cassette rule — a miss would also error,
2299 // so we assert the *refusal* message to prove the guard, not a fallthrough).
2300 #[tokio::test]
2301 async fn jj_remove_worktree_refuses_the_main_workspace() {
2302 let repo = jj_repo(
2303 ScriptedRunner::new()
2304 .on(
2305 ["jj", "workspace", "list"],
2306 Reply::ok("\"default\"\tc0ffee\t\n"),
2307 )
2308 .on(
2309 [
2310 "jj",
2311 "--ignore-working-copy",
2312 "workspace",
2313 "root",
2314 "--name",
2315 "default",
2316 ],
2317 Reply::ok("/repo\n"),
2318 ),
2319 );
2320 let err = repo
2321 .remove_worktree(WorktreeRemove::new("/repo").force())
2322 .await
2323 .expect_err("the main workspace must be refused");
2324 assert!(
2325 err.to_string().contains("main workspace"),
2326 "refusal message, not a cassette miss: {err}"
2327 );
2328 }
2329
2330 // C1: a secondary workspace with un-snapshotted edits (`current_change` reports
2331 // non-empty) is refused under force = false, and its directory is NOT deleted.
2332 #[tokio::test]
2333 async fn jj_remove_worktree_refuses_dirty_workspace_without_force() {
2334 let tmp = TempDir::new("rmw-dirty");
2335 let root = tmp.path().to_string_lossy().into_owned();
2336 let repo = Repo::from_jj(
2337 &root,
2338 &root,
2339 Jj::with_runner(
2340 ScriptedRunner::new()
2341 .on(
2342 ["jj", "workspace", "list"],
2343 Reply::ok("\"ws1\"\tc0ffee\t\n"),
2344 )
2345 .on(
2346 [
2347 "jj",
2348 "--ignore-working-copy",
2349 "workspace",
2350 "root",
2351 "--name",
2352 "ws1",
2353 ],
2354 Reply::ok(format!("{root}\n")),
2355 )
2356 // `current_change` → 3rd field `false` = not empty = dirty.
2357 .on(["jj", "log"], Reply::ok("aaa\tbbb\tfalse\t\"work\"\n")),
2358 ),
2359 );
2360 let err = repo
2361 .remove_worktree(WorktreeRemove::new(tmp.path()))
2362 .await
2363 .expect_err("a dirty workspace must be refused without force");
2364 assert!(
2365 err.to_string().contains("uncommitted changes"),
2366 "refusal message: {err}"
2367 );
2368 assert!(
2369 tmp.path().exists(),
2370 "the workspace directory must survive a refusal"
2371 );
2372 }
2373
2374 // C1: force = true skips the dirty check and removes the directory (no
2375 // `current_change` rule is scripted, proving the check is bypassed).
2376 #[tokio::test]
2377 async fn jj_remove_worktree_with_force_removes_the_dir() {
2378 let tmp = TempDir::new("rmw-force");
2379 let ws = tmp.path().join("ws1");
2380 std::fs::create_dir_all(&ws).expect("mkdir ws");
2381 let root = tmp.path().to_string_lossy().into_owned();
2382 let ws_str = ws.to_string_lossy().into_owned();
2383 let repo = Repo::from_jj(
2384 &root,
2385 &root,
2386 Jj::with_runner(
2387 ScriptedRunner::new()
2388 .on(
2389 ["jj", "workspace", "list"],
2390 Reply::ok("\"ws1\"\tc0ffee\t\n"),
2391 )
2392 .on(
2393 [
2394 "jj",
2395 "--ignore-working-copy",
2396 "workspace",
2397 "root",
2398 "--name",
2399 "ws1",
2400 ],
2401 Reply::ok(format!("{ws_str}\n")),
2402 )
2403 .on(["jj", "workspace", "forget"], Reply::ok("")),
2404 ),
2405 );
2406 repo.remove_worktree(WorktreeRemove::new(ws.clone()).force())
2407 .await
2408 .expect("force removes a dirty worktree");
2409 assert!(!ws.exists(), "the worktree directory was removed");
2410 }
2411
2412 // C1: the main-workspace guard's store-directory branch — a workspace whose
2413 // name was changed away from `default` (via `jj workspace rename`) still owns
2414 // the object store (`.jj/repo` is a *directory*, not a secondary's file
2415 // pointer), so removal is refused even with force = true, and the dir survives.
2416 // Exercises the `|| .jj/repo.is_dir()` half of the guard (the name is not
2417 // `default`), which the name-based test can't reach.
2418 #[tokio::test]
2419 async fn jj_remove_worktree_refuses_renamed_store_owning_workspace() {
2420 let tmp = TempDir::new("rmw-store");
2421 std::fs::create_dir_all(tmp.path().join(".jj").join("repo")).expect("mk .jj/repo dir");
2422 let root = tmp.path().to_string_lossy().into_owned();
2423 let repo = Repo::from_jj(
2424 &root,
2425 &root,
2426 Jj::with_runner(
2427 ScriptedRunner::new()
2428 .on(
2429 ["jj", "workspace", "list"],
2430 Reply::ok("\"mainws\"\tc0ffee\t\n"),
2431 )
2432 .on(
2433 [
2434 "jj",
2435 "--ignore-working-copy",
2436 "workspace",
2437 "root",
2438 "--name",
2439 "mainws",
2440 ],
2441 Reply::ok(format!("{root}\n")),
2442 ),
2443 ),
2444 );
2445 let err = repo
2446 .remove_worktree(WorktreeRemove::new(tmp.path()).force())
2447 .await
2448 .expect_err("a renamed store-owning workspace is still refused");
2449 assert!(
2450 err.to_string().contains("main workspace"),
2451 "refusal message: {err}"
2452 );
2453 assert!(
2454 tmp.path().exists(),
2455 "the store-owning directory must not be deleted"
2456 );
2457 }
2458
2459 #[tokio::test]
2460 async fn kind_and_escape_hatches_reflect_backend() {
2461 let repo = git_repo(ScriptedRunner::new());
2462 assert_eq!(repo.kind(), BackendKind::Git);
2463 assert!(repo.git().is_some());
2464 assert!(repo.jj().is_none());
2465 }
2466
2467 // The cwd-bound views mirror the backend, and `at` re-binds them to another
2468 // directory without a separate client.
2469 #[tokio::test]
2470 async fn bound_views_reflect_backend_and_cwd() {
2471 let git = git_repo(ScriptedRunner::new());
2472 assert!(git.git_at().is_some());
2473 assert!(git.jj_at().is_none());
2474 // A sibling handle bound elsewhere yields a view rooted at that dir.
2475 assert_eq!(git.at("/repo/wt").cwd(), Path::new("/repo/wt"));
2476
2477 let jj = jj_repo(ScriptedRunner::new());
2478 assert!(jj.jj_at().is_some());
2479 assert!(jj.git_at().is_none());
2480 }
2481
2482 #[tokio::test]
2483 async fn current_branch_maps_detached_head_to_none() {
2484 // git's `current_branch` now runs `symbolic-ref --quiet --short HEAD`:
2485 // exit 0 → the branch name, exit 1 → detached HEAD → None.
2486 let named =
2487 git_repo(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")));
2488 assert_eq!(
2489 named.current_branch().await.unwrap().as_deref(),
2490 Some("main")
2491 );
2492 let detached =
2493 git_repo(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
2494 assert!(detached.current_branch().await.unwrap().is_none());
2495 }
2496
2497 #[tokio::test]
2498 async fn changed_files_maps_git_status() {
2499 let repo = git_repo(ScriptedRunner::new().on(
2500 ["git", "status"],
2501 Reply::ok(" M a.rs\0?? b.rs\0R new.rs\0old.rs\0"),
2502 ));
2503 let changes = repo.changed_files().await.unwrap();
2504 assert_eq!(changes.len(), 3);
2505 assert_eq!(changes[0].kind, ChangeKind::Modified);
2506 assert_eq!(changes[1].kind, ChangeKind::Added);
2507 assert_eq!(changes[2].kind, ChangeKind::Renamed);
2508 assert_eq!(changes[2].old_path.as_deref(), Some(Path::new("old.rs")));
2509 }
2510
2511 #[tokio::test]
2512 async fn local_branches_maps_git_branch_output() {
2513 let repo =
2514 git_repo(ScriptedRunner::new().on(["git", "branch"], Reply::ok("* main\n feat\n")));
2515 assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
2516 }
2517
2518 #[tokio::test]
2519 async fn branch_exists_reads_show_ref_exit() {
2520 let yes = git_repo(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
2521 assert!(yes.branch_exists("main").await.unwrap());
2522 let no = git_repo(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
2523 assert!(!no.branch_exists("nope").await.unwrap());
2524 }
2525
2526 #[tokio::test]
2527 async fn has_uncommitted_changes_reflects_status() {
2528 let dirty = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0")));
2529 assert!(dirty.has_uncommitted_changes().await.unwrap());
2530 let clean = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok("")));
2531 assert!(!clean.has_uncommitted_changes().await.unwrap());
2532 }
2533
2534 #[tokio::test]
2535 async fn at_rebinds_cwd_and_shares_backend() {
2536 let repo = git_repo(ScriptedRunner::new());
2537 let moved = repo.at("/repo/sub");
2538 assert_eq!(moved.cwd(), Path::new("/repo/sub"));
2539 assert_eq!(moved.root(), Path::new("/repo"));
2540 assert_eq!(moved.kind(), BackendKind::Git);
2541 }
2542
2543 // --- dispatch: jj backend (hermetic) -----------------------------------
2544
2545 #[tokio::test]
2546 async fn jj_kind_and_escape_hatches_reflect_backend() {
2547 let repo = jj_repo(ScriptedRunner::new());
2548 assert_eq!(repo.kind(), BackendKind::Jj);
2549 assert!(repo.jj().is_some() && repo.git().is_none());
2550 }
2551
2552 #[tokio::test]
2553 async fn jj_current_branch_reads_bookmark() {
2554 // current_branch derives from `reachable_bookmarks`, whose template is
2555 // `<bookmarks space-joined>\t<commit>` — distinct from the strict
2556 // `current_bookmark(@)` comma-joined template.
2557 let repo =
2558 jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("\"main\"\t53e4e879\n")));
2559 assert_eq!(
2560 repo.current_branch().await.unwrap().as_deref(),
2561 Some("main")
2562 );
2563 }
2564
2565 #[tokio::test]
2566 async fn jj_current_branch_persists_across_commit() {
2567 // After a jj commit the new working-copy change carries no bookmark, but
2568 // the described parent does. `reachable_bookmarks` resolves the nearest
2569 // bookmarked ancestor, so the facade still reports it — git-like "I'm
2570 // still on my branch". Under the old strict `current_bookmark(@)` rule
2571 // this returned `None`; feeding the reachable template (`feat\t…`,
2572 // unparseable as a comma-joined bookmark name) pins the new derivation.
2573 let repo =
2574 jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("\"feat\"\tc8d49332\n")));
2575 assert_eq!(
2576 repo.current_branch().await.unwrap().as_deref(),
2577 Some("feat")
2578 );
2579 }
2580
2581 #[tokio::test]
2582 async fn jj_current_branch_tie_break_is_deterministic() {
2583 // `heads(::@ & bookmarks())` can yield several equally-near bookmarks —
2584 // a merge of two bookmarked lines (one row each) or one commit carrying
2585 // several (one row, space-joined). current_branch returns the
2586 // lexicographically-smallest name regardless of jj's row order, so the
2587 // result is stable. Here: rows `zeta` then `alpha beta` ⇒ `alpha`.
2588 let repo = jj_repo(ScriptedRunner::new().on(
2589 ["jj", "log"],
2590 Reply::ok("\"zeta\"\tabc1234\n\"alpha\" \"beta\"\tdef5678\n"),
2591 ));
2592 assert_eq!(
2593 repo.current_branch().await.unwrap().as_deref(),
2594 Some("alpha")
2595 );
2596 }
2597
2598 #[tokio::test]
2599 async fn jj_local_branches_maps_bookmark_list() {
2600 // BOOKMARK_LIST_TEMPLATE rows: `<present>\t<remote>\t"<name>"\t<commit>`.
2601 let repo = jj_repo(ScriptedRunner::new().on(
2602 ["jj", "bookmark", "list"],
2603 Reply::ok("1\t\t\"main\"\tcmt\n1\t\t\"feat\"\tm2\n"),
2604 ));
2605 assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
2606 }
2607
2608 #[tokio::test]
2609 async fn jj_branch_exists_scans_bookmarks() {
2610 let repo = jj_repo(ScriptedRunner::new().on(
2611 ["jj", "bookmark", "list"],
2612 Reply::ok("1\t\t\"main\"\tcmt\n"),
2613 ));
2614 assert!(repo.branch_exists("main").await.unwrap());
2615 let repo2 = jj_repo(ScriptedRunner::new().on(
2616 ["jj", "bookmark", "list"],
2617 Reply::ok("1\t\t\"main\"\tcmt\n"),
2618 ));
2619 assert!(!repo2.branch_exists("missing").await.unwrap());
2620 }
2621
2622 #[tokio::test]
2623 async fn jj_has_uncommitted_changes_reads_empty_flag() {
2624 // CHANGE_TEMPLATE row: change_id \t commit_id \t empty \t description
2625 let dirty =
2626 jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\tfalse\t\"wip\"\n")));
2627 assert!(dirty.has_uncommitted_changes().await.unwrap());
2628 let clean =
2629 jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\ttrue\t\"\"\n")));
2630 assert!(!clean.has_uncommitted_changes().await.unwrap());
2631 }
2632
2633 // M18: a conflicted-but-**empty** `@` is uncommitted state (it needs resolution),
2634 // so `has_uncommitted_changes` returns true — agreeing with `snapshot().dirty`,
2635 // which already treats `conflict ⇒ dirty`. First `jj log` = current_change (empty),
2636 // second = is_conflicted (`"1"`).
2637 #[tokio::test]
2638 async fn jj_has_uncommitted_changes_true_when_conflicted_even_if_empty() {
2639 let repo = jj_repo(ScriptedRunner::new().on_sequence(
2640 ["jj", "log"],
2641 [
2642 Reply::ok("kz\t38\ttrue\t\"\"\n"), // current_change: empty = true
2643 Reply::ok("1\n"), // is_conflicted: conflicted
2644 ],
2645 ));
2646 assert!(
2647 repo.has_uncommitted_changes().await.unwrap(),
2648 "a conflicted empty @ is dirty"
2649 );
2650 }
2651
2652 #[tokio::test]
2653 async fn jj_changed_files_maps_diff_summary() {
2654 let repo = jj_repo(
2655 ScriptedRunner::new()
2656 .on(["jj", "root"], Reply::ok("/repo\n"))
2657 .on(["jj", "diff"], Reply::ok("M src/a.rs\nA b.rs\nD gone.rs\n")),
2658 );
2659 let changes = repo.changed_files().await.unwrap();
2660 assert_eq!(changes.len(), 3);
2661 assert_eq!(changes[0].kind, ChangeKind::Modified);
2662 assert_eq!(changes[1].kind, ChangeKind::Added);
2663 assert_eq!(changes[2].kind, ChangeKind::Deleted);
2664 assert!(changes.iter().all(|c| c.old_path.is_none()));
2665 }
2666
2667 // jj DOES supply the rename's original path (its `{old => new}` summary
2668 // form) — `old_path` is populated on both backends, as the DTO documents.
2669 #[tokio::test]
2670 async fn jj_changed_files_populates_rename_old_path() {
2671 let repo = jj_repo(
2672 ScriptedRunner::new()
2673 .on(["jj", "root"], Reply::ok("/repo\n"))
2674 .on(["jj", "diff"], Reply::ok("R src/{old.rs => new.rs}\n")),
2675 );
2676 let changes = repo.changed_files().await.unwrap();
2677 assert_eq!(changes.len(), 1);
2678 assert_eq!(changes[0].kind, ChangeKind::Renamed);
2679 assert_eq!(changes[0].path, Path::new("src/new.rs"));
2680 assert_eq!(
2681 changes[0].old_path.as_deref(),
2682 Some(Path::new("src/old.rs"))
2683 );
2684 }
2685
2686 // `commit_paths(&[])` is refused up front on BOTH backends: the runners have
2687 // no rules, so reaching the CLI would error differently — the guard must trip
2688 // first (on jj an empty fileset would otherwise commit the whole working
2689 // copy; on git it would exit 128).
2690 #[tokio::test]
2691 async fn commit_paths_refuses_an_empty_path_set() {
2692 for repo in [
2693 git_repo(ScriptedRunner::new()),
2694 jj_repo(ScriptedRunner::new()),
2695 ] {
2696 let err = repo
2697 .commit_paths(&[], "msg")
2698 .await
2699 .expect_err("empty paths must be refused");
2700 assert!(
2701 err.to_string().contains("at least one path"),
2702 "unexpected error: {err}"
2703 );
2704 }
2705 }
2706
2707 // `create_branch` dispatches to `git branch <name>` (no checkout) on git and to
2708 // `jj bookmark create <name> -r @` (anchored on the current head, not moving it)
2709 // on jj.
2710 #[tokio::test]
2711 async fn create_branch_dispatches_per_backend() {
2712 use processkit::testing::RecordingRunner;
2713 let grec = RecordingRunner::replying(Reply::ok(""));
2714 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2715 .create_branch("feat")
2716 .await
2717 .unwrap();
2718 assert_eq!(grec.only_call().args_str(), ["branch", "feat"]);
2719
2720 let jrec = RecordingRunner::replying(Reply::ok(""));
2721 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2722 .create_branch("feat")
2723 .await
2724 .unwrap();
2725 assert_eq!(
2726 jrec.only_call().args_str(),
2727 ["bookmark", "create", "feat", "-r", "@", "--color", "never"]
2728 );
2729 }
2730
2731 // The `RefName`/`BookmarkName` newtype guards reject a leading `-` (an
2732 // injectable flag-like name) and an empty name — before either backend spawns
2733 // anything (`ScriptedRunner::new()` has no rules, so a spawn attempt would
2734 // panic on an unmatched command instead of hitting this assertion).
2735 #[tokio::test]
2736 async fn create_branch_rejects_invalid_name_without_spawning() {
2737 for repo in [
2738 git_repo(ScriptedRunner::new()),
2739 jj_repo(ScriptedRunner::new()),
2740 ] {
2741 for bad in ["-evil", ""] {
2742 repo.create_branch(bad)
2743 .await
2744 .expect_err(&format!("{bad:?} must be refused before spawning"));
2745 }
2746 }
2747 }
2748
2749 #[tokio::test]
2750 async fn jj_rename_branch_builds_bookmark_rename() {
2751 use processkit::testing::RecordingRunner;
2752 let rec = RecordingRunner::replying(Reply::ok(""));
2753 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
2754 repo.rename_branch("old", "new").await.unwrap();
2755 assert_eq!(
2756 rec.only_call().args_str(),
2757 ["bookmark", "rename", "old", "new", "--color", "never"]
2758 );
2759 }
2760
2761 // The widened common surface dispatches `checkout` to each backend's verb:
2762 // git `checkout`, jj `edit`.
2763 #[tokio::test]
2764 async fn checkout_dispatches_per_backend() {
2765 use processkit::testing::RecordingRunner;
2766 let grec = RecordingRunner::replying(Reply::ok(""));
2767 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2768 .checkout("feat")
2769 .await
2770 .unwrap();
2771 // Trailing `--` so a path-like ref can't fall into pathspec mode (C2).
2772 assert_eq!(grec.only_call().args_str(), ["checkout", "feat", "--"]);
2773
2774 let jrec = RecordingRunner::replying(Reply::ok(""));
2775 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2776 .checkout("feat")
2777 .await
2778 .unwrap();
2779 assert_eq!(
2780 jrec.only_call().args_str(),
2781 ["edit", "feat", "--color", "never"]
2782 );
2783 }
2784
2785 #[tokio::test]
2786 async fn new_child_dispatches_per_backend() {
2787 use processkit::testing::RecordingRunner;
2788 let grec = RecordingRunner::replying(Reply::ok(""));
2789 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2790 .new_child("feat")
2791 .await
2792 .unwrap();
2793 assert_eq!(grec.only_call().args_str(), ["checkout", "feat", "--"]);
2794
2795 let jrec = RecordingRunner::replying(Reply::ok(""));
2796 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2797 .new_child("feat")
2798 .await
2799 .unwrap();
2800 assert_eq!(
2801 jrec.only_call().args_str(),
2802 ["new", "feat", "--color", "never"]
2803 );
2804 }
2805
2806 // A1: `delete_branch` takes a `BranchDelete` spec; `.force()` threads through to
2807 // git's `-D` (vs `-d`), and jj ignores it (its `bookmark delete` has no force).
2808 #[tokio::test]
2809 async fn delete_branch_spec_threads_force_to_git_only() {
2810 use processkit::testing::RecordingRunner;
2811 let forced = RecordingRunner::replying(Reply::ok(""));
2812 Repo::from_git("/repo", "/repo", Git::with_runner(&forced))
2813 .delete_branch(BranchDelete::new("feat").force())
2814 .await
2815 .unwrap();
2816 assert!(
2817 forced.only_call().args_str().iter().any(|a| a == "-D"),
2818 "force → branch -D"
2819 );
2820
2821 let unforced = RecordingRunner::replying(Reply::ok(""));
2822 Repo::from_git("/repo", "/repo", Git::with_runner(&unforced))
2823 .delete_branch(BranchDelete::new("feat"))
2824 .await
2825 .unwrap();
2826 assert!(
2827 unforced.only_call().args_str().iter().any(|a| a == "-d"),
2828 "no force → branch -d"
2829 );
2830
2831 let jj = RecordingRunner::replying(Reply::ok(""));
2832 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jj))
2833 .delete_branch(BranchDelete::new("feat").force())
2834 .await
2835 .unwrap();
2836 assert!(
2837 !jj.only_call()
2838 .args_str()
2839 .iter()
2840 .any(|a| a == "-D" || a == "--force"),
2841 "jj bookmark delete has no force flag"
2842 );
2843 }
2844
2845 #[tokio::test]
2846 async fn fetch_branch_dispatches_per_backend() {
2847 use processkit::testing::RecordingRunner;
2848 let grec = RecordingRunner::replying(Reply::ok(""));
2849 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2850 .fetch_branch("main")
2851 .await
2852 .unwrap();
2853 assert!(
2854 grec.only_call()
2855 .args_str()
2856 .starts_with(&["fetch".to_string()])
2857 );
2858
2859 let jrec = RecordingRunner::replying(Reply::ok(""));
2860 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2861 .fetch_branch("main")
2862 .await
2863 .unwrap();
2864 let args = jrec.only_call().args_str();
2865 assert_eq!(&args[..2], &["git", "fetch"]);
2866 }
2867
2868 // The facade push is the honest LCD: git pushes the ref with `-u origin`,
2869 // jj pushes the bookmark's state with `-b`. Argv pinned on both backends.
2870 #[tokio::test]
2871 async fn push_dispatches_per_backend() {
2872 use processkit::testing::RecordingRunner;
2873 let grec = RecordingRunner::replying(Reply::ok(""));
2874 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2875 .push("feature")
2876 .await
2877 .unwrap();
2878 assert_eq!(
2879 grec.only_call().args_str(),
2880 ["push", "-u", "origin", "feature"]
2881 );
2882
2883 let jrec = RecordingRunner::replying(Reply::ok(""));
2884 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2885 .push("feature")
2886 .await
2887 .unwrap();
2888 let args = jrec.only_call().args_str();
2889 // `exact:` disables jj's glob matching so a `*` can't push every bookmark (H1).
2890 assert_eq!(&args[..4], &["git", "push", "-b", "exact:feature"]);
2891 }
2892
2893 // A flag-like branch is now rejected the same way on BOTH backends: the
2894 // facade converts the branch string into the validated newtype at the
2895 // boundary (`vcs_git::RefName` / `vcs_jj::BookmarkName`), so `--force` is
2896 // refused with a classifiable input-validation error BEFORE any process
2897 // spawns — no longer a per-backend difference.
2898 #[tokio::test]
2899 async fn push_flag_like_branch_rejected_before_spawn_on_both_backends() {
2900 use processkit::testing::RecordingRunner;
2901 let grec = RecordingRunner::replying(Reply::ok(""));
2902 let err = Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2903 .push("--force")
2904 .await
2905 .unwrap_err();
2906 assert!(err.is_invalid_input(), "git: got {err:?}");
2907 assert_eq!(grec.calls().len(), 0, "git: no process must have spawned");
2908
2909 let jrec = RecordingRunner::replying(Reply::ok(""));
2910 let err = Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2911 .push("--force")
2912 .await
2913 .unwrap_err();
2914 assert!(err.is_invalid_input(), "jj: got {err:?}");
2915 assert_eq!(jrec.calls().len(), 0, "jj: no process must have spawned");
2916 }
2917
2918 #[tokio::test]
2919 async fn fetch_from_names_the_remote_on_both_backends() {
2920 use processkit::testing::RecordingRunner;
2921 let grec = RecordingRunner::replying(Reply::ok(""));
2922 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2923 .fetch_from("upstream")
2924 .await
2925 .unwrap();
2926 assert_eq!(
2927 grec.only_call().args_str(),
2928 ["fetch", "--quiet", "upstream"]
2929 );
2930
2931 let jrec = RecordingRunner::replying(Reply::ok(""));
2932 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2933 .fetch_from("upstream")
2934 .await
2935 .unwrap();
2936 let args = jrec.only_call().args_str();
2937 // `exact:` disables jj's glob matching on the remote name (H1).
2938 assert_eq!(&args[..4], &["git", "fetch", "--remote", "exact:upstream"]);
2939 }
2940
2941 // git: untracked files count as uncommitted but not as *tracked* changes.
2942 #[tokio::test]
2943 async fn git_has_tracked_changes_ignores_untracked() {
2944 let dirty = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0")));
2945 assert!(dirty.has_tracked_changes().await.unwrap());
2946 // `--untracked-files=no` means git itself omits `??` entries; an empty
2947 // reply is what a tracked-clean tree returns.
2948 let clean = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok("")));
2949 assert!(!clean.has_tracked_changes().await.unwrap());
2950 }
2951
2952 // jj has no untracked concept — `has_tracked_changes` follows `@`'s emptiness.
2953 #[tokio::test]
2954 async fn jj_has_tracked_changes_follows_working_copy() {
2955 let dirty =
2956 jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\tfalse\t\"wip\"\n")));
2957 assert!(dirty.has_tracked_changes().await.unwrap());
2958 }
2959
2960 #[tokio::test]
2961 async fn conflicted_files_dispatches_per_backend() {
2962 let git =
2963 git_repo(ScriptedRunner::new().on(["git", "diff"], Reply::ok("a.rs\0b dir/c.rs\0")));
2964 assert_eq!(
2965 git.conflicted_files().await.unwrap(),
2966 [PathBuf::from("a.rs"), PathBuf::from("b dir/c.rs")]
2967 );
2968
2969 let jj = jj_repo(
2970 ScriptedRunner::new().on(["jj", "resolve"], Reply::ok("a.rs 2-sided conflict\n")),
2971 );
2972 assert_eq!(
2973 jj.conflicted_files().await.unwrap(),
2974 [PathBuf::from("a.rs")]
2975 );
2976 // The benign "no conflicts" non-zero exit still reads as an empty list.
2977 let clean = jj_repo(ScriptedRunner::new().on(
2978 ["jj", "resolve"],
2979 Reply::fail(2, "Error: No conflicts found at this revision"),
2980 ));
2981 assert!(clean.conflicted_files().await.unwrap().is_empty());
2982 }
2983
2984 #[test]
2985 fn merge_probe_is_clean() {
2986 assert!(MergeProbe::Clean.is_clean());
2987 assert!(!MergeProbe::Conflicts(vec!["a.rs".into()]).is_clean());
2988 }
2989
2990 // git try_merge, clean: probe merge, no MERGE_HEAD afterwards (the scripted
2991 // git-dir doesn't exist) → no abort, `Clean`.
2992 #[tokio::test]
2993 async fn git_try_merge_reports_clean_and_skips_needless_abort() {
2994 use processkit::testing::RecordingRunner;
2995 let rec = RecordingRunner::new(
2996 ScriptedRunner::new()
2997 .on(["git", "merge"], Reply::ok("Already up to date.\n"))
2998 .on(["git", "rev-parse"], Reply::ok("/vcs-core-no-such-git-dir")),
2999 );
3000 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3001 assert_eq!(repo.try_merge("other").await.unwrap(), MergeProbe::Clean);
3002 assert!(
3003 rec.calls()
3004 .iter()
3005 .all(|c| !c.args_str().contains(&"--abort".to_string())),
3006 "no merge to abort"
3007 );
3008 }
3009
3010 // git try_merge, conflict: conflicted paths are read BEFORE the abort (abort
3011 // clears the unmerged index), then the merge is aborted.
3012 #[tokio::test]
3013 async fn git_try_merge_collects_conflicts_then_aborts() {
3014 use processkit::testing::RecordingRunner;
3015 let rec = RecordingRunner::new(
3016 ScriptedRunner::new()
3017 // Order matters: ["merge","--abort"] must outrank the ["merge"] rule.
3018 .on(["git", "merge", "--abort"], Reply::ok(""))
3019 .on(
3020 ["git", "merge"],
3021 Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
3022 )
3023 .on(["git", "diff"], Reply::ok("a.rs\0")),
3024 );
3025 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3026 assert_eq!(
3027 repo.try_merge("other").await.unwrap(),
3028 MergeProbe::Conflicts(vec![PathBuf::from("a.rs")])
3029 );
3030 let calls = rec.calls();
3031 let diff_pos = calls.iter().position(|c| c.args_str()[0] == "diff");
3032 let abort_pos = calls
3033 .iter()
3034 .position(|c| c.args_str().contains(&"--abort".to_string()));
3035 assert!(diff_pos.unwrap() < abort_pos.unwrap(), "{calls:?}");
3036 }
3037
3038 // git try_merge: a failing rollback must propagate, not be reported as a
3039 // clean/conflicted probe.
3040 #[tokio::test]
3041 async fn git_try_merge_propagates_abort_failure() {
3042 let tmp = TempDir::new("probe-abort");
3043 std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
3044 let repo = git_repo(
3045 ScriptedRunner::new()
3046 .on(
3047 ["git", "merge", "--abort"],
3048 Reply::fail(128, "fatal: cannot abort"),
3049 )
3050 .on(["git", "merge"], Reply::ok(""))
3051 .on(
3052 ["git", "rev-parse"],
3053 Reply::ok(tmp.path().to_str().unwrap()),
3054 ),
3055 );
3056 assert!(repo.try_merge("other").await.is_err());
3057 }
3058
3059 // A thin shim over the standard `RecordingRunner`/`ScriptedRunner` that fires a
3060 // cancellation token the instant a command whose argv satisfies `trip` is
3061 // dispatched — modelling the client's `default_cancel_on` firing at a *precise*
3062 // point during `try_merge`. Needed because a plain scripted reply cannot express
3063 // this: an already-fired token short-circuits the *first* command (so the later
3064 // stages are never reached), and the harness has no mid-sequence hook. Choosing
3065 // `trip` pins the exact moment — at the rollback abort, or earlier, at the
3066 // in-progress probe.
3067 struct CancelWhen<R: ProcessRunner> {
3068 inner: R,
3069 token: CancellationToken,
3070 trip: fn(&processkit::Command) -> bool,
3071 }
3072
3073 #[async_trait::async_trait]
3074 impl<R: ProcessRunner> ProcessRunner for CancelWhen<R> {
3075 async fn output_string(
3076 &self,
3077 command: &processkit::Command,
3078 ) -> processkit::Result<processkit::ProcessResult<String>> {
3079 // Fire the client token as `trip` selects. A detached cleanup command
3080 // (fresh token) survives it; a token-inheriting one is cancelled. Firing
3081 // during command N's dispatch leaves the token fired for command N+1,
3082 // which `ScriptedRunner` short-circuits when the token is inherited.
3083 if (self.trip)(command) {
3084 self.token.cancel();
3085 }
3086 self.inner.output_string(command).await
3087 }
3088 }
3089
3090 fn arg_present(command: &processkit::Command, needle: &str) -> bool {
3091 command
3092 .arguments()
3093 .iter()
3094 .any(|a| a.to_str() == Some(needle))
3095 }
3096
3097 // The facade `try_merge`'s rollback must survive the client's cancellation
3098 // firing as it reaches cleanup — the git analogue of jj's unit test
3099 // `rollback_to_survives_fired_cancellation`. With the fix, the cleanup
3100 // `merge --abort` runs on a FRESH cancel token and completes (→ `Clean`); the
3101 // old token-inheriting abort would be cancelled and surface `Error::Cancelled`,
3102 // abandoning the staged trial merge — so this test fails on the pre-fix code.
3103 #[tokio::test]
3104 async fn git_try_merge_cleanup_survives_cancellation_fired_at_rollback() {
3105 use processkit::testing::RecordingRunner;
3106 let tmp = TempDir::new("probe-cancel");
3107 std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
3108 let token = CancellationToken::new();
3109 let runner = CancelWhen {
3110 inner: RecordingRunner::new(
3111 ScriptedRunner::new()
3112 .on(["git", "merge", "--abort"], Reply::ok(""))
3113 .on(["git", "merge"], Reply::ok(""))
3114 .on(
3115 ["git", "rev-parse"],
3116 Reply::ok(tmp.path().to_str().unwrap()),
3117 ),
3118 ),
3119 token: token.clone(),
3120 // Fire exactly as the rollback abort is issued.
3121 trip: |c| arg_present(c, "--abort"),
3122 };
3123 let repo = Repo::from_git(
3124 "/repo",
3125 "/repo",
3126 Git::with_runner(&runner).default_cancel_on(token),
3127 );
3128 // The probe merge is clean and MERGE_HEAD is present, so `try_merge` reaches
3129 // the cleanup abort — which runs on a fresh cancel token and completes
3130 // despite the client cancellation the shim fires at that exact moment.
3131 assert_eq!(repo.try_merge("other").await.unwrap(), MergeProbe::Clean);
3132 // ...and the detached abort really was issued, not skipped.
3133 assert!(
3134 runner
3135 .inner
3136 .calls()
3137 .iter()
3138 .any(|c| c.args_str().contains(&"--abort".to_string())),
3139 "the cleanup abort must have run: {:?}",
3140 runner.inner.calls()
3141 );
3142 }
3143
3144 // The gap R-01 caught: the cleanup DECISION — `is_merge_in_progress`, whose
3145 // `rev-parse --git-dir` used to inherit the client token — must also survive a
3146 // cancellation that fires DURING the probe, before the abort is ever reached.
3147 // Here the token fires as that `rev-parse --git-dir` is dispatched (the Ok
3148 // branch: the `--no-ff` probe merge staged a real merge, then the deadline
3149 // hit). With the fix the probe runs detached (fresh token) → sees MERGE_HEAD →
3150 // the detached abort runs → `Clean`. On the pre-fix code the probe's `?`
3151 // propagated `Cancelled` and the abort was skipped, abandoning the staged trial
3152 // merge — so this test fails there. Firing at the probe, not at `--abort`, is
3153 // exactly what the older `..._fired_at_rollback` test could not cover.
3154 #[tokio::test]
3155 async fn git_try_merge_cleanup_survives_cancellation_fired_at_probe() {
3156 use processkit::testing::RecordingRunner;
3157 let tmp = TempDir::new("probe-cancel-at-probe");
3158 std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
3159 let token = CancellationToken::new();
3160 let runner = CancelWhen {
3161 inner: RecordingRunner::new(
3162 ScriptedRunner::new()
3163 .on(["git", "merge", "--abort"], Reply::ok(""))
3164 .on(["git", "merge"], Reply::ok(""))
3165 .on(
3166 ["git", "rev-parse"],
3167 Reply::ok(tmp.path().to_str().unwrap()),
3168 ),
3169 ),
3170 token: token.clone(),
3171 // Fire as the in-progress probe's `rev-parse --git-dir` is dispatched —
3172 // strictly BEFORE the abort, unlike `..._fired_at_rollback`.
3173 trip: |c| arg_present(c, "--git-dir"),
3174 };
3175 let repo = Repo::from_git(
3176 "/repo",
3177 "/repo",
3178 Git::with_runner(&runner).default_cancel_on(token),
3179 );
3180 // Decision + abort both run on fresh tokens, so the probe still reports the
3181 // staged merge and the abort still undoes it despite the fired client token.
3182 assert_eq!(repo.try_merge("other").await.unwrap(), MergeProbe::Clean);
3183 assert!(
3184 runner
3185 .inner
3186 .calls()
3187 .iter()
3188 .any(|c| c.args_str().contains(&"--abort".to_string())),
3189 "cleanup abort must run even when cancellation fires at the probe: {:?}",
3190 runner.inner.calls()
3191 );
3192 }
3193
3194 // jj try_merge: op head captured first, probe runs, op restore always runs.
3195 #[tokio::test]
3196 async fn jj_try_merge_probes_and_restores() {
3197 use processkit::testing::RecordingRunner;
3198 let rec = RecordingRunner::new(
3199 ScriptedRunner::new()
3200 .on(["jj", "op", "log"], Reply::ok("op42\n"))
3201 .on(["jj", "op", "restore"], Reply::ok(""))
3202 .on(["jj", "new"], Reply::ok(""))
3203 .on(["jj", "log"], Reply::ok("1\n")) // is_conflicted → true
3204 .on(["jj", "resolve"], Reply::ok("a.rs 2-sided conflict\n")),
3205 );
3206 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
3207 assert_eq!(
3208 repo.try_merge("feature").await.unwrap(),
3209 MergeProbe::Conflicts(vec![PathBuf::from("a.rs")])
3210 );
3211 let calls = rec.calls();
3212 assert_eq!(calls[0].args_str()[..2], ["op", "log"]);
3213 assert_eq!(calls[1].args_str()[0], "new");
3214 let last = calls.last().unwrap().args_str();
3215 assert_eq!(last[..3], ["op", "restore", "op42"]);
3216 }
3217
3218 #[tokio::test]
3219 async fn jj_try_merge_clean_and_restore_failure() {
3220 // Conflict-free probe → Clean (no resolve call needed).
3221 let clean = jj_repo(
3222 ScriptedRunner::new()
3223 .on(["jj", "op", "log"], Reply::ok("op42\n"))
3224 .on(["jj", "op", "restore"], Reply::ok(""))
3225 .on(["jj", "new"], Reply::ok(""))
3226 .on(["jj", "log"], Reply::ok("0\n")),
3227 );
3228 assert_eq!(clean.try_merge("feature").await.unwrap(), MergeProbe::Clean);
3229
3230 // A failing op restore breaks the rollback guarantee → error, not Clean.
3231 let broken = jj_repo(
3232 ScriptedRunner::new()
3233 .on(["jj", "op", "log"], Reply::ok("op42\n"))
3234 .on(["jj", "op", "restore"], Reply::fail(1, "op not found"))
3235 .on(["jj", "new"], Reply::ok(""))
3236 .on(["jj", "log"], Reply::ok("0\n")),
3237 );
3238 assert!(broken.try_merge("feature").await.is_err());
3239 }
3240
3241 // jj try_merge shares `Jj::rollback_to`'s concurrency guard: if a concurrent jj
3242 // process advances the op log during the trial merge (jj records a `>= 2`-parent
3243 // "reconcile divergent operations" merge), the rollback is REFUSED rather than
3244 // clobbering that work — try_merge surfaces `Error::Rollback` instead of a stale,
3245 // untrustworthy `Clean`, and issues no `op restore`.
3246 #[tokio::test]
3247 async fn jj_try_merge_refuses_rollback_on_op_log_divergence() {
3248 use processkit::testing::RecordingRunner;
3249 let rec = RecordingRunner::new(
3250 ScriptedRunner::new()
3251 .on_sequence(
3252 ["jj", "op", "log"],
3253 [
3254 Reply::ok("op42\n"), // capture → pre
3255 Reply::ok("merge\t2\nop42\t1\n"), // probe → foreign reconcile merge
3256 ],
3257 )
3258 .on(["jj", "op", "restore"], Reply::ok(""))
3259 .on(["jj", "new"], Reply::ok(""))
3260 .on(["jj", "log"], Reply::ok("0\n")),
3261 );
3262 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
3263 let err = repo
3264 .try_merge("feature")
3265 .await
3266 .expect_err("a divergence must error, not report a stale Clean");
3267 assert!(
3268 matches!(err, Error::Rollback(vcs_jj::Rollback::SkippedDiverged)),
3269 "expected Error::Rollback(SkippedDiverged), got {err:?}"
3270 );
3271 assert!(
3272 rec.calls()
3273 .iter()
3274 .all(|c| c.args_str()[..2] != ["op", "restore"]),
3275 "the concurrent op must not be clobbered by a restore: {:?}",
3276 rec.calls()
3277 );
3278 }
3279
3280 // continue_in_progress with unresolved paths reports `Conflict` and must NOT
3281 // attempt the continue (git would hard-error).
3282 #[tokio::test]
3283 async fn git_continue_blocked_by_conflicts_does_not_act() {
3284 use processkit::testing::RecordingRunner;
3285 let rec =
3286 RecordingRunner::new(ScriptedRunner::new().on(["git", "diff"], Reply::ok("a.rs\0")));
3287 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3288 assert_eq!(
3289 repo.continue_in_progress().await.unwrap(),
3290 OperationState::Conflict
3291 );
3292 assert!(
3293 rec.calls().iter().all(|c| c.args_str()[0] == "diff"),
3294 "only the conflict probe may run: {:?}",
3295 rec.calls()
3296 );
3297 }
3298
3299 // A continued rebase that stops on the NEXT patch's conflict exits non-zero;
3300 // continue_in_progress must report that as `Conflict`, not as an error. The
3301 // first conflict probe must see a clean index (else continue is blocked), the
3302 // post-continue probe must see the new conflict — a stateful predicate
3303 // sequences the two `diff` replies.
3304 #[tokio::test]
3305 async fn git_continue_maps_rebase_re_conflict() {
3306 use std::sync::Arc as StdArc;
3307 use std::sync::atomic::{AtomicBool, Ordering};
3308 let tmp = TempDir::new("rebase-restop");
3309 std::fs::create_dir_all(tmp.path().join("rebase-merge")).unwrap();
3310 let seen_first_diff = StdArc::new(AtomicBool::new(false));
3311 let flag = StdArc::clone(&seen_first_diff);
3312 let repo = git_repo(
3313 ScriptedRunner::new()
3314 .when(
3315 move |cmd| {
3316 cmd.arguments().first().and_then(|a| a.to_str()) == Some("diff")
3317 && flag.swap(true, Ordering::SeqCst)
3318 },
3319 Reply::ok("a.rs\0"),
3320 )
3321 .on(["git", "diff"], Reply::ok(""))
3322 .on(
3323 ["git", "rev-parse"],
3324 Reply::ok(tmp.path().to_str().unwrap()),
3325 )
3326 .on(
3327 ["git", "rebase", "--continue"],
3328 Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
3329 ),
3330 );
3331 assert_eq!(
3332 repo.continue_in_progress().await.unwrap(),
3333 OperationState::Conflict
3334 );
3335 }
3336
3337 // abort_in_progress dispatches to `merge --abort` when MERGE_HEAD is present.
3338 #[tokio::test]
3339 async fn git_abort_dispatches_on_merge_in_progress() {
3340 use processkit::testing::RecordingRunner;
3341 let tmp = TempDir::new("abort");
3342 std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
3343 let rec = RecordingRunner::new(
3344 ScriptedRunner::new()
3345 .on(
3346 ["git", "rev-parse"],
3347 Reply::ok(tmp.path().to_str().unwrap()),
3348 )
3349 .on(["git", "merge", "--abort"], Reply::ok("")),
3350 );
3351 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3352 repo.abort_in_progress().await.unwrap();
3353 assert!(
3354 rec.calls()
3355 .iter()
3356 .any(|c| c.args_str() == ["merge", "--abort"]),
3357 "{:?}",
3358 rec.calls()
3359 );
3360 }
3361
3362 // git surfaces an interrupted op as on-disk state: in_progress_state returns
3363 // Merge when MERGE_HEAD is present and Rebase when a rebase dir is — the
3364 // documented asymmetry (git's conflict IS that paused state, never `Conflict`
3365 // from this method).
3366 #[tokio::test]
3367 async fn git_in_progress_state_maps_merge_and_rebase() {
3368 let merging = TempDir::new("inprog-merge");
3369 std::fs::write(merging.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
3370 let merge_repo = Repo::from_git(
3371 "/repo",
3372 "/repo",
3373 Git::with_runner(ScriptedRunner::new().on(
3374 ["git", "rev-parse"],
3375 Reply::ok(merging.path().to_str().unwrap()),
3376 )),
3377 );
3378 assert_eq!(
3379 merge_repo.in_progress_state().await.unwrap(),
3380 OperationState::Merge
3381 );
3382
3383 let rebasing = TempDir::new("inprog-rebase");
3384 std::fs::create_dir_all(rebasing.path().join("rebase-merge")).unwrap();
3385 let rebase_repo = Repo::from_git(
3386 "/repo",
3387 "/repo",
3388 Git::with_runner(ScriptedRunner::new().on(
3389 ["git", "rev-parse"],
3390 Reply::ok(rebasing.path().to_str().unwrap()),
3391 )),
3392 );
3393 assert_eq!(
3394 rebase_repo.in_progress_state().await.unwrap(),
3395 OperationState::Rebase
3396 );
3397 }
3398
3399 // T-044: the sequencer states are read from their own git-dir markers and,
3400 // crucially, a cherry-pick/revert marker is NOT mistaken for a merge (which
3401 // would then dispatch `merge --abort`). `snapshot().operation` must agree with
3402 // `in_progress_state`, since the watcher diffs the snapshot.
3403 #[tokio::test]
3404 async fn git_in_progress_state_maps_cherry_pick_revert_and_bisect() {
3405 for (marker, expected) in [
3406 ("CHERRY_PICK_HEAD", OperationState::CherryPick),
3407 ("REVERT_HEAD", OperationState::Revert),
3408 ("BISECT_LOG", OperationState::Bisect),
3409 ] {
3410 let gd = TempDir::new("inprog-seq");
3411 std::fs::write(gd.path().join(marker), "deadbeef\n").unwrap();
3412 let repo = Repo::from_git(
3413 "/repo",
3414 "/repo",
3415 // `snapshot` also runs `status --porcelain=v2 --branch`; a clean reply
3416 // lets it reach the operation probe. Both methods resolve the git dir
3417 // via `rev-parse`.
3418 Git::with_runner(
3419 ScriptedRunner::new()
3420 .on(["git", "status"], Reply::ok(""))
3421 .on(["git", "rev-parse"], Reply::ok(gd.path().to_str().unwrap())),
3422 ),
3423 );
3424 assert_eq!(
3425 repo.in_progress_state().await.unwrap(),
3426 expected,
3427 "{marker} must read as {expected:?}"
3428 );
3429 assert_eq!(
3430 repo.snapshot().await.unwrap().operation,
3431 expected,
3432 "snapshot().operation must agree for {marker}"
3433 );
3434 }
3435 }
3436
3437 // T-044: abort dispatches the state's OWN git command — the whole point of
3438 // keeping the states distinct. A cherry-pick must abort with `cherry-pick
3439 // --abort`, never `merge --abort`.
3440 #[tokio::test]
3441 async fn git_abort_dispatches_each_sequencer_command() {
3442 use processkit::testing::RecordingRunner;
3443 for (marker, argv) in [
3444 ("CHERRY_PICK_HEAD", vec!["cherry-pick", "--abort"]),
3445 ("REVERT_HEAD", vec!["revert", "--abort"]),
3446 ("BISECT_LOG", vec!["bisect", "reset"]),
3447 ] {
3448 let gd = TempDir::new("abort-seq");
3449 let marker_path = gd.path().join(marker);
3450 std::fs::write(&marker_path, "x\n").unwrap();
3451 // The abort command's ScriptedRunner side-effect: remove the marker so the
3452 // *post-call* `in_progress_state` re-probe reads `Clear`.
3453 let mp = marker_path.clone();
3454 let rec = RecordingRunner::new(
3455 ScriptedRunner::new()
3456 .on(["git", "rev-parse"], Reply::ok(gd.path().to_str().unwrap()))
3457 .when(
3458 move |cmd| {
3459 let a0 = cmd.arguments().first().and_then(|a| a.to_str());
3460 let is_abort = matches!(a0, Some("cherry-pick" | "revert" | "bisect"));
3461 if is_abort {
3462 let _ = std::fs::remove_file(&mp);
3463 }
3464 is_abort
3465 },
3466 Reply::ok(""),
3467 ),
3468 );
3469 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3470 assert_eq!(
3471 repo.abort_in_progress().await.unwrap(),
3472 OperationState::Clear,
3473 "{marker} abort must leave the repo Clear"
3474 );
3475 assert!(
3476 rec.calls().iter().any(|c| c.args_str() == argv),
3477 "{marker} must dispatch {argv:?}, got {:?}",
3478 rec.calls()
3479 );
3480 }
3481 }
3482
3483 // T-044: a bisect has no continue step — `continue_in_progress` must refuse it
3484 // with `Error::Unsupported`, not silently report it still in progress. And no
3485 // git mutation may run (only the conflict probe + git-dir resolution).
3486 #[tokio::test]
3487 async fn git_continue_on_bisect_is_unsupported_and_inert() {
3488 use processkit::testing::RecordingRunner;
3489 let gd = TempDir::new("continue-bisect");
3490 std::fs::write(gd.path().join("BISECT_LOG"), "x\n").unwrap();
3491 let rec = RecordingRunner::new(
3492 ScriptedRunner::new()
3493 .on(["git", "diff"], Reply::ok("")) // no conflicted paths
3494 .on(["git", "rev-parse"], Reply::ok(gd.path().to_str().unwrap())),
3495 );
3496 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3497 let err = repo
3498 .continue_in_progress()
3499 .await
3500 .expect_err("bisect continue must be refused");
3501 assert!(err.is_unsupported(), "expected Unsupported, got {err:?}");
3502 assert!(
3503 rec.calls()
3504 .iter()
3505 .all(|c| matches!(c.args_str()[0].as_str(), "diff" | "rev-parse")),
3506 "no git mutation may run for an unsupported continue: {:?}",
3507 rec.calls()
3508 );
3509 }
3510
3511 // T-044: a cherry-pick that continues cleanly commits and reports the post-call
3512 // state; the routing calls `cherry-pick --continue`, not a merge/rebase continue.
3513 #[tokio::test]
3514 async fn git_continue_dispatches_cherry_pick_continue() {
3515 use processkit::testing::RecordingRunner;
3516 let gd = TempDir::new("continue-cp");
3517 let marker = gd.path().join("CHERRY_PICK_HEAD");
3518 std::fs::write(&marker, "x\n").unwrap();
3519 let mp = marker.clone();
3520 let rec = RecordingRunner::new(
3521 ScriptedRunner::new()
3522 .on(["git", "diff"], Reply::ok("")) // nothing conflicted → not blocked
3523 .on(["git", "rev-parse"], Reply::ok(gd.path().to_str().unwrap()))
3524 .when(
3525 move |cmd| {
3526 let is_cont =
3527 cmd.arguments().first().and_then(|a| a.to_str()) == Some("cherry-pick");
3528 if is_cont {
3529 let _ = std::fs::remove_file(&mp); // completes the pick
3530 }
3531 is_cont
3532 },
3533 Reply::ok(""),
3534 ),
3535 );
3536 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3537 assert_eq!(
3538 repo.continue_in_progress().await.unwrap(),
3539 OperationState::Clear
3540 );
3541 assert!(
3542 rec.calls()
3543 .iter()
3544 .any(|c| c.args_str() == ["cherry-pick", "--continue"]),
3545 "must dispatch cherry-pick --continue: {:?}",
3546 rec.calls()
3547 );
3548 }
3549
3550 // T-065: an interrupted `git am` is driven forward with `am --continue`, not left
3551 // as a silent no-op. A clean continue finishes the mailbox and reports the
3552 // post-call `Clear`; the routing must call `am --continue`, never a rebase/merge
3553 // continue (an am shares `rebase-apply/` but marks it `applying`, M20).
3554 #[tokio::test]
3555 async fn git_continue_dispatches_am_continue() {
3556 use processkit::testing::RecordingRunner;
3557 let gd = TempDir::new("continue-am");
3558 // `git am` marks its `rebase-apply/` dir with an `applying` file.
3559 let apply = gd.path().join("rebase-apply");
3560 std::fs::create_dir_all(&apply).unwrap();
3561 std::fs::write(apply.join("applying"), "x\n").unwrap();
3562 let apply_dir = apply.clone();
3563 let rec = RecordingRunner::new(
3564 ScriptedRunner::new()
3565 .on(["git", "diff"], Reply::ok("")) // nothing conflicted → not blocked
3566 .on(["git", "rev-parse"], Reply::ok(gd.path().to_str().unwrap()))
3567 .when(
3568 move |cmd| {
3569 let is_am = cmd.arguments().first().and_then(|a| a.to_str()) == Some("am");
3570 if is_am {
3571 // A completed `am --continue` clears the whole
3572 // `rebase-apply/` dir, so the post-call probe reads `Clear`
3573 // (removing only `applying` would leave it looking like a
3574 // paused apply-backend rebase).
3575 let _ = std::fs::remove_dir_all(&apply_dir);
3576 }
3577 is_am
3578 },
3579 Reply::ok(""),
3580 ),
3581 );
3582 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3583 assert_eq!(
3584 repo.continue_in_progress().await.unwrap(),
3585 OperationState::Clear
3586 );
3587 assert!(
3588 rec.calls()
3589 .iter()
3590 .any(|c| c.args_str() == ["am", "--continue"]),
3591 "must dispatch am --continue: {:?}",
3592 rec.calls()
3593 );
3594 }
3595
3596 // T-065: an `am --continue` that stops on the NEXT patch's conflict exits
3597 // non-zero; continue_in_progress must map that to `Conflict`, not an error — the
3598 // same re-stop handling the rebase/cherry-pick/revert paths get. The first
3599 // conflict probe must see a clean index (else continue is blocked), the
3600 // post-continue probe the new conflict, so a stateful predicate sequences the two
3601 // `diff` replies (as in the rebase re-conflict test).
3602 #[tokio::test]
3603 async fn git_continue_maps_am_re_conflict() {
3604 use std::sync::Arc as StdArc;
3605 use std::sync::atomic::{AtomicBool, Ordering};
3606 let gd = TempDir::new("am-restop");
3607 let apply = gd.path().join("rebase-apply");
3608 std::fs::create_dir_all(&apply).unwrap();
3609 std::fs::write(apply.join("applying"), "x\n").unwrap();
3610 let seen_first_diff = StdArc::new(AtomicBool::new(false));
3611 let flag = StdArc::clone(&seen_first_diff);
3612 let repo = git_repo(
3613 ScriptedRunner::new()
3614 .when(
3615 move |cmd| {
3616 cmd.arguments().first().and_then(|a| a.to_str()) == Some("diff")
3617 && flag.swap(true, Ordering::SeqCst)
3618 },
3619 Reply::ok("a.rs\0"),
3620 )
3621 .on(["git", "diff"], Reply::ok(""))
3622 .on(["git", "rev-parse"], Reply::ok(gd.path().to_str().unwrap()))
3623 .on(
3624 ["git", "am", "--continue"],
3625 Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
3626 ),
3627 );
3628 assert_eq!(
3629 repo.continue_in_progress().await.unwrap(),
3630 OperationState::Conflict
3631 );
3632 }
3633
3634 // On an unborn git repo (no commits) diff_stat probes is_unborn and stats
3635 // against the empty tree instead of the unresolvable HEAD, so a fresh working
3636 // tree reports its additions rather than erroring. The empty-tree id is
3637 // resolved from git (`hash-object`), so it tracks the repo's object format
3638 // rather than being a hard-coded SHA-1 value.
3639 #[tokio::test]
3640 async fn git_diff_stat_unborn_uses_empty_tree() {
3641 use processkit::testing::RecordingRunner;
3642 // A SHA-256 repo's empty-tree id (64 hex): the value `hash-object` returns,
3643 // which `diff_stat` must then target verbatim.
3644 let oid = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321";
3645 let rec = RecordingRunner::new(
3646 ScriptedRunner::new()
3647 .on(["git", "rev-parse"], Reply::fail(1, "")) // HEAD unborn
3648 .on(["git", "hash-object"], Reply::ok(format!("{oid}\n")))
3649 .on(
3650 ["git", "diff", "--shortstat"],
3651 Reply::ok(" 1 file changed, 2 insertions(+)\n"),
3652 ),
3653 );
3654 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3655 let stat = repo.diff_stat().await.unwrap();
3656 assert_eq!(stat.insertions, 2);
3657 assert!(
3658 rec.calls()
3659 .iter()
3660 .any(|c| c.args_str() == ["diff", "--shortstat", oid, "--"]),
3661 "diff_stat should target the resolved empty tree on an unborn repo: {:?}",
3662 rec.calls()
3663 );
3664 }
3665
3666 // `Repo::diff` on git dispatches to `GitApi::diff(DiffSpec::WorkingTree)` — the
3667 // same argv shape `diff_text_builds_working_tree_args` pins in `vcs-git`
3668 // (`is_unborn` probe, then `diff HEAD --no-color --no-ext-diff -M
3669 // --src-prefix=a/ --dst-prefix=b/ --`) — and parses the git-format output into
3670 // `FileDiff`s.
3671 #[tokio::test]
3672 async fn git_diff_dispatches_working_tree_diff() {
3673 use processkit::testing::RecordingRunner;
3674 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
3675 let rec = RecordingRunner::new(
3676 ScriptedRunner::new()
3677 .on(["git", "rev-parse"], Reply::ok("deadbeef\n")) // HEAD resolves
3678 .on(["git", "diff"], Reply::ok(out)),
3679 );
3680 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3681 let files = repo.diff().await.unwrap();
3682 assert_eq!(files.len(), 1);
3683 assert_eq!(files[0].path, Path::new("m"));
3684 assert_eq!(files[0].change, ChangeKind::Modified);
3685 assert!(
3686 rec.calls().iter().any(|c| c.args_str()
3687 == [
3688 "diff",
3689 "HEAD",
3690 "--no-color",
3691 "--no-ext-diff",
3692 "-M",
3693 "--src-prefix=a/",
3694 "--dst-prefix=b/",
3695 "--",
3696 ]),
3697 "diff should target HEAD (working tree, same scope as diff_stat): {:?}",
3698 rec.calls()
3699 );
3700 }
3701
3702 // On an unborn git repo `Repo::diff` targets the resolved empty tree instead of
3703 // the unresolvable `HEAD` — same fallback `diff_stat` uses, exercised here
3704 // through `GitApi::diff`'s own internal `is_unborn` probe rather than the
3705 // manual one `git_backend::diff_stat` performs.
3706 #[tokio::test]
3707 async fn git_diff_unborn_uses_empty_tree() {
3708 let oid = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321";
3709 let repo = git_repo(
3710 ScriptedRunner::new()
3711 .on(["git", "rev-parse"], Reply::fail(1, "")) // HEAD unborn
3712 .on(["git", "hash-object"], Reply::ok(format!("{oid}\n")))
3713 .on(["git", "diff", oid], Reply::ok("")),
3714 );
3715 let files = repo.diff().await.unwrap();
3716 assert!(files.is_empty());
3717 }
3718
3719 // `Repo::diff` on jj dispatches to `JjApi::diff(DiffSpec::WorkingTree)`, which
3720 // targets `@` (vs its parent) — the same scope `diff_stat` targets via
3721 // `rev("@")` — and parses the `--git`-format output into `FileDiff`s.
3722 #[tokio::test]
3723 async fn jj_diff_dispatches_at_change() {
3724 use processkit::testing::RecordingRunner;
3725 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
3726 let rec = RecordingRunner::new(ScriptedRunner::new().on(["jj", "diff"], Reply::ok(out)));
3727 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
3728 let files = repo.diff().await.unwrap();
3729 assert_eq!(files.len(), 1);
3730 assert_eq!(files[0].path, Path::new("m"));
3731 assert_eq!(files[0].change, ChangeKind::Modified);
3732 assert!(
3733 rec.calls()
3734 .iter()
3735 .any(|c| c.args_str() == ["diff", "-r", "@", "--git", "--color", "never"]),
3736 "diff should target @ vs its parent (same scope as diff_stat): {:?}",
3737 rec.calls()
3738 );
3739 }
3740
3741 // R-01 (T-068 review): `Repo::diff()` must stay consistent with the already-
3742 // shipped `Repo::diff_stat()` precedent on jj's working-copy-snapshot
3743 // behaviour — neither passes `--ignore-working-copy`, so both let jj snapshot
3744 // the working copy (record an operation) exactly the same way. Pinned
3745 // hermetically so the two can't silently drift apart (e.g. one gaining the
3746 // flag while the other doesn't, which would make the "same scope as
3747 // diff_stat" doc claim false). See `Repo::diff`/`Repo::diff_stat`'s rustdoc
3748 // for why `--ignore-working-copy` is deliberately not used by either: it would
3749 // make the read blind to any not-yet-snapshotted working-tree edit.
3750 #[tokio::test]
3751 async fn jj_diff_and_diff_stat_snapshot_the_working_copy_consistently() {
3752 use processkit::testing::RecordingRunner;
3753
3754 let diff_rec =
3755 RecordingRunner::new(ScriptedRunner::new().on(["jj", "diff"], Reply::ok("")));
3756 let diff_repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&diff_rec));
3757 diff_repo.diff().await.unwrap();
3758
3759 let stat_rec = RecordingRunner::new(
3760 ScriptedRunner::new().on(["jj", "diff"], Reply::ok("0 files changed\n")),
3761 );
3762 let stat_repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&stat_rec));
3763 stat_repo.diff_stat().await.unwrap();
3764
3765 for (name, rec) in [("diff", &diff_rec), ("diff_stat", &stat_rec)] {
3766 assert!(
3767 rec.calls()
3768 .iter()
3769 .all(|c| !c.args_str().iter().any(|a| a == "--ignore-working-copy")),
3770 "{name} must let jj snapshot the working copy (no --ignore-working-copy), \
3771 consistent with its sibling: {:?}",
3772 rec.calls()
3773 );
3774 }
3775 }
3776
3777 // `Repo::log` on git maps `GitApi::log`'s typed `Commit` (hash/author/date/
3778 // subject) onto the facade `Commit`, with author/date populated.
3779 #[tokio::test]
3780 async fn git_log_maps_commit_fields() {
3781 let repo = git_repo(ScriptedRunner::new().on(
3782 ["git", "log"],
3783 Reply::ok("deadbeef\u{1f}dead\u{1f}Jane\u{1f}2026-05-31T10:00:00+00:00\u{1f}Fix bug\0"),
3784 ));
3785 let commits = repo.log("HEAD", 10).await.unwrap();
3786 assert_eq!(commits.len(), 1);
3787 assert_eq!(commits[0].id, "deadbeef");
3788 assert_eq!(commits[0].description, "Fix bug");
3789 assert_eq!(commits[0].author.as_deref(), Some("Jane"));
3790 assert_eq!(
3791 commits[0].date.as_deref(),
3792 Some("2026-05-31T10:00:00+00:00")
3793 );
3794 }
3795
3796 // `Repo::log` on jj maps `JjApi::log`'s typed `Change` (change-id/commit-id/
3797 // empty/description) onto the facade `Commit` — author/date stay `None`, since
3798 // jj's typed log doesn't surface them.
3799 #[tokio::test]
3800 async fn jj_log_maps_change_with_no_author_or_date() {
3801 let repo = jj_repo(ScriptedRunner::new().on(
3802 ["jj", "log"],
3803 Reply::ok("kztuxlro\t38e00654\tfalse\t\"wip\"\n"),
3804 ));
3805 let commits = repo.log("@", 10).await.unwrap();
3806 assert_eq!(commits.len(), 1);
3807 assert_eq!(commits[0].id, "38e00654");
3808 assert_eq!(commits[0].description, "wip");
3809 assert_eq!(commits[0].author, None);
3810 assert_eq!(commits[0].date, None);
3811 }
3812
3813 // `Repo::annotate` maps git blame's richer per-line metadata into the common
3814 // DTO and forwards the optional revspec before the pathspec separator.
3815 #[tokio::test]
3816 async fn git_annotate_maps_blame_and_forwards_revspec() {
3817 use processkit::testing::RecordingRunner;
3818
3819 let sha = "a".repeat(40);
3820 let rec = RecordingRunner::replying(Reply::ok(format!(
3821 "{sha} 3 7 1\nauthor Jane\nauthor-time 1717700000\nauthor-tz +0200\n\tlet x = 1;\n"
3822 )));
3823 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3824 let lines = repo.annotate("src/lib.rs", Some("HEAD~1")).await.unwrap();
3825
3826 assert_eq!(lines.len(), 1);
3827 assert_eq!(lines[0].id, sha);
3828 assert_eq!(lines[0].line, 7);
3829 assert_eq!(lines[0].content, "let x = 1;");
3830 assert_eq!(lines[0].author.as_deref(), Some("Jane"));
3831 assert_eq!(lines[0].date, Some(1_717_700_000));
3832 assert_eq!(
3833 rec.only_call().args_str(),
3834 ["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
3835 );
3836 }
3837
3838 // jj's annotation has the common id/line/content fields but no author/date;
3839 // it takes a plain path after `--` and keeps jj's default snapshotting mode.
3840 #[tokio::test]
3841 async fn jj_annotate_maps_annotation_and_forwards_revset() {
3842 use processkit::testing::RecordingRunner;
3843
3844 let rec = RecordingRunner::replying(Reply::ok("kz\tline one\n"));
3845 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
3846 let lines = repo.annotate("src/lib.rs", Some("@-")).await.unwrap();
3847
3848 assert_eq!(lines.len(), 1);
3849 assert_eq!(lines[0].id, "kz");
3850 assert_eq!(lines[0].line, 1);
3851 assert_eq!(lines[0].content, "line one");
3852 assert_eq!(lines[0].author, None);
3853 assert_eq!(lines[0].date, None);
3854 let args = rec.only_call().args_str();
3855 assert_eq!(&args[..4], ["file", "annotate", "-r", "@-"]);
3856 assert_eq!(&args[args.len() - 2..], ["--", "src/lib.rs"]);
3857 assert!(
3858 !args.iter().any(|arg| arg == "--ignore-working-copy"),
3859 "annotate must use jj's live, snapshotting working-copy mode: {args:?}"
3860 );
3861 }
3862
3863 // `Repo::show_file` on git dispatches to `GitApi::show_file` and forwards its
3864 // content verbatim.
3865 #[tokio::test]
3866 async fn git_show_file_dispatches_to_git_backend() {
3867 let repo = git_repo(ScriptedRunner::new().on(["git", "show"], Reply::ok("fn main() {}\n")));
3868 let content = repo.show_file("HEAD", "src/main.rs").await.unwrap();
3869 assert_eq!(content, "fn main() {}\n");
3870 }
3871
3872 // `Repo::show_file` on jj dispatches to `JjApi::file_show` and forwards its
3873 // content verbatim.
3874 #[tokio::test]
3875 async fn jj_show_file_dispatches_to_jj_backend() {
3876 let repo =
3877 jj_repo(ScriptedRunner::new().on(["jj", "file", "show"], Reply::ok("fn main() {}\n")));
3878 let content = repo.show_file("@-", "src/main.rs").await.unwrap();
3879 assert_eq!(content, "fn main() {}\n");
3880 }
3881
3882 // On jj, abort/continue are reporting no-ops (nothing is ever paused).
3883 #[tokio::test]
3884 async fn jj_abort_and_continue_are_reporting_noops() {
3885 let conflicted = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("1\n")));
3886 assert_eq!(
3887 conflicted.abort_in_progress().await.unwrap(),
3888 OperationState::Conflict
3889 );
3890 let clear = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("0\n")));
3891 assert_eq!(
3892 clear.continue_in_progress().await.unwrap(),
3893 OperationState::Clear
3894 );
3895 }
3896
3897 // jj records conflicts on the change; the facade maps that to `Conflict`.
3898 #[tokio::test]
3899 async fn jj_in_progress_state_maps_conflict() {
3900 let conflicted = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("1\n")));
3901 assert_eq!(
3902 conflicted.in_progress_state().await.unwrap(),
3903 OperationState::Conflict
3904 );
3905 let clear = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("0\n")));
3906 assert_eq!(
3907 clear.in_progress_state().await.unwrap(),
3908 OperationState::Clear
3909 );
3910 }
3911
3912 // `&dyn VcsRepo` must dispatch through the real inherent methods (a delegating
3913 // body that recursed would stack-overflow here instead of returning).
3914 #[tokio::test]
3915 async fn vcs_repo_trait_object_dispatches() {
3916 let repo = git_repo(
3917 ScriptedRunner::new()
3918 .on(["git", "symbolic-ref"], Reply::ok("main\n"))
3919 .on(["git", "show-ref"], Reply::ok("")),
3920 );
3921 let dynamic: &dyn VcsRepo = &repo;
3922 assert_eq!(dynamic.kind(), BackendKind::Git);
3923 assert_eq!(
3924 dynamic.current_branch().await.unwrap().as_deref(),
3925 Some("main")
3926 );
3927 // Exercise a reference-argument async method through `&dyn` — pins the
3928 // async_trait lifetime capture the macro relies on (no-arg calls don't).
3929 assert!(dynamic.branch_exists("main").await.unwrap());
3930 }
3931
3932 // When the backend has no native trunk (git `origin/HEAD` unset), the facade
3933 // falls back to a local `main`, then `master`.
3934 #[tokio::test]
3935 async fn trunk_falls_back_to_main() {
3936 let repo = git_repo(
3937 ScriptedRunner::new()
3938 .on(["git", "symbolic-ref"], Reply::fail(1, "")) // origin/HEAD unset → None
3939 .on(["git", "show-ref"], Reply::ok("")), // branch_exists("main") → exit 0
3940 );
3941 assert_eq!(repo.trunk().await.unwrap().as_deref(), Some("main"));
3942 }
3943
3944 #[test]
3945 fn error_classifiers_recognise_markers() {
3946 let conflict = Error::Vcs(processkit::Error::exit(
3947 "git",
3948 1,
3949 "CONFLICT (content): Merge conflict in a.rs",
3950 "",
3951 ));
3952 assert!(conflict.is_merge_conflict());
3953 assert!(!conflict.is_nothing_to_commit());
3954 // A non-Vcs error classifies as none of them.
3955 assert!(!Error::NotARepository("/x".into()).is_merge_conflict());
3956 }
3957}
3958
3959// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
3960#[doc = include_str!("../docs/core.md")]
3961#[allow(rustdoc::broken_intra_doc_links)]
3962pub mod guide {
3963 #[doc = include_str!("../docs/cookbook.md")]
3964 #[allow(rustdoc::broken_intra_doc_links)]
3965 pub mod cookbook {}
3966 #[doc = include_str!("../docs/process-model.md")]
3967 #[allow(rustdoc::broken_intra_doc_links)]
3968 pub mod process_model {}
3969 #[doc = include_str!("../docs/positioning.md")]
3970 #[allow(rustdoc::broken_intra_doc_links)]
3971 pub mod positioning {}
3972 #[doc = include_str!("../docs/stability.md")]
3973 #[allow(rustdoc::broken_intra_doc_links)]
3974 pub mod stability {}
3975}