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:
237
test/integration/git-ops.test.ts
Normal file
237
test/integration/git-ops.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user