spec: git
Implements ADR-0050 (operator-facing git pane). This spec is the daemon + wire surface: operation semantics, porcelain mapping, diff production, error model, and the path allowlist. UI grammar (pane layout, badges) lives in
specs/ui/git.mdand is out of scope here.
1. Roots
1.1 Resolution
A project declares git roots in its DSL:
git:
- project:/
git:is a list of root scopes. Omitted ⇒[project:/].- The
project:scheme resolves against the project's directory on the project-repos PVC.project:/is the project root;project:/packages/fooa subdirectory. No other scheme is accepted in MVP; absolute host paths are rejected at config-load time. - Each entry is resolved to an absolute path under the project directory, then checked for a working git repository (
git rev-parse --git-dirsucceeds with cwd = root). A non-repo root is retained but flagged: itsgit.rootssummary carriesvalid: falseand a reason, and all other operations against it returngit.not_a_repo. A non-repo root never crashes the daemon. - Roots are identified in the wire surface by their declared scope string (
"project:/"), not their resolved absolute path. The absolute path never crosses the wire — it is a host detail.
1.2 The roots are the allowlist
Every path argument to every operation is validated before any git invocation:
- Resolve the candidate against the operation's root.
realpathit (following symlinks).- Assert the result is lexically within the root's resolved directory.
Any path that escapes its root (via .., an absolute path, or a symlink pointing outside) is rejected with git.path_outside_root and no subprocess runs. This is the git analogue of the caged FS allowlist; it is enforced in the daemon, not the UI.
2. Execution model
- The daemon shells out to the system
gitbinary.gitis a hard image dependency. - Every invocation: cwd = the resolved root, argv passed as an array (never a shell string; no interpolation), paths separated from options with
--. - Machine-readable output is used wherever git offers it (
--porcelain=v2 -z,stash list --format,--numstat). - The daemon's environment provides locale-stable output:
LC_ALL=C,GIT_OPTIONAL_LOCKS=0on read-only operations to avoid taking the index lock for status/diff. - Mutating operations serialize per root behind an async mutex keyed by the root's resolved path. Reads may run concurrently. If git still reports a held
index.lock(e.g. an external process), the op returnsgit.busyrather than blocking. - Each invocation has a timeout: default 30s for local ops, 120s for remote ops (fetch/pull/push/sync). Both are operator-configurable. On expiry the child is killed and the op returns
git.timeout.
3. Wire contracts
All entries live in @kaged/wire; handler return types derive from the contract entry. Root is the declared scope string. Shapes below are authoritative for MVP.
type Root = string; // e.g. "project:/"
type RepoPath = string; // root-relative, POSIX, no leading slash
type Oid = string;
type XY =
| '.' | 'M' | 'T' | 'A' | 'D' | 'R' | 'C' | 'U';
type ConflictKind =
| 'both-modified' | 'both-added' | 'both-deleted'
| 'added-by-us' | 'added-by-them'
| 'deleted-by-us' | 'deleted-by-them';
interface BranchInfo {
oid: Oid | null; // null on an unborn branch
head: string | null; // branch name, or null when detached
detached: boolean;
upstream: string | null;
ahead: number; // 0 when no upstream
behind: number;
}
interface StatusEntry {
path: RepoPath;
origPath?: RepoPath; // present for renames/copies
index: XY; // staged side (X)
worktree: XY; // unstaged side (Y)
conflict?: ConflictKind; // present iff unmerged
untracked?: true;
}
interface RootSummary {
root: Root;
valid: boolean;
reason?: string; // when !valid
branch?: BranchInfo; // omitted when !valid
counts: { staged: number; unstaged: number; untracked: number; conflicted: number };
}
3.1 Reads
| op | request | response |
|---|---|---|
git.roots |
{} |
RootSummary[] |
git.status |
{ root: Root } |
{ branch: BranchInfo; entries: StatusEntry[] } |
git.diff |
{ root: Root; path: RepoPath; side: 'staged' | 'unstaged' } |
DiffResult |
git.fileAtConflict |
{ root: Root; path: RepoPath } |
{ content: string; binary?: true; truncated?: true } |
git.stashList |
{ root: Root } |
{ ref: string; message: string; branch: string }[] |
type DiffResult =
| { kind: 'text'; original: string; modified: string; truncated?: true }
| { kind: 'binary'; bytesOriginal: number | null; bytesModified: number | null }
| { kind: 'too-large'; bytes: number };
3.2 Writes (each an explicit operator action)
| op | request | response | notes |
|---|---|---|---|
git.stage |
{ root, paths: RepoPath[] } |
RootSummary |
also resolves conflicts and stages untracked |
git.unstage |
{ root, paths: RepoPath[] } |
RootSummary |
restore --staged |
git.discard |
{ root, paths: RepoPath[] } |
RootSummary |
destructive; UI confirmation-gated |
git.commit |
{ root, message: string } |
{ summary: RootSummary; oid: Oid } |
rejects on empty message or nothing staged |
git.stash |
{ root, message?: string, includeUntracked?: boolean } |
RootSummary |
|
git.stashPop |
{ root, ref: string } |
{ summary: RootSummary; conflicts: RepoPath[] } |
pop may conflict |
git.resolveOurs |
{ root, paths: RepoPath[] } |
RootSummary |
convenience: checkout --ours + stage |
git.resolveTheirs |
{ root, paths: RepoPath[] } |
RootSummary |
convenience: checkout --theirs + stage |
3.3 Remote (run-as user's ambient auth)
| op | request | response |
|---|---|---|
git.fetch |
{ root, remote?: string } |
RootSummary |
git.pull |
{ root, remote?: string, branch?: string } |
PullResult |
git.push |
{ root, remote?: string, branch?: string } |
RootSummary |
git.sync |
{ root } |
PullResult |
type PullResult =
| { kind: 'ok'; summary: RootSummary }
| { kind: 'conflicts'; summary: RootSummary; conflicted: RepoPath[] };
Remotes use only the credentials the daemon's run-as user already has (SSH keys, agent, known_hosts, any configured helper). No credential is stored, prompted for, or injected. Auth failures, unknown hosts, and non-fast-forward rejections surface as typed errors (§5); they are not retried or worked around. git.sync is pull-then-push; if the pull yields conflicts it returns { kind: 'conflicts' } and does not attempt the push.
4. Porcelain mapping
Status is parsed from:
git -c core.quotepath=false status --porcelain=v2 --branch -z
NUL-separated records. Header lines (# branch.*) populate BranchInfo:
# branch.oid <oid> | (initial)→oid(null on(initial)).# branch.head <name> | (detached)→head/detached.# branch.upstream <name>→upstream(absent ⇒ null, andahead/behind= 0).# branch.ab +<a> -<b>→ahead/behind.
Change records:
1 <XY> …ordinary →{ index: X, worktree: Y, path }.2 <XY> … <Xscore> <path>rename/copy → same, plusorigPath(the second NUL-delimited field that type-2 records carry under-z). The parser must consume that extra field or it desyncs the stream.u <XY> …unmerged → conflict entry; map XY →ConflictKind:XY ConflictKind UU both-modified AA both-added DD both-deleted AU added-by-us UA added-by-them DU deleted-by-us UD deleted-by-them ? <path>→{ untracked: true }, counts towarduntracked.! <path>ignored → dropped (not surfaced).
counts derive from the parsed set: staged = entries with index !== '.' and not conflicted; unstaged = worktree !== '.' and not conflicted; conflicted = entries with a conflict.
5. Diff production
git.diff returns the two sides for Monaco's diff editor, not a unified-diff string.
- unstaged side compares index → worktree:
original= index blob (git cat-file blob :<path>/git show :<path>),modified= the worktree file on disk. Untracked ⇒original = "". - staged side compares HEAD → index:
original=git show HEAD:<path>,modified= index blob. Newly added ⇒original = "". Deleted ⇒modified = "". - renames diff
origPath@HEADagainstpathin the relevant tree. - conflicted files are not 2-way-diffed in MVP — the pane opens them via
git.fileAtConflict, which returns the worktree content with conflict markers intact for a plain editor; resolution is edit-then-git.stage(§6).
Guards, in order:
- Binary detection via
git diff --numstat -- <path>: a-in either column ⇒{ kind: 'binary' }. - Size cap (operator-configurable, default 2 MiB per side). Over the cap ⇒
{ kind: 'too-large', bytes }. - A per-side soft cap may set
truncated: trueon atextresult when content is clipped for transport.
6. Conflict handling
Conflict resolution introduces no new primitive. git add is resolution; git restore --staged is un-resolution:
- A conflicted file (
urecord) is surfaced with itsConflictKind. - The operator opens it (
git.fileAtConflict), edits out the markers, and callsgit.stage— which marks it resolved. git.resolveOurs/git.resolveTheirsare convenience shortcuts (checkout --ours|--theirsthen stage) for the whole-side cases.- A commit while any unmerged entry remains is rejected with
git.conflict.
The richer inline accept-current/incoming/both UI is foreclosed in ADR-0050 and not specified here.
7. Error model
Operations fail with a typed wire error { code, message, detail? }. message is operator-facing; detail may carry sanitized git stderr (never tokens/paths outside roots).
| code | raised when |
|---|---|
git.root_not_configured |
root scope not in the project's git: list |
git.not_a_repo |
configured root has no working repository |
git.path_outside_root |
a path argument escapes its root (no subprocess runs) |
git.busy |
index.lock held / root mutex contended beyond timeout |
git.timeout |
invocation exceeded its timeout |
git.conflict |
mutating op blocked by unresolved conflicts (e.g. commit) |
git.nothing_staged |
commit with an empty index |
git.empty_message |
commit with blank message |
git.binary_file |
diff requested on a binary file (when caller forces text) |
git.too_large |
diff side over the size cap (when caller forces text) |
git.auth_failed |
remote rejected the run-as user's credentials |
git.host_key_unknown |
remote host key not in known_hosts (fail closed — see §8) |
git.non_fast_forward |
push rejected because the remote is ahead |
git.command_failed |
any other non-zero git exit; carries exit code + sanitized stderr |
Pull/stash-pop producing conflicts is not an error — it returns the conflicts variant of its result so the operator drops into §6.
8. Security & posture
- Operator-privileged, behind Cloudflare Access. Not routed through the subagent bwrap sandbox — these are the operator's own repos.
- No arbitrary-command surface; only the enumerated operations above.
- argv arrays only;
--before paths; path allowlist enforced before every invocation (§1.2). - Remote ops fail closed on unknown host keys (
git.host_key_unknown). Trust-on-first-use is not implemented in MVP — seedingknown_hostsis the run-as user's environment, and TOFU is deferred to the credential-management ADR.
9. Out of scope (per ADR-0050)
History/log/blame; rebase/cherry-pick/interactive; branch create/switch; rich 3-way merge editor; agent-initiated git (wants the approval-gate primitive); credential management (storage, prompting, deploy keys, host-key TOFU); submodule/nested-repo semantics beyond declaring each as its own root.
Open question
Host-key handling is specified as fail-closed to match ADR-0050. If MVP should instead TOFU on first connect (write the key to the run-as user's known_hosts), that is a one-line policy flip here and a note in the credential-management ADR — flagged, not assumed.