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:
+17
-6
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user