feat(lanes,git): publish a repo with no remote, and read a repo with no commits

What broke: a lane on a fresh `git init` repo had no working git surface.
`statusCounts` ran `rev-parse --short HEAD` unguarded, so `gitFacts` threw
and `GET /api/lanes/:id/git` answered `{available:false}` — the Changes
panel showed no header, and `commit`/`switch` 500'd through `isDirty`.
Add lane in Worktree mode accepted such a repo and left a `failed` row
behind, and a repo with commits but no remote had a permanently disabled
Push with no way to give it one short of a terminal.

What changed:
- worktree.js: an unborn HEAD is a normal state. `statusCounts` reports
  `head: null`; `gitFacts` reads the branch via `symbolic-ref`, an empty
  subject, and a new `unborn` field.
- git-ops.js: an 11th write, `publish` — `remote add origin -- <url>` then
  `push -u origin HEAD`, only with a commit (ENOCOMMITS) and NO remote at
  all (EREMOTEEXISTS, so it never re-points one), the url allowlisted by
  `validateRemoteUrl` (https without userinfo, ssh://, scp-like; refuses
  local paths, file://, ext::, fd::, git://, http://, leading `-`:
  EBADURL), and `origin` removed again when the push fails.
- POST /api/lanes/worktree answers 400 EEMPTYREPO for a repo with no
  commits instead of creating a row that can only fail.
- LaneGitBar: unborn repos show "no commits yet" and a commit-first hint;
  no remote offers Publish… with an inline URL box; a remote but no
  upstream relabels Push as "Publish branch". AddLaneModal disables Add
  on an empty repo and offers "Use as a Repo lane".
- The two tests that used an unborn repo to force a provisioning failure
  now use a `feat` ref clash, since that repo is refused up front.
- CLAUDE.md rule reworded to "the boring eleven" with publish's guards;
  docs/API.md, docs/LANES.md, ARCHITECTURE.md, server/README.md, openapi.
This commit is contained in:
2026-09-23 12:17:32 +07:00
parent f514602697
commit aff79a787c
22 changed files with 613 additions and 102 deletions
+73 -8
View File
@@ -1,13 +1,14 @@
/**
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
* stage/unstage a file, commit, discard a file, switch branch, create or
* delete a branch, and merge a branch with its conflict resolution (issue
* #45: merge, resolve ours/theirs/hand-edited, merge-commit, merge-abort).
* There is no rebase, no reset and no stash, because a dashboard button is
* the wrong place to start an operation whose recovery needs a terminal
* (`reset` stays where it was: behind the three-check destroy guard in
* worktree.js). A merge is the exception because its recovery is ONE button
* here too — `merge-abort` puts the branch back where it was.
* delete a branch, publish a repo that has no remote yet, and merge a branch
* with its conflict resolution (issue #45: merge, resolve ours/theirs/
* hand-edited, merge-commit, merge-abort). There is no rebase, no reset and
* no stash, because a dashboard button is the wrong place to start an
* operation whose recovery needs a terminal (`reset` stays where it was:
* behind the three-check destroy guard in worktree.js). A merge is the
* exception because its recovery is ONE button here too — `merge-abort` puts
* the branch back where it was.
*
* Four rules hold for every action:
* - argv only, via worktree.js's `git()` — never a shell string.
@@ -28,6 +29,11 @@
* of, so each demands an explicit `confirm: true` in the body. Do not add
* another destructive action without the same gate.
*
* `publish` is the ONE action whose destination comes from the request (a
* remote URL), so it is gated differently: only when `git remote` prints
* nothing, always named `origin`, the URL allowlisted by `validateRemoteUrl`,
* and the remote removed again if the push fails.
*
* Network actions carry a timeout: a lane pointed at an unreachable remote
* should fail in half a minute, not hold an HTTP request open.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -66,6 +72,7 @@ const ACTIONS = new Set([
"discard-hunk",
"branch-create",
"branch-delete",
"publish",
"merge",
"resolve",
"merge-commit",
@@ -78,6 +85,12 @@ const BLOCKED_WHILE_MERGING = new Set(["pull", "switch", "merge", "stage-hunk",
/** A line git writes to open or close a conflict block. */
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
/** A remote URL is at most this long — a forge URL, not a payload. */
const MAX_URL_LEN = 1024;
/** `user@host:path`, git's scp-like ssh form. Nothing may start with `-`. */
const SCP_LIKE =
/^[A-Za-z0-9._][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*:[A-Za-z0-9._~/][A-Za-z0-9._~/-]*$/;
/** Branches this module refuses to delete, whatever the request says. */
const PROTECTED = new Set(["main", "master", "development", "develop"]);
@@ -87,6 +100,36 @@ function bad(code, message) {
return err;
}
/**
* The destination `publish` may push to, or an EBADURL throw.
*
* An allowlist, because this is the one write whose destination comes from
* the request: `https://` with NO userinfo (a token typed into the URL would
* land in .git/config in plain text), `ssh://`, and scp-like `user@host:path`.
* Everything else is refused — a local path or `file://` (a push into some
* other directory on this machine), `ext::` (git runs it as a command),
* `fd::`, `git://` and `http://` (unauthenticated / cleartext), and anything
* starting with `-` that could be read as an option.
*/
function validateRemoteUrl(url) {
const refuse = () => bad("EBADURL", "url must be https://, ssh:// or user@host:path");
if (typeof url !== "string" || !url || url.length > MAX_URL_LEN) throw refuse();
// eslint-disable-next-line no-control-regex
if (/[\s\x00-\x1f\x7f]/.test(url) || url.startsWith("-")) throw refuse();
if (SCP_LIKE.test(url)) return url;
let parsed;
try {
parsed = new URL(url);
} catch {
throw refuse();
}
const { protocol, username, password, hostname } = parsed;
if (!hostname || hostname.startsWith("-") || password) throw refuse();
if (protocol === "https:" && !username) return url;
if (protocol === "ssh:" && !username.startsWith("-")) return url;
throw refuse();
}
/** Whether the working tree has anything uncommitted (tracked or not). */
async function isDirty(dir) {
const { dirty, untracked } = await statusCounts(dir);
@@ -253,6 +296,28 @@ async function runGitAction(dir, action, body = {}) {
return done(action, await networkGit(dir, ["push", "-u", remote, "HEAD"], body));
}
if (action === "publish") {
// Gives a remote to a repo that has NONE, then does what `push` does. It
// never re-points an existing remote, so it cannot redirect a repo that
// already publishes somewhere, and the remote name is fixed like `push`'s.
if (!(await hasHead(dir))) throw bad("ENOCOMMITS", "make the first commit before publishing");
if ((await git(dir, ["remote"])).stdout.trim()) {
throw bad("EREMOTEEXISTS", "this repo already has a remote");
}
const url = validateRemoteUrl(body.url);
await git(dir, ["remote", "add", "origin", "--", url]);
let result;
try {
result = await networkGit(dir, ["push", "-u", "origin", "HEAD"], body);
} catch (err) {
// A remote left behind by a failed push (a typo, a missing credential)
// would trip EREMOTEEXISTS forever and need a terminal to undo.
await git(dir, ["remote", "remove", "origin"]).catch(() => {});
throw err;
}
return { ...done(action, result), remote: "origin" };
}
if (action === "stage") {
const paths = await resolvePaths(dir, body);
// Staging a conflicted file marks it resolved: not while git's markers
@@ -551,4 +616,4 @@ function done(action, result, branch) {
return { action, ok: true, output, ...(branch ? { branch } : {}) };
}
module.exports = { runGitAction, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
module.exports = { runGitAction, validateRemoteUrl, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
+17 -6
View File
@@ -641,9 +641,14 @@ async function statusCounts(dir) {
}
}
// Get short commit hash
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
const head = headResult.stdout.trim();
// Short commit hash — null in a repo with no commit yet (an unborn HEAD),
// which is a normal state for a fresh `git init`, not an unreadable repo.
let head = null;
try {
head = (await git(dir, ["rev-parse", "--short", "HEAD"])).stdout.trim() || null;
} catch {
/* unborn */
}
return { dirty, untracked, head, files };
}
@@ -690,7 +695,8 @@ async function laneFiles(dir) {
/**
* What a lane's working copy looks like right now: which branch it is on, the
* short HEAD, that commit's subject, and how much is uncommitted.
* short HEAD, that commit's subject, and how much is uncommitted. A repo with
* no commit yet reports `unborn: true`, `head: null` and an empty subject.
*
* Read-only and cheap, but it is five subprocesses, which is why it lives
* behind its own endpoint rather than inside the polled `GET /api/lanes`
@@ -700,11 +706,16 @@ async function laneFiles(dir) {
async function gitFacts(dir) {
const { head } = await statusCounts(dir);
const { dirty, untracked, files } = await laneFiles(dir);
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
// An unborn HEAD has no commit to abbreviate or log, but it does name the
// branch the first commit will land on — symbolic-ref reads that.
const branch = head
? (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim()
: (await git(dir, ["symbolic-ref", "--short", "HEAD"])).stdout.trim();
const subject = head ? (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim() : "";
return {
branch,
head,
unborn: !head,
subject,
dirty,
untracked,