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:
361
src/http/git-ops.ts
Normal file
361
src/http/git-ops.ts
Normal 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
49
src/http/git-path.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user