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

@@ -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;
}