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
+25 -2
View File
@@ -524,6 +524,8 @@ curl http://localhost:4820/api/lanes/5/git
top of the Changes panel opens this list as a panel: one row per file, its status letter
colour-coded, the directory truncated before the filename ever is, and each
row's `+N −M` aligned on the right under a total.
- A repo with no commit yet is **not** unavailable: it reports `unborn: true`,
`head: null`, an empty `subject` and the branch its first commit will land on.
- An unknown lane id is a `404`.
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
@@ -547,7 +549,8 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
| --- | --- | --- |
| **Fetch** | `git fetch --all --prune` | the repo has no remote |
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
| **Push** | `git push -u origin HEAD` | the remote rejects it |
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
| **Branch** | `git switch …` | the tree is dirty, or another worktree holds that branch |
| **Create** | `git branch <name>` — never switches | the branch already exists |
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
@@ -578,6 +581,21 @@ one hidden in the git bar. There is no **Commit** here: committing is the
overview's staged-only box below, the one commit path, and it only ever takes
what was staged.
**Publish…** is the one button whose destination the user types, so the server
keeps it narrow: it only gives a remote to a repo that has none (it never
re-points one), always names it `origin`, and accepts only `https://` without
credentials in the URL, `ssh://` and scp-like `git@host:owner/repo.git` — never
a local path, `file://`, `ext::` or `http://`. Credentials come from the
machine's own credential helper, ssh agent or AgentHub's own git login store
(the login form below), exactly as for **Push**.
A repo with **no commits yet** (a fresh `git init`) still gets the header: its
branch, "no commits yet", and a hint to commit first — the network and branch
buttons are hidden because there is nothing to push, fetch into or branch from.
The commit box below makes the first commit, and **Publish…** appears once it
exists. **Add lane** in Worktree mode refuses such a repo (`400 EEMPTYREPO`,
nothing to fork a worktree from) and offers **Use as a Repo lane** instead.
A failed action prints git's own `stderr` verbatim — `! [rejected] … (non-fast-forward)`
says more than any sentence the dashboard could write.
@@ -1538,7 +1556,7 @@ Terminate the running session. If the lane has no live run, this is a no-op (no
curl -X POST http://localhost:4820/api/lanes/5/stop
```
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete / publish
The git writes the Changes panel and its action row offer. See
"Acting on a lane's working copy" above for what each one refuses and why.
@@ -1558,6 +1576,11 @@ curl -X POST http://localhost:4820/api/lanes/5/git/commit \
curl -X POST http://localhost:4820/api/lanes/5/git/discard \
-H "Content-Type: application/json" \
-d '{"paths": ["src/scratch.ts"], "confirm": true}'
# A repo with commits but no remote: add `origin` and push HEAD to it
curl -X POST http://localhost:4820/api/lanes/5/git/publish \
-H "Content-Type: application/json" \
-d '{"url": "git@github.com:owner/repo.git"}'
```
### clear