ADR-0050: Operator-facing git pane for projects
- Status: Accepted
- Date: 2026-06-27
- Deciders: @karasu
- Supersedes: —
- Related: ADR-0031 (transcript rendering / Monaco usage), ADR-0034 (issue-bound todos, operator-confirm loop), conventions.md (extraction criteria, wire contracts, allowlists)
Context and problem statement
Routine git work against a running daemon currently requires spawning a bash task to drop into a shell. That is friction on local projects and genuine pain on remote daemons — notably the Kubernetes deployment, where "see what changed and commit it" means opening a terminal into a pod just to run git status.
The project-repos PVC already holds real git repositories. The UI already renders files through Monaco. What is missing is a first-class, terminal-free surface for the everyday loop: see what changed → review the diff → stage → commit, plus stash/pop and conflict resolution.
This also fits the operator-agency posture directly: when a caged subagent edits files, those edits land in the working tree. The git pane is the operator's review-and-confirm surface for that work — the diff the operator inspects before committing is the agent's proposed change. The agent proposes; the operator commits. Nothing commits autonomously.
The working layout is already multi-root (monorepo, models.dev, kaged-sso, kaged-utils, kaged-releases, plugin repos — six SCM providers in one workspace). Any design has to assume more than one repo per project.
Decision drivers
- No terminal for routine git, especially on remote/k8s daemons.
- Operator agency is load-bearing — review-then-commit is the operator's confirmation surface; no autonomous commits.
- Typed wire contracts over conventions — enumerated, contract-bound operations; no arbitrary git passthrough surface to parse or secure.
- Reuse what exists — Monaco's diff editor is already available; "see the changes" is mostly wiring, not new tech.
- Portable, project-relative config — the DSL is the portable artifact; no absolute host paths.
- Roots are an allowlist — same philosophy as caged subagent FS allowlists; the configured roots bound what the pane can touch.
Considered options
Git execution
- Shell out to the system
gitbinary with machine-readable output (status --porcelain=v2,stash list, etc.), parsed into typed structures. - isomorphic-git (pure JS).
- nodegit / libgit2 (native bindings).
Operation surface
- (a) A general "run git command" passthrough.
- (b) An enumerated set of typed operations, each a
@kaged/wirecontract entry.
Root configuration
- Single implicit root (project dir) vs a declared list with a default.
- Auto-discover nested repos vs explicit operator declaration.
Decision outcome
Execution: shell out to the system git binary (option 1)
The daemon runs git as a subprocess against the project-repos PVC, using porcelain output and parsing into typed structures.
- isomorphic-git rejected: stash support is effectively absent and merge/conflict handling is weak — exactly the operations being asked for. Disqualifying.
- nodegit rejected: native weight and friction under
bun build --compile --bytecode; libgit2 buys nothing over the real CLI for this surface. - The
gitbinary is added to the daemon image (no exotic pinning — systemgit). This matches "we don't force shit": use real git, the same choice VS Code made.
Surface: enumerated typed operations (option b)
Each operation is a @kaged/wire contract entry; handler return types derive from the contract, per convention. No arbitrary-command surface.
This deletes two problem classes rather than mitigating them:
- No untyped output to parse defensively and no "operator runs an exotic command and corrupts state" footgun — the surface is exactly the bounded set below.
git addis the resolution primitive. Marking a conflict resolved (git add <file>) and unstaging (git restore --staged <file>) are not new concepts — they aregit.stage/git.unstage. We do not invent a parallel "resolve" contract. Conflict resolution = edit the file to remove markers, then stage.
If an operator genuinely needs an operation outside this set, the bash-task escape hatch still exists. The pane covers the 95% loop, not all of git.
Config: declared list with a default, explicit declaration
git: is a list of root scopes in the project DSL. Omitted ⇒ [project:/].
git:
- project:/
project:scheme resolves against the project's repo directory on the project-repos PVC.project:/is the project root;project:/packages/fooa subdirectory. Project-relative and portable — absolute host paths are not permitted (breaks portability and the sandbox posture).- No auto-discovery. The operator declares additional roots explicitly (a multi-repo layout adds entries). Auto-walking the tree for
.gitdirs would surprise the operator by surfacing repos they did not mean to expose — that violates operator agency. - The declared roots are the allowlist. Every path argument to
git.stage/git.diff/ etc. must resolve inside a configured root; anything outside is rejected at the contract boundary. This is the git analogue of caged FS allowlists — the DSL list is load-bearing security, not cosmetics. - A root that is not a valid repo surfaces as an error in the pane for that root, never a daemon crash.
- Commits are per-root. Git is per-repository; the pane commits each root independently. There is no cross-root "commit all" semantic in MVP.
Remote ops: rely on the run-as user's ambient auth
Push / pull / fetch / sync are in MVP, but credential management is not. The daemon runs git as its run-as user; remote ops use whatever auth that user already has — SSH keys, ssh-agent, known_hosts, and any credential helper already configured on the PVC. In practice this is no-auth and SSH-auth remotes, which is what the deployment already provides.
The boundary is sharp: if the run-as user can already authenticate, it works; if a remote needs credentials that user does not have, the operation surfaces the auth failure and the operator resolves it in their environment. kaged stores, prompts for, and manages no git credentials in MVP — that (deploy keys, token storage via daemon_kv, per-project identities) is the deferred credential-management ADR.
Honest edges that fall out of this posture, surfaced rather than solved:
- Host-key verification is the run-as user's
known_hosts. An unknown host fails the op; the pane shows the error, it is not auto-accepted. - Passphrase-protected keys need an agent; without one, the op fails. Ambient environment, not managed here.
- Rejected push (remote ahead) surfaces with ahead/behind; the operator pulls first.
- Pull producing conflicts loops straight into the existing conflict-resolution path — no new surface.
Remote ops are the first daemon operations that hit the network and can block, so each carries a timeout and surfaces pending/failed state; no silent hangs.
Diff rendering: Monaco diff editor
git.diff returns the two sides (e.g. HEAD-or-index blob + working-tree content) and the pane feeds them to createDiffEditor. Inline by default, side-by-side as a toggle (side-by-side is explicitly not required, but free). Guards: binary files render as "binary, N bytes changed" with no diff; text diffs above a size cap are summarized rather than loaded whole into Monaco.
Packaging
Not an extraction. Against the conventions.md criteria — same toolchain, no independent cadence, tightly coupled to daemon + UI, high coordinated-change frequency — it stays in the monorepo. Git logic in the daemon's project service (a @kaged/git internal module if it earns one), contracts in @kaged/wire, pane in the SPA. No new repo.
Scope
In scope (this ADR)
- Multi-root pane; roots from
git:, default[project:/]. - Per-root status: branch, upstream, ahead/behind, staged / unstaged / untracked / conflicted.
- File diff via Monaco (staged and unstaged), inline default + side-by-side toggle.
- Stage / unstage (paths).
- Commit (message).
- Stash / stash pop / stash list.
- Conflict handling: surface conflicted files, open with conflict markers, resolve via stage. Convenience
--ours/--theirsactions optional within this scope. - Discard working-tree changes (a guarded, confirmation-gated action — it pairs with diff review).
- Remote ops: fetch / pull / push, and a sync convenience (pull-then-push), per-root — using the run-as user's ambient auth only (no-auth and SSH remotes). No credential storage or prompting.
Out of scope (foreclosed)
- Git credential management. Remote ops are in MVP, but only via the run-as user's ambient auth (above). Storing/prompting for credentials, per-project deploy keys, token storage via
daemon_kv, and host-key trust-on-first-use are all deferred to their own ADR. Until then, a remote that needs credentials the run-as user lacks simply fails the op. - Agent-initiated git. A separate question that wants the approval-gate primitive (on the horizon). The operator-facing pane is review-and-commit; the agent only edits files.
- History / log / blame.
- Rich three-way merge editor. Standalone
monaco-editorhas no workbench merge editor; baseline is marker-based resolution. An inline accept-current/incoming/both UI (Monaco decorations + code lenses) is a viable follow-on. - Rebase / cherry-pick / interactive operations.
- Submodule / nested-repo semantics beyond "declare each as its own root." Interacts with the nested-kaged-projects work and former-submodule-boundary merge questions already noted elsewhere; not solved here.
Wire contract sketch
Indicative shapes; the spec carries the authoritative definitions.
Reads
git.roots→ per-root summary{ root, branch, upstream, ahead, behind, counts: { staged, unstaged, untracked, conflicted } }git.status(root)→ entries{ path, index, worktree, kind }(porcelain v2 mapped), plus branch infogit.diff(root, path, side: 'staged' | 'unstaged')→{ original, modified, binary?, truncated? }git.stashList(root)→[{ ref, message, branch }]
Writes (each an explicit operator action)
git.stage(root, paths)/git.unstage(root, paths)— also the conflict-resolution and restore-staged primitivesgit.commit(root, message)git.stash(root, message?, includeUntracked?)/git.stashPop(root, ref)git.discard(root, paths)— guarded, confirmation-gated (destructive)
Remote (run-as user's ambient auth; may fail on auth, surfaced not handled)
git.fetch(root, remote?)/git.pull(root, remote?, branch?)/git.push(root, remote?, branch?)git.sync(root)— pull-then-push convenience (the pane action behind the ahead/behind indicator)
Consequences
Good
- The everyday git loop works terminal-free, including against the k8s daemon.
- Operator review of agent edits gets a natural home; the commit is the confirmation.
- The surface is bounded, typed, and parse-safe; the diff view costs almost nothing given Monaco is already present.
- Roots double as a security allowlist with no extra machinery.
Bad / accepted costs
- The pane deliberately does not cover all of git; exotic operations still need a bash task.
- Remote ops depend on the run-as user's ambient auth; a remote needing credentials that user lacks fails the op until the credential-management ADR lands. No prompting, no fallback.
- Marker-based conflict resolution is functional but plainer than VS Code's inline UI until the follow-on.
gitbinary becomes a hard image dependency.
Follow-ons
specs/git.md— operation semantics, porcelain mapping, error model, path-allowlist enforcement.specs/ui/git.md— pane layout and badge grammar (hue = kind, brightness = state; e.g.[DIRTY]/[CLEAN]/[CONFLICT]/[STASH]with ahead/behind indicators).- ADR for git credential management (deploy keys, token storage, host-key trust) — unblocks remotes the run-as user can't already auth to.
- ADR for agent-initiated git via the approval-gate primitive.