vcs-core 0.3.0

Unified facade over vcs-git and vcs-jj: repo detection and a backend-agnostic VCS handle.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
# vcs-core — backend-agnostic facade guide

`vcs-core` lifts one layer that every downstream tool kept re-implementing:
*detect whether a directory is git or jj, then dispatch the operations both tools
share behind a single interface.* It returns backend-agnostic DTOs, so a caller
codes against "the repository" rather than against `git` or `jj` specifically.

It is deliberately a thin common surface — not a replacement for the underlying
clients. Rich, tool-specific operations (a full `merge`, jj's `op restore`,
range/revset-scoped queries) stay on [`vcs-git`](https://docs.rs/vcs-git/latest/vcs_git/guide/) / [`vcs-jj`](https://docs.rs/vcs-jj/latest/vcs_jj/guide/) and
are reachable through escape hatches on the handle. Reach for the facade when the
code must work on both backends; drop to the raw client the moment you need
power only one of them offers.

Examples use `vcs_core::Result<()>` and hidden `# ` setup lines. The `no_run`
ones compile against this crate's API (but don't spawn `git`/`jj`); a few
signature snippets are marked `ignore`.

```rust,no_run
use vcs_core::Repo;
# fn run() -> vcs_core::Result<()> {
let repo = Repo::open(".")?;
println!("backend: {}", repo.kind().as_str()); // "git" / "jj"
# Ok(()) }
```

## Detection

```rust,ignore
pub fn detect(start: &Path) -> Option<Located>
```

`detect` walks up from `start` to the filesystem root, returning the first
repository it finds. A `.jj` directory **wins over `.git`** — colocated repos are
driven through jj, since that's the tool actually managing the working copy.
`.git` may be a directory *or* a gitlink file (a linked worktree or submodule),
so the git probe is `.exists()`, not `.is_dir()`. Pure filesystem probing — no
subprocess is ever spawned.

`start` is walked via `Path::parent`, so pass an **absolute** path to search
ancestors. A relative path like `"."` has no ancestor chain — only its own
directory is checked. (`Repo::open` absolutises for you; `detect` does not.)

```rust,ignore
#[non_exhaustive]
pub struct Located {
    pub kind: BackendKind, // Git / Jj
    pub root: PathBuf,     // the directory holding .git/.jj — the worktree root
}
```

`BackendKind` is `Git` or `Jj`, with `as_str(self) -> &'static str` returning
`"git"` / `"jj"`.

```rust,ignore
# use std::path::Path;
# use vcs_core::{detect, BackendKind};
# fn run() {
if let Some(loc) = detect(Path::new("/abs/path/to/checkout/src")) {
    match loc.kind {
        BackendKind::Jj  => println!("jj at {}", loc.root.display()),
        BackendKind::Git => println!("git at {}", loc.root.display()),
    }
}
# }
```

## Opening a repo

```rust,ignore
impl Repo<JobRunner> {
    pub fn open(dir: impl AsRef<Path>) -> Result<Self>;
}
```

`Repo::open` detects the repository at or above `dir` and opens a handle **bound
to `dir`**, using the real job-backed process runner. It errors with
`Error::NotARepository(dir)` when no `.git`/`.jj` is found from the start dir up
to the filesystem root.

For tests or a pre-configured client, build a handle from an explicit client —
these are generic over the `ProcessRunner` so you can inject a fake:

```rust,ignore
pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self;
pub fn from_jj (root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>)  -> Self;
```

### Properties and re-anchoring

```rust,ignore
pub fn kind(&self) -> BackendKind;  // which backend drives this handle
pub fn root(&self) -> &Path;        // the repo root detected at open time
pub fn cwd(&self)  -> &Path;        // the directory operations run against
pub fn at(&self, dir: impl Into<PathBuf>) -> Self; // sibling handle bound elsewhere
```

`at` returns a sibling handle bound to `dir`, sharing this handle's client (the
backend is held behind an `Arc`) and root. It's cheap — no client rebuild, no
re-detection — so threading work across worktrees doesn't mean re-opening:

```rust,no_run
# async fn f(repo: vcs_core::Repo, wt: &std::path::Path) -> vcs_core::Result<()> {
let wt = repo.at(wt);                 // owns the re-anchored handle
let dirty = wt.has_uncommitted_changes().await?;
# Ok(()) }
```

## Escape hatches to the underlying client

The facade only covers what both tools share. For anything else — tool-specific
operations, range queries, jj transactions — drop to the typed client:

```rust,ignore
pub fn git(&self) -> Option<&Git<R>>;       // None when jj-backed
pub fn jj(&self)  -> Option<&Jj<R>>;        // None when git-backed
pub fn git_at(&self) -> Option<GitAt<'_, R>>; // bound to self.cwd(); None when jj-backed
pub fn jj_at(&self)  -> Option<JjAt<'_, R>>;  // bound to self.cwd(); None when git-backed
```

`git()`/`jj()` hand out a borrow of the raw client (whose methods still take a
`dir` argument). `git_at()`/`jj_at()` hand out the cwd-bound view (`GitAt` /
`JjAt`) whose methods omit `dir` — the dir-free counterpart. The bound view
borrows `self`, so to work in another worktree **bind the re-anchored handle
first** — the view can't outlive a temporary `at`:

```rust,no_run
# async fn f(repo: vcs_core::Repo, wt: &std::path::Path) -> vcs_core::Result<()> {
let wt = repo.at(wt);          // owns the re-anchored handle
let git = wt.git_at().unwrap();
git.fetch().await?;
# Ok(()) }
```

`vcs_core` re-exports `vcs_git` and `vcs_jj`, so a consumer depending only on
`vcs-core` still reaches the raw client types (`GitApi`, `JjApi`, `WorktreeAdd`,
`JjFileset`, …) without adding those crates as separate dependencies.

## Status & files

```rust,ignore
pub async fn current_branch(&self) -> Result<Option<String>>;
pub async fn trunk(&self)          -> Result<Option<String>>;
pub async fn local_branches(&self) -> Result<Vec<String>>;
pub async fn branch_exists(&self, name: &str) -> Result<bool>;
pub async fn conflicted_files(&self) -> Result<Vec<String>>;
pub async fn changed_files(&self)    -> Result<Vec<FileChange>>;
pub async fn diff_stat(&self)        -> Result<DiffStat>;
pub async fn snapshot(&self)         -> Result<RepoSnapshot>;
```

`current_branch` is the current branch (git) or bookmark (jj); `None` when
detached / no bookmark on the working copy.

`trunk` resolves in order: the backend's own notion (git's `origin/HEAD`, jj's
`trunk()` revset), then a fallback to a local `main`, then `master`; `None` when
none resolve.

`local_branches` lists local branch (git) / bookmark (jj) names. `branch_exists`
checks one by name.

`conflicted_files` returns paths with unresolved merge conflicts in the working
copy — **repo-relative, `/` separators** (git `diff --diff-filter=U` / jj
`resolve --list -r @`). Empty when there are none.

`changed_files` is the working-copy change set (git `status` / jj
`diff -r @ --summary`), as `Vec<FileChange>`. `diff_stat` is the aggregate
insertion/deletion counts.

`snapshot` is the **batched** state query for a prompt/status-bar/TUI refresh —
branch, upstream, ahead/behind, HEAD, dirtiness, change count, and operation
state in **one or two** spawns rather than a call per field
([`RepoSnapshot`](#reposnapshot)). git issues one `status --porcelain=v2 --branch`
plus the cheap in-progress probe; jj issues one `log -r @` template plus a change
count only when dirty. Note the asymmetry: `upstream`/`ahead`/`behind` are always
`None` on jj (no git-style upstream tracking).

> **Backend nuance — untracked files.** `diff_stat` counts the git working tree
> against `HEAD` (`git diff`, which **excludes untracked files**), but on jj it
> counts the `@` change against its parent (which **includes** newly-added
> files). So a brand-new file shows in `changed_files` but *not* in `diff_stat`
> on git, whereas on jj it shows in both.

```rust,no_run
# async fn f(repo: vcs_core::Repo) -> vcs_core::Result<()> {
for c in repo.changed_files().await? {
    match c.old_path {
        Some(from) => println!("rename {from} -> {}", c.path),
        None       => println!("{:?} {}", c.kind, c.path),
    }
}
let stat = repo.diff_stat().await?;
println!("{} files, +{} -{}", stat.files_changed, stat.insertions, stat.deletions);
# Ok(()) }
```

## Uncommitted state

```rust,ignore
pub async fn has_uncommitted_changes(&self) -> Result<bool>;
pub async fn has_tracked_changes(&self)     -> Result<bool>;
```

`has_uncommitted_changes` — whether the working copy has *any* uncommitted
change (git: a non-empty `status`; jj: a non-empty working-copy change `@`).

`has_tracked_changes` — whether *tracked* files have uncommitted changes. git
ignores untracked files here (`status --untracked-files=no`); jj auto-tracks new
files, so it has no untracked concept and this is identical to
`has_uncommitted_changes` on jj.

## Branch mutations

```rust,ignore
pub async fn delete_branch(&self, name: &str, force: bool) -> Result<()>;
pub async fn rename_branch(&self, old: &str, new: &str)     -> Result<()>;
```

`delete_branch` deletes a local branch (git) / bookmark (jj). **`force` applies
to git only** (`branch -D` vs `-d`); jj has no force and ignores the flag.

`rename_branch` renames a local branch (git) / bookmark (jj).

## Commits & paths

```rust,ignore
pub async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()>;
```

Commit exactly `paths` with `message` (git `commit --only`, jj
`commit <filesets>`). **Paths are repo-relative.**

## Remotes

```rust,ignore
pub async fn fetch(&self)                          -> Result<()>;
pub async fn fetch_from(&self, remote: &str)       -> Result<()>;
pub async fn fetch_remote_branch(&self, branch: &str) -> Result<()>;
pub async fn push(&self, branch: &str)             -> Result<()>;
```

- `fetch` — from the default remote (git `fetch` / jj `git fetch`).
- `fetch_from` — from a *named* remote (git `fetch <remote>` / jj
  `git fetch --remote <remote>`).
- `fetch_remote_branch` — a single branch/bookmark from `origin` into its
  remote-tracking ref (git `fetch_remote_branch` / jj `git fetch -b`).
- `push` — an **existing** local branch/bookmark to `origin` (git
  `push -u origin <branch>` / jj `git push -b <branch>`). The backends honestly
  differ: git pushes the *ref* and records the upstream (`-u`, idempotent); jj
  pushes the *bookmark's state* — including a remote deletion if the bookmark
  was deleted locally. Renamed refspecs (`local:remote`) and non-`origin`
  remotes are git-only — use the escape hatch (`vcs_git::GitPush`).

Transient network failures are retried by the underlying client; for retrying a
higher-level flow, classify with `Error::is_transient_fetch_error`.

## Checkout / rebase

```rust,ignore
pub async fn checkout(&self, reference: &str) -> Result<()>;
pub async fn rebase(&self, onto: &str)        -> Result<()>;
```

`checkout` switches the working copy to `reference` — and this is where the two
tools genuinely differ in verb: **git `checkout`, jj `edit`.** The facade
dispatches to whichever the backend uses.

`rebase` rebases the current work onto `onto` (git `rebase` / jj `rebase -d`).
`onto` is a branch/bookmark name or revision the backend understands.

## Merge probe & operation state

```rust,ignore
pub async fn try_merge(&self, source: &str)    -> Result<MergeProbe>;
pub async fn in_progress_state(&self)          -> Result<OperationState>;
pub async fn continue_in_progress(&self)       -> Result<OperationState>;
pub async fn abort_in_progress(&self)          -> Result<OperationState>;
```

`try_merge` probes whether merging `source` into the current work would conflict,
**without leaving any trace** — the probe is rolled back before returning,
whatever the outcome (git: `merge --no-commit --no-ff` then `merge --abort`; jj:
a merge change probed and undone via `op restore`). It only *reports* what a real
merge would do.

- git requires a clean-enough working tree: a dirty-tree refusal propagates as a
  plain error, **not** as `MergeProbe::Conflicts`.
- A failing *rollback* **propagates as an error** rather than returning a result
  that would misdescribe the on-disk state.

```rust,ignore
# use vcs_core::MergeProbe;
# async fn f(repo: vcs_core::Repo) -> vcs_core::Result<()> {
match repo.try_merge("feature").await? {
    MergeProbe::Clean            => println!("merges cleanly"),
    MergeProbe::Conflicts(paths) => println!("would conflict in {paths:?}"),
}
# Ok(()) }
```

The remaining three deal with operation state, and this is the sharpest
git-vs-jj asymmetry the facade has to paper over. git models an in-progress merge
or rebase as *paused on-disk state* (`MERGE_HEAD`, a `rebase-*` dir); jj has no
paused multi-step operations at all — it records a conflict directly on the
working-copy change.

`in_progress_state` reports whether the working copy is mid-operation. On git it
returns `Merge`/`Rebase` and **never `Conflict`** — a git conflict *is* that
paused state, and the conflict itself surfaces on the failed op (via
`Error::is_merge_conflict`) or via `continue_in_progress`. On jj, which has no paused
op, it reports `Conflict` directly.

`continue_in_progress` continues after conflict resolution (git:
`commit --no-edit` for a merge / `rebase --continue`; jj: a **no-op** —
resolving the files *is* the continuation). It returns the fresh *post-call*
state:
- `Conflict` when unresolved paths still block continuing (and **here git
  *does* report `Conflict`**, unlike `in_progress_state`), or when a continued
  rebase stops on the next patch's conflict.
- `Clear` when the operation finished.

`abort_in_progress` aborts the in-progress operation, if any (git:
`merge --abort` / `rebase --abort`; jj: a **no-op** — nothing is ever paused;
roll back explicitly via the jj client's `transaction` / `op_restore`). It
returns the fresh *post-call* state — `Clear` when nothing was, or remains, in
progress.

## Worktrees / workspaces

```rust,ignore
pub async fn list_worktrees(&self)  -> Result<Vec<WorktreeInfo>>;
pub async fn create_worktree(&self, path: &Path, branch: &str, base: &str) -> Result<CreateOutcome>;
pub async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()>;
pub fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()>;
```

`list_worktrees` lists attached worktrees (git) / workspaces (jj).

`create_worktree` creates a worktree/workspace at `path` on a **new** `branch`
based on `base`. It always reports `CreateOutcome::Plain` — a copy-on-write
strategy stays in the consumer. `branch` must not already exist. **The jj path is
not atomic**: it's two steps (`workspace add`, then `bookmark create`), and if
the bookmark step fails the freshly-added workspace is left in place for the
caller to clean up. A consumer needing resume-existing or rollback semantics
should drive the underlying client via `jj()` / `git()`.

`remove_worktree` removes the worktree/workspace at `path`. For jj this resolves
the workspace name by matching `path`, deletes the directory, then forgets it; a
jj `path` that matches no attached workspace returns `Error::WorktreeNotFound`
(contrast `cleanup_worktree_blocking` below, where no-match is a `Ok` no-op).

`cleanup_worktree_blocking` is the **synchronous** counterpart — for a context
that cannot `.await`, chiefly a `Drop` guard. It force-removes the worktree at
`path` (git: `worktree remove --force`; jj: resolve the workspace name by
`path`, delete the directory, then `workspace forget`). It is best-effort and
short-lived: it **shells out directly with no job-containment**, unlike the async
methods. A jj `path` that matches no workspace is a no-op (`Ok`).

```rust,no_run
# use std::path::Path;
# use vcs_core::CreateOutcome;
# async fn f(repo: vcs_core::Repo) -> vcs_core::Result<()> {
let out = repo.create_worktree(Path::new("/tmp/feat"), "feature", "main").await?;
assert_eq!(out, CreateOutcome::Plain);
repo.remove_worktree(Path::new("/tmp/feat"), false).await?;
# Ok(()) }
```

## DTOs

All facade DTOs are `#[non_exhaustive]` — construct via the facade, match with a
`..` rest pattern, and don't rely on field-init syntax from outside the crate.

### `BackendKind`

`Git` | `Jj`. `as_str(self) -> &'static str``"git"` / `"jj"`.

### `ChangeKind`

`Added` (added or untracked) | `Modified` | `Deleted` | `Renamed` (see
`FileChange::old_path`).

### `FileChange`

```rust,ignore
#[non_exhaustive]
pub struct FileChange {
    pub path: String,             // the path (the *new* path for a rename)
    pub old_path: Option<String>, // original path for a rename; jj never supplies it
    pub kind: ChangeKind,
}
```

### `DiffStat`

```rust,ignore
#[non_exhaustive]
pub struct DiffStat {
    pub files_changed: usize,
    pub insertions: usize,
    pub deletions: usize,
}
```

`Default` derives to all-zero.

### `WorktreeInfo`

```rust,ignore
#[non_exhaustive]
pub struct WorktreeInfo {
    pub path: PathBuf,             // working copy of the worktree
    pub branch: Option<String>,   // branch (git) / first bookmark (jj); None when detached/none
    pub commit: Option<String>,   // checked-out commit; None when unavailable (e.g. a bare git entry)
    pub is_bare: bool,            // a bare git worktree entry (always false for jj)
}
```

### `OperationState`

Unifies the backends' different models of "mid-operation":

| Variant    | When it arises |
| ---------- | -------------- |
| `Clear`    | No operation in progress and no conflict. |
| `Merge`    | A git merge is in progress (`MERGE_HEAD` present). git only. |
| `Rebase`   | A git rebase is in progress (a `rebase-merge`/`rebase-apply` dir present). git only. |
| `Conflict` | The working copy has an unresolved conflict — chiefly jj, which records conflicts on the change rather than pausing an operation. On git this surfaces from `continue_in_progress`, not `in_progress_state`. |

### `RepoSnapshot`

The batched state from [`snapshot`](#status--files). `#[non_exhaustive]`.

```rust,ignore
#[non_exhaustive]
pub struct RepoSnapshot {
    pub head: Option<String>,      // working-copy commit's FULL oid (both backends); None on an unborn git repo; truncate for display
    pub branch: Option<String>,    // current branch (git) / bookmark (jj); None when detached/unset
    pub upstream: Option<String>,  // upstream tracking branch; None when unset, ALWAYS None on jj
    pub ahead: Option<usize>,      // commits ahead of upstream; None with no upstream (always on jj)
    pub behind: Option<usize>,     // commits behind upstream; None with no upstream (always on jj)
    pub dirty: bool,               // any uncommitted change (tracked or untracked)
    pub change_count: usize,       // number of changed paths
    pub conflicted: bool,          // an unresolved conflict is present
    pub operation: OperationState, // in-progress operation / conflict state
}
```

### `MergeProbe`

`Clean` | `Conflicts(Vec<String>)` — the conflicting paths, repo-relative with
`/` separators, the same contract as `conflicted_files`. `is_clean(&self) ->
bool` returns whether the probe found no conflicts. The probe is always rolled
back before it returns; this type only *reports* what a real merge would do.

### `CreateOutcome`

`Plain` (the tool materialised the working copy itself) | `CowCloned` (a
copy-on-write clone populated the working copy). The facade **always** reports
`Plain`; the `CowCloned` variant exists so a consumer that layers a
copy-on-write strategy on top can reuse this type rather than inventing its own.

### `Error`

The facade error wraps `processkit::Error` and adds detection failures:
`NotARepository(PathBuf)`, `WorktreeNotFound(PathBuf)`, `Io(io::Error)`,
`Vcs(processkit::Error)`. Classifiers let a caller branch without matching on
internals: `is_merge_conflict()`, `is_nothing_to_commit()`, `is_transient_fetch_error()`
(named to match the wrapper classifiers — one name per concept workspace-wide).
`Result<T>` is `std::result::Result<T, Error>`. See
[Process model & errors](https://docs.rs/vcs-core/latest/vcs_core/guide/process_model/).

### The `VcsRepo` trait

```rust,ignore
#[async_trait::async_trait]
pub trait VcsRepo: Send + Sync { /* … */ }
```

The backend-agnostic common surface of `Repo`, as an **object-safe** trait — so a
consumer can hold a `Box<dyn VcsRepo>` / `&dyn VcsRepo` and code against the
operations *without* naming the `ProcessRunner` generic or wrapping `Repo`
themselves. Every method mirrors the like-named inherent method on `Repo`; the
trait adds nothing but the abstraction boundary. Tool-specific operations stay
off it — reach those through the concrete `Repo` and its bound handles.

For hermetic tests, build a `Repo` over a fake runner with `from_git` /
`from_jj` rather than mocking this trait.

```rust,no_run
# async fn report(repo: &dyn vcs_core::VcsRepo) -> vcs_core::Result<()> {
println!("{} on {:?}", repo.kind().as_str(), repo.current_branch().await?);
# Ok(()) }
```

## When to use the facade vs the raw client

Use the **facade** (`Repo` / `VcsRepo`) for code that must run on both backends:
status, diffs, fetch/push, partial commits, worktree lifecycle, conflict probing.
You get one code path and backend-agnostic DTOs.

Drop to the **raw client** — `repo.git()` / `repo.jj()` (or the bound
`git_at()` / `jj_at()`) — the moment you need power only one tool offers: a full
`merge`, jj's `op restore` / transactions, range/revset-scoped queries (git's
`a..b` and jj's revsets aren't interchangeable, so they're deliberately *not* on
the common surface). The handle hands out a borrow; the consumer decides, per
call, whether to go through the facade or straight to the tool.

A quick router:

| You need… | Use |
|---|---|
| Backend-portable state/lifecycle (status, snapshot, branches, commit-paths, fetch/push, worktrees, conflict probe) | the facade method |
| An op with no faithful analogue on the other backend (full merge, rebase `--onto`, jj transactions / `op restore`, stash, tags, range diffs, revsets) | the raw client: `repo.git()?` / `repo.jj()?` |
| The same, dir-free at the handle's cwd | the bound view: `repo.git_at()?` / `repo.jj_at()?` |
| Options the facade's LCD drops (push refspecs/remotes via `GitPush`, amend via `CommitPaths`, `--no-ff` via `MergeCommit`…) | the raw client with its spec/builder |
| A flag/subcommand the wrapper doesn't model at all | the wrapper's raw `run(dir, args)` |

### The three call shapes

Every git/jj operation is reachable in three equivalent shapes — pick by how
much context the call site already holds:

1. **Dir-threading** (`Git::new().fetch(dir)`) — the wrapper client itself;
   right when one client serves many repos, or `dir` varies per call.
2. **At-view** (`repo.git_at()?.fetch()`) — dir-free, bound to the handle's
   cwd; right inside facade-holding code that needs one tool-specific call.
3. **Facade** (`repo.fetch()`) — backend-portable; right whenever the operation
   is on the common surface (prefer it there — you get jj support for free).

## See also

- [vcs-git guide]https://docs.rs/vcs-git/latest/vcs_git/guide/
- [vcs-jj guide]https://docs.rs/vcs-jj/latest/vcs_jj/guide/
- [Testing & mocking]https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/
- [Process model & errors]https://docs.rs/vcs-core/latest/vcs_core/guide/process_model/
- [crate docs]https://docs.rs/vcs-core