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 ──────────────────────────────────────────────────────────── */
/** CSS class prefix for diff line kinds. */
@@ -148,13 +212,21 @@ const LINE_KIND_CLASS: Record<DiffLine['kind'], string> = {
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.
* <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')
// ── file header ──────────────────────────────────────────────────────────
@@ -175,6 +247,13 @@ export function renderDiffFile(file: DiffFile): HTMLElement {
const statusEl = el('span', `df-status df-status-${file.status}`, file.status)
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)
// ── binary indicator ─────────────────────────────────────────────────────
@@ -215,10 +294,15 @@ function renderLine(line: DiffLine): HTMLElement {
/**
* 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.
*/
export function renderDiff(result: DiffResult): HTMLElement {
export function renderDiff(
result: DiffResult,
onToggleStage?: (file: DiffFile) => void,
): HTMLElement {
const container = el('div', 'df-result')
// Truncated warning
@@ -234,8 +318,11 @@ export function renderDiff(result: DiffResult): HTMLElement {
return container
}
const stageCtl: StageControl | undefined =
onToggleStage !== undefined ? { staged: result.staged, onToggle: onToggleStage } : undefined
for (const file of result.files) {
container.append(renderDiffFile(file))
container.append(renderDiffFile(file, stageCtl))
}
return container
@@ -319,21 +406,124 @@ export function mountDiffViewer(
toolbar.append(closeBtn)
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 {
const inBase = base !== null
workingBtn.disabled = inBase
stagedBtn.disabled = inBase
workingBtn.classList.toggle('df-tab-disabled', inBase)
stagedBtn.classList.toggle('df-tab-disabled', inBase)
commitBar.style.display = inBase ? 'none' : ''
}
// Content area
const content = el('div', 'df-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)
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 ────────────────────────────────────────────────────────
workingBtn.addEventListener('click', () => {
if (!staged) return
@@ -369,7 +559,9 @@ export function mountDiffViewer(
if (result === null) {
content.append(el('div', 'df-error', 'Failed to load diff.'))
} 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 {
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;
}