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({})

View File

@@ -19,6 +19,10 @@ const {
renderDiffFile,
renderDiff,
mountDiffViewer,
isGitOpResult,
postStage,
postCommit,
postPush,
} = await import('../public/diff.js')
// ── helpers ───────────────────────────────────────────────────────────────────
@@ -615,3 +619,227 @@ describe('mountDiffViewer', () => {
handle.destroy()
})
})
// ── W4 git-write POST helpers ─────────────────────────────────────────────────
describe('isGitOpResult', () => {
it('accepts an object with a boolean ok, rejects everything else', () => {
expect(isGitOpResult({ ok: true })).toBe(true)
expect(isGitOpResult({ ok: false, error: 'x' })).toBe(true)
expect(isGitOpResult(null)).toBe(false)
expect(isGitOpResult({ ok: 'yes' })).toBe(false)
expect(isGitOpResult('nope')).toBe(false)
})
})
describe('postStage / postCommit / postPush', () => {
it('postStage POSTs the path/files/stage body to /projects/git/stage', async () => {
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, staged: true, count: 1 }) }))
vi.stubGlobal('fetch', mockFetch)
const r = await postStage('/repo', ['a.ts', 'b.ts'], true)
expect(r).toMatchObject({ ok: true, staged: true, count: 1 })
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/projects/git/stage')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body as string)).toEqual({ path: '/repo', files: ['a.ts', 'b.ts'], stage: true })
})
it('postCommit POSTs the message to /projects/git/commit', async () => {
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, commit: 'abc1234' }) }))
vi.stubGlobal('fetch', mockFetch)
const r = await postCommit('/repo', 'hello')
expect(r).toMatchObject({ ok: true, commit: 'abc1234' })
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/projects/git/commit')
expect(JSON.parse(init.body as string)).toEqual({ path: '/repo', message: 'hello' })
})
it('postPush POSTs the path to /projects/git/push', async () => {
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, branch: 'main', remote: 'origin' }) }))
vi.stubGlobal('fetch', mockFetch)
const r = await postPush('/repo')
expect(r).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
expect((mockFetch.mock.calls[0] as [string])[0]).toBe('/projects/git/push')
})
it('synthesizes a failure {ok:false,status} when the body is not a GitOpResult', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 429, json: async () => ({}) })))
const r = await postStage('/repo', ['a'], true)
expect(r).toMatchObject({ ok: false, status: 429 })
})
it('returns null on a network error', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
expect(await postPush('/repo')).toBeNull()
})
})
// ── W4 mountDiffViewer: stage toggles + commit/push bar ───────────────────────
describe('mountDiffViewer — W4 stage / commit / push', () => {
function makeContainer(): HTMLDivElement {
return document.createElement('div')
}
/** A fetch mock that routes GET /projects/diff → a diff, and each POST git route
* → a caller-supplied response (defaults to ok:true). */
function routingFetch(gitResponses: Record<string, unknown> = {}): ReturnType<typeof vi.fn> {
return vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
const body = gitResponses[url] ?? { ok: true }
return { ok: true, status: 200, json: async () => body }
}
return { ok: true, status: 200, json: async () => makeResult({ staged: false }) }
})
}
it('renders a "Stage" button on each file row in the working view', async () => {
vi.stubGlobal('fetch', routingFetch())
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
const btn = container.querySelector('.df-file-stage')
expect(btn).not.toBeNull()
expect(btn?.textContent).toBe('Stage')
handle.destroy()
})
it('labels the toggle "Unstage" in the staged view', async () => {
const mockFetch = vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST') return { ok: true, status: 200, json: async () => ({ ok: true }) }
return { ok: true, status: 200, json: async () => makeResult({ staged: true }) }
})
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
handle.showStaged()
await new Promise((r) => setTimeout(r, 0))
expect(container.querySelector('.df-file-stage')?.textContent).toBe('Unstage')
handle.destroy()
})
it('clicking Stage POSTs {path,files,stage:true} then re-fetches the diff', async () => {
const mockFetch = routingFetch()
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
const initialCalls = mockFetch.mock.calls.length
;(container.querySelector('.df-file-stage') as HTMLButtonElement).click()
await new Promise((r) => setTimeout(r, 0))
const post = mockFetch.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'POST')
expect(post?.[0]).toBe('/projects/git/stage')
expect(JSON.parse((post?.[1] as RequestInit).body as string)).toMatchObject({
path: '/repo',
files: ['src/foo.ts'],
stage: true,
})
// Re-fetched the diff after the successful stage.
expect(mockFetch.mock.calls.length).toBeGreaterThan(initialCalls + 1)
handle.destroy()
})
it('disables Commit until the message is non-empty, then POSTs the commit', async () => {
const mockFetch = routingFetch({ '/projects/git/commit': { ok: true, commit: 'deadbee' } })
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
const commitBtn = container.querySelector('.df-commit-btn') as HTMLButtonElement
const msg = container.querySelector('.df-commit-msg') as HTMLTextAreaElement
expect(commitBtn.disabled).toBe(true)
msg.value = 'phone commit'
msg.dispatchEvent(new Event('input'))
expect(commitBtn.disabled).toBe(false)
commitBtn.click()
await new Promise((r) => setTimeout(r, 0))
const post = mockFetch.mock.calls.find((c) => c[0] === '/projects/git/commit')
expect(post).toBeDefined()
expect(JSON.parse((post?.[1] as RequestInit).body as string)).toEqual({ path: '/repo', message: 'phone commit' })
// Message cleared on success.
expect(msg.value).toBe('')
handle.destroy()
})
it('shows a commit failure via textContent with zero innerHTML injection (SEC-H4)', async () => {
const evil = '<script>alert(1)</script>'
const mockFetch = routingFetch({ '/projects/git/commit': { ok: false, status: 409, error: evil } })
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
const msg = container.querySelector('.df-commit-msg') as HTMLTextAreaElement
msg.value = 'x'
msg.dispatchEvent(new Event('input'))
;(container.querySelector('.df-commit-btn') as HTMLButtonElement).click()
await new Promise((r) => setTimeout(r, 0))
const status = container.querySelector('.df-op-status') as HTMLElement
expect(status.textContent).toContain(evil)
expect(container.querySelectorAll('script').length).toBe(0)
handle.destroy()
})
it('Push POSTs /projects/git/push and reflects busy/disabled while in flight', async () => {
let resolvePush: (v: unknown) => void = () => undefined
const pending = new Promise((r) => {
resolvePush = r
})
const mockFetch = vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST' && url === '/projects/git/push') {
await pending
return { ok: true, status: 200, json: async () => ({ ok: true, branch: 'main', remote: 'origin' }) }
}
return { ok: true, status: 200, json: async () => makeResult() }
})
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
const pushBtn = container.querySelector('.df-push-btn') as HTMLButtonElement
pushBtn.click()
await new Promise((r) => setTimeout(r, 0))
// In flight → disabled.
expect(pushBtn.disabled).toBe(true)
resolvePush(undefined)
await new Promise((r) => setTimeout(r, 0))
expect(pushBtn.disabled).toBe(false)
expect((container.querySelector('.df-op-status') as HTMLElement).textContent).toContain('main')
handle.destroy()
})
it('hides the commit bar in base-compare mode', async () => {
vi.stubGlobal('fetch', routingFetch())
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', { bases: ['main'] })
await new Promise((r) => setTimeout(r, 0))
const bar = container.querySelector('.df-commitbar') as HTMLElement
expect(bar.style.display).toBe('') // visible in working mode
const select = container.querySelector('.df-base-select') as HTMLSelectElement
select.value = 'main'
select.dispatchEvent(new Event('change'))
await new Promise((r) => setTimeout(r, 0))
expect(bar.style.display).toBe('none') // hidden in base mode
// No stage buttons in base mode either.
expect(container.querySelector('.df-file-stage')).toBeNull()
handle.destroy()
})
})

322
test/http/git-ops.test.ts Normal file
View File

@@ -0,0 +1,322 @@
/**
* test/http/git-ops.test.ts (w4-commit-push) — the git WRITE engine.
*
* Covers src/http/git-ops.ts:
* - classifyGitError (pure: stderr → safe {status,error}, SEC-M10)
* - validateRepoFiles (realpath containment: rejects escapes/absolute/flags)
* - stageFiles (git add / restore --staged, real temp repos)
* - commit (staged commit, empty/identity/length guards)
* - push (upstream vs no-upstream argv, bare-remote real push)
*
* Pure functions are unit-tested; the git verbs run against real throwaway git
* repos in os.tmpdir() (no network — a local bare repo is the push remote),
* mirroring test/http/worktrees-create.test.ts.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import {
classifyGitError,
validateRepoFiles,
stageFiles,
commit,
push,
} from '../../src/http/git-ops.js'
const execFileP = promisify(execFile)
const TIMEOUT_MS = 15000
const OPTS = { timeoutMs: TIMEOUT_MS, maxFiles: 300 }
let gitAvailable = false
beforeAll(async () => {
try {
await execFileP('git', ['--version'])
gitAvailable = true
} catch {
gitAvailable = false
}
})
/** Init a temp git repo with one commit; returns its REALPATH (macOS /var fix). */
async function makeRepo(): Promise<string> {
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-')))
const git = async (args: string[]): Promise<void> => {
await execFileP('git', args, { cwd: dir })
}
await git(['init', '-q', '-b', 'main'])
await git(['config', 'user.email', 't@t.local'])
await git(['config', 'user.name', 'tester'])
await git(['config', 'commit.gpgsign', 'false'])
await fs.writeFile(path.join(dir, 'tracked.txt'), 'v1\n', 'utf8')
await git(['add', '.'])
await git(['commit', '-q', '-m', 'init'])
return dir
}
/** A bare repo to serve as a push `origin`. Returns its REALPATH. */
async function makeBareRemote(): Promise<string> {
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-bare-')))
await execFileP('git', ['init', '-q', '--bare', '-b', 'main', dir])
return dir
}
// ── classifyGitError (pure, SEC-M10) ────────────────────────────────────────────
describe('classifyGitError', () => {
const cases: Array<[string, number]> = [
['nothing to commit, working tree clean', 409],
['no changes added to commit (use "git add")', 409],
['Please tell me who you are', 400],
[' ! [rejected] main -> main (non-fast-forward)', 409],
['Updates were rejected because the tip of your current branch is behind\nfetch first', 409],
['fatal: Authentication failed for \'https://github.com/x/y.git/\'', 401],
["fatal: could not read Username for 'https://github.com': terminal prompts disabled", 401],
['git@github.com: Permission denied (publickey).', 401],
["fatal: Unable to create '/repo/.git/index.lock': File exists.", 409],
['some totally unexpected failure', 500],
]
it('maps each stderr signature to the expected status', () => {
for (const [stderr, status] of cases) {
expect(classifyGitError(stderr).status).toBe(status)
}
})
it('never leaks raw git stderr (no "fatal:" in any safe message)', () => {
for (const [stderr] of cases) {
expect(classifyGitError(stderr).error.toLowerCase()).not.toContain('fatal:')
}
})
it('is case-insensitive and tolerant of a non-string input', () => {
expect(classifyGitError('NOTHING TO COMMIT').status).toBe(409)
// @ts-expect-error — defensive: non-string must not throw
expect(classifyGitError(undefined).status).toBe(500)
})
})
// ── validateRepoFiles (realpath containment) ────────────────────────────────────
describe('validateRepoFiles', () => {
it('rejects an empty list', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, [], 300)).toBeNull()
})
it('rejects more than maxFiles entries', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, ['a', 'b', 'c'], 2)).toBeNull()
})
it('rejects non-string, absolute, and leading-dash entries', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, [123], 300)).toBeNull()
expect(await validateRepoFiles(repo, [''], 300)).toBeNull()
expect(await validateRepoFiles(repo, ['/etc/passwd'], 300)).toBeNull()
expect(await validateRepoFiles(repo, ['-rf'], 300)).toBeNull()
})
it('rejects a ../ traversal escape', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, ['../escape'], 300)).toBeNull()
})
it('rejects the repo root itself (".")', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, ['.'], 300)).toBeNull()
})
it('accepts in-repo relative paths (existing AND not-yet-on-disk / deleted)', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
await fs.writeFile(path.join(repo, 'exists.txt'), 'x', 'utf8')
const ok = await validateRepoFiles(repo, ['exists.txt', 'sub/deleted.txt'], 300)
expect(ok).toEqual(['exists.txt', 'sub/deleted.txt'])
})
it('rejects a pre-planted symlink that escapes the repo after realpath (M2)', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
const outside = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-out-')))
await fs.writeFile(path.join(outside, 'secret.txt'), 'top', 'utf8')
await fs.symlink(path.join(outside, 'secret.txt'), path.join(repo, 'link.txt'))
expect(await validateRepoFiles(repo, ['link.txt'], 300)).toBeNull()
})
})
// ── stageFiles (real temp repos) ────────────────────────────────────────────────
describe('stageFiles', () => {
it('stages the given files (git add) and unstages them (restore --staged)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8') // modify tracked
await fs.writeFile(path.join(repo, 'new.txt'), 'new\n', 'utf8') // add untracked
const staged = await stageFiles(repo, ['tracked.txt', 'new.txt'], true, OPTS)
expect(staged).toMatchObject({ ok: true, staged: true, count: 2 })
const cached = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout
expect(cached).toContain('tracked.txt')
expect(cached).toContain('new.txt')
const unstaged = await stageFiles(repo, ['tracked.txt'], false, OPTS)
expect(unstaged).toMatchObject({ ok: true, staged: false, count: 1 })
const cached2 = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout
expect(cached2).not.toContain('tracked.txt')
})
it('returns 404 for a non-git directory', async () => {
const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-plain-')))
const res = await stageFiles(plain, ['x.txt'], true, OPTS)
expect(res).toMatchObject({ ok: false, status: 404 })
})
it('returns 400 for an escaping file path (never runs git)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await stageFiles(repo, ['../../etc/passwd'], true, OPTS)
expect(res).toMatchObject({ ok: false, status: 400 })
})
})
// ── commit (real temp repos) ────────────────────────────────────────────────────
describe('commit', () => {
it('commits staged changes and returns a short SHA', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8')
await execFileP('git', ['add', 'tracked.txt'], { cwd: repo })
const res = await commit(repo, 'second commit', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
expect(res.ok).toBe(true)
expect(typeof res.commit).toBe('string')
const head = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
expect(head.startsWith(res.commit as string)).toBe(true)
})
it('returns 409 when nothing is staged', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await commit(repo, 'noop', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
expect(res).toMatchObject({ ok: false, status: 409 })
expect(res.error).not.toContain('fatal:')
})
it('rejects an empty message with 400 (never runs git)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
expect(await commit(repo, '', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({
ok: false,
status: 400,
})
expect(await commit(repo, ' \n ', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({
ok: false,
status: 400,
})
})
it('rejects an over-long message with 400', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await commit(repo, 'x'.repeat(51), { timeoutMs: TIMEOUT_MS, maxLen: 50 })
expect(res).toMatchObject({ ok: false, status: 400 })
})
it('returns 400 when the author identity is unset', async () => {
if (!gitAvailable) return
// Init WITHOUT any local identity, then hide the global/system config so no
// identity resolves — git must fail with "Please tell me who you are" → 400.
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-noid-')))
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo })
// useConfigOnly=true stops git auto-detecting an identity from the OS user, so
// with no configured identity it hard-fails "Please tell me who you are".
await execFileP('git', ['config', '--local', 'user.useConfigOnly', 'true'], { cwd: repo })
await fs.writeFile(path.join(repo, 'f.txt'), 'x\n', 'utf8')
await execFileP('git', ['add', 'f.txt'], { cwd: repo }) // stage on unborn HEAD
const prevG = process.env['GIT_CONFIG_GLOBAL']
const prevS = process.env['GIT_CONFIG_SYSTEM']
process.env['GIT_CONFIG_GLOBAL'] = '/dev/null'
process.env['GIT_CONFIG_SYSTEM'] = '/dev/null'
try {
const res = await commit(repo, 'msg', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
expect(res).toMatchObject({ ok: false, status: 400 })
expect(res.error).not.toContain('fatal:')
} finally {
if (prevG === undefined) delete process.env['GIT_CONFIG_GLOBAL']
else process.env['GIT_CONFIG_GLOBAL'] = prevG
if (prevS === undefined) delete process.env['GIT_CONFIG_SYSTEM']
else process.env['GIT_CONFIG_SYSTEM'] = prevS
}
})
})
// ── push (real bare-repo remote, no network) ────────────────────────────────────
describe('push', () => {
it('first push sets upstream (-u) and the bare remote receives the ref', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const bare = await makeBareRemote()
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim()
const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
expect(remoteHead).toBe(localHead)
})
it('a second push (upstream now set) succeeds via a plain push', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const bare = await makeBareRemote()
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
expect((await push(repo, { timeoutMs: TIMEOUT_MS })).ok).toBe(true)
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8')
await execFileP('git', ['add', 'tracked.txt'], { cwd: repo })
await execFileP('git', ['commit', '-q', '-m', 'second'], { cwd: repo })
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim()
const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
expect(remoteHead).toBe(localHead)
})
it('returns 400 for a detached HEAD', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const sha = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
await execFileP('git', ['checkout', '-q', sha], { cwd: repo }) // detach
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 400 })
})
it('returns 400 when the repo has zero remotes', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 400 })
})
it('returns 409 with two remotes and no upstream (ambiguous)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const bare1 = await makeBareRemote()
const bare2 = await makeBareRemote()
await execFileP('git', ['remote', 'add', 'origin', bare1], { cwd: repo })
await execFileP('git', ['remote', 'add', 'backup', bare2], { cwd: repo })
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 409 })
})
it('returns 404 for a non-git directory', async () => {
const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-nrepo-')))
expect(await push(plain, { timeoutMs: TIMEOUT_MS })).toMatchObject({ ok: false, status: 404 })
})
})

View File

@@ -0,0 +1,237 @@
/**
* test/integration/git-ops.test.ts (w4-commit-push) — the three git-WRITE routes
* against a real startServer:
* POST /projects/git/stage — Origin guard, kill-switch, missing field, non-git,
* real stage in a temp repo
* POST /projects/git/commit — same guards + real commit + safe 409/400 bodies
* POST /projects/git/push — same guards + real push to a temp BARE remote
*
* Mirrors test/integration/worktree.test.ts (startServer + fetch + Origin + real
* temp repos). No network — a local bare repo is the push remote.
*/
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
const GIT_AVAILABLE = (() => {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' })
return true
} catch {
return false
}
})()
const itGit = GIT_AVAILABLE ? it : it.skip
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('bad addr'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
const handles: { close(): Promise<void> }[] = []
const tmpDirs: string[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): Promise<{ port: number; origin: string }> {
const port = await getFreePort()
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
...overrides,
})
const handle = startServer(cfg)
handles.push(handle)
await new Promise<void>((r) => setTimeout(r, 80))
return { port, origin: `http://127.0.0.1:${port}` }
}
/** Temp git repo on `main` with one committed file. Returns its absolute path. */
async function makeRealRepo(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitops-'))
tmpDirs.push(dir)
const git = (args: string[]): void => {
execFileSync('git', args, { cwd: dir, stdio: 'ignore' })
}
git(['init', '-b', 'main'])
git(['config', 'user.email', 'test@localhost'])
git(['config', 'user.name', 'Test'])
git(['config', 'commit.gpgsign', 'false'])
await fs.writeFile(path.join(dir, 'file.txt'), 'line1\nline2\n', 'utf8')
git(['add', '.'])
git(['commit', '-m', 'init'])
return dir
}
/** A bare repo to serve as a push `origin`. Returns its absolute path. */
async function makeBareRemote(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitops-bare-'))
tmpDirs.push(dir)
execFileSync('git', ['init', '--bare', '-b', 'main', dir], { stdio: 'ignore' })
return dir
}
async function postJson(
port: number,
route: string,
body: unknown,
headers: Record<string, string>,
): Promise<Response> {
return fetch(`http://127.0.0.1:${port}${route}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
}
afterEach(async () => {
while (handles.length > 0) await handles.pop()?.close()
for (const d of tmpDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }).catch(() => undefined)
await new Promise<void>((r) => setTimeout(r, 30))
})
// ── shared guard matrix ─────────────────────────────────────────────────────────
const ROUTES = ['/projects/git/stage', '/projects/git/commit', '/projects/git/push'] as const
describe('git-write routes — shared guards (CSRF / kill-switch)', () => {
for (const route of ROUTES) {
it(`${route}: rejects a foreign Origin with 403`, async () => {
const { port } = await spawnServer()
const res = await postJson(port, route, { path: '/tmp/x' }, { Origin: 'http://evil.example' })
expect(res.status).toBe(403)
})
it(`${route}: rejects a missing Origin with 403 (default-deny)`, async () => {
const { port } = await spawnServer()
const res = await postJson(port, route, { path: '/tmp/x' }, {})
expect(res.status).toBe(403)
})
it(`${route}: returns 403 when GIT_OPS_ENABLED=0`, async () => {
const { port, origin } = await spawnServer({ GIT_OPS_ENABLED: '0' })
const res = await postJson(port, route, { path: '/tmp/x' }, { Origin: origin })
expect(res.status).toBe(403)
})
}
})
// ── POST /projects/git/stage ──────────────────────────────────────────────────────
describe('POST /projects/git/stage', () => {
it('returns 400 when files are missing', async () => {
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/stage', { path: '/tmp/x' }, { Origin: origin })
expect(res.status).toBe(400)
})
it('returns 404 for a non-git directory', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-'))
tmpDirs.push(dir)
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/stage', { path: dir, files: ['a'] }, { Origin: origin })
expect(res.status).toBe(404)
})
itGit('stages a modified file in a real repo (200) and git records it', async () => {
const repo = await makeRealRepo()
await fs.writeFile(path.join(repo, 'file.txt'), 'line1\nCHANGED\n', 'utf8')
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/stage', { path: repo, files: ['file.txt'] }, { Origin: origin })
expect(res.status).toBe(200)
const body = (await res.json()) as { ok: boolean; staged: boolean; count: number }
expect(body).toMatchObject({ ok: true, staged: true, count: 1 })
const cached = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: repo }).toString()
expect(cached).toContain('file.txt')
})
})
// ── POST /projects/git/commit ─────────────────────────────────────────────────────
describe('POST /projects/git/commit', () => {
itGit('returns 400 for an empty message', async () => {
const repo = await makeRealRepo()
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/commit', { path: repo, message: ' ' }, { Origin: origin })
expect(res.status).toBe(400)
})
itGit('returns a safe 409 (no "fatal:") when nothing is staged', async () => {
const repo = await makeRealRepo()
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/commit', { path: repo, message: 'noop' }, { Origin: origin })
expect(res.status).toBe(409)
const body = (await res.json()) as { ok: boolean; error: string }
// Failure body carries ok:false + a SAFE message so the FE guard surfaces it.
expect(body.ok).toBe(false)
expect(body.error).not.toContain('fatal:')
expect(body.error).toBe('Nothing staged to commit.')
})
itGit('commits staged changes and returns a short SHA (200)', async () => {
const repo = await makeRealRepo()
await fs.writeFile(path.join(repo, 'file.txt'), 'line1\nCHANGED\n', 'utf8')
execFileSync('git', ['add', 'file.txt'], { cwd: repo })
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/commit', { path: repo, message: 'phone commit' }, { Origin: origin })
expect(res.status).toBe(200)
const body = (await res.json()) as { ok: boolean; commit: string }
expect(body.ok).toBe(true)
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo }).toString().trim()
expect(head.startsWith(body.commit)).toBe(true)
})
})
// ── POST /projects/git/push ───────────────────────────────────────────────────────
describe('POST /projects/git/push', () => {
itGit('returns a safe 400 when the repo has no remote', async () => {
const repo = await makeRealRepo()
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/push', { path: repo }, { Origin: origin })
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).not.toContain('fatal:')
})
itGit('pushes the current branch to a bare remote and sets upstream (200)', async () => {
const repo = await makeRealRepo()
const bare = await makeBareRemote()
execFileSync('git', ['remote', 'add', 'origin', bare], { cwd: repo })
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/push', { path: repo }, { Origin: origin })
expect(res.status).toBe(200)
const body = (await res.json()) as { ok: boolean; branch: string; remote: string }
expect(body).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
const remoteHead = execFileSync('git', ['--git-dir', bare, 'rev-parse', 'main']).toString().trim()
const localHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo }).toString().trim()
expect(remoteHead).toBe(localHead)
})
})