feat(git): stage / commit / push from the diff viewer (W4)

The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).

- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
  throws). Path containment: every files[] entry realpath-contained under the repo,
  argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
  single -m argv. Push: current branch only — has-upstream → plain `git push`;
  no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
  ≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
  client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
  fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
  GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
  + isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
  (all text via textContent; re-loads the diff on success).

Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.
This commit is contained in:
Yaojia Wang
2026-07-12 22:09:43 +02:00
parent 552f35c690
commit 19f241d7a3
11 changed files with 1677 additions and 6 deletions

View File

@@ -72,6 +72,11 @@ export interface Config {
readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000
// W4 git write (stage / commit / push) — the highest-risk write channel
readonly gitOpsEnabled: boolean; // GIT_OPS_ENABLED, default true (kill-switch → all 3 routes 403)
readonly gitOpsTimeoutMs: number; // GIT_OPS_TIMEOUT_MS, default 10000 (stage/commit exec bound)
readonly gitPushTimeoutMs: number; // GIT_PUSH_TIMEOUT_MS, default 120000 (network-bound push)
readonly commitMsgMaxLen: number; // COMMIT_MSG_MAX_LEN, default 5000 (message length cap)
// B4 permission-mode relay
readonly defaultPermissionMode: PermissionMode; // DEFAULT_PERMISSION_MODE, default 'default'
readonly allowAutoMode: boolean; // ALLOW_AUTO_MODE, default false (SEC-M5)
@@ -614,6 +619,24 @@ export interface PruneWorktreesResult {
error?: string;
}
/* ── W4 git write: stage / commit / push (§w4-commit-push) ── */
/** Result of the three W4 git-write routes (POST /projects/git/{stage,commit,push}).
* ONE interface for all three (like CreateWorktreeResult) to keep the shared
* contract small. `error` carries a SAFE message only — never raw git stderr
* (SEC-M10); `status` is the HTTP status to return on failure. Success payloads
* are route-specific and all optional. */
export interface GitOpResult {
ok: boolean;
status?: number; // HTTP status on failure
error?: string; // SAFE message only (never raw git stderr)
staged?: boolean; // stage: direction applied (true = added, false = unstaged)
count?: number; // stage: number of files affected
commit?: string; // commit: short SHA of the new commit
branch?: string; // push: branch that was pushed
remote?: string; // push: remote it was pushed to
}
/* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */
/** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts).