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.md and 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/foo a 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-dir succeeds with cwd = root). A non-repo root is retained but flagged: its git.roots summary carries valid: false and a reason, and all other operations against it return git.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:

  1. Resolve the candidate against the operation's root.
  2. realpath it (following symlinks).
  3. 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 git binary. git is 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=0 on 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 returns git.busy rather 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, and ahead/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, plus origPath (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 toward untracked.

  • ! <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@HEAD against path in 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:

  1. Binary detection via git diff --numstat -- <path>: a - in either column ⇒ { kind: 'binary' }.
  2. Size cap (operator-configurable, default 2 MiB per side). Over the cap ⇒ { kind: 'too-large', bytes }.
  3. A per-side soft cap may set truncated: true on a text result 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 (u record) is surfaced with its ConflictKind.
  • The operator opens it (git.fileAtConflict), edits out the markers, and calls git.stage — which marks it resolved.
  • git.resolveOurs / git.resolveTheirs are convenience shortcuts (checkout --ours|--theirs then 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 — seeding known_hosts is 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.