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::open(".")?; // 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//! - **[`detect`]** — 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::open`] (real job-backed runner) or build it over an explicit client
52//! with [`Repo::from_git`] / [`Repo::from_jj`] (the test seam). Re-anchor it to
53//! another directory cheaply with [`Repo::at`] — the backend is shared behind an
54//! `Arc`, so threading work across worktrees never re-detects or rebuilds the
55//! client. Inspect it with [`kind`](Repo::kind) / [`root`](Repo::root) /
56//! [`cwd`](Repo::cwd).
57//! - **[`VcsRepo`]** — the same common surface as an object-safe trait, so a
58//! consumer can hold a `Box<dyn VcsRepo>` / `&dyn VcsRepo` without naming the
59//! [`ProcessRunner`] generic. Every method mirrors the like-named inherent method
60//! on [`Repo`]; it adds nothing but the abstraction boundary.
61//!
62//! ## The common operations
63//!
64//! All on [`Repo`] (and [`VcsRepo`]), dir-free, dispatched per backend:
65//!
66//! - **Refs** — [`current_branch`](Repo::current_branch),
67//! [`trunk`](Repo::trunk), [`local_branches`](Repo::local_branches),
68//! [`branch_exists`](Repo::branch_exists),
69//! [`delete_branch`](Repo::delete_branch),
70//! [`rename_branch`](Repo::rename_branch) (branch on git, bookmark on jj).
71//! - **Status** — [`changed_files`](Repo::changed_files),
72//! [`diff_stat`](Repo::diff_stat),
73//! [`has_uncommitted_changes`](Repo::has_uncommitted_changes),
74//! [`has_tracked_changes`](Repo::has_tracked_changes),
75//! [`conflicted_files`](Repo::conflicted_files), and
76//! [`snapshot`](Repo::snapshot) — a **batched** prompt/status-bar read of the
77//! lot in one or two spawns.
78//! - **Mutations** — [`commit_paths`](Repo::commit_paths) (partial commit),
79//! [`fetch`](Repo::fetch) / [`fetch_from`](Repo::fetch_from) /
80//! [`fetch_branch`](Repo::fetch_branch) /
81//! [`push`](Repo::push), [`checkout`](Repo::checkout),
82//! [`rebase`](Repo::rebase).
83//! - **Merge & operation state** — [`try_merge`](Repo::try_merge) (a
84//! trace-free conflict probe → [`MergeProbe`]),
85//! [`in_progress_state`](Repo::in_progress_state) /
86//! [`abort_in_progress`](Repo::abort_in_progress) /
87//! [`continue_in_progress`](Repo::continue_in_progress) → [`OperationState`].
88//! - **Worktrees / workspaces** — [`list_worktrees`](Repo::list_worktrees),
89//! [`create_worktree`](Repo::create_worktree),
90//! [`remove_worktree`](Repo::remove_worktree), and the **synchronous**
91//! [`cleanup_worktree_blocking`](Repo::cleanup_worktree_blocking) for a `Drop`
92//! guard that cannot `.await`.
93//!
94//! Because the backends genuinely diverge in places, several common methods carry
95//! a documented asymmetry (e.g. `upstream`/`ahead`/`behind` are always `None` on
96//! jj; [`diff_stat`](Repo::diff_stat) excludes untracked files on git but not jj;
97//! [`in_progress_state`](Repo::in_progress_state) never returns `Conflict` on git).
98//! The method docs spell each one out — the facade unifies the *shape*, not away
99//! the truth.
100//!
101//! ## The escape hatches
102//!
103//! Tool-specific work reaches the underlying typed clients without adding
104//! `vcs-git`/`vcs-jj` as separate dependencies (both are re-exported):
105//! [`git_at`](Repo::git_at) / [`jj_at`](Repo::jj_at) hand out a cwd-bound view
106//! ([`GitAt`] / [`JjAt`], `dir` dropped); the raw
107//! [`git`](Repo::git) / [`jj`](Repo::jj) hand out a borrow of the client itself.
108//! Each returns `None` for the other backend.
109//!
110//! ## What's deliberately *not* unified
111//!
112//! Three families stay off the common surface because no honest single shape
113//! exists — reach them through the bound handles:
114//!
115//! - **Full `merge`** — jj composes `new` + `squash` + bookmark moves; git runs a
116//! single command. Only the *conflict probe* unifies, as
117//! [`try_merge`](Repo::try_merge).
118//! - **Operation rollback** — jj's `op restore` has no faithful git analogue; use
119//! [`Jj::transaction`](vcs_jj::Jj::transaction) on the jj client.
120//! - **Range / revset queries** — commit counts and diff stats over a range: git's
121//! `a..b` and jj's revsets aren't interchangeable, so neither is forced onto a
122//! shared signature.
123//!
124//! # Recipes
125//!
126//! Probe a merge for conflicts (trace-free), or spin up a worktree:
127//!
128//! ```no_run
129//! use std::path::Path;
130//! use vcs_core::{MergeProbe, Repo, WorktreeCreate};
131//! # async fn demo(repo: &Repo) -> vcs_core::Result<()> {
132//! match repo.try_merge("feature").await? {
133//! MergeProbe::Clean => println!("merges cleanly"),
134//! MergeProbe::Conflicts(paths) => println!("would conflict in {paths:?}"),
135//! _ => {} // #[non_exhaustive]
136//! }
137//! let wt = repo
138//! .create_worktree(WorktreeCreate::new(Path::new("/tmp/feat"), "feature").base("main"))
139//! .await?;
140//! # let _ = wt;
141//! # Ok(()) }
142//! ```
143//!
144//! # Testing
145//!
146//! There is **no mock feature** on the facade traits — the runner is the seam.
147//! Build a [`Repo`] over a fake [`ProcessRunner`] with [`Repo::from_git`] /
148//! [`Repo::from_jj`] (e.g. a [`ScriptedRunner`](processkit::testing::ScriptedRunner)
149//! replying to canned argv), so the *real* per-backend dispatch, argv-building and
150//! parsing run against canned output — exactly what a mocked `VcsRepo` would skip.
151//! The cross-cutting patterns live in
152//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
153//!
154//! ```no_run
155//! use processkit::testing::{Reply, ScriptedRunner};
156//! use vcs_core::{vcs_git::Git, Repo};
157//! # async fn demo() -> vcs_core::Result<()> {
158//! let runner = ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0"));
159//! let repo = Repo::from_git("/repo", "/repo", Git::with_runner(runner));
160//! assert!(repo.has_uncommitted_changes().await?);
161//! # Ok(()) }
162//! ```
163//!
164//! # In-depth guide
165//!
166//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
167//! from `docs/`. See the [`guide`] module, which walks every operation in depth
168//! and hosts the cross-cutting sub-guides: a [`cookbook`](guide::cookbook) of
169//! end-to-end flows, the [`process_model`](guide::process_model) (job containment,
170//! errors, cancellation), [`positioning`](guide::positioning) (facade-vs-raw-client
171//! and the three call shapes), and the [`stability`](guide::stability) contract.
172
173use std::path::{Path, PathBuf};
174use std::sync::Arc;
175
176use processkit::{JobRunner, ProcessRunner};
177use vcs_git::{Git, GitAt};
178use vcs_jj::{Jj, JjAt};
179
180mod dto;
181mod error;
182mod git_backend;
183mod jj_backend;
184
185pub use dto::{
186 BackendKind, BranchDelete, ChangeKind, CreateOutcome, DiffStat, FileChange, MergeProbe,
187 OperationState, RepoSnapshot, UpstreamTracking, WorktreeCreate, WorktreeCreatePartial,
188 WorktreeInfo, WorktreeRemove,
189};
190pub use error::{Error, Result};
191
192// Re-export the underlying typed clients so a consumer depending only on
193// `vcs-core` can still reach raw, tool-specific operations — and their types
194// (`GitApi`, `JjApi`, `WorktreeAdd`, `JjFileset`, …) — without adding `vcs-git`
195// / `vcs-jj` as separate dependencies. [`Repo::git`] / [`Repo::jj`] hand out
196// borrows of these clients; the consumer decides, per call, whether to go
197// through the facade or straight to the tool.
198pub use vcs_git;
199pub use vcs_jj;
200// Re-export `processkit` itself so a `vcs-core`-only consumer can name the
201// wrapped error directly — `match err { Error::Vcs(vcs_core::processkit::Error::
202// Timeout { .. }) => … }` — and reach `Outcome`/`CancellationToken`/… without
203// adding `processkit` as a separate dependency. (`Error::Vcs` carries a
204// `processkit::Error`; the classifiers below cover the common branches.)
205pub use processkit;
206// Also surfaced at the crate root so the token a `default_cancel_on` client takes
207// (built via `Git`/`Jj`, then passed to `Repo::from_git`/`from_jj`) is one name
208// away. (Cancellation is core in processkit 0.10 — always available, no feature.)
209pub use processkit::CancellationToken;
210
211/// The result of [`detect`]: which backend, and the repository root it was found
212/// at.
213#[derive(Debug, Clone, PartialEq, Eq)]
214#[non_exhaustive]
215pub struct Located {
216 /// The detected backend.
217 pub kind: BackendKind,
218 /// The directory holding `.git`/`.jj` — the worktree root.
219 pub root: PathBuf,
220}
221
222/// Walk up from `start` to the filesystem root looking for a repository. A `.jj`
223/// directory wins over `.git` (colocated repos are driven through jj); `.git` may
224/// be a directory or a gitlink file (a linked worktree/submodule). Pure
225/// filesystem probing — no subprocess.
226///
227/// `start` is walked exactly as given via [`Path::parent`], so pass an **absolute**
228/// path to search ancestors — a relative path like `"."` has no ancestor chain
229/// and only its own directory is checked. ([`Repo::open`] absolutises for you.)
230pub fn detect(start: &Path) -> Option<Located> {
231 let mut current = Some(start);
232 while let Some(dir) = current {
233 if is_jj_marker(&dir.join(".jj")) {
234 return Some(Located {
235 kind: BackendKind::Jj,
236 root: dir.to_path_buf(),
237 });
238 }
239 if is_git_marker(&dir.join(".git")) {
240 return Some(Located {
241 kind: BackendKind::Git,
242 root: dir.to_path_buf(),
243 });
244 }
245 current = dir.parent();
246 }
247 None
248}
249
250/// Whether `path` (a candidate `.jj`) is a real jj repository marker — a `.jj`
251/// **directory** that contains a **`repo`** entry (the store: a *directory* in a
252/// repo's main workspace / a colocated repo, a *file* pointer in a secondary
253/// workspace). A stray/empty directory merely *named* `.jj` (e.g. a leftover
254/// `mkdir .jj`) has no `repo` entry, so it can't shadow a healthy `.git` repo in the
255/// same or a higher directory (M19). Symmetric with [`is_git_marker`]: both require a
256/// *valid* marker, not mere existence.
257fn is_jj_marker(path: &Path) -> bool {
258 path.is_dir() && path.join("repo").exists()
259}
260
261/// Whether `path` (a candidate `.git`) is a real git repository marker — a `.git`
262/// **directory**, or a **gitlink file** (a linked worktree / submodule) whose
263/// content starts with `gitdir:`. A stray/garbage file merely *named* `.git` is
264/// rejected, so it can't shadow a real repository higher up the tree, and a binary
265/// or unreadable file is rejected too (the read fails → `false`). Symmetric with
266/// [`is_jj_marker`]: both require a *valid* marker, not mere existence.
267fn is_git_marker(path: &Path) -> bool {
268 use std::io::Read;
269 match std::fs::metadata(path) {
270 Ok(meta) if meta.is_dir() => true,
271 Ok(meta) if meta.is_file() => {
272 // A gitlink file is tiny (`gitdir: <path>\n`), so read only a small
273 // prefix: `detect` walks *up to the filesystem root*, so a huge/garbage
274 // file merely named `.git` in an ancestor we don't own must not force an
275 // unbounded read. `read_to_end` loops over short reads (unlike a single
276 // `read`, which the `Read` contract lets return fewer bytes), and
277 // `from_utf8_lossy` tolerates a binary file or a multibyte char split at
278 // the cap — the `gitdir:` marker is ASCII and within the first bytes.
279 let Ok(file) = std::fs::File::open(path) else {
280 return false;
281 };
282 let mut buf = Vec::new();
283 let _ = file.take(32).read_to_end(&mut buf);
284 String::from_utf8_lossy(&buf)
285 .trim_start()
286 .starts_with("gitdir:")
287 }
288 _ => false,
289 }
290}
291
292/// The per-tool client behind a [`Repo`]. Shared via `Arc` so [`Repo::at`] can
293/// re-anchor the cwd cheaply without rebuilding the client.
294enum Backend<R: ProcessRunner> {
295 Git(Arc<Git<R>>),
296 Jj(Arc<Jj<R>>),
297}
298
299impl<R: ProcessRunner> Backend<R> {
300 fn shared(&self) -> Self {
301 match self {
302 Backend::Git(g) => Backend::Git(Arc::clone(g)),
303 Backend::Jj(j) => Backend::Jj(Arc::clone(j)),
304 }
305 }
306}
307
308/// A cwd-bound, backend-agnostic VCS handle. Operations run against the bound
309/// directory ([`cwd`](Repo::cwd)); use [`at`](Repo::at) to get a sibling handle
310/// bound elsewhere.
311pub struct Repo<R: ProcessRunner = JobRunner> {
312 root: PathBuf,
313 cwd: PathBuf,
314 backend: Backend<R>,
315}
316
317impl Repo<JobRunner> {
318 /// Detect the repository at or above `dir` and open a handle bound to `dir`,
319 /// using the real job-backed runner. Errors with
320 /// [`Error::NotARepository`] when no `.git`/`.jj` is found.
321 pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
322 // Absolutise first: `detect` walks parents, and a relative path like "."
323 // has no real ancestor chain (`Path::new(".").parent()` is `""`, then
324 // `None`), so a relative input would never find a repo above the cwd.
325 let dir = std::path::absolute(dir.as_ref())?;
326 let located = detect(&dir).ok_or_else(|| Error::NotARepository(dir.clone()))?;
327 let backend = match located.kind {
328 BackendKind::Git => Backend::Git(Arc::new(Git::new())),
329 BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
330 };
331 Ok(Repo {
332 root: located.root,
333 cwd: dir,
334 backend,
335 })
336 }
337}
338
339impl<R: ProcessRunner> Repo<R> {
340 /// Build a git-backed handle from an explicit client — for a custom runner
341 /// (e.g. a test seam) or a pre-configured [`Git`].
342 pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self {
343 Repo {
344 root: root.into(),
345 cwd: cwd.into(),
346 backend: Backend::Git(Arc::new(client)),
347 }
348 }
349
350 /// Build a jj-backed handle from an explicit client.
351 pub fn from_jj(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>) -> Self {
352 Repo {
353 root: root.into(),
354 cwd: cwd.into(),
355 backend: Backend::Jj(Arc::new(client)),
356 }
357 }
358
359 /// Which backend drives this handle.
360 pub fn kind(&self) -> BackendKind {
361 match &self.backend {
362 Backend::Git(_) => BackendKind::Git,
363 Backend::Jj(_) => BackendKind::Jj,
364 }
365 }
366
367 /// The repository root detected at open time.
368 pub fn root(&self) -> &Path {
369 &self.root
370 }
371
372 /// The directory operations run against.
373 pub fn cwd(&self) -> &Path {
374 &self.cwd
375 }
376
377 /// A sibling handle bound to `dir`, sharing this handle's client and root.
378 pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
379 Repo {
380 root: self.root.clone(),
381 cwd: dir.into(),
382 backend: self.backend.shared(),
383 }
384 }
385
386 /// The underlying [`Git`] client, or `None` when jj-backed — an escape hatch
387 /// to git-only operations not on the common surface.
388 pub fn git(&self) -> Option<&Git<R>> {
389 match &self.backend {
390 Backend::Git(g) => Some(g.as_ref()),
391 Backend::Jj(_) => None,
392 }
393 }
394
395 /// The underlying [`Jj`] client, or `None` when git-backed.
396 pub fn jj(&self) -> Option<&Jj<R>> {
397 match &self.backend {
398 Backend::Jj(j) => Some(j.as_ref()),
399 Backend::Git(_) => None,
400 }
401 }
402
403 /// The git client bound to this handle's [`cwd`](Repo::cwd) — a [`GitAt`] whose
404 /// methods omit the `dir` argument — or `None` when jj-backed. The dir-free
405 /// counterpart of [`git`](Repo::git): `repo.git_at()?.merge_continue().await?`.
406 ///
407 /// The returned view borrows `self`. To work in another worktree, **bind the
408 /// re-anchored handle first** (the view can't outlive a temporary
409 /// [`at`](Repo::at)):
410 ///
411 /// ```no_run
412 /// # async fn f(repo: vcs_core::Repo, wt: &std::path::Path) -> vcs_core::Result<()> {
413 /// let wt = repo.at(wt); // owns the re-anchored handle
414 /// let git = wt.git_at().unwrap();
415 /// git.fetch().await?;
416 /// # Ok(()) }
417 /// ```
418 pub fn git_at(&self) -> Option<GitAt<'_, R>> {
419 match &self.backend {
420 Backend::Git(g) => Some(g.at(&self.cwd)),
421 Backend::Jj(_) => None,
422 }
423 }
424
425 /// The jj client bound to this handle's [`cwd`](Repo::cwd) — a [`JjAt`] whose
426 /// methods omit the `dir` argument — or `None` when git-backed. The dir-free
427 /// counterpart of [`jj`](Repo::jj). For another workspace, bind the re-anchored
428 /// handle first (`let ws = repo.at(path); ws.jj_at()…`) — see [`git_at`](Repo::git_at).
429 pub fn jj_at(&self) -> Option<JjAt<'_, R>> {
430 match &self.backend {
431 Backend::Jj(j) => Some(j.at(&self.cwd)),
432 Backend::Git(_) => None,
433 }
434 }
435
436 /// The current branch (git) or bookmark (jj). On jj this is the nearest
437 /// bookmark reachable from the working copy (`heads(::@ & bookmarks())`),
438 /// so it stays set across a `jj describe`/`jj new`/`jj commit` — which leave
439 /// the bookmark on the described parent while the new change carries none —
440 /// matching git's "still on my branch" reporting. When several bookmarks are
441 /// equally near `@`, the lexicographically-smallest name is returned
442 /// (deterministic). `None` only when detached / no bookmark on or above `@`.
443 pub async fn current_branch(&self) -> Result<Option<String>> {
444 match &self.backend {
445 Backend::Git(g) => git_backend::current_branch(g, &self.cwd).await,
446 Backend::Jj(j) => jj_backend::current_branch(j, &self.cwd).await,
447 }
448 }
449
450 /// The trunk branch/bookmark. Resolution order: the backend's own notion
451 /// (git's `origin/HEAD`, jj's `trunk()` revset), then a fallback to a local
452 /// `main`, then `master`; `None` when none of those resolve.
453 pub async fn trunk(&self) -> Result<Option<String>> {
454 let native = match &self.backend {
455 Backend::Git(g) => git_backend::trunk(g, &self.cwd).await?,
456 Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await?,
457 };
458 if native.is_some() {
459 return Ok(native);
460 }
461 for candidate in ["main", "master"] {
462 if self.branch_exists(candidate).await? {
463 return Ok(Some(candidate.to_string()));
464 }
465 }
466 Ok(None)
467 }
468
469 /// Local branch (git) / bookmark (jj) names.
470 ///
471 /// Backend divergence: on **jj**, a bookmark deleted locally but still **tracked**
472 /// on a remote lingers as a *tombstone* row (jj keeps it so the deletion can be
473 /// propagated) until the deletion is pushed — so this can list a name a
474 /// `delete_branch` just removed, unlike git. (The tombstone is not filtered here
475 /// because jj renders it and a *conflicted* bookmark identically — filtering would
476 /// also hide a real, conflicted bookmark; M21.)
477 pub async fn local_branches(&self) -> Result<Vec<String>> {
478 match &self.backend {
479 Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
480 Backend::Jj(j) => jj_backend::local_branches(j, &self.cwd).await,
481 }
482 }
483
484 /// Whether a local branch/bookmark named `name` exists. See
485 /// [`local_branches`](Repo::local_branches) for the jj deleted-but-tracked
486 /// *tombstone* divergence (a just-deleted tracked bookmark can still read as
487 /// existing until the deletion is pushed).
488 pub async fn branch_exists(&self, name: &str) -> Result<bool> {
489 match &self.backend {
490 Backend::Git(g) => git_backend::branch_exists(g, &self.cwd, name).await,
491 Backend::Jj(j) => jj_backend::branch_exists(j, &self.cwd, name).await,
492 }
493 }
494
495 /// Whether the working copy has uncommitted changes (git: a non-empty
496 /// `status`; jj: a non-empty working-copy change `@`).
497 pub async fn has_uncommitted_changes(&self) -> Result<bool> {
498 match &self.backend {
499 Backend::Git(g) => git_backend::has_uncommitted_changes(g, &self.cwd).await,
500 Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
501 }
502 }
503
504 /// Whether the working copy has uncommitted changes to *tracked* files.
505 ///
506 /// Backend nuance: git ignores untracked files here
507 /// (`status --untracked-files=no`); jj auto-tracks new files, so there is no
508 /// untracked concept and this equals
509 /// [`has_uncommitted_changes`](Self::has_uncommitted_changes).
510 pub async fn has_tracked_changes(&self) -> Result<bool> {
511 match &self.backend {
512 Backend::Git(g) => git_backend::has_tracked_changes(g, &self.cwd).await,
513 Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
514 }
515 }
516
517 /// Paths with unresolved merge conflicts in the working copy, repo-relative
518 /// with `/` separators (git `diff --diff-filter=U` / jj `resolve --list -r @`).
519 /// Empty when there are none.
520 pub async fn conflicted_files(&self) -> Result<Vec<String>> {
521 match &self.backend {
522 Backend::Git(g) => git_backend::conflicted_files(g, &self.cwd).await,
523 Backend::Jj(j) => jj_backend::conflicted_files(j, &self.cwd).await,
524 }
525 }
526
527 /// Delete a local branch (git) / bookmark (jj). The [`BranchDelete`] spec's
528 /// [`force`](BranchDelete::force) applies to git only (`branch -D` vs `-d`); jj
529 /// has no force and ignores it.
530 pub async fn delete_branch(&self, spec: BranchDelete) -> Result<()> {
531 match &self.backend {
532 Backend::Git(g) => {
533 git_backend::delete_branch(g, &self.cwd, &spec.name, spec.force).await
534 }
535 Backend::Jj(j) => jj_backend::delete_branch(j, &self.cwd, &spec.name).await,
536 }
537 }
538
539 /// Rename a local branch (git) / bookmark (jj).
540 pub async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
541 match &self.backend {
542 Backend::Git(g) => git_backend::rename_branch(g, &self.cwd, old, new).await,
543 Backend::Jj(j) => jj_backend::rename_branch(j, &self.cwd, old, new).await,
544 }
545 }
546
547 /// The working-copy changes (git `status` / jj `diff -r @ --summary`).
548 pub async fn changed_files(&self) -> Result<Vec<FileChange>> {
549 match &self.backend {
550 Backend::Git(g) => git_backend::changed_files(g, &self.cwd).await,
551 Backend::Jj(j) => jj_backend::changed_files(j, &self.cwd).await,
552 }
553 }
554
555 /// Aggregate insertion/deletion counts for the working copy.
556 ///
557 /// Backend nuance: git counts the working tree against `HEAD` (`git diff`,
558 /// which **excludes untracked files**), while jj counts the `@` change against
559 /// its parent (which **includes** newly-added files). So on git a brand-new
560 /// file shows in [`changed_files`](Self::changed_files) but not here, whereas
561 /// on jj it shows in both. On an unborn git repo (no commits yet) the count is
562 /// taken against the empty tree, so a pre-first-commit working tree stats
563 /// instead of erroring.
564 pub async fn diff_stat(&self) -> Result<DiffStat> {
565 match &self.backend {
566 Backend::Git(g) => git_backend::diff_stat(g, &self.cwd).await,
567 Backend::Jj(j) => jj_backend::diff_stat(j, &self.cwd).await,
568 }
569 }
570
571 /// A batched [`RepoSnapshot`] of the common repo state — branch, upstream,
572 /// ahead/behind, dirtiness, change count, and operation state — in a **small
573 /// fixed** number of spawns instead of a call per field (git: `status
574 /// --porcelain=v2 --branch` + the in-progress probe; jj: a `log -r @`
575 /// template for head/empty/conflict, a `reachable_bookmarks` query for
576 /// `branch`, and a change count only when dirty). Built for prompt/status-bar/
577 /// TUI refreshes. Note the asymmetry: [`tracking`](RepoSnapshot::tracking)
578 /// (the upstream ref + ahead/behind) is always `None` on jj, which has no
579 /// git-style upstream tracking.
580 pub async fn snapshot(&self) -> Result<RepoSnapshot> {
581 match &self.backend {
582 Backend::Git(g) => git_backend::snapshot(g, &self.cwd).await,
583 Backend::Jj(j) => jj_backend::snapshot(j, &self.cwd).await,
584 }
585 }
586
587 /// Commit exactly `paths` with `message` (git `commit --only`, jj
588 /// `commit <filesets>`). Paths are repo-relative. `paths` must be non-empty:
589 /// an empty set is refused up front, because the backends would diverge
590 /// dangerously — git errors out, while jj's `commit` with no filesets would
591 /// silently commit the **entire** working copy.
592 pub async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
593 if paths.is_empty() {
594 return Err(Error::Io(std::io::Error::new(
595 std::io::ErrorKind::InvalidInput,
596 "commit_paths requires at least one path: an empty set would error \
597 on git but commit the entire working copy on jj",
598 )));
599 }
600 match &self.backend {
601 Backend::Git(g) => git_backend::commit_paths(g, &self.cwd, paths, message).await,
602 Backend::Jj(j) => jj_backend::commit_paths(j, &self.cwd, paths, message).await,
603 }
604 }
605
606 /// Fetch from the default remote (git `fetch` / jj `git fetch`).
607 pub async fn fetch(&self) -> Result<()> {
608 match &self.backend {
609 Backend::Git(g) => git_backend::fetch(g, &self.cwd).await,
610 Backend::Jj(j) => jj_backend::fetch(j, &self.cwd).await,
611 }
612 }
613
614 /// Fetch from a *named* remote (git `fetch <remote>` / jj
615 /// `git fetch --remote <remote>`). Transient network failures are retried by
616 /// the underlying client.
617 pub async fn fetch_from(&self, remote: &str) -> Result<()> {
618 match &self.backend {
619 Backend::Git(g) => git_backend::fetch_from(g, &self.cwd, remote).await,
620 Backend::Jj(j) => jj_backend::fetch_from(j, &self.cwd, remote).await,
621 }
622 }
623
624 /// Fetch a single branch/bookmark from `origin` into its remote-tracking ref
625 /// (git `fetch_branch` / jj `git fetch -b`). Transient network failures
626 /// are retried by the underlying client.
627 pub async fn fetch_branch(&self, branch: &str) -> Result<()> {
628 match &self.backend {
629 Backend::Git(g) => git_backend::fetch_branch(g, &self.cwd, branch).await,
630 Backend::Jj(j) => jj_backend::fetch_branch(j, &self.cwd, branch).await,
631 }
632 }
633
634 /// Push `branch` to `origin` (git `push -u origin <branch>` / jj
635 /// `git push -b <branch>`).
636 ///
637 /// The branch (jj: bookmark) must already exist locally. The two backends
638 /// honestly differ in what "push" means: git pushes the *ref* and records
639 /// the upstream (`-u`; idempotent on repeat pushes), while jj pushes the
640 /// *bookmark's state* — including deleting the remote branch if the
641 /// bookmark was deleted locally. Renamed refspecs (`local:remote`) and
642 /// non-`origin` remotes are git-only concepts; use the
643 /// [`git()`](Repo::git) escape hatch ([`vcs_git::GitPush`]) for those.
644 pub async fn push(&self, branch: &str) -> Result<()> {
645 match &self.backend {
646 Backend::Git(g) => git_backend::push(g, &self.cwd, branch).await,
647 Backend::Jj(j) => jj_backend::push(j, &self.cwd, branch).await,
648 }
649 }
650
651 /// Switch the working copy to `reference` (git `checkout` / jj `edit`).
652 ///
653 /// ⚠ **Backend divergence — this is not "detach and build on top" on jj.** On
654 /// **git**, a subsequent commit *appends* on top of `reference` (its tip is
655 /// untouched). On **jj**, `checkout` maps to `jj edit`, which makes `reference`'s
656 /// commit *itself* the working-copy change — so a following
657 /// [`commit_paths`](Repo::commit_paths) (or any edit) **rewrites that commit in
658 /// place** (a new change-id, a replaced
659 /// description), silently amending a possibly-already-pushed commit rather than
660 /// adding a new one.
661 ///
662 /// So backend-agnostic "start fresh work on top of `main`" code must **not** rely
663 /// on `checkout` alone. If you want git-like append-on-top semantics on both
664 /// backends, start a new child change explicitly — on jj that is `jj new
665 /// <reference>` via the raw [`jj`](Repo::jj) client (a first-class `new_child`
666 /// facade primitive is planned); on git, `checkout` already appends.
667 pub async fn checkout(&self, reference: &str) -> Result<()> {
668 match &self.backend {
669 Backend::Git(g) => git_backend::checkout(g, &self.cwd, reference).await,
670 Backend::Jj(j) => jj_backend::checkout(j, &self.cwd, reference).await,
671 }
672 }
673
674 /// Rebase the current work onto `onto` (git `rebase` / jj `rebase -d`). The
675 /// `onto` is a branch/bookmark name or revision the backend understands.
676 pub async fn rebase(&self, onto: &str) -> Result<()> {
677 match &self.backend {
678 Backend::Git(g) => git_backend::rebase(g, &self.cwd, onto).await,
679 Backend::Jj(j) => jj_backend::rebase(j, &self.cwd, onto).await,
680 }
681 }
682
683 /// Probe whether merging `source` into the current work would conflict,
684 /// **without leaving any trace**: the probe is rolled back before returning
685 /// (git: `merge --no-commit --no-ff` then `merge --abort`; jj: a merge
686 /// change probed and undone via `op restore`).
687 ///
688 /// Preconditions/behaviour:
689 /// - git: requires a clean-enough working tree — a dirty-tree refusal
690 /// propagates as a plain error, not as [`MergeProbe::Conflicts`].
691 /// - A failing rollback **propagates as an error** rather than returning a
692 /// result that misdescribes the on-disk state.
693 /// - **Cancellation caveat:** the rollback runs on the same client, so if the
694 /// client carries a `default_cancel_on` token (the `cancellation` feature)
695 /// that fires during the probe, the rollback command is cancelled too and the
696 /// probe change may be left behind (`Error::Cancelled` surfaces). Re-probe and
697 /// reset with an un-cancelled client if you need a clean tree.
698 pub async fn try_merge(&self, source: &str) -> Result<MergeProbe> {
699 match &self.backend {
700 Backend::Git(g) => git_backend::try_merge(g, &self.cwd, source).await,
701 Backend::Jj(j) => jj_backend::try_merge(j, &self.cwd, source).await,
702 }
703 }
704
705 /// Abort the in-progress operation, if any (git: `merge --abort` /
706 /// `rebase --abort`; jj: a no-op — there are no paused operations, roll back
707 /// explicitly via `Jj::transaction` / `op_restore`). Returns the fresh
708 /// *post-call* [`OperationState`]; `Clear` when nothing was (or remains) in
709 /// progress.
710 pub async fn abort_in_progress(&self) -> Result<OperationState> {
711 match &self.backend {
712 Backend::Git(g) => git_backend::abort_in_progress(g, &self.cwd).await,
713 Backend::Jj(j) => jj_backend::abort_in_progress(j, &self.cwd).await,
714 }
715 }
716
717 /// Continue the in-progress operation after conflict resolution (git:
718 /// `commit --no-edit` for a merge / `rebase --continue`; jj: a no-op —
719 /// resolving the files *is* the continuation). Returns the fresh *post-call*
720 /// [`OperationState`]:
721 /// - `Conflict` when unresolved paths still block continuing (also on git —
722 /// unlike [`in_progress_state`](Self::in_progress_state), this method
723 /// *does* report `Conflict` for git), or when a continued rebase stops on
724 /// the next patch's conflict.
725 /// - `Clear` when the operation finished.
726 pub async fn continue_in_progress(&self) -> Result<OperationState> {
727 match &self.backend {
728 Backend::Git(g) => git_backend::continue_in_progress(g, &self.cwd).await,
729 Backend::Jj(j) => jj_backend::continue_in_progress(j, &self.cwd).await,
730 }
731 }
732
733 /// Whether the working copy is mid-operation or conflicted — see
734 /// [`OperationState`]. Lets a caller decide between abort/continue without
735 /// knowing the backend's model. Note the asymmetry: *this method* reports
736 /// `Merge`/`Rebase` (never `Conflict`) on git — a git conflict *is* that
737 /// paused state, and the conflict itself surfaces on the failed op via
738 /// [`Error::is_merge_conflict`] (or as `Conflict` from
739 /// [`continue_in_progress`](Self::continue_in_progress)) — while jj has no
740 /// paused op and reports `Conflict` directly.
741 pub async fn in_progress_state(&self) -> Result<OperationState> {
742 match &self.backend {
743 Backend::Git(g) => git_backend::in_progress_state(g, &self.cwd).await,
744 Backend::Jj(j) => jj_backend::in_progress_state(j, &self.cwd).await,
745 }
746 }
747
748 /// List attached worktrees (git) / workspaces (jj).
749 pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
750 match &self.backend {
751 Backend::Git(g) => git_backend::list_worktrees(g, &self.cwd).await,
752 Backend::Jj(j) => jj_backend::list_worktrees(j, &self.cwd).await,
753 }
754 }
755
756 /// Create a worktree/workspace at `path` on a **new** `branch` based on
757 /// `base`. Always [`CreateOutcome::Plain`]; a copy-on-write strategy stays in
758 /// the consumer.
759 ///
760 /// `branch` must not already exist. The jj path is two steps (`workspace add`
761 /// then `bookmark create`) and is not atomic, but a failed bookmark step
762 /// **rolls back**: the workspace directory is removed only when `workspace add`
763 /// created it (a pre-existing directory the caller already had is left intact),
764 /// the workspace is forgotten best-effort, and the original error is surfaced —
765 /// so a failed call doesn't leak a half-made worktree.
766 pub async fn create_worktree(&self, spec: WorktreeCreate) -> Result<CreateOutcome> {
767 let WorktreeCreate { path, branch, base } = &spec;
768 match &self.backend {
769 Backend::Git(g) => git_backend::create_worktree(g, &self.cwd, path, branch, base).await,
770 Backend::Jj(j) => jj_backend::create_worktree(j, &self.cwd, path, branch, base).await,
771 }
772 }
773
774 /// Remove the worktree/workspace at `path`. For jj this resolves the
775 /// workspace name by matching `path`, deletes the directory, then forgets it;
776 /// a `path` that matches no attached jj workspace returns
777 /// [`Error::WorktreeNotFound`]. (For the best-effort, never-erroring variant,
778 /// see [`cleanup_worktree_blocking`](Self::cleanup_worktree_blocking).)
779 ///
780 /// The [`WorktreeRemove`] spec's [`force`](WorktreeRemove::force) mirrors git's
781 /// `worktree remove`: without it a worktree that still has **uncommitted changes**
782 /// is refused (`Err`) rather than deleted, so a stray edit isn't silently lost —
783 /// build `WorktreeRemove::new(path).force()` to remove it anyway. On **jj** the
784 /// changes are snapshotted into the op log before the check, so a refusal keeps
785 /// them recoverable; note that checking spawns a jj command in the target
786 /// workspace, so a genuinely stale working copy can surface an error without
787 /// `force` (use `.force()` there). The repository's **main** workspace is always
788 /// refused (it can't be removed without destroying the repo), regardless of `force`.
789 pub async fn remove_worktree(&self, spec: WorktreeRemove) -> Result<()> {
790 match &self.backend {
791 Backend::Git(g) => {
792 git_backend::remove_worktree(g, &self.cwd, &spec.path, spec.force).await
793 }
794 Backend::Jj(j) => {
795 jj_backend::remove_worktree(j, &self.cwd, &spec.path, spec.force).await
796 }
797 }
798 }
799
800 /// **Synchronous** worktree cleanup for a context that cannot `.await` —
801 /// chiefly a `Drop` guard. Force-removes the worktree at `path` (git:
802 /// `worktree remove --force`; jj: resolve the workspace name by `path`, delete
803 /// the directory, then `workspace forget`). Best-effort and short-lived: it
804 /// shells out directly (no job-containment); a jj `path` that matches no
805 /// workspace is a no-op (`Ok`). Like the async
806 /// [`remove_worktree`](Self::remove_worktree), it **refuses the repository's
807 /// main workspace** (whose directory is the main working copy) — deleting it
808 /// would wipe the repo — even on this force-by-contract path.
809 pub fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()> {
810 match &self.backend {
811 Backend::Git(_) => {
812 vcs_git::blocking::worktree_remove(&self.cwd, path, true).map_err(Error::Io)
813 }
814 Backend::Jj(_) => {
815 // jj resolves a relative worktree path against the repo dir (its
816 // cwd), so resolve it the same way here — the lookup and the dir
817 // removal must target the location jj used, not one under the process
818 // cwd (which may differ from `self.cwd`).
819 let abs_path = self.cwd.join(path);
820 match vcs_jj::blocking::workspace_name_for_path(&self.cwd, &abs_path) {
821 Some(name) => {
822 // Same main-workspace guard as the async `remove_worktree`
823 // (jj_backend.rs): never `remove_dir_all` the repository's
824 // main working copy — its directory owns the object store, so
825 // deleting it wipes the whole repo. The `default` name and the
826 // store-owning `.jj/repo` *directory* (a secondary's is a file
827 // pointer) both flag it, so a `jj workspace rename` can't
828 // bypass it. Force is implied on this Drop path, but this guard
829 // is unconditional — a repo-wipe is never the intent.
830 if name == "default" || abs_path.join(".jj").join("repo").is_dir() {
831 return Err(Error::Io(std::io::Error::new(
832 std::io::ErrorKind::InvalidInput,
833 "refusing to remove the repository's main workspace",
834 )));
835 }
836 // Delete the on-disk dir first (jj `forget` leaves it), then
837 // drop jj's record of the workspace.
838 let _ = std::fs::remove_dir_all(&abs_path);
839 vcs_jj::blocking::workspace_forget(&self.cwd, &name).map_err(Error::Io)
840 }
841 None => Ok(()),
842 }
843 }
844 }
845 }
846}
847
848/// Generate a facade trait from one signature table: the `#[async_trait]` trait
849/// declaration *and* the delegating `impl … for $Ty<R>`, so the two can never drift
850/// out of sync (a hazard when each is hand-maintained). Every generated body is a
851/// trivial delegation to the like-named inherent method — which method resolution
852/// prefers, so this never recurses; the real backend-`match` dispatch stays
853/// hand-written on the inherent `impl`. `async` methods doc-link to their inherent
854/// twin; `sync` methods carry an explicit doc string (their docs aren't uniform).
855///
856/// A near-identical copy lives in `vcs-forge`; the two are deliberately not shared
857/// (separate crates, ~40-line macro — duplication beats a new dependency).
858///
859/// Signatures only: each entry is a bare `&self` (or sync) method — no method-level
860/// generics, no `&mut self`, no default bodies (a new method shaped that way needs a
861/// grammar tweak, not just a table row).
862///
863/// No `mockall::automock`: a Wave-S spike proved it can't process a trait whose
864/// signatures come from `macro_rules!`. Captured `$_:ty` fragments reach `automock`
865/// as opaque nonterminal token groups; its `syn` parser rejects them ("unsupported
866/// type in this position"), whereas `#[async_trait]` tolerates them. So the facade
867/// traits stay test-seam-tested (build a handle over a fake runner — see the trait
868/// docs), which is also what their docs already recommend over mocking.
869macro_rules! facade_trait {
870 (
871 $(#[doc = $tdoc:expr])*
872 trait $Trait:ident for $Ty:ident;
873 sync {
874 $( #[doc = $sdoc:expr] fn $sn:ident( $($sa:ident: $sat:ty),* $(,)? ) -> $sr:ty; )*
875 }
876 async {
877 $( fn $an:ident( $($aa:ident: $aat:ty),* $(,)? ) -> $ar:ty; )*
878 }
879 ) => {
880 $(#[doc = $tdoc])*
881 #[async_trait::async_trait]
882 pub trait $Trait: Send + Sync {
883 $(
884 #[doc = $sdoc]
885 fn $sn(&self, $($sa: $sat),*) -> $sr;
886 )*
887 $(
888 #[doc = concat!("See [`", stringify!($Ty), "::", stringify!($an), "`].")]
889 async fn $an(&self, $($aa: $aat),*) -> $ar;
890 )*
891 }
892
893 // Delegates to the inherent methods, which method resolution prefers — so
894 // these bodies dispatch through the concrete type's real implementations,
895 // not back into the trait.
896 #[async_trait::async_trait]
897 impl<R: ProcessRunner> $Trait for $Ty<R> {
898 $(
899 fn $sn(&self, $($sa: $sat),*) -> $sr {
900 self.$sn($($sa),*)
901 }
902 )*
903 $(
904 async fn $an(&self, $($aa: $aat),*) -> $ar {
905 self.$an($($aa),*).await
906 }
907 )*
908 }
909 };
910}
911
912facade_trait! {
913 /// The backend-agnostic common surface of [`Repo`], as a trait — so a consumer can
914 /// hold a `Box<dyn VcsRepo>` / `&dyn VcsRepo` and code against the operations
915 /// without naming the [`ProcessRunner`] generic or wrapping `Repo` themselves.
916 ///
917 /// Every method mirrors the like-named inherent method on [`Repo`]; the trait adds
918 /// nothing but the abstraction boundary. Tool-specific operations stay off it (see
919 /// the crate docs) — reach those through the concrete [`Repo`] and its bound
920 /// handles. For hermetic tests, build a `Repo` over a fake runner with
921 /// [`Repo::from_git`] / [`Repo::from_jj`] rather than mocking this trait.
922 trait VcsRepo for Repo;
923 sync {
924 #[doc = "Which backend drives this handle."]
925 fn kind() -> BackendKind;
926 #[doc = "The repository root detected at open time."]
927 fn root() -> &Path;
928 #[doc = "The directory operations run against."]
929 fn cwd() -> &Path;
930 #[doc = "See [`Repo::cleanup_worktree_blocking`]."]
931 fn cleanup_worktree_blocking(path: &Path) -> Result<()>;
932 }
933 async {
934 fn current_branch() -> Result<Option<String>>;
935 fn trunk() -> Result<Option<String>>;
936 fn local_branches() -> Result<Vec<String>>;
937 fn branch_exists(name: &str) -> Result<bool>;
938 fn has_uncommitted_changes() -> Result<bool>;
939 fn has_tracked_changes() -> Result<bool>;
940 fn conflicted_files() -> Result<Vec<String>>;
941 fn delete_branch(spec: BranchDelete) -> Result<()>;
942 fn rename_branch(old: &str, new: &str) -> Result<()>;
943 fn changed_files() -> Result<Vec<FileChange>>;
944 fn diff_stat() -> Result<DiffStat>;
945 fn snapshot() -> Result<RepoSnapshot>;
946 fn commit_paths(paths: &[String], message: &str) -> Result<()>;
947 fn fetch() -> Result<()>;
948 fn fetch_from(remote: &str) -> Result<()>;
949 fn fetch_branch(branch: &str) -> Result<()>;
950 fn push(branch: &str) -> Result<()>;
951 fn checkout(reference: &str) -> Result<()>;
952 fn rebase(onto: &str) -> Result<()>;
953 fn try_merge(source: &str) -> Result<MergeProbe>;
954 fn abort_in_progress() -> Result<OperationState>;
955 fn continue_in_progress() -> Result<OperationState>;
956 fn in_progress_state() -> Result<OperationState>;
957 fn list_worktrees() -> Result<Vec<WorktreeInfo>>;
958 fn create_worktree(spec: WorktreeCreate) -> Result<CreateOutcome>;
959 fn remove_worktree(spec: WorktreeRemove) -> Result<()>;
960 }
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use processkit::testing::{Reply, ScriptedRunner};
967 // The shared sandbox fixture — a unique temp dir removed on drop. Using the
968 // testkit's one impl instead of a private copy means the wrappers/facades
969 // don't each carry a fixture that could drift.
970 use vcs_testkit::TempDir;
971
972 // --- detect ------------------------------------------------------------
973
974 #[test]
975 fn detect_finds_git_and_jj_and_prefers_jj() {
976 let tmp = TempDir::new("detect");
977 let root = tmp.path();
978
979 // Plain git repo.
980 std::fs::create_dir_all(root.join(".git")).unwrap();
981 let located = detect(root).expect("git detected");
982 assert_eq!(located.kind, BackendKind::Git);
983 assert_eq!(located.root, root);
984
985 // Colocated: adding a *valid* .jj (with its `repo` store) makes jj win.
986 std::fs::create_dir_all(root.join(".jj").join("repo")).unwrap();
987 assert_eq!(detect(root).unwrap().kind, BackendKind::Jj);
988 }
989
990 // M19: a stray/empty `.jj` directory (no `repo` store — e.g. a leftover
991 // `mkdir .jj`) is NOT a jj marker and must not shadow a healthy `.git` repo in the
992 // same directory. A valid `.jj` (with `repo`, dir or file) still wins.
993 #[test]
994 fn detect_ignores_a_dotjj_without_a_repo_store() {
995 let tmp = TempDir::new("stray-jj");
996 let root = tmp.path();
997 std::fs::create_dir_all(root.join(".git")).unwrap();
998 std::fs::create_dir_all(root.join(".jj")).unwrap(); // empty — no `repo`
999 assert_eq!(
1000 detect(root).expect("git still detected").kind,
1001 BackendKind::Git,
1002 "an empty .jj must not shadow a real .git"
1003 );
1004
1005 // A secondary workspace's `.jj/repo` is a *file* pointer — still valid.
1006 let sec = TempDir::new("jj-secondary");
1007 std::fs::create_dir_all(sec.path().join(".jj")).unwrap();
1008 std::fs::write(sec.path().join(".jj").join("repo"), b"/path/to/store\n").unwrap();
1009 assert_eq!(detect(sec.path()).unwrap().kind, BackendKind::Jj);
1010 }
1011
1012 #[test]
1013 fn detect_walks_up_to_ancestor() {
1014 let tmp = TempDir::new("walkup");
1015 let root = tmp.path();
1016 std::fs::create_dir_all(root.join(".git")).unwrap();
1017 let nested = root.join("a").join("b");
1018 std::fs::create_dir_all(&nested).unwrap();
1019 let located = detect(&nested).expect("found via ancestor walk");
1020 assert_eq!(located.kind, BackendKind::Git);
1021 assert_eq!(located.root, root);
1022 }
1023
1024 #[test]
1025 fn detect_returns_none_outside_repo() {
1026 let tmp = TempDir::new("norepo");
1027 assert!(detect(tmp.path()).is_none());
1028 }
1029
1030 // A gitlink `.git` *file* (a linked worktree / submodule) is a valid git marker;
1031 // a stray file merely named `.git` is NOT — so it can't shadow a real repo above.
1032 #[test]
1033 fn detect_validates_dotgit_file_is_a_gitlink() {
1034 let tmp = TempDir::new("gitlink");
1035 let root = tmp.path();
1036
1037 // A gitlink file → detected as a git repo at this dir.
1038 std::fs::write(root.join(".git"), "gitdir: /somewhere/.git/worktrees/wt\n").unwrap();
1039 assert_eq!(
1040 detect(root).expect("gitlink detected").kind,
1041 BackendKind::Git
1042 );
1043
1044 // A garbage file named `.git` (not a gitlink) is rejected — and must NOT
1045 // shadow a real `.git` directory in the parent.
1046 let parent = TempDir::new("gitlink-parent");
1047 std::fs::create_dir_all(parent.path().join(".git")).unwrap();
1048 let child = parent.path().join("sub");
1049 std::fs::create_dir_all(&child).unwrap();
1050 std::fs::write(child.join(".git"), "not a gitlink, just noise\n").unwrap();
1051 let located = detect(&child).expect("walks up past the bogus .git file");
1052 assert_eq!(located.root, parent.path(), "the real repo is the parent");
1053
1054 // An empty `.git` file is not a marker.
1055 let empty = TempDir::new("gitlink-empty");
1056 std::fs::write(empty.path().join(".git"), "").unwrap();
1057 assert!(detect(empty.path()).is_none(), "empty .git is not a repo");
1058
1059 // Leading whitespace before `gitdir:` is tolerated (the `trim_start`).
1060 let spaced = TempDir::new("gitlink-spaced");
1061 std::fs::write(
1062 spaced.path().join(".git"),
1063 " gitdir: /x/.git/worktrees/w\n",
1064 )
1065 .unwrap();
1066 assert_eq!(
1067 detect(spaced.path()).expect("spaced gitlink detected").kind,
1068 BackendKind::Git
1069 );
1070 }
1071
1072 // --- dispatch (hermetic, ScriptedRunner-backed) ------------------------
1073
1074 fn git_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
1075 Repo::from_git("/repo", "/repo", Git::with_runner(runner))
1076 }
1077
1078 fn jj_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
1079 Repo::from_jj("/repo", "/repo", Jj::with_runner(runner))
1080 }
1081
1082 // --- snapshot ----------------------------------------------------------
1083
1084 // git: one porcelain-v2 call + a git-dir probe → a combined RepoSnapshot.
1085 #[tokio::test]
1086 async fn git_snapshot_combines_v2_status_and_op_state() {
1087 let v2 = concat!(
1088 "# branch.oid abc123\0",
1089 "# branch.head main\0",
1090 "# branch.upstream origin/main\0",
1091 "# branch.ab +2 -0\0",
1092 "1 .M N... 100644 100644 100644 1 2 a.rs\0",
1093 "? new.txt\0",
1094 );
1095 // An empty git dir → no MERGE_HEAD / rebase dir → Clear.
1096 let gitdir = TempDir::new("snap-git");
1097 let repo = git_repo(
1098 ScriptedRunner::new()
1099 .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1100 .on(
1101 ["git", "rev-parse", "--git-dir"],
1102 Reply::ok(gitdir.path().to_str().unwrap()),
1103 ),
1104 );
1105 let s = repo.snapshot().await.unwrap();
1106 assert_eq!(s.branch.as_deref(), Some("main"));
1107 let tracking = s.tracking.as_ref().expect("upstream tracking");
1108 assert_eq!(tracking.branch, "origin/main");
1109 assert_eq!((tracking.ahead, tracking.behind), (Some(2), Some(0)));
1110 assert!(s.dirty);
1111 assert_eq!(s.change_count, 2, "1 tracked + 1 untracked");
1112 assert!(!s.conflicted);
1113 assert_eq!(s.operation, OperationState::Clear);
1114 }
1115
1116 // M20 (whole-solution): `snapshot()` has its OWN operation probe (separate from
1117 // `in_progress_state`); it too must report a `git am` as `ApplyMailbox`, not
1118 // `Rebase` — otherwise the new variant is dead on the snapshot → watch → mcp path.
1119 #[tokio::test]
1120 async fn git_snapshot_reports_git_am_as_apply_mailbox() {
1121 let v2 = concat!("# branch.oid abc\0", "# branch.head main\0");
1122 let gitdir = TempDir::new("snap-git-am");
1123 // A `git am` in progress: `rebase-apply/` WITH the `applying` marker.
1124 let apply = gitdir.path().join("rebase-apply");
1125 std::fs::create_dir_all(&apply).unwrap();
1126 std::fs::write(apply.join("applying"), b"").unwrap();
1127 let repo = git_repo(
1128 ScriptedRunner::new()
1129 .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1130 .on(
1131 ["git", "rev-parse", "--git-dir"],
1132 Reply::ok(gitdir.path().to_str().unwrap()),
1133 ),
1134 );
1135 let s = repo.snapshot().await.unwrap();
1136 assert_eq!(
1137 s.operation,
1138 OperationState::ApplyMailbox,
1139 "a git am must not read as Rebase in snapshot()"
1140 );
1141 }
1142
1143 // git with NO upstream configured: porcelain v2 omits the `# branch.upstream`
1144 // and `# branch.ab` lines, so `tracking` is None (the all-or-nothing invariant —
1145 // git is the only backend that can produce either) — mirrors the jj None case.
1146 #[tokio::test]
1147 async fn git_snapshot_without_upstream_has_no_tracking() {
1148 let v2 = concat!("# branch.oid abc123\0", "# branch.head main\0");
1149 let gitdir = TempDir::new("snap-git-noup");
1150 let repo = git_repo(
1151 ScriptedRunner::new()
1152 .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1153 .on(
1154 ["git", "rev-parse", "--git-dir"],
1155 Reply::ok(gitdir.path().to_str().unwrap()),
1156 ),
1157 );
1158 let s = repo.snapshot().await.unwrap();
1159 assert_eq!(s.branch.as_deref(), Some("main"));
1160 assert!(s.tracking.is_none(), "no upstream → no tracking");
1161 }
1162
1163 // M17: an upstream that is SET but GONE (deleted on the remote, or not yet
1164 // fetched) — porcelain v2 emits `# branch.upstream` but OMITS `# branch.ab`, so the
1165 // counts are uncountable. `tracking` must be `Some { branch, ahead: None, behind:
1166 // None }` (tracking configured but uncountable), NOT a fabricated in-sync `0`/`0`.
1167 #[tokio::test]
1168 async fn git_snapshot_upstream_set_but_gone_is_uncountable() {
1169 let v2 = concat!(
1170 "# branch.oid abc123\0",
1171 "# branch.head main\0",
1172 "# branch.upstream origin/main\0", // upstream named…
1173 // …but no `# branch.ab` line — it doesn't resolve.
1174 );
1175 let gitdir = TempDir::new("snap-git-gone");
1176 let repo = git_repo(
1177 ScriptedRunner::new()
1178 .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1179 .on(
1180 ["git", "rev-parse", "--git-dir"],
1181 Reply::ok(gitdir.path().to_str().unwrap()),
1182 ),
1183 );
1184 let s = repo.snapshot().await.unwrap();
1185 let tracking = s.tracking.as_ref().expect("upstream is set");
1186 assert_eq!(tracking.branch, "origin/main");
1187 assert_eq!(
1188 (tracking.ahead, tracking.behind),
1189 (None, None),
1190 "a gone upstream is uncountable, not in-sync 0/0"
1191 );
1192 }
1193
1194 // jj: one template row + a status count; a conflicted @ maps to Conflict; no
1195 // git-style upstream/ahead/behind.
1196 #[tokio::test]
1197 async fn jj_snapshot_dirty_with_change_count() {
1198 let repo = jj_repo(
1199 ScriptedRunner::new()
1200 // snapshot template (`jj log -r @`): commit_id \t empty \t conflict
1201 .on(["jj", "log", "-r", "@"], Reply::ok("deadbeef\t0\t1\n")) // empty=0 dirty, conflict=1
1202 // `branch` via `current_branch` → `reachable_bookmarks`
1203 // (`jj log -r heads(::@ & bookmarks())`): bookmarks \t commit
1204 .on(
1205 ["jj", "log", "-r", "heads(::@ & bookmarks())"],
1206 Reply::ok("main\tdeadbeef\n"),
1207 )
1208 .on(["jj", "diff"], Reply::ok("M a.rs\nA b.rs\n")), // status -r @ --summary → 2
1209 );
1210 let s = repo.snapshot().await.unwrap();
1211 assert_eq!(s.head.as_deref(), Some("deadbeef"));
1212 assert_eq!(s.branch.as_deref(), Some("main"));
1213 assert!(s.dirty);
1214 assert_eq!(s.change_count, 2);
1215 assert!(s.conflicted);
1216 assert_eq!(s.operation, OperationState::Conflict);
1217 assert!(s.tracking.is_none(), "jj has no upstream tracking");
1218 }
1219
1220 // jj: a clean `@` (empty=1) skips the change-count spawn entirely — the test
1221 // scripts NO `diff` rule, so calling `status` would error.
1222 #[tokio::test]
1223 async fn jj_snapshot_clean_skips_change_count() {
1224 let repo = jj_repo(
1225 ScriptedRunner::new()
1226 .on(["jj", "log", "-r", "@"], Reply::ok("c0ffee\t1\t0\n"))
1227 .on(
1228 ["jj", "log", "-r", "heads(::@ & bookmarks())"],
1229 Reply::ok(""),
1230 ),
1231 );
1232 let s = repo.snapshot().await.unwrap();
1233 assert_eq!(s.head.as_deref(), Some("c0ffee"));
1234 assert_eq!(s.branch, None, "no bookmark");
1235 assert!(!s.dirty);
1236 assert_eq!(s.change_count, 0);
1237 assert!(!s.conflicted);
1238 assert_eq!(s.operation, OperationState::Clear);
1239 }
1240
1241 // jj: a conflicted `@` that jj marks `empty` (conflict but no net content change)
1242 // is still reported `dirty` — the conflict is uncommitted state needing
1243 // resolution — so the count runs and the snapshot is coherent (no
1244 // `conflicted: true` next to `dirty: false`), mirroring git's conflict handling.
1245 #[tokio::test]
1246 async fn jj_snapshot_conflicted_empty_change_is_dirty() {
1247 let repo = jj_repo(
1248 ScriptedRunner::new()
1249 .on(["jj", "log", "-r", "@"], Reply::ok("c0ffee\t1\t1\n")) // empty=1, conflict=1
1250 .on(
1251 ["jj", "log", "-r", "heads(::@ & bookmarks())"],
1252 Reply::ok(""),
1253 ) // no bookmark
1254 .on(["jj", "diff"], Reply::ok("M conflicted.rs\n")), // status → 1
1255 );
1256 let s = repo.snapshot().await.unwrap();
1257 assert!(s.conflicted);
1258 assert!(s.dirty, "a conflicted change is a dirty working copy");
1259 assert_eq!(s.change_count, 1);
1260 assert_eq!(s.operation, OperationState::Conflict);
1261 }
1262
1263 // jj `list_worktrees` resolves each workspace's root via the batched
1264 // `workspace_roots` fan-out (one `workspace root --name <n>` per `workspace
1265 // list` row), then builds a `WorktreeInfo` per workspace. Hermetic: scripts the
1266 // template rows + the per-name root replies — the backend glue that the
1267 // `#[ignore]` integration tests otherwise cover only with a real `jj`.
1268 #[tokio::test]
1269 async fn jj_list_worktrees_batches_root_lookups() {
1270 let repo = jj_repo(
1271 ScriptedRunner::new()
1272 .on(
1273 ["jj", "workspace", "list"],
1274 Reply::ok("default\tc0ffee\tmain\nws1\tdecaf0\t\n"),
1275 )
1276 .on(
1277 ["jj", "workspace", "root", "--name", "default"],
1278 Reply::ok("/repo\n"),
1279 )
1280 .on(
1281 ["jj", "workspace", "root", "--name", "ws1"],
1282 Reply::ok("/repo/ws1\n"),
1283 ),
1284 );
1285 let worktrees = repo.list_worktrees().await.expect("list_worktrees");
1286 assert_eq!(worktrees.len(), 2);
1287 assert_eq!(worktrees[0].path, Path::new("/repo"));
1288 assert_eq!(worktrees[0].branch.as_deref(), Some("main"));
1289 assert_eq!(worktrees[1].path, Path::new("/repo/ws1"));
1290 assert_eq!(worktrees[1].branch, None);
1291 }
1292
1293 // A workspace whose `workspace root` lookup errors is skipped (no useful path),
1294 // mirroring the old sequential loop — the batch maps that slot to `Err`.
1295 #[tokio::test]
1296 async fn jj_list_worktrees_skips_unresolvable_root() {
1297 let repo = jj_repo(
1298 ScriptedRunner::new()
1299 .on(
1300 ["jj", "workspace", "list"],
1301 Reply::ok("default\tc0ffee\tmain\ngone\tdecaf0\t\n"),
1302 )
1303 .on(
1304 ["jj", "workspace", "root", "--name", "default"],
1305 Reply::ok("/repo\n"),
1306 )
1307 .on(
1308 ["jj", "workspace", "root", "--name", "gone"],
1309 Reply::fail(1, "Error: No such workspace"),
1310 ),
1311 );
1312 let worktrees = repo.list_worktrees().await.expect("list_worktrees");
1313 assert_eq!(worktrees.len(), 1, "the unresolvable workspace is skipped");
1314 assert_eq!(worktrees[0].path, Path::new("/repo"));
1315 }
1316
1317 // remove_worktree surfaces a `workspace forget` failure rather than swallowing
1318 // it — name resolution already proved the workspace is registered, so a forget
1319 // error is a real dangling-registration the caller should see.
1320 #[tokio::test]
1321 async fn jj_remove_worktree_surfaces_forget_error() {
1322 let repo = jj_repo(
1323 ScriptedRunner::new()
1324 .on(["jj", "workspace", "list"], Reply::ok("ws1\tc0ffee\t\n"))
1325 .on(
1326 ["jj", "workspace", "root", "--name", "ws1"],
1327 Reply::ok("/repo/ws1\n"),
1328 )
1329 .on(
1330 ["jj", "workspace", "forget"],
1331 Reply::fail(1, "Error: cannot forget workspace"),
1332 ),
1333 );
1334 // `/repo/ws1` does not exist on disk, so the dir-removal step is skipped and
1335 // the forget error is the sole outcome.
1336 let res = repo.remove_worktree(WorktreeRemove::new("/repo/ws1")).await;
1337 assert!(res.is_err(), "a forget failure is surfaced, not swallowed");
1338 }
1339
1340 // C1: the default workspace resolves at the repo root; removing it would wipe
1341 // the whole repository, so it is refused even with force = true and WITHOUT
1342 // running `workspace forget` (no such cassette rule — a miss would also error,
1343 // so we assert the *refusal* message to prove the guard, not a fallthrough).
1344 #[tokio::test]
1345 async fn jj_remove_worktree_refuses_the_main_workspace() {
1346 let repo = jj_repo(
1347 ScriptedRunner::new()
1348 .on(
1349 ["jj", "workspace", "list"],
1350 Reply::ok("default\tc0ffee\t\n"),
1351 )
1352 .on(
1353 ["jj", "workspace", "root", "--name", "default"],
1354 Reply::ok("/repo\n"),
1355 ),
1356 );
1357 let err = repo
1358 .remove_worktree(WorktreeRemove::new("/repo").force())
1359 .await
1360 .expect_err("the main workspace must be refused");
1361 assert!(
1362 err.to_string().contains("main workspace"),
1363 "refusal message, not a cassette miss: {err}"
1364 );
1365 }
1366
1367 // C1: a secondary workspace with un-snapshotted edits (`current_change` reports
1368 // non-empty) is refused under force = false, and its directory is NOT deleted.
1369 #[tokio::test]
1370 async fn jj_remove_worktree_refuses_dirty_workspace_without_force() {
1371 let tmp = TempDir::new("rmw-dirty");
1372 let root = tmp.path().to_string_lossy().into_owned();
1373 let repo = Repo::from_jj(
1374 &root,
1375 &root,
1376 Jj::with_runner(
1377 ScriptedRunner::new()
1378 .on(["jj", "workspace", "list"], Reply::ok("ws1\tc0ffee\t\n"))
1379 .on(
1380 ["jj", "workspace", "root", "--name", "ws1"],
1381 Reply::ok(format!("{root}\n")),
1382 )
1383 // `current_change` → 3rd field `false` = not empty = dirty.
1384 .on(["jj", "log"], Reply::ok("aaa\tbbb\tfalse\twork\n")),
1385 ),
1386 );
1387 let err = repo
1388 .remove_worktree(WorktreeRemove::new(tmp.path()))
1389 .await
1390 .expect_err("a dirty workspace must be refused without force");
1391 assert!(
1392 err.to_string().contains("uncommitted changes"),
1393 "refusal message: {err}"
1394 );
1395 assert!(
1396 tmp.path().exists(),
1397 "the workspace directory must survive a refusal"
1398 );
1399 }
1400
1401 // C1: force = true skips the dirty check and removes the directory (no
1402 // `current_change` rule is scripted, proving the check is bypassed).
1403 #[tokio::test]
1404 async fn jj_remove_worktree_with_force_removes_the_dir() {
1405 let tmp = TempDir::new("rmw-force");
1406 let ws = tmp.path().join("ws1");
1407 std::fs::create_dir_all(&ws).expect("mkdir ws");
1408 let root = tmp.path().to_string_lossy().into_owned();
1409 let ws_str = ws.to_string_lossy().into_owned();
1410 let repo = Repo::from_jj(
1411 &root,
1412 &root,
1413 Jj::with_runner(
1414 ScriptedRunner::new()
1415 .on(["jj", "workspace", "list"], Reply::ok("ws1\tc0ffee\t\n"))
1416 .on(
1417 ["jj", "workspace", "root", "--name", "ws1"],
1418 Reply::ok(format!("{ws_str}\n")),
1419 )
1420 .on(["jj", "workspace", "forget"], Reply::ok("")),
1421 ),
1422 );
1423 repo.remove_worktree(WorktreeRemove::new(ws.clone()).force())
1424 .await
1425 .expect("force removes a dirty worktree");
1426 assert!(!ws.exists(), "the worktree directory was removed");
1427 }
1428
1429 // C1: the main-workspace guard's store-directory branch — a workspace whose
1430 // name was changed away from `default` (via `jj workspace rename`) still owns
1431 // the object store (`.jj/repo` is a *directory*, not a secondary's file
1432 // pointer), so removal is refused even with force = true, and the dir survives.
1433 // Exercises the `|| .jj/repo.is_dir()` half of the guard (the name is not
1434 // `default`), which the name-based test can't reach.
1435 #[tokio::test]
1436 async fn jj_remove_worktree_refuses_renamed_store_owning_workspace() {
1437 let tmp = TempDir::new("rmw-store");
1438 std::fs::create_dir_all(tmp.path().join(".jj").join("repo")).expect("mk .jj/repo dir");
1439 let root = tmp.path().to_string_lossy().into_owned();
1440 let repo = Repo::from_jj(
1441 &root,
1442 &root,
1443 Jj::with_runner(
1444 ScriptedRunner::new()
1445 .on(["jj", "workspace", "list"], Reply::ok("mainws\tc0ffee\t\n"))
1446 .on(
1447 ["jj", "workspace", "root", "--name", "mainws"],
1448 Reply::ok(format!("{root}\n")),
1449 ),
1450 ),
1451 );
1452 let err = repo
1453 .remove_worktree(WorktreeRemove::new(tmp.path()).force())
1454 .await
1455 .expect_err("a renamed store-owning workspace is still refused");
1456 assert!(
1457 err.to_string().contains("main workspace"),
1458 "refusal message: {err}"
1459 );
1460 assert!(
1461 tmp.path().exists(),
1462 "the store-owning directory must not be deleted"
1463 );
1464 }
1465
1466 #[tokio::test]
1467 async fn kind_and_escape_hatches_reflect_backend() {
1468 let repo = git_repo(ScriptedRunner::new());
1469 assert_eq!(repo.kind(), BackendKind::Git);
1470 assert!(repo.git().is_some());
1471 assert!(repo.jj().is_none());
1472 }
1473
1474 // The cwd-bound views mirror the backend, and `at` re-binds them to another
1475 // directory without a separate client.
1476 #[tokio::test]
1477 async fn bound_views_reflect_backend_and_cwd() {
1478 let git = git_repo(ScriptedRunner::new());
1479 assert!(git.git_at().is_some());
1480 assert!(git.jj_at().is_none());
1481 // A sibling handle bound elsewhere yields a view rooted at that dir.
1482 assert_eq!(git.at("/repo/wt").cwd(), Path::new("/repo/wt"));
1483
1484 let jj = jj_repo(ScriptedRunner::new());
1485 assert!(jj.jj_at().is_some());
1486 assert!(jj.git_at().is_none());
1487 }
1488
1489 #[tokio::test]
1490 async fn current_branch_maps_detached_head_to_none() {
1491 // git's `current_branch` now runs `symbolic-ref --quiet --short HEAD`:
1492 // exit 0 → the branch name, exit 1 → detached HEAD → None.
1493 let named =
1494 git_repo(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")));
1495 assert_eq!(
1496 named.current_branch().await.unwrap().as_deref(),
1497 Some("main")
1498 );
1499 let detached =
1500 git_repo(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
1501 assert!(detached.current_branch().await.unwrap().is_none());
1502 }
1503
1504 #[tokio::test]
1505 async fn changed_files_maps_git_status() {
1506 let repo = git_repo(ScriptedRunner::new().on(
1507 ["git", "status"],
1508 Reply::ok(" M a.rs\0?? b.rs\0R new.rs\0old.rs\0"),
1509 ));
1510 let changes = repo.changed_files().await.unwrap();
1511 assert_eq!(changes.len(), 3);
1512 assert_eq!(changes[0].kind, ChangeKind::Modified);
1513 assert_eq!(changes[1].kind, ChangeKind::Added);
1514 assert_eq!(changes[2].kind, ChangeKind::Renamed);
1515 assert_eq!(changes[2].old_path.as_deref(), Some("old.rs"));
1516 }
1517
1518 #[tokio::test]
1519 async fn local_branches_maps_git_branch_output() {
1520 let repo =
1521 git_repo(ScriptedRunner::new().on(["git", "branch"], Reply::ok("* main\n feat\n")));
1522 assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
1523 }
1524
1525 #[tokio::test]
1526 async fn branch_exists_reads_show_ref_exit() {
1527 let yes = git_repo(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
1528 assert!(yes.branch_exists("main").await.unwrap());
1529 let no = git_repo(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
1530 assert!(!no.branch_exists("nope").await.unwrap());
1531 }
1532
1533 #[tokio::test]
1534 async fn has_uncommitted_changes_reflects_status() {
1535 let dirty = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0")));
1536 assert!(dirty.has_uncommitted_changes().await.unwrap());
1537 let clean = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok("")));
1538 assert!(!clean.has_uncommitted_changes().await.unwrap());
1539 }
1540
1541 #[tokio::test]
1542 async fn at_rebinds_cwd_and_shares_backend() {
1543 let repo = git_repo(ScriptedRunner::new());
1544 let moved = repo.at("/repo/sub");
1545 assert_eq!(moved.cwd(), Path::new("/repo/sub"));
1546 assert_eq!(moved.root(), Path::new("/repo"));
1547 assert_eq!(moved.kind(), BackendKind::Git);
1548 }
1549
1550 // --- dispatch: jj backend (hermetic) -----------------------------------
1551
1552 #[tokio::test]
1553 async fn jj_kind_and_escape_hatches_reflect_backend() {
1554 let repo = jj_repo(ScriptedRunner::new());
1555 assert_eq!(repo.kind(), BackendKind::Jj);
1556 assert!(repo.jj().is_some() && repo.git().is_none());
1557 }
1558
1559 #[tokio::test]
1560 async fn jj_current_branch_reads_bookmark() {
1561 // current_branch derives from `reachable_bookmarks`, whose template is
1562 // `<bookmarks space-joined>\t<commit>` — distinct from the strict
1563 // `current_bookmark(@)` comma-joined template.
1564 let repo = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("main\t53e4e879\n")));
1565 assert_eq!(
1566 repo.current_branch().await.unwrap().as_deref(),
1567 Some("main")
1568 );
1569 }
1570
1571 #[tokio::test]
1572 async fn jj_current_branch_persists_across_commit() {
1573 // After a jj commit the new working-copy change carries no bookmark, but
1574 // the described parent does. `reachable_bookmarks` resolves the nearest
1575 // bookmarked ancestor, so the facade still reports it — git-like "I'm
1576 // still on my branch". Under the old strict `current_bookmark(@)` rule
1577 // this returned `None`; feeding the reachable template (`feat\t…`,
1578 // unparseable as a comma-joined bookmark name) pins the new derivation.
1579 let repo = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("feat\tc8d49332\n")));
1580 assert_eq!(
1581 repo.current_branch().await.unwrap().as_deref(),
1582 Some("feat")
1583 );
1584 }
1585
1586 #[tokio::test]
1587 async fn jj_current_branch_tie_break_is_deterministic() {
1588 // `heads(::@ & bookmarks())` can yield several equally-near bookmarks —
1589 // a merge of two bookmarked lines (one row each) or one commit carrying
1590 // several (one row, space-joined). current_branch returns the
1591 // lexicographically-smallest name regardless of jj's row order, so the
1592 // result is stable. Here: rows `zeta` then `alpha beta` ⇒ `alpha`.
1593 let repo = jj_repo(ScriptedRunner::new().on(
1594 ["jj", "log"],
1595 Reply::ok("zeta\tabc1234\nalpha beta\tdef5678\n"),
1596 ));
1597 assert_eq!(
1598 repo.current_branch().await.unwrap().as_deref(),
1599 Some("alpha")
1600 );
1601 }
1602
1603 #[tokio::test]
1604 async fn jj_local_branches_maps_bookmark_list() {
1605 // BOOKMARK_LIST_TEMPLATE rows: `name\t<commit>`.
1606 let repo = jj_repo(ScriptedRunner::new().on(
1607 ["jj", "bookmark", "list"],
1608 Reply::ok("main\tcmt\nfeat\tm2\n"),
1609 ));
1610 assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
1611 }
1612
1613 #[tokio::test]
1614 async fn jj_branch_exists_scans_bookmarks() {
1615 let repo =
1616 jj_repo(ScriptedRunner::new().on(["jj", "bookmark", "list"], Reply::ok("main\tcmt\n")));
1617 assert!(repo.branch_exists("main").await.unwrap());
1618 let repo2 =
1619 jj_repo(ScriptedRunner::new().on(["jj", "bookmark", "list"], Reply::ok("main\tcmt\n")));
1620 assert!(!repo2.branch_exists("missing").await.unwrap());
1621 }
1622
1623 #[tokio::test]
1624 async fn jj_has_uncommitted_changes_reads_empty_flag() {
1625 // CHANGE_TEMPLATE row: change_id \t commit_id \t empty \t description
1626 let dirty =
1627 jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\tfalse\twip\n")));
1628 assert!(dirty.has_uncommitted_changes().await.unwrap());
1629 let clean = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\ttrue\t\n")));
1630 assert!(!clean.has_uncommitted_changes().await.unwrap());
1631 }
1632
1633 // M18: a conflicted-but-**empty** `@` is uncommitted state (it needs resolution),
1634 // so `has_uncommitted_changes` returns true — agreeing with `snapshot().dirty`,
1635 // which already treats `conflict ⇒ dirty`. First `jj log` = current_change (empty),
1636 // second = is_conflicted (`"1"`).
1637 #[tokio::test]
1638 async fn jj_has_uncommitted_changes_true_when_conflicted_even_if_empty() {
1639 let repo = jj_repo(ScriptedRunner::new().on_sequence(
1640 ["jj", "log"],
1641 [
1642 Reply::ok("kz\t38\ttrue\t\n"), // current_change: empty = true
1643 Reply::ok("1\n"), // is_conflicted: conflicted
1644 ],
1645 ));
1646 assert!(
1647 repo.has_uncommitted_changes().await.unwrap(),
1648 "a conflicted empty @ is dirty"
1649 );
1650 }
1651
1652 #[tokio::test]
1653 async fn jj_changed_files_maps_diff_summary() {
1654 let repo = jj_repo(
1655 ScriptedRunner::new().on(["jj", "diff"], Reply::ok("M src/a.rs\nA b.rs\nD gone.rs\n")),
1656 );
1657 let changes = repo.changed_files().await.unwrap();
1658 assert_eq!(changes.len(), 3);
1659 assert_eq!(changes[0].kind, ChangeKind::Modified);
1660 assert_eq!(changes[1].kind, ChangeKind::Added);
1661 assert_eq!(changes[2].kind, ChangeKind::Deleted);
1662 assert!(changes.iter().all(|c| c.old_path.is_none()));
1663 }
1664
1665 // jj DOES supply the rename's original path (its `{old => new}` summary
1666 // form) — `old_path` is populated on both backends, as the DTO documents.
1667 #[tokio::test]
1668 async fn jj_changed_files_populates_rename_old_path() {
1669 let repo = jj_repo(
1670 ScriptedRunner::new().on(["jj", "diff"], Reply::ok("R src/{old.rs => new.rs}\n")),
1671 );
1672 let changes = repo.changed_files().await.unwrap();
1673 assert_eq!(changes.len(), 1);
1674 assert_eq!(changes[0].kind, ChangeKind::Renamed);
1675 assert_eq!(changes[0].path, "src/new.rs");
1676 assert_eq!(changes[0].old_path.as_deref(), Some("src/old.rs"));
1677 }
1678
1679 // `commit_paths(&[])` is refused up front on BOTH backends: the runners have
1680 // no rules, so reaching the CLI would error differently — the guard must trip
1681 // first (on jj an empty fileset would otherwise commit the whole working
1682 // copy; on git it would exit 128).
1683 #[tokio::test]
1684 async fn commit_paths_refuses_an_empty_path_set() {
1685 for repo in [
1686 git_repo(ScriptedRunner::new()),
1687 jj_repo(ScriptedRunner::new()),
1688 ] {
1689 let err = repo
1690 .commit_paths(&[], "msg")
1691 .await
1692 .expect_err("empty paths must be refused");
1693 assert!(
1694 err.to_string().contains("at least one path"),
1695 "unexpected error: {err}"
1696 );
1697 }
1698 }
1699
1700 #[tokio::test]
1701 async fn jj_rename_branch_builds_bookmark_rename() {
1702 use processkit::testing::RecordingRunner;
1703 let rec = RecordingRunner::replying(Reply::ok(""));
1704 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
1705 repo.rename_branch("old", "new").await.unwrap();
1706 assert_eq!(
1707 rec.only_call().args_str(),
1708 ["bookmark", "rename", "old", "new", "--color", "never"]
1709 );
1710 }
1711
1712 // The widened common surface dispatches `checkout` to each backend's verb:
1713 // git `checkout`, jj `edit`.
1714 #[tokio::test]
1715 async fn checkout_dispatches_per_backend() {
1716 use processkit::testing::RecordingRunner;
1717 let grec = RecordingRunner::replying(Reply::ok(""));
1718 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1719 .checkout("feat")
1720 .await
1721 .unwrap();
1722 // Trailing `--` so a path-like ref can't fall into pathspec mode (C2).
1723 assert_eq!(grec.only_call().args_str(), ["checkout", "feat", "--"]);
1724
1725 let jrec = RecordingRunner::replying(Reply::ok(""));
1726 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1727 .checkout("feat")
1728 .await
1729 .unwrap();
1730 assert_eq!(
1731 jrec.only_call().args_str(),
1732 ["edit", "feat", "--color", "never"]
1733 );
1734 }
1735
1736 // A1: `delete_branch` takes a `BranchDelete` spec; `.force()` threads through to
1737 // git's `-D` (vs `-d`), and jj ignores it (its `bookmark delete` has no force).
1738 #[tokio::test]
1739 async fn delete_branch_spec_threads_force_to_git_only() {
1740 use processkit::testing::RecordingRunner;
1741 let forced = RecordingRunner::replying(Reply::ok(""));
1742 Repo::from_git("/repo", "/repo", Git::with_runner(&forced))
1743 .delete_branch(BranchDelete::new("feat").force())
1744 .await
1745 .unwrap();
1746 assert!(
1747 forced.only_call().args_str().iter().any(|a| a == "-D"),
1748 "force → branch -D"
1749 );
1750
1751 let unforced = RecordingRunner::replying(Reply::ok(""));
1752 Repo::from_git("/repo", "/repo", Git::with_runner(&unforced))
1753 .delete_branch(BranchDelete::new("feat"))
1754 .await
1755 .unwrap();
1756 assert!(
1757 unforced.only_call().args_str().iter().any(|a| a == "-d"),
1758 "no force → branch -d"
1759 );
1760
1761 let jj = RecordingRunner::replying(Reply::ok(""));
1762 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jj))
1763 .delete_branch(BranchDelete::new("feat").force())
1764 .await
1765 .unwrap();
1766 assert!(
1767 !jj.only_call()
1768 .args_str()
1769 .iter()
1770 .any(|a| a == "-D" || a == "--force"),
1771 "jj bookmark delete has no force flag"
1772 );
1773 }
1774
1775 #[tokio::test]
1776 async fn fetch_branch_dispatches_per_backend() {
1777 use processkit::testing::RecordingRunner;
1778 let grec = RecordingRunner::replying(Reply::ok(""));
1779 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1780 .fetch_branch("main")
1781 .await
1782 .unwrap();
1783 assert!(
1784 grec.only_call()
1785 .args_str()
1786 .starts_with(&["fetch".to_string()])
1787 );
1788
1789 let jrec = RecordingRunner::replying(Reply::ok(""));
1790 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1791 .fetch_branch("main")
1792 .await
1793 .unwrap();
1794 let args = jrec.only_call().args_str();
1795 assert_eq!(&args[..2], &["git", "fetch"]);
1796 }
1797
1798 // The facade push is the honest LCD: git pushes the ref with `-u origin`,
1799 // jj pushes the bookmark's state with `-b`. Argv pinned on both backends.
1800 #[tokio::test]
1801 async fn push_dispatches_per_backend() {
1802 use processkit::testing::RecordingRunner;
1803 let grec = RecordingRunner::replying(Reply::ok(""));
1804 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1805 .push("feature")
1806 .await
1807 .unwrap();
1808 assert_eq!(
1809 grec.only_call().args_str(),
1810 ["push", "-u", "origin", "feature"]
1811 );
1812
1813 let jrec = RecordingRunner::replying(Reply::ok(""));
1814 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1815 .push("feature")
1816 .await
1817 .unwrap();
1818 let args = jrec.only_call().args_str();
1819 // `exact:` disables jj's glob matching so a `*` can't push every bookmark (H1).
1820 assert_eq!(&args[..4], &["git", "push", "-b", "exact:feature"]);
1821 }
1822
1823 // The two backends handle a flag-like branch per the documented guard
1824 // convention: git rejects it BEFORE spawning (the branch lands in GitPush's
1825 // bare-positional refspec slot, where `--force` would otherwise be parsed
1826 // as a flag); jj passes it verbatim in the `-b` flag-VALUE slot, where jj
1827 // reads it as a bookmark name and errors itself — no flag injection is
1828 // possible there, so no pre-spawn guard exists (same as rebase/fetch_from).
1829 #[tokio::test]
1830 async fn push_flag_like_branch_follows_guard_convention() {
1831 use processkit::testing::RecordingRunner;
1832 let grec = RecordingRunner::replying(Reply::ok(""));
1833 let err = Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1834 .push("--force")
1835 .await
1836 .unwrap_err();
1837 assert!(
1838 matches!(err, Error::Vcs(processkit::Error::Spawn { .. })),
1839 "got: {err:?}"
1840 );
1841 assert_eq!(grec.calls().len(), 0, "no process must have spawned");
1842
1843 let jrec = RecordingRunner::replying(Reply::ok(""));
1844 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1845 .push("--force")
1846 .await
1847 .expect("jj path spawns; the value rides -b verbatim");
1848 assert_eq!(
1849 &jrec.only_call().args_str()[..4],
1850 &["git", "push", "-b", "exact:--force"],
1851 "the flag-like value must ride the -b flag-VALUE slot, not become argv"
1852 );
1853 }
1854
1855 #[tokio::test]
1856 async fn fetch_from_names_the_remote_on_both_backends() {
1857 use processkit::testing::RecordingRunner;
1858 let grec = RecordingRunner::replying(Reply::ok(""));
1859 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1860 .fetch_from("upstream")
1861 .await
1862 .unwrap();
1863 assert_eq!(
1864 grec.only_call().args_str(),
1865 ["fetch", "--quiet", "upstream"]
1866 );
1867
1868 let jrec = RecordingRunner::replying(Reply::ok(""));
1869 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1870 .fetch_from("upstream")
1871 .await
1872 .unwrap();
1873 let args = jrec.only_call().args_str();
1874 // `exact:` disables jj's glob matching on the remote name (H1).
1875 assert_eq!(&args[..4], &["git", "fetch", "--remote", "exact:upstream"]);
1876 }
1877
1878 // git: untracked files count as uncommitted but not as *tracked* changes.
1879 #[tokio::test]
1880 async fn git_has_tracked_changes_ignores_untracked() {
1881 let dirty = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0")));
1882 assert!(dirty.has_tracked_changes().await.unwrap());
1883 // `--untracked-files=no` means git itself omits `??` entries; an empty
1884 // reply is what a tracked-clean tree returns.
1885 let clean = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok("")));
1886 assert!(!clean.has_tracked_changes().await.unwrap());
1887 }
1888
1889 // jj has no untracked concept — `has_tracked_changes` follows `@`'s emptiness.
1890 #[tokio::test]
1891 async fn jj_has_tracked_changes_follows_working_copy() {
1892 let dirty =
1893 jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\tfalse\twip\n")));
1894 assert!(dirty.has_tracked_changes().await.unwrap());
1895 }
1896
1897 #[tokio::test]
1898 async fn conflicted_files_dispatches_per_backend() {
1899 let git =
1900 git_repo(ScriptedRunner::new().on(["git", "diff"], Reply::ok("a.rs\0b dir/c.rs\0")));
1901 assert_eq!(
1902 git.conflicted_files().await.unwrap(),
1903 ["a.rs", "b dir/c.rs"]
1904 );
1905
1906 let jj = jj_repo(
1907 ScriptedRunner::new().on(["jj", "resolve"], Reply::ok("a.rs 2-sided conflict\n")),
1908 );
1909 assert_eq!(jj.conflicted_files().await.unwrap(), ["a.rs"]);
1910 // The benign "no conflicts" non-zero exit still reads as an empty list.
1911 let clean = jj_repo(ScriptedRunner::new().on(
1912 ["jj", "resolve"],
1913 Reply::fail(2, "Error: No conflicts found at this revision"),
1914 ));
1915 assert!(clean.conflicted_files().await.unwrap().is_empty());
1916 }
1917
1918 #[test]
1919 fn merge_probe_is_clean() {
1920 assert!(MergeProbe::Clean.is_clean());
1921 assert!(!MergeProbe::Conflicts(vec!["a.rs".into()]).is_clean());
1922 }
1923
1924 // git try_merge, clean: probe merge, no MERGE_HEAD afterwards (the scripted
1925 // git-dir doesn't exist) → no abort, `Clean`.
1926 #[tokio::test]
1927 async fn git_try_merge_reports_clean_and_skips_needless_abort() {
1928 use processkit::testing::RecordingRunner;
1929 let rec = RecordingRunner::new(
1930 ScriptedRunner::new()
1931 .on(["git", "merge"], Reply::ok("Already up to date.\n"))
1932 .on(["git", "rev-parse"], Reply::ok("/vcs-core-no-such-git-dir")),
1933 );
1934 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1935 assert_eq!(repo.try_merge("other").await.unwrap(), MergeProbe::Clean);
1936 assert!(
1937 rec.calls()
1938 .iter()
1939 .all(|c| !c.args_str().contains(&"--abort".to_string())),
1940 "no merge to abort"
1941 );
1942 }
1943
1944 // git try_merge, conflict: conflicted paths are read BEFORE the abort (abort
1945 // clears the unmerged index), then the merge is aborted.
1946 #[tokio::test]
1947 async fn git_try_merge_collects_conflicts_then_aborts() {
1948 use processkit::testing::RecordingRunner;
1949 let rec = RecordingRunner::new(
1950 ScriptedRunner::new()
1951 // Order matters: ["merge","--abort"] must outrank the ["merge"] rule.
1952 .on(["git", "merge", "--abort"], Reply::ok(""))
1953 .on(
1954 ["git", "merge"],
1955 Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
1956 )
1957 .on(["git", "diff"], Reply::ok("a.rs\0")),
1958 );
1959 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1960 assert_eq!(
1961 repo.try_merge("other").await.unwrap(),
1962 MergeProbe::Conflicts(vec!["a.rs".to_string()])
1963 );
1964 let calls = rec.calls();
1965 let diff_pos = calls.iter().position(|c| c.args_str()[0] == "diff");
1966 let abort_pos = calls
1967 .iter()
1968 .position(|c| c.args_str().contains(&"--abort".to_string()));
1969 assert!(diff_pos.unwrap() < abort_pos.unwrap(), "{calls:?}");
1970 }
1971
1972 // git try_merge: a failing rollback must propagate, not be reported as a
1973 // clean/conflicted probe.
1974 #[tokio::test]
1975 async fn git_try_merge_propagates_abort_failure() {
1976 let tmp = TempDir::new("probe-abort");
1977 std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
1978 let repo = git_repo(
1979 ScriptedRunner::new()
1980 .on(
1981 ["git", "merge", "--abort"],
1982 Reply::fail(128, "fatal: cannot abort"),
1983 )
1984 .on(["git", "merge"], Reply::ok(""))
1985 .on(
1986 ["git", "rev-parse"],
1987 Reply::ok(tmp.path().to_str().unwrap()),
1988 ),
1989 );
1990 assert!(repo.try_merge("other").await.is_err());
1991 }
1992
1993 // jj try_merge: op head captured first, probe runs, op restore always runs.
1994 #[tokio::test]
1995 async fn jj_try_merge_probes_and_restores() {
1996 use processkit::testing::RecordingRunner;
1997 let rec = RecordingRunner::new(
1998 ScriptedRunner::new()
1999 .on(["jj", "op", "log"], Reply::ok("op42\n"))
2000 .on(["jj", "op", "restore"], Reply::ok(""))
2001 .on(["jj", "new"], Reply::ok(""))
2002 .on(["jj", "log"], Reply::ok("1\n")) // is_conflicted → true
2003 .on(["jj", "resolve"], Reply::ok("a.rs 2-sided conflict\n")),
2004 );
2005 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
2006 assert_eq!(
2007 repo.try_merge("feature").await.unwrap(),
2008 MergeProbe::Conflicts(vec!["a.rs".to_string()])
2009 );
2010 let calls = rec.calls();
2011 assert_eq!(calls[0].args_str()[..2], ["op", "log"]);
2012 assert_eq!(calls[1].args_str()[0], "new");
2013 let last = calls.last().unwrap().args_str();
2014 assert_eq!(last[..3], ["op", "restore", "op42"]);
2015 }
2016
2017 #[tokio::test]
2018 async fn jj_try_merge_clean_and_restore_failure() {
2019 // Conflict-free probe → Clean (no resolve call needed).
2020 let clean = jj_repo(
2021 ScriptedRunner::new()
2022 .on(["jj", "op", "log"], Reply::ok("op42\n"))
2023 .on(["jj", "op", "restore"], Reply::ok(""))
2024 .on(["jj", "new"], Reply::ok(""))
2025 .on(["jj", "log"], Reply::ok("0\n")),
2026 );
2027 assert_eq!(clean.try_merge("feature").await.unwrap(), MergeProbe::Clean);
2028
2029 // A failing op restore breaks the rollback guarantee → error, not Clean.
2030 let broken = jj_repo(
2031 ScriptedRunner::new()
2032 .on(["jj", "op", "log"], Reply::ok("op42\n"))
2033 .on(["jj", "op", "restore"], Reply::fail(1, "op not found"))
2034 .on(["jj", "new"], Reply::ok(""))
2035 .on(["jj", "log"], Reply::ok("0\n")),
2036 );
2037 assert!(broken.try_merge("feature").await.is_err());
2038 }
2039
2040 // continue_in_progress with unresolved paths reports `Conflict` and must NOT
2041 // attempt the continue (git would hard-error).
2042 #[tokio::test]
2043 async fn git_continue_blocked_by_conflicts_does_not_act() {
2044 use processkit::testing::RecordingRunner;
2045 let rec =
2046 RecordingRunner::new(ScriptedRunner::new().on(["git", "diff"], Reply::ok("a.rs\0")));
2047 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2048 assert_eq!(
2049 repo.continue_in_progress().await.unwrap(),
2050 OperationState::Conflict
2051 );
2052 assert!(
2053 rec.calls().iter().all(|c| c.args_str()[0] == "diff"),
2054 "only the conflict probe may run: {:?}",
2055 rec.calls()
2056 );
2057 }
2058
2059 // A continued rebase that stops on the NEXT patch's conflict exits non-zero;
2060 // continue_in_progress must report that as `Conflict`, not as an error. The
2061 // first conflict probe must see a clean index (else continue is blocked), the
2062 // post-continue probe must see the new conflict — a stateful predicate
2063 // sequences the two `diff` replies.
2064 #[tokio::test]
2065 async fn git_continue_maps_rebase_re_conflict() {
2066 use std::sync::Arc as StdArc;
2067 use std::sync::atomic::{AtomicBool, Ordering};
2068 let tmp = TempDir::new("rebase-restop");
2069 std::fs::create_dir_all(tmp.path().join("rebase-merge")).unwrap();
2070 let seen_first_diff = StdArc::new(AtomicBool::new(false));
2071 let flag = StdArc::clone(&seen_first_diff);
2072 let repo = git_repo(
2073 ScriptedRunner::new()
2074 .when(
2075 move |cmd| {
2076 cmd.arguments().first().and_then(|a| a.to_str()) == Some("diff")
2077 && flag.swap(true, Ordering::SeqCst)
2078 },
2079 Reply::ok("a.rs\0"),
2080 )
2081 .on(["git", "diff"], Reply::ok(""))
2082 .on(
2083 ["git", "rev-parse"],
2084 Reply::ok(tmp.path().to_str().unwrap()),
2085 )
2086 .on(
2087 ["git", "rebase", "--continue"],
2088 Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
2089 ),
2090 );
2091 assert_eq!(
2092 repo.continue_in_progress().await.unwrap(),
2093 OperationState::Conflict
2094 );
2095 }
2096
2097 // abort_in_progress dispatches to `merge --abort` when MERGE_HEAD is present.
2098 #[tokio::test]
2099 async fn git_abort_dispatches_on_merge_in_progress() {
2100 use processkit::testing::RecordingRunner;
2101 let tmp = TempDir::new("abort");
2102 std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
2103 let rec = RecordingRunner::new(
2104 ScriptedRunner::new()
2105 .on(
2106 ["git", "rev-parse"],
2107 Reply::ok(tmp.path().to_str().unwrap()),
2108 )
2109 .on(["git", "merge", "--abort"], Reply::ok("")),
2110 );
2111 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2112 repo.abort_in_progress().await.unwrap();
2113 assert!(
2114 rec.calls()
2115 .iter()
2116 .any(|c| c.args_str() == ["merge", "--abort"]),
2117 "{:?}",
2118 rec.calls()
2119 );
2120 }
2121
2122 // git surfaces an interrupted op as on-disk state: in_progress_state returns
2123 // Merge when MERGE_HEAD is present and Rebase when a rebase dir is — the
2124 // documented asymmetry (git's conflict IS that paused state, never `Conflict`
2125 // from this method).
2126 #[tokio::test]
2127 async fn git_in_progress_state_maps_merge_and_rebase() {
2128 let merging = TempDir::new("inprog-merge");
2129 std::fs::write(merging.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
2130 let merge_repo = Repo::from_git(
2131 "/repo",
2132 "/repo",
2133 Git::with_runner(ScriptedRunner::new().on(
2134 ["git", "rev-parse"],
2135 Reply::ok(merging.path().to_str().unwrap()),
2136 )),
2137 );
2138 assert_eq!(
2139 merge_repo.in_progress_state().await.unwrap(),
2140 OperationState::Merge
2141 );
2142
2143 let rebasing = TempDir::new("inprog-rebase");
2144 std::fs::create_dir_all(rebasing.path().join("rebase-merge")).unwrap();
2145 let rebase_repo = Repo::from_git(
2146 "/repo",
2147 "/repo",
2148 Git::with_runner(ScriptedRunner::new().on(
2149 ["git", "rev-parse"],
2150 Reply::ok(rebasing.path().to_str().unwrap()),
2151 )),
2152 );
2153 assert_eq!(
2154 rebase_repo.in_progress_state().await.unwrap(),
2155 OperationState::Rebase
2156 );
2157 }
2158
2159 // On an unborn git repo (no commits) diff_stat probes is_unborn and stats
2160 // against the empty tree instead of the unresolvable HEAD, so a fresh working
2161 // tree reports its additions rather than erroring.
2162 #[tokio::test]
2163 async fn git_diff_stat_unborn_uses_empty_tree() {
2164 use processkit::testing::RecordingRunner;
2165 let rec = RecordingRunner::new(
2166 ScriptedRunner::new()
2167 .on(["git", "rev-parse"], Reply::fail(1, "")) // HEAD unborn
2168 .on(
2169 ["git", "diff", "--shortstat"],
2170 Reply::ok(" 1 file changed, 2 insertions(+)\n"),
2171 ),
2172 );
2173 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2174 let stat = repo.diff_stat().await.unwrap();
2175 assert_eq!(stat.insertions, 2);
2176 assert!(
2177 rec.calls()
2178 .iter()
2179 .any(|c| c.args_str() == ["diff", "--shortstat", vcs_git::EMPTY_TREE]),
2180 "diff_stat should target the empty tree on an unborn repo: {:?}",
2181 rec.calls()
2182 );
2183 }
2184
2185 // On jj, abort/continue are reporting no-ops (nothing is ever paused).
2186 #[tokio::test]
2187 async fn jj_abort_and_continue_are_reporting_noops() {
2188 let conflicted = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("1\n")));
2189 assert_eq!(
2190 conflicted.abort_in_progress().await.unwrap(),
2191 OperationState::Conflict
2192 );
2193 let clear = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("0\n")));
2194 assert_eq!(
2195 clear.continue_in_progress().await.unwrap(),
2196 OperationState::Clear
2197 );
2198 }
2199
2200 // jj records conflicts on the change; the facade maps that to `Conflict`.
2201 #[tokio::test]
2202 async fn jj_in_progress_state_maps_conflict() {
2203 let conflicted = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("1\n")));
2204 assert_eq!(
2205 conflicted.in_progress_state().await.unwrap(),
2206 OperationState::Conflict
2207 );
2208 let clear = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("0\n")));
2209 assert_eq!(
2210 clear.in_progress_state().await.unwrap(),
2211 OperationState::Clear
2212 );
2213 }
2214
2215 // `&dyn VcsRepo` must dispatch through the real inherent methods (a delegating
2216 // body that recursed would stack-overflow here instead of returning).
2217 #[tokio::test]
2218 async fn vcs_repo_trait_object_dispatches() {
2219 let repo = git_repo(
2220 ScriptedRunner::new()
2221 .on(["git", "symbolic-ref"], Reply::ok("main\n"))
2222 .on(["git", "show-ref"], Reply::ok("")),
2223 );
2224 let dynamic: &dyn VcsRepo = &repo;
2225 assert_eq!(dynamic.kind(), BackendKind::Git);
2226 assert_eq!(
2227 dynamic.current_branch().await.unwrap().as_deref(),
2228 Some("main")
2229 );
2230 // Exercise a reference-argument async method through `&dyn` — pins the
2231 // async_trait lifetime capture the macro relies on (no-arg calls don't).
2232 assert!(dynamic.branch_exists("main").await.unwrap());
2233 }
2234
2235 // When the backend has no native trunk (git `origin/HEAD` unset), the facade
2236 // falls back to a local `main`, then `master`.
2237 #[tokio::test]
2238 async fn trunk_falls_back_to_main() {
2239 let repo = git_repo(
2240 ScriptedRunner::new()
2241 .on(["git", "symbolic-ref"], Reply::fail(1, "")) // origin/HEAD unset → None
2242 .on(["git", "show-ref"], Reply::ok("")), // branch_exists("main") → exit 0
2243 );
2244 assert_eq!(repo.trunk().await.unwrap().as_deref(), Some("main"));
2245 }
2246
2247 #[test]
2248 fn error_classifiers_recognise_markers() {
2249 let conflict = Error::Vcs(processkit::Error::Exit {
2250 program: "git".into(),
2251 code: 1,
2252 stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
2253 stderr: String::new(),
2254 });
2255 assert!(conflict.is_merge_conflict());
2256 assert!(!conflict.is_nothing_to_commit());
2257 // A non-Vcs error classifies as none of them.
2258 assert!(!Error::NotARepository("/x".into()).is_merge_conflict());
2259 }
2260}
2261
2262// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
2263#[doc = include_str!("../docs/core.md")]
2264#[allow(rustdoc::broken_intra_doc_links)]
2265pub mod guide {
2266 #[doc = include_str!("../docs/cookbook.md")]
2267 #[allow(rustdoc::broken_intra_doc_links)]
2268 pub mod cookbook {}
2269 #[doc = include_str!("../docs/process-model.md")]
2270 #[allow(rustdoc::broken_intra_doc_links)]
2271 pub mod process_model {}
2272 #[doc = include_str!("../docs/positioning.md")]
2273 #[allow(rustdoc::broken_intra_doc_links)]
2274 pub mod positioning {}
2275 #[doc = include_str!("../docs/stability.md")]
2276 #[allow(rustdoc::broken_intra_doc_links)]
2277 pub mod stability {}
2278}