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
+22 -3
View File
@@ -224,7 +224,10 @@ existing `lane_update` message when the lane becomes `idle` or `failed`.
runs will spawn. Immutable once the lane is created; an attempt to update it on
an existing lane returns `400 EBADTOOL`.
`sourceRepo` must exist, be absolute, and be a git repository. When `base` is
`sourceRepo` must exist, be absolute, and be a git repository with at least
one commit — a repo with no commits has nothing to branch from and is refused
with `400 EEMPTYREPO` before any lane row is created (adopt it as a Repo lane
with `POST /api/lanes/ensure` instead, commit, then `publish`). When `base` is
omitted, it defaults to the `LANE_BASE_BRANCH` env var, or `main` when that is
also unset. The feature branch is named `<LANE_BRANCH_PREFIX><slug>` (prefix
defaults to `feat/`). `title` (or an optional `slug`) determines the
@@ -380,6 +383,7 @@ What the lane's directory looks like to git right now:
"available": true,
"branch": "feat/rename-metric",
"head": "9b3e74a",
"unborn": false,
"subject": "free-text rule mode in the form",
"dirty": 2,
"untracked": 1,
@@ -399,6 +403,9 @@ What the lane's directory looks like to git right now:
not count its siblings' files (the same scoping as `/git/changes`).
- A `cwd` that is missing, is not a git repository, or makes git fail returns
`{"available": false}` with HTTP **200** — a normal state, not a fault.
- A repo with **no commit yet** (a fresh `git init`) is available too: it
reports `unborn: true`, `head: null`, `subject: ""` and the branch the first
commit will land on.
- `files` names the files the counts came from, **capped at 20** (the counts
themselves are never capped). `status` is the two-character
`git status --porcelain` code; `path` is the new path for a rename.
@@ -585,6 +592,7 @@ cannot collide on `index.lock`.
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
| `merge` | `git merge --no-edit <ref>` — a local or remote-tracking branch that exists | `{"branch": "origin/main"}` |
| `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` |
| `merge-commit` | `git commit --no-edit` — concludes the merge | — |
@@ -635,9 +643,20 @@ cannot collide on `index.lock`.
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
- `publish` is the one write whose **destination comes from the request**, so
it only ever gives a remote to a repo that has none: `400 ENOCOMMITS` before
the first commit, `400 EREMOTEEXISTS` once any remote exists (it never
re-points one), and the name is always `origin`. The URL is allowlisted —
`https://` with no userinfo (a token in the URL would land in `.git/config`),
`ssh://` and scp-like `user@host:path`; a local path, `file://`, `ext::`,
`fd::`, `git://`, `http://` and anything starting with `-` are
`400 EBADURL`. If the push fails, `origin` is removed again, so a typo or a
missing credential can simply be retried. The response adds
`"remote": "origin"`.
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
`ECONFIRM`, `EUNTRACKED`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`); git itself exiting
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`, `EBADURL`, `EREMOTEEXISTS`,
`ENOCOMMITS`); git itself exiting
non-zero (a rejected push, a diverged pull) is
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a
rejection reads better in git's words. Anything else (git not installed, a
@@ -647,7 +666,7 @@ cannot collide on `index.lock`.
`400 ESTAGEDOUTSIDE` while the index holds a change outside that directory:
`git commit` takes the whole index whatever the cwd, and scoping it with a
pathspec would commit the working-tree copy (unstaged edits included).
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
- Network actions (`fetch`/`pull`/`push`/`publish`) are killed after 60s so an unreachable
remote fails instead of holding the request open.
- **A remote that wants a login** (issue #45) is **409 `EGITAUTH`**, not
`EGIT`: the dashboard's git has no terminal to prompt in. The error carries