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
+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