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

361
src/http/git-ops.ts Normal file
View File

@@ -0,0 +1,361 @@
/**
* src/http/git-ops.ts (w4-commit-push) — the git WRITE engine (stage/commit/push).
*
* The highest-risk side-channel in the app: it stages files, commits, and pushes
* the current branch. It mirrors the read-only diff.ts / worktrees.ts execFile
* pattern exactly and adds three non-negotiable safety spines:
*
* 1. NO SHELL, EVER — execFile('git', argv, …) only. Untrusted strings (the
* commit message, every file path) are argv ELEMENTS, never interpolated. A
* trailing `--` terminates options before any pathspec, and file paths that
* start with `-` are rejected, so a path can never be read as a flag.
* 2. REALPATH CONTAINMENT — every file in `files[]` is realpath-resolved and
* proven inside the repo (git-path.ts, the M2 pattern). Defeats `../` and
* pre-planted symlink escapes even though the route's isValidGitDir is lighter.
* 3. SERVER-DERIVED push target — push NEVER accepts a remote or refspec from the
* client; both branch and remote are read back from the repo, and it is never
* a force-push (no `--force`, no `+refspec`).
*
* Every export never throws — failures return a structured GitOpResult with a
* SAFE message (never raw git stderr, SEC-M10), classified by classifyGitError.
*/
import fs from 'node:fs/promises'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { GitOpResult } from '../types.js'
import { resolveRealPath, isContained } from './git-path.js'
const execFileAsync = promisify(execFile)
const GIT_MAX_BUFFER = 1024 * 1024
// GIT_TERMINAL_PROMPT=0 + ssh BatchMode make a credential/host-key prompt fail
// FAST (→ classified 401) instead of hanging until the exec timeout backstop.
const NO_PROMPT_ENV: NodeJS.ProcessEnv = {
...process.env,
GIT_TERMINAL_PROMPT: '0',
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
}
export interface GitOpOptions {
readonly timeoutMs: number
/** Max number of files a single stage call may touch (reuses cfg.diffMaxFiles). */
readonly maxFiles?: number
}
export interface CommitOptions {
readonly timeoutMs: number
/** Commit-message length cap (cfg.commitMsgMaxLen). */
readonly maxLen: number
}
export interface PushOptions {
readonly timeoutMs: number
}
// ── error classification (pure, SEC-M10) ──────────────────────────────────────
/** Pull a best-effort diagnostic string off a child_process error (never throws).
* Combines stderr AND stdout because git writes some conditions ("nothing to
* commit") to stdout, others ("! [rejected]", auth) to stderr; message is the
* fallback. Only substrings are matched — the raw text is never surfaced. */
function extractStderr(err: unknown): string {
if (typeof err === 'object' && err !== null) {
const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown }
const parts: string[] = []
if (typeof e.stderr === 'string') parts.push(e.stderr)
if (typeof e.stdout === 'string') parts.push(e.stdout)
if (parts.join('').trim() !== '') return parts.join('\n')
if (typeof e.message === 'string') return e.message
}
return ''
}
/**
* Map a git stderr string to a structured {status, error}. The returned `error`
* is ALWAYS a hard-coded safe phrase — raw git stderr (which can contain repo
* paths / remote URLs) is never surfaced (SEC-M10). Ordered most-specific first.
* Pure + unit-tested. Unknown → 500.
*/
export function classifyGitError(stderr: string): { status: number; error: string } {
const s = (typeof stderr === 'string' ? stderr : '').toLowerCase()
if (s.includes('index.lock') || (s.includes('unable to create') && s.includes('.lock'))) {
return { status: 409, error: 'Another git operation is in progress.' }
}
if (
s.includes('nothing to commit') ||
s.includes('nothing added to commit') ||
s.includes('no changes added to commit')
) {
return { status: 409, error: 'Nothing staged to commit.' }
}
if (s.includes('please tell me who you are')) {
return { status: 400, error: 'Set a git author identity (user.name / user.email) first.' }
}
if (s.includes('rejected') || s.includes('non-fast-forward') || s.includes('fetch first')) {
return { status: 409, error: 'Push rejected — pull or rebase first.' }
}
if (
s.includes('authentication failed') ||
s.includes('could not read from remote') ||
s.includes('could not read username') ||
s.includes('terminal prompts disabled') ||
s.includes('permission denied (publickey)') ||
s.includes('no anonymous write access')
) {
return { status: 401, error: 'Push authentication required on the host.' }
}
if (s.includes('not a git repository')) {
return { status: 404, error: 'Not a git repository.' }
}
return { status: 500, error: 'Git operation failed.' }
}
/** Wrap a caught child_process error as a failed GitOpResult (never throws). */
function failFromError(err: unknown): GitOpResult {
const { status, error } = classifyGitError(extractStderr(err))
return { ok: false, status, error }
}
// ── repo + file validation ─────────────────────────────────────────────────────
/** Three-prong entry check: absolute + isDirectory + has a `.git` entry. */
async function isGitDir(repoPath: string): Promise<boolean> {
if (typeof repoPath !== 'string' || !path.isAbsolute(repoPath)) return false
try {
const st = await fs.stat(repoPath)
if (!st.isDirectory()) return false
await fs.stat(path.join(repoPath, '.git'))
return true
} catch {
return false
}
}
/**
* Validate an untrusted `files[]` for a stage call. Returns the ACCEPTED relative
* paths (verbatim, to hand to `git <verb> -- <paths>`), or null on ANY rejection.
* `repoRealPath` MUST already be the realpath of the repo. Rejections:
* - empty list / more than `maxFiles` entries (DoS bound)
* - non-string / empty entry
* - absolute path (a stage path is always repo-relative)
* - leading '-' (flag injection — even after `--`, belt-and-braces)
* - realpath escapes the repo (../ traversal or a symlink pointing outside, M2)
* A deleted file's path (absent on disk) is fine: resolveRealPath resolves the
* existing prefix and re-appends the missing tail, still contained. Never throws.
*/
export async function validateRepoFiles(
repoRealPath: string,
files: readonly unknown[],
maxFiles: number,
): Promise<string[] | null> {
if (!Array.isArray(files) || files.length === 0) return null
if (files.length > maxFiles) return null
const accepted: string[] = []
for (const raw of files) {
if (typeof raw !== 'string' || raw.length === 0) return null
if (path.isAbsolute(raw)) return null
if (raw.startsWith('-')) return null
const realCandidate = await resolveRealPath(path.join(repoRealPath, raw))
// Must be strictly INSIDE the repo — never the repo root itself (e.g. '.').
if (realCandidate === repoRealPath || !isContained(repoRealPath, realCandidate)) {
return null
}
accepted.push(raw)
}
return accepted
}
// ── stage / unstage ─────────────────────────────────────────────────────────────
/**
* Stage (`git add`) or unstage (`git restore --staged`) the given repo-relative
* files. `stage=true` → add; `false` → restore --staged. Validates the repo and
* realpath-contains every file before running git (no shell, `--` terminates
* options). Returns {ok, staged, count} on success. Never throws.
*/
export async function stageFiles(
repoPath: string,
files: readonly unknown[],
stage: boolean,
opts: GitOpOptions,
): Promise<GitOpResult> {
if (!(await isGitDir(repoPath))) {
return { ok: false, status: 404, error: 'Not a git repository.' }
}
const repoReal = await resolveRealPath(repoPath)
const maxFiles = opts.maxFiles ?? 300
const valid = await validateRepoFiles(repoReal, files, maxFiles)
if (valid === null) {
return { ok: false, status: 400, error: 'Invalid file selection.' }
}
const args = stage ? ['add', '--', ...valid] : ['restore', '--staged', '--', ...valid]
try {
await execFileAsync('git', args, {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
})
return { ok: true, staged: stage, count: valid.length }
} catch (err: unknown) {
return failFromError(err)
}
}
// ── commit ──────────────────────────────────────────────────────────────────────
/**
* Commit the STAGED changes with `message`. The message is validated (non-empty
* after trim, ≤ maxLen) and passed as the single value of `-m` via argv — never
* a shell string, never a pathspec — so newlines / leading '-' / emoji are all
* safe. Returns {ok, commit:<short sha>}. Empty index → 409, identity unset →
* 400 (classified). Never throws.
*/
export async function commit(
repoPath: string,
message: string,
opts: CommitOptions,
): Promise<GitOpResult> {
if (!(await isGitDir(repoPath))) {
return { ok: false, status: 404, error: 'Not a git repository.' }
}
if (typeof message !== 'string' || message.trim() === '') {
return { ok: false, status: 400, error: 'Commit message is required.' }
}
if (message.length > opts.maxLen) {
return { ok: false, status: 400, error: 'Commit message is too long.' }
}
try {
await execFileAsync('git', ['commit', '-m', message], {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
})
} catch (err: unknown) {
return failFromError(err)
}
// Read back the new commit's short SHA (best-effort; commit already succeeded).
try {
const { stdout } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
})
return { ok: true, commit: stdout.trim() }
} catch {
return { ok: true, commit: '' }
}
}
// ── push ──────────────────────────────────────────────────────────────────────
/** Read the current branch, or null if git failed / detached (output 'HEAD'). */
async function currentBranch(repoPath: string, timeoutMs: number): Promise<string | null> {
try {
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: repoPath,
timeout: timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
})
const branch = stdout.trim()
return branch === '' || branch === 'HEAD' ? null : branch
} catch {
return null
}
}
/** The upstream remote name (e.g. 'origin') for the current branch, or null. */
async function upstreamRemote(repoPath: string, timeoutMs: number): Promise<string | null> {
try {
const { stdout } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
{ cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER },
)
const ref = stdout.trim() // e.g. "origin/main"
const slash = ref.indexOf('/')
return slash > 0 ? ref.slice(0, slash) : null
} catch {
return null // no upstream configured
}
}
/** List configured remotes (one per line), or [] on error. */
async function listRemotes(repoPath: string, timeoutMs: number): Promise<string[]> {
try {
const { stdout } = await execFileAsync('git', ['remote'], {
cwd: repoPath,
timeout: timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
})
return stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '')
} catch {
return []
}
}
/**
* Push the CURRENT branch. If it already has an upstream → plain `git push`
* (git pushes the current branch to its configured upstream ref; never other
* branches, never a force). If it has NO upstream: exactly one remote →
* `git push -u <remote> <branch>` (sets it); zero remotes → 400; ≥2 remotes →
* 409 (ambiguous, set an upstream first). The remote and branch are ALWAYS
* derived server-side from the repo — never taken from the client — and it is
* NEVER a force-push. Returns {ok, branch, remote}. Never throws.
*/
export async function push(repoPath: string, opts: PushOptions): Promise<GitOpResult> {
if (!(await isGitDir(repoPath))) {
return { ok: false, status: 404, error: 'Not a git repository.' }
}
const branch = await currentBranch(repoPath, opts.timeoutMs)
if (branch === null) {
return { ok: false, status: 400, error: 'Cannot push a detached HEAD.' }
}
// Defence in depth: a real checked-out branch never starts with '-', but a
// branch/remote that did could be read as a flag in the -u form below.
if (branch.startsWith('-')) {
return { ok: false, status: 400, error: 'Cannot push this branch.' }
}
const remote = await upstreamRemote(repoPath, opts.timeoutMs)
let args: string[]
let targetRemote: string
if (remote !== null) {
// Upstream already set → plain push (safe; no remote/refspec from client).
args = ['push']
targetRemote = remote
} else {
const remotes = await listRemotes(repoPath, opts.timeoutMs)
if (remotes.length === 0) {
return { ok: false, status: 400, error: 'No remote configured.' }
}
if (remotes.length > 1) {
return { ok: false, status: 409, error: 'Set an upstream first (multiple remotes).' }
}
const sole = remotes[0] as string
if (sole.startsWith('-')) {
return { ok: false, status: 400, error: 'Cannot push to this remote.' }
}
args = ['push', '-u', sole, branch]
targetRemote = sole
}
try {
await execFileAsync('git', args, {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
env: NO_PROMPT_ENV,
})
return { ok: true, branch, remote: targetRemote }
} catch (err: unknown) {
return failFromError(err)
}
}

49
src/http/git-path.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* src/http/git-path.ts (w4-commit-push) — realpath-based path containment.
*
* Extracted so the git-write engine (git-ops.ts) shares ONE containment routine
* instead of duplicating the M2 pattern from worktrees.ts. Following the plan,
* only the two primitives live here; refactoring worktrees.ts onto them is a
* deferred follow-up (out of this task's lane).
*
* Security (M2 / SEC-H3): a path is proven contained by realpath-resolving BOTH
* the base and the candidate (symlinks followed) and then doing a prefix compare.
* This defeats `../` traversal AND pre-planted-symlink escapes — the lighter
* three-prong isValidGitDir check at the route does NOT follow symlinks, so this
* is the defense-in-depth backstop for every untrusted file path.
*/
import fs from 'node:fs/promises'
import path from 'node:path'
/**
* Resolve symlinks on the longest existing prefix of `target`, re-appending any
* not-yet-existing trailing segments. Lets us canonicalise a path that may not
* exist on disk (e.g. a staged deletion whose file is already gone) without it
* having to exist. Mirrors worktrees.ts resolveRealPath (M2). Never throws.
*/
export async function resolveRealPath(target: string): Promise<string> {
let current = path.resolve(target)
const tail: string[] = []
for (;;) {
try {
const real = await fs.realpath(current)
return tail.length === 0 ? real : path.join(real, ...tail.slice().reverse())
} catch {
const parent = path.dirname(current)
if (parent === current) return path.resolve(target) // reached an unresolvable root
tail.push(path.basename(current))
current = parent
}
}
}
/**
* True iff `realCandidate` is `realBase` itself or lives strictly inside it. Both
* arguments must ALREADY be realpath-resolved (call resolveRealPath first). The
* `realBase + path.sep` guard prevents a sibling-prefix escape (`/repo-evil` is
* NOT inside `/repo`). Pure.
*/
export function isContained(realBase: string, realCandidate: string): boolean {
return realCandidate === realBase || realCandidate.startsWith(realBase + path.sep)
}