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

@@ -137,6 +137,70 @@ export async function fetchDiff(repoPath: string, opts: FetchDiffOpts = {}): Pro
} }
} }
/* ── W4 git write: stage / commit / push helpers ─────────────────────────────── */
/** Server response shape for the three W4 git-write routes (mirrors GitOpResult).
* Only `ok` is guaranteed; the rest are route-specific success/failure fields. */
export interface GitOpResponse {
ok: boolean
status?: number
error?: string
staged?: boolean
count?: number
commit?: string
branch?: string
remote?: string
}
/** Narrow an untrusted JSON body to a GitOpResponse (mirrors isWorktreeResult). */
export function isGitOpResult(v: unknown): v is GitOpResponse {
return v !== null && typeof v === 'object' && typeof (v as Record<string, unknown>)['ok'] === 'boolean'
}
/** POST JSON to a same-origin git-write route; returns the parsed GitOpResponse
* or null on any transport/parse error. Same-origin, so the browser attaches the
* Origin header the server's CSRF guard checks — no manual header needed. */
async function postGitOp(url: string, body: unknown): Promise<GitOpResponse | null> {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
let data: unknown
try {
data = await res.json()
} catch {
data = null
}
if (isGitOpResult(data)) return data
// Non-JSON / unexpected body (e.g. a 403/429 with no body): synthesize a
// failure carrying the HTTP status so callers can show a message.
return { ok: false, status: res.status }
} catch {
return null
}
}
/** Stage (`stage=true`) or unstage the given repo-relative files. */
export function postStage(
repoPath: string,
files: string[],
stage: boolean,
): Promise<GitOpResponse | null> {
return postGitOp('/projects/git/stage', { path: repoPath, files, stage })
}
/** Commit the staged changes with `message`. */
export function postCommit(repoPath: string, message: string): Promise<GitOpResponse | null> {
return postGitOp('/projects/git/commit', { path: repoPath, message })
}
/** Push the current branch to its upstream (or `-u <sole-remote> <branch>`). */
export function postPush(repoPath: string): Promise<GitOpResponse | null> {
return postGitOp('/projects/git/push', { path: repoPath })
}
/* ── renderDiffFile ──────────────────────────────────────────────────────────── */ /* ── renderDiffFile ──────────────────────────────────────────────────────────── */
/** CSS class prefix for diff line kinds. */ /** CSS class prefix for diff line kinds. */
@@ -148,13 +212,21 @@ const LINE_KIND_CLASS: Record<DiffLine['kind'], string> = {
meta: 'df-meta', meta: 'df-meta',
} }
/** Optional per-file stage/unstage control (W4). `staged` picks the button label
* (Unstage in the staged view, Stage otherwise); `onToggle` fires on click. */
export interface StageControl {
staged: boolean
onToggle: (file: DiffFile) => void
}
/** /**
* Render a single DiffFile into an HTMLElement. * Render a single DiffFile into an HTMLElement. When `stageCtl` is provided (W4,
* working/staged views only) a Stage/Unstage button is added to the file header.
* *
* Security: ALL text content is set via textContent — zero innerHTML. * Security: ALL text content is set via textContent — zero innerHTML.
* <script>, ANSI sequences, & etc. are rendered as literal characters. * <script>, ANSI sequences, & etc. are rendered as literal characters.
*/ */
export function renderDiffFile(file: DiffFile): HTMLElement { export function renderDiffFile(file: DiffFile, stageCtl?: StageControl): HTMLElement {
const section = el('div', 'df-file') const section = el('div', 'df-file')
// ── file header ────────────────────────────────────────────────────────── // ── file header ──────────────────────────────────────────────────────────
@@ -175,6 +247,13 @@ export function renderDiffFile(file: DiffFile): HTMLElement {
const statusEl = el('span', `df-status df-status-${file.status}`, file.status) const statusEl = el('span', `df-status df-status-${file.status}`, file.status)
header.append(pathEl, statsEl, statusEl) header.append(pathEl, statsEl, statusEl)
if (stageCtl !== undefined) {
const stageBtn = el('button', 'df-file-stage', stageCtl.staged ? 'Unstage' : 'Stage')
stageBtn.addEventListener('click', () => stageCtl.onToggle(file))
header.append(stageBtn)
}
section.append(header) section.append(header)
// ── binary indicator ───────────────────────────────────────────────────── // ── binary indicator ─────────────────────────────────────────────────────
@@ -215,10 +294,15 @@ function renderLine(line: DiffLine): HTMLElement {
/** /**
* Render a full DiffResult: all files grouped, with empty state and truncated warning. * Render a full DiffResult: all files grouped, with empty state and truncated warning.
* When `onToggleStage` is provided (W4, working/staged views), each file row gets a
* Stage/Unstage button whose direction is derived from `result.staged`.
* *
* Security: ALL content via textContent — zero innerHTML. * Security: ALL content via textContent — zero innerHTML.
*/ */
export function renderDiff(result: DiffResult): HTMLElement { export function renderDiff(
result: DiffResult,
onToggleStage?: (file: DiffFile) => void,
): HTMLElement {
const container = el('div', 'df-result') const container = el('div', 'df-result')
// Truncated warning // Truncated warning
@@ -234,8 +318,11 @@ export function renderDiff(result: DiffResult): HTMLElement {
return container return container
} }
const stageCtl: StageControl | undefined =
onToggleStage !== undefined ? { staged: result.staged, onToggle: onToggleStage } : undefined
for (const file of result.files) { for (const file of result.files) {
container.append(renderDiffFile(file)) container.append(renderDiffFile(file, stageCtl))
} }
return container return container
@@ -319,21 +406,124 @@ export function mountDiffViewer(
toolbar.append(closeBtn) toolbar.append(closeBtn)
root.append(toolbar) root.append(toolbar)
// In base mode the Working/Staged tabs are inert (a base diff can't be staged). // In base mode the Working/Staged tabs are inert (a base diff can't be staged)
// and the commit/push bar is hidden (you can only commit the working index).
function updateTabState(): void { function updateTabState(): void {
const inBase = base !== null const inBase = base !== null
workingBtn.disabled = inBase workingBtn.disabled = inBase
stagedBtn.disabled = inBase stagedBtn.disabled = inBase
workingBtn.classList.toggle('df-tab-disabled', inBase) workingBtn.classList.toggle('df-tab-disabled', inBase)
stagedBtn.classList.toggle('df-tab-disabled', inBase) stagedBtn.classList.toggle('df-tab-disabled', inBase)
commitBar.style.display = inBase ? 'none' : ''
} }
// Content area // Content area
const content = el('div', 'df-content') const content = el('div', 'df-content')
root.append(content) root.append(content)
// ── W4 commit / push bar (working/staged mode only) ─────────────────────────
// Message textarea + Commit + Push. Hidden in base-compare mode. All rendered
// status/error text goes through textContent (SEC-H4) — zero innerHTML.
const commitBar = el('div', 'df-commitbar')
const commitMsg = el('textarea', 'df-commit-msg')
commitMsg.placeholder = 'Commit message'
commitMsg.rows = 2
const commitBtn = el('button', 'df-commit-btn', 'Commit')
commitBtn.disabled = true // enabled once the message is non-empty
const pushBtn = el('button', 'df-push-btn', 'Push')
const opStatus = el('div', 'df-op-status')
opStatus.style.display = 'none'
commitBar.append(commitMsg, commitBtn, pushBtn, opStatus)
root.append(commitBar)
container.append(root) container.append(root)
let busy = false
/** Reflect an in-flight git-write op: disable buttons, mark the root busy. */
function setBusy(v: boolean): void {
busy = v
root.classList.toggle('df-op-busy', v)
pushBtn.disabled = v
commitBtn.disabled = v || commitMsg.value.trim() === ''
}
/** Show a status line (error or notice) via textContent only (SEC-H4). */
function showOp(msg: string, kind: 'error' | 'notice'): void {
opStatus.textContent = msg
opStatus.className = kind === 'error' ? 'df-op-status df-op-error' : 'df-op-status df-op-notice'
opStatus.style.display = ''
}
function clearOp(): void {
opStatus.textContent = ''
opStatus.style.display = 'none'
}
/** Stage (working view) or unstage (staged view) one file, then reload. */
async function toggleStage(file: DiffFile): Promise<void> {
if (busy || destroyed) return
const files =
file.status === 'renamed' && file.oldPath !== file.newPath
? [file.oldPath, file.newPath]
: [file.newPath]
setBusy(true)
clearOp()
const r = await postStage(repoPath, files, !staged) // working → add; staged → unstage
if (destroyed) return
setBusy(false)
if (r === null || !r.ok) {
showOp(r?.error ?? 'Stage failed.', 'error')
return
}
void loadDiff()
}
/** Commit the staged changes with the textarea message, then reload. */
async function doCommit(): Promise<void> {
if (busy || destroyed) return
const message = commitMsg.value
if (message.trim() === '') return
setBusy(true)
clearOp()
const r = await postCommit(repoPath, message)
if (destroyed) return
setBusy(false)
if (r === null || !r.ok) {
showOp(r?.error ?? 'Commit failed.', 'error')
return
}
commitMsg.value = ''
commitBtn.disabled = true
showOp(r.commit !== undefined && r.commit !== '' ? `Committed ${r.commit}` : 'Committed.', 'notice')
void loadDiff()
}
/** Push the current branch; surface a safe success/error notice (no reload). */
async function doPush(): Promise<void> {
if (busy || destroyed) return
setBusy(true)
clearOp()
const r = await postPush(repoPath)
if (destroyed) return
setBusy(false)
if (r === null || !r.ok) {
showOp(r?.error ?? 'Push failed.', 'error')
return
}
const to = [r.branch, r.remote].filter((x): x is string => typeof x === 'string' && x !== '').join(' → ')
showOp(to !== '' ? `Pushed ${to}` : 'Pushed.', 'notice')
}
commitMsg.addEventListener('input', () => {
if (!busy) commitBtn.disabled = commitMsg.value.trim() === ''
})
commitBtn.addEventListener('click', () => {
void doCommit()
})
pushBtn.addEventListener('click', () => {
void doPush()
})
// ── event handlers ──────────────────────────────────────────────────────── // ── event handlers ────────────────────────────────────────────────────────
workingBtn.addEventListener('click', () => { workingBtn.addEventListener('click', () => {
if (!staged) return if (!staged) return
@@ -369,7 +559,9 @@ export function mountDiffViewer(
if (result === null) { if (result === null) {
content.append(el('div', 'df-error', 'Failed to load diff.')) content.append(el('div', 'df-error', 'Failed to load diff.'))
} else { } else {
content.append(renderDiff(result)) // Stage toggles only in working/staged mode (never for a base-compare diff).
const onToggle = base !== null ? undefined : (file: DiffFile) => void toggleStage(file)
content.append(renderDiff(result, onToggle))
} }
} }

View File

@@ -2385,3 +2385,87 @@ body.home-open #term {
.gp-save-btn:hover { .gp-save-btn:hover {
filter: brightness(1.05); filter: brightness(1.05);
} }
/* ── W4 git write: per-file Stage/Unstage toggle + commit/push bar ─────────── */
.df-file-header {
display: flex;
align-items: center;
gap: 8px;
}
.df-file-stage {
margin-left: auto;
flex: 0 0 auto;
padding: 3px 10px;
font-size: 12px;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
}
.df-file-stage:hover {
background: var(--surface-3);
border-color: var(--accent);
}
.df-commitbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px;
border-top: 1px solid var(--border);
background: var(--surface-1);
}
.df-commit-msg {
flex: 1 1 220px;
min-width: 0;
resize: vertical;
padding: 8px;
font: inherit;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 6px;
}
.df-commit-msg:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.df-commit-btn,
.df-push-btn {
flex: 0 0 auto;
padding: 8px 16px;
font: inherit;
font-weight: 600;
border-radius: 6px;
border: 1px solid var(--accent);
background: var(--accent);
color: #1a1712;
cursor: pointer;
}
.df-push-btn {
background: var(--surface-2);
color: var(--text);
border-color: var(--border-strong);
}
.df-commit-btn:disabled,
.df-push-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.df-op-status {
flex: 1 1 100%;
font-size: 13px;
word-break: break-word;
}
.df-op-error {
color: var(--red);
}
.df-op-notice {
color: var(--text-dim);
}
.df-op-busy {
opacity: 0.7;
}

View File

@@ -65,6 +65,10 @@ const DEFAULT_GH_TIMEOUT_MS = 8_000
const DEFAULT_STATUSLINE_TTL_MS = 30_000 const DEFAULT_STATUSLINE_TTL_MS = 30_000
// B3 worktrees // B3 worktrees
const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000 const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000
// W4 git write (stage / commit / push)
const DEFAULT_GIT_OPS_TIMEOUT_MS = 10_000 // stage/commit exec bound (local, like worktree)
const DEFAULT_GIT_PUSH_TIMEOUT_MS = 120_000 // push is network-bound → longer bound
const DEFAULT_COMMIT_MSG_MAX_LEN = 5_000 // commit-message length cap
// W2 inject queue // W2 inject queue
const DEFAULT_QUEUE_MAX_ITEMS = 10 const DEFAULT_QUEUE_MAX_ITEMS = 10
const DEFAULT_QUEUE_ITEM_MAX_BYTES = 4096 const DEFAULT_QUEUE_ITEM_MAX_BYTES = 4096
@@ -406,6 +410,24 @@ export function loadConfig(env: EnvLike): Config {
DEFAULT_WORKTREE_TIMEOUT_MS, DEFAULT_WORKTREE_TIMEOUT_MS,
) )
// W4 git write (stage / commit / push) — the highest-risk write channel
const gitOpsEnabled = parseBool(env['GIT_OPS_ENABLED'], true) // master kill-switch
const gitOpsTimeoutMs = parseNonNegativeInt(
env['GIT_OPS_TIMEOUT_MS'],
'GIT_OPS_TIMEOUT_MS',
DEFAULT_GIT_OPS_TIMEOUT_MS,
)
const gitPushTimeoutMs = parseNonNegativeInt(
env['GIT_PUSH_TIMEOUT_MS'],
'GIT_PUSH_TIMEOUT_MS',
DEFAULT_GIT_PUSH_TIMEOUT_MS,
)
const commitMsgMaxLen = parseNonNegativeInt(
env['COMMIT_MSG_MAX_LEN'],
'COMMIT_MSG_MAX_LEN',
DEFAULT_COMMIT_MSG_MAX_LEN,
)
// B4 permission mode relay // B4 permission mode relay
const defaultPermissionMode = parsePermissionMode( const defaultPermissionMode = parsePermissionMode(
env['DEFAULT_PERMISSION_MODE'], env['DEFAULT_PERMISSION_MODE'],
@@ -475,6 +497,11 @@ export function loadConfig(env: EnvLike): Config {
worktreeEnabled, worktreeEnabled,
worktreeRoot, worktreeRoot,
worktreeTimeoutMs, worktreeTimeoutMs,
// W4 git write (stage / commit / push)
gitOpsEnabled,
gitOpsTimeoutMs,
gitPushTimeoutMs,
commitMsgMaxLen,
defaultPermissionMode, defaultPermissionMode,
allowAutoMode, allowAutoMode,
// W2 inject queue // W2 inject queue

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

View File

@@ -45,6 +45,7 @@ import { buildDigest, clampSince } from './http/digest.js'
import { getPrStatus } from './http/gh.js' import { getPrStatus } from './http/gh.js'
import { parseStatusLine } from './http/statusline.js' import { parseStatusLine } from './http/statusline.js'
import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js' import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js'
import { stageFiles, commit as gitCommit, push as gitPush } from './http/git-ops.js'
import { createSessionManager } from './session/manager.js' import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js' import { detachWs, writeInput, setClientDims } from './session/session.js'
import { loadSubscriptionStore } from './push/subscription-store.js' import { loadSubscriptionStore } from './push/subscription-store.js'
@@ -80,6 +81,8 @@ const RATE_LIMIT_WINDOW_MS = 60_000
const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP
const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP
const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP
const GIT_WRITE_RATE_MAX = 30 // W4: POST /projects/git/{stage,commit} ≤ 30/min/IP
const GIT_PUSH_RATE_MAX = 6 // W4: POST /projects/git/push ≤ 6/min/IP (network-bound)
/** The four permission modes the WS approve relay accepts (B4); else undefined. */ /** The four permission modes the WS approve relay accepts (B4); else undefined. */
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([ const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
@@ -224,6 +227,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS) const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS) const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2 const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2
const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit
const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push
// W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook // W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook
// schedules a debounced drain of one queue entry after cfg.queueSettleMs, so // schedules a debounced drain of one queue entry after cfg.queueSettleMs, so
@@ -980,6 +985,116 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to prune worktrees.' }) res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to prune worktrees.' })
}) })
// ── W4 git write: stage / commit / push (the HIGHEST-risk channel) ───────────
// Every route: requireAllowedOrigin (CSRF — this is a WRITE channel, unlike the
// read-only /projects/diff) → gitOpsEnabled kill-switch (403) → per-IP rate
// limit (429) → isValidGitDir three-prong (404). The delegates in git-ops.ts
// re-validate and realpath-CONTAIN every untrusted path/message (defense in
// depth) and return SAFE, classified errors only (never raw git stderr).
// Stage / unstage specific files. `stage` (default true) → git add; false →
// git restore --staged. `files[]` is capped + realpath-contained in git-ops.
app.post('/projects/git/stage', express.json({ limit: '64kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.gitOpsEnabled) {
res.status(403).json({ error: 'Git operations are disabled.' })
return
}
if (!gitWriteLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).json({ error: 'Too many requests.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
const files = Array.isArray(body['files']) ? (body['files'] as unknown[]) : undefined
const stage = body['stage'] === undefined ? true : body['stage'] === true
if (repoPath === undefined || files === undefined) {
res.status(400).json({ error: 'path and files are required' })
return
}
if (!(await isValidGitDir(repoPath))) {
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
return
}
const result = await stageFiles(repoPath, files, stage, {
timeoutMs: cfg.gitOpsTimeoutMs,
maxFiles: cfg.diffMaxFiles,
})
if (result.ok) {
res.status(200).json({ ok: true, staged: result.staged, count: result.count })
return
}
res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
})
// Commit the STAGED changes. Message is length-capped + non-empty checked and
// passed as a single `-m <msg>` argv (never a shell string, never a pathspec).
app.post('/projects/git/commit', express.json({ limit: '16kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.gitOpsEnabled) {
res.status(403).json({ error: 'Git operations are disabled.' })
return
}
if (!gitWriteLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).json({ error: 'Too many requests.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
const message = typeof body['message'] === 'string' ? body['message'] : ''
if (repoPath === undefined) {
res.status(400).json({ error: 'path is required' })
return
}
if (!(await isValidGitDir(repoPath))) {
res.status(404).json({ error: 'project not found' })
return
}
// Audit (write): who/what, sanitized + truncated (never raw control chars).
console.error(`[server] git commit: path=${sanitizeForLog(repoPath)}`)
const result = await gitCommit(repoPath, message, {
timeoutMs: cfg.gitOpsTimeoutMs,
maxLen: cfg.commitMsgMaxLen,
})
if (result.ok) {
res.status(200).json({ ok: true, commit: result.commit })
return
}
res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
})
// Push the CURRENT branch to its existing upstream, or `-u <sole-remote>
// <branch>` if none. Remote/branch are derived server-side; never a force-push.
// Tighter per-IP rate limit than stage/commit (network-bound).
app.post('/projects/git/push', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.gitOpsEnabled) {
res.status(403).json({ error: 'Git operations are disabled.' })
return
}
if (!gitPushLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).json({ error: 'Too many requests.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
if (repoPath === undefined) {
res.status(400).json({ error: 'path is required' })
return
}
if (!(await isValidGitDir(repoPath))) {
res.status(404).json({ error: 'project not found' })
return
}
console.error(`[server] git push: path=${sanitizeForLog(repoPath)}`)
const result = await gitPush(repoPath, { timeoutMs: cfg.gitPushTimeoutMs })
if (result.ok) {
res.status(200).json({ ok: true, branch: result.branch, remote: result.remote })
return
}
res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
})
// ── GET /config/ui (review #4) — client-readable UI config (read-only) ──── // ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
app.get('/config/ui', (_req, res) => { app.get('/config/ui', (_req, res) => {
const uiConfig: UiConfig = { const uiConfig: UiConfig = {

View File

@@ -72,6 +72,11 @@ export interface Config {
readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed) readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000 readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000
// W4 git write (stage / commit / push) — the highest-risk write channel
readonly gitOpsEnabled: boolean; // GIT_OPS_ENABLED, default true (kill-switch → all 3 routes 403)
readonly gitOpsTimeoutMs: number; // GIT_OPS_TIMEOUT_MS, default 10000 (stage/commit exec bound)
readonly gitPushTimeoutMs: number; // GIT_PUSH_TIMEOUT_MS, default 120000 (network-bound push)
readonly commitMsgMaxLen: number; // COMMIT_MSG_MAX_LEN, default 5000 (message length cap)
// B4 permission-mode relay // B4 permission-mode relay
readonly defaultPermissionMode: PermissionMode; // DEFAULT_PERMISSION_MODE, default 'default' readonly defaultPermissionMode: PermissionMode; // DEFAULT_PERMISSION_MODE, default 'default'
readonly allowAutoMode: boolean; // ALLOW_AUTO_MODE, default false (SEC-M5) readonly allowAutoMode: boolean; // ALLOW_AUTO_MODE, default false (SEC-M5)
@@ -614,6 +619,24 @@ export interface PruneWorktreesResult {
error?: string; error?: string;
} }
/* ── W4 git write: stage / commit / push (§w4-commit-push) ── */
/** Result of the three W4 git-write routes (POST /projects/git/{stage,commit,push}).
* ONE interface for all three (like CreateWorktreeResult) to keep the shared
* contract small. `error` carries a SAFE message only — never raw git stderr
* (SEC-M10); `status` is the HTTP status to return on failure. Success payloads
* are route-specific and all optional. */
export interface GitOpResult {
ok: boolean;
status?: number; // HTTP status on failure
error?: string; // SAFE message only (never raw git stderr)
staged?: boolean; // stage: direction applied (true = added, false = unstaged)
count?: number; // stage: number of files affected
commit?: string; // commit: short SHA of the new commit
branch?: string; // push: branch that was pushed
remote?: string; // push: remote it was pushed to
}
/* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */ /* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */
/** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts). /** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts).

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', () => { describe('loadConfig — v0.7 B4 permission mode', () => {
beforeEach(() => { beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({}) mockNetworkInterfaces.mockReturnValue({})

View File

@@ -19,6 +19,10 @@ const {
renderDiffFile, renderDiffFile,
renderDiff, renderDiff,
mountDiffViewer, mountDiffViewer,
isGitOpResult,
postStage,
postCommit,
postPush,
} = await import('../public/diff.js') } = await import('../public/diff.js')
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
@@ -615,3 +619,227 @@ describe('mountDiffViewer', () => {
handle.destroy() 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)
})
})