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
+10 -1
View File
@@ -32,7 +32,8 @@
"addLaneBranchModeExisting": "Existing branch",
"addLaneBranchModeNew": "New branch",
"addLaneBranchPlaceholder": "Automatic — named from the title",
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
"addLaneNoBranches": "This repo has no commits yet, so a worktree has nothing to branch from. Add it as a Repo lane: commit first, then publish it from the Changes panel.",
"addLaneNoBranchesUseRepo": "Use as a Repo lane",
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
@@ -181,7 +182,14 @@
"gitBar.newLane": "New lane here",
"gitBar.newLaneHeld": "Already checked out at {{path}} — git allows one worktree per branch",
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
"gitBar.noCommits": "no commits yet",
"gitBar.noUpstream": "not pushed",
"gitBar.publish": "Publish…",
"gitBar.publishApply": "Publish",
"gitBar.publishBranch": "Publish branch",
"gitBar.publishBranchHint": "git push -u origin HEAD — this branch has no upstream yet",
"gitBar.publishHint": "Add a remote named origin and push this branch to it (git remote add origin + git push -u origin HEAD)",
"gitBar.publishUrl": "Remote URL: https://host/owner/repo.git or git@host:owner/repo.git",
"gitBar.pull": "Pull",
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
"gitBar.push": "Push",
@@ -192,6 +200,7 @@
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
"gitBar.switchHint": "Switch this worktree to another local branch",
"gitBar.switchLabel": "Switch branch…",
"gitBar.unbornHint": "Commit first, then publish this repo to a remote.",
"kind.adopted": "adopted",
"kind.managed": "managed",
"laneDetail.hide": "Hide details",
+10 -1
View File
@@ -32,7 +32,8 @@
"addLaneBranchModeExisting": "Nhánh có sẵn",
"addLaneBranchModeNew": "Nhánh mới",
"addLaneBranchPlaceholder": "Tự động — đặt theo tiêu đề",
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
"addLaneNoBranches": "Repo này chưa có commit nào nên worktree không có gì để rẽ nhánh. Hãy thêm nó thành lane Repo: commit trước, rồi publish từ bảng Changes.",
"addLaneNoBranchesUseRepo": "Dùng làm lane Repo",
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
@@ -181,7 +182,14 @@
"gitBar.newLane": "Tạo lane mới",
"gitBar.newLaneHeld": "Nhánh đã được checkout tại {{path}} — git chỉ cho một worktree trên mỗi nhánh",
"gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn",
"gitBar.noCommits": "chưa có commit",
"gitBar.noUpstream": "chưa push",
"gitBar.publish": "Publish…",
"gitBar.publishApply": "Publish",
"gitBar.publishBranch": "Publish nhánh",
"gitBar.publishBranchHint": "git push -u origin HEAD — nhánh này chưa có upstream",
"gitBar.publishHint": "Thêm remote tên origin rồi push nhánh này lên đó (git remote add origin + git push -u origin HEAD)",
"gitBar.publishUrl": "URL remote: https://host/owner/repo.git hoặc git@host:owner/repo.git",
"gitBar.pull": "Pull",
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
"gitBar.push": "Push",
@@ -192,6 +200,7 @@
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
"gitBar.switchLabel": "Đổi nhánh…",
"gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.",
"kind.adopted": "đã nhận",
"kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết",