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
+6 -3
View File
@@ -2533,12 +2533,13 @@ export const api = {
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
* @param id The lane id.
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
* `unstage`, `discard`, `commit`, `switch`, `branch-create` or
* `branch-delete`.
* `unstage`, `discard`, `commit`, `switch`, `branch-create`,
* `branch-delete` or `publish`.
* @param body `{message, only}` for commit; `{branch, create}` for switch;
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
* `{paths, confirm: true}` for discard — which is irreversible, so the
* server refuses it without that flag.
* server refuses it without that flag; `{url}` for publish (a repo with
* no remote yet: adds `origin` and pushes HEAD with an upstream).
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
*/
gitAction: (
@@ -2556,11 +2557,13 @@ export const api = {
| "discard-hunk"
| "branch-create"
| "branch-delete"
| "publish"
| "merge"
| "resolve"
| "merge-commit"
| "merge-abort",
body: {
url?: string;
message?: string;
only?: "staged";
branch?: string;
+5 -3
View File
@@ -2584,9 +2584,11 @@ export type LaneGitFacts =
available: true;
/** Branch name, or the literal `HEAD` for a detached checkout. */
branch: string;
/** Short commit hash of HEAD. */
head: string;
/** Subject line of the HEAD commit. */
/** Short commit hash of HEAD; null in a repo with no commit yet. */
head: string | null;
/** True when the repo has no commit yet (a fresh `git init`). */
unborn?: boolean;
/** Subject line of the HEAD commit; "" when unborn. */
subject: string;
/** Tracked files with uncommitted changes. */
dirty: number;