sparkles:dman — VCS Backend (per-repo)
The per-repository layer: the branch / worktree / status data model and the VcsRepo backend that produces it. Git-first, kept jj-shaped (D8). Discovery and cataloging of repos is a separate concern — see Repo catalog; how a git command is actually invoked is the Command schema.
Data model
All types are plain structs (wired-decodable, Expected-returning at the edges):
// 5-way, mutually exclusive, priority-ordered (current > protected > gone >
// merged > unmerged), matching the prior-art classification.
enum BranchStatus { current, protected_, goneUpstream, safeMerged, unmerged }
// safe-to-delete = goneUpstream | safeMerged ; deletable = !(current | protected_)
struct BranchInfo {
string name;
Nullable!string upstream;
Nullable!int ahead, behind; // vs upstream
BranchStatus status;
bool stale; // orthogonal age flag, NOT a 6th status
string tipSha, tipAuthor, tipSubject;
SysTime tipTime; // last activity (a sort key)
SysTime createdTime; // first commit trunk..branch
string createdAuthor;
Nullable!WorktreeRef worktree; // checked-out-here / elsewhere
Nullable!PrInfo pr; // optional gh enrichment
}
struct WorktreeInfo {
string path;
Nullable!string branch; // null when detached
string headSha;
bool bare, detached, locked, prunable;
}
struct WorktreeRef { string path; bool isCurrent; } // branch → worktree link
struct RepoStatus {
string root, currentBranch;
bool detached, clean;
int staged, unstaged, untracked;
}
enum PrState { open, merged, closed }
struct PrInfo { uint number; PrState state; string title, url; }Two refinements over the prior art: staleness is an orthogonal flag (a branch can be unmerged and stale — a mutually-exclusive "stale" bucket would lose that), with a configurable age threshold; and branch↔worktree association is first-class, so a branch checked out in another worktree is shown correctly instead of misclassified.
Branch classification
Priority order (first match wins):
- current — the branch checked out in this worktree.
- protected_ — the trunk, or a configured protected name (default set:
main,master,develop,development). - goneUpstream — upstream tracked but deleted (
[gone]). - safeMerged — reachable from the trunk (
git branch --merged <trunk>). - unmerged — everything else.
Trunk detection ladder: explicit config → symbolic-ref --short refs/remotes/origin/HEAD (strip origin/) → first existing of main / master → main. Staleness is computed separately from tipTime against a configurable threshold and never changes the status.
Delete safety & command hardening
gone → auto-force— a branch whose upstream is gone auto-escalates to force-delete even without global force mode, because squash/rebase merges leave it "not fully merged" though it is actually safe (safeMergedis force-free;unmergedstill requires explicit force).- Undo record — a delete records the removed ref's SHA (and a removed-worktree record) so it can be undone; on jj this rides the operation log (D8), on git it is dman's own record.
- No-shell arg-vector invariant — every git/jj call is an explicit argument vector, never a formatted shell string, so a hostile ref/branch name cannot inject. This is already the command-schema render shape (D5) — stated here as a security invariant.
- Protected-branch write guard — mutating commands refuse to run on a protected/primary branch (patterns from config).
- Tool preflight — verify the required backend binary is on PATH (
isInPath) with an actionable message before invoking it. - Output-drift guard — pin a minimum VCS version and prefer stable machine formats (git plumbing
--format/--porcelain; jj-Ttemplates) so parsing doesn't break across tool versions.
The VcsRepo backend
A capability-based interface (D8; Design-by-Introspection), with Git as v1's sole implementation and jj added at P3 — a common core both backends fill, plus capability-gated optional operations (see Designing for jj). Every operation is a command schema rendered → spawned via event-horizon proc → decoded, per D4/D5; nothing hand-parses at the call site:
interface VcsRepo {
Expected!(string, VcsError) root(), defaultBranch();
Expected!(RepoStatus, VcsError) status();
Expected!(BranchInfo[], VcsError) branches();
Expected!(WorktreeInfo[], VcsError) worktrees();
Expected!(void, VcsError) deleteBranch(string name, bool force);
Expected!(WorktreeInfo, VcsError) addWorktree(string path, string branch, bool create);
Expected!(void, VcsError) removeWorktree(string path, bool force);
Expected!(void, VcsError) pruneWorktrees();
}Git emits no JSON, so reads use stable plumbing formats parsed by a small base.text porcelain parser — the %x1f / %x1e field/record-separator trick already proven in release/git.d — rather than wired. (gh, which does emit --json, decodes through wired.) The git commands behind each datum:
| Datum | git command |
|---|---|
| branch list + upstream + track | for-each-ref --format='%(refname:short)%1f%(upstream:short)%1f%(upstream:track)' refs/heads/ |
| merged-into-trunk set | branch --merged <trunk> --format='%(refname:short)' |
| ahead / behind | rev-list --left-right --count <branch>...<upstream> |
| tip meta | log -1 --format='%h%x1f%an%x1f%cI%x1f%s' <branch> (%cI = strict ISO-8601 → SysTime) |
| creation time + author | log --reverse --format='%cI%x1f%an' <trunk>..<branch> (first record) |
| trunk | symbolic-ref --short refs/remotes/origin/HEAD (+ fallbacks) |
| status | status --porcelain=v2 --branch |
| worktrees | worktree list --porcelain |
| delete | branch -d / branch -D |
| remote slug | remote get-url origin (→ owner/repo) |
Each VcsRepo method returns Expected and never throws (git failures become a VcsError carrying stderr). Because the spawner is a capability, the whole backend is testable with a FakeSpawner returning canned output keyed by argv — see Command schema.
Worktree model (net-new)
The prior art has no worktree support, so dman designs it from scratch, built on git worktree list --porcelain (path / HEAD / branch / bare / detached / locked / prunable per entry). This drives the branch↔worktree link and a "checked out elsewhere" state that the naive single-current model gets wrong.
On-disk layout (D9): sibling directories named <repo>-<branch> next to the main checkout by default (matching the existing sparkles worktree convention, e.g. sparkles-dman), configurable via a naming template; the repo scanner discovers them as ordinary repos. Operations: addWorktree (optionally creating the branch), removeWorktree, pruneWorktrees, and enter/exit (a shell in the worktree).
Worktree workflow (D13)
Composable, worktree-native primitives (no agent machinery):
enter/exec—enterchanges into a worktree's directory and records its context;execruns a command in that context non-interactively and returns the child's exit code. Each is a reusable primitive that scripts and automation compose rather than reimplement.- Branch-per-task naming — a deterministic template maps a unit of work to a branch and its worktree (the D9 layout), giving a predictable work → branch → worktree mapping.
- Working-copy mode — a 2-mode taxonomy on each worktree record:
inPlace(work in the user's own checkout — isolation off, mutation explicit) vsisolatedWorktree(the default sibling worktree). Anoverlaymode is reserved for the deferred snapshot subsystem (D12). - File-based context descriptor — the branch/worktree context is published as a small on-disk descriptor and resolved by a precedence chain (explicit flag → descriptor file → auto-detect), robust where environment variables don't propagate through nested / child processes.
Three cross-cutting host helpers belong in the shared layer (reused by the PR column, opening forge pages, and tool detection): a remote-URL → owner/repo slug parser (HTTPS + SSH forms, directory-basename fallback), an "open URL in the browser" helper, and an "is external tool on PATH" probe.
PR enrichment (optional)
gh pr list --head <branch> --state all --limit 1 --json number,state,title,url → decoded via wired into PrInfo. It is an opt-in column; its cache lives with the catalog persistence — see Repo catalog § persistence.
Designing for jj (the P3 backend)
jj (P3) diverges from git deeply enough that the interface above is capability-based rather than git-shaped. The full analysis is in Designing for Jujutsu; the shape it imposes here:
- Output decode is backend-pluggable. git reads parse porcelain (
--porcelain=v2/for-each-ref --format, the%x1f/%x1eparser); jj has no porcelain, so its reads render a template (jj … -T 'json(self)' --no-graph) decoded throughwired— the samerun!Tmachinery, a different collector. Both backends stay schema-driven command invocations. - The data model widens (each field git-only ⇒ capability-gated):
BranchInfo→ a ref/bookmark that may be unnamed (an anonymous head / the working-copy change) and may resolve to 0..N targets (conflicted);tipShabecomes an opaquecommitIdwith an optional stablechangeId;upstream/ahead/behindwiden to a per-remote tracked set with bound-valued (not scalar) ahead/behind.RepoStatus→currentBranchbecomes nullable (jj has no current branch — expose a separate working revision);staged/unstaged/untrackedbecome capability-gated (jj has no index); addconflictedandstale.WorktreeInfo→ add a workspacenameand astaleflag;branchbecomes truly optional (a workspace checks out a commit);bare/detached/locked/prunablebecome git-only.BranchStatusclassification becomes backend-provided (jj computes "merged" as the::trunk()revset, "gone" via tracking state) and gains jj-only buckets (anonymous head, divergent).
- Capability-gated operation groups (present on jj, absent on git): the operation log + undo (
jj op log/jj undo→ a real "undo the branch delete" dman cannot offer on git), first-class conflict handling (a three-way merge/rebase outcome +jj resolve), workspace stale repair, and track/untrack + delete-vs-forget bookmark semantics. - Worktree ops map to
jj workspace(notgit worktree):forgetkeeps files (dman deletes), there is noremove/prune(dman reconciles), and reads pass--ignore-working-copyto avoid jj's snapshot-on-read side effect.
v1 implements only the git capabilities; the interface is shaped so the jj backend slots in at P3 without reworking callers.