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