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

@@ -650,6 +650,39 @@ describe('loadConfig — v0.7 B3 worktree', () => {
})
})
describe('loadConfig — W4 git write (stage / commit / push)', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('GIT_OPS_ENABLED: default true, parses on/off', () => {
expect(loadConfig({}).gitOpsEnabled).toBe(true)
expect(loadConfig({ GIT_OPS_ENABLED: '0' }).gitOpsEnabled).toBe(false)
expect(loadConfig({ GIT_OPS_ENABLED: 'false' }).gitOpsEnabled).toBe(false)
})
it('GIT_OPS_TIMEOUT_MS: default 10000, reads from env', () => {
expect(loadConfig({}).gitOpsTimeoutMs).toBe(10_000)
expect(loadConfig({ GIT_OPS_TIMEOUT_MS: '5000' }).gitOpsTimeoutMs).toBe(5_000)
})
it('GIT_PUSH_TIMEOUT_MS: default 120000, reads from env', () => {
expect(loadConfig({}).gitPushTimeoutMs).toBe(120_000)
expect(loadConfig({ GIT_PUSH_TIMEOUT_MS: '30000' }).gitPushTimeoutMs).toBe(30_000)
})
it('COMMIT_MSG_MAX_LEN: default 5000, reads from env', () => {
expect(loadConfig({}).commitMsgMaxLen).toBe(5_000)
expect(loadConfig({ COMMIT_MSG_MAX_LEN: '200' }).commitMsgMaxLen).toBe(200)
})
it('throws for a non-integer numeric var', () => {
expect(() => loadConfig({ GIT_OPS_TIMEOUT_MS: 'slow' })).toThrow(/GIT_OPS_TIMEOUT_MS/)
expect(() => loadConfig({ COMMIT_MSG_MAX_LEN: 'lots' })).toThrow(/COMMIT_MSG_MAX_LEN/)
})
})
describe('loadConfig — v0.7 B4 permission mode', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})