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