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:
@@ -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({})
|
||||
|
||||
Reference in New Issue
Block a user