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