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
+13 -2
View File
@@ -379,7 +379,7 @@ export function AddLaneModal({
!done &&
(!sourceRepo.trim() ||
(mode === "worktree" &&
(!title.trim() || !branches || (branchMode === "existing" && !branch.trim()))));
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
/** branch name -> the worktree directory already holding it. git allows one
* checkout per branch, so these are the branches this form cannot use. */
@@ -553,7 +553,18 @@ export function AddLaneModal({
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
// No commit, nothing to fork: the server refuses this with
// EEMPTYREPO, so the one way forward is offered instead.
<div className="space-y-1">
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
<button
type="button"
onClick={() => setMode("repo")}
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
>
{t("addLaneNoBranchesUseRepo")}
</button>
</div>
) : (
<select
id="add-lane-base"
+120 -49
View File
@@ -1,7 +1,8 @@
/**
* @file The lane's git action row: ahead/behind against the upstream, and the
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
* fetch, pull, push, switch branch, create/delete a branch, and start a merge
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
* (the picked branch, or the upstream once diverged; resolving it is the
* Changes panel's job). Committing is
* NOT here: it is the Changes panel's staged-only commit box, beside the
@@ -26,6 +27,7 @@ import { useTranslation } from "react-i18next";
import {
ArrowDown,
ArrowUp,
CloudUpload,
GitBranch,
GitMerge,
Plus,
@@ -36,7 +38,15 @@ import {
import { api, type GitAuthNeeded } from "../../lib/api";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge";
type Action =
| "fetch"
| "pull"
| "push"
| "switch"
| "branch-create"
| "branch-delete"
| "merge"
| "publish";
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
@@ -107,6 +117,8 @@ export function LaneGitBar({
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(true);
const [publishing, setPublishing] = useState(false);
const [url, setUrl] = useState("");
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
// All of the state above describes ONE lane — a branch picked for lane A,
// applied after switching, would move B's HEAD — so it is reset during
@@ -126,6 +138,8 @@ export function LaneGitBar({
setNewBranch("");
setLogin(null);
setPassword("");
setPublishing(false);
setUrl("");
inFlight.current = false;
}
const laneNow = useRef(laneId);
@@ -154,7 +168,10 @@ export function LaneGitBar({
setPassword("");
// The branch list this bar is showing is stale the moment a branch is
// created, deleted or switched to, so it is re-read rather than patched.
if (action === "switch" || action.startsWith("branch-")) {
if (action === "publish") {
setPublishing(false);
setUrl("");
} else if (action === "switch" || action.startsWith("branch-")) {
if (action === "switch") setSwitching(false);
else setNewBranch("");
setPicked("");
@@ -224,7 +241,11 @@ export function LaneGitBar({
{/* Null counts with an upstream named means git could not compare
the two (the upstream ref is gone): claiming "in sync" there
would be a lie, so it reads as not pushed. */}
{git.upstream && git.ahead != null && git.behind != null ? (
{git.unborn ? (
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
{t("gitBar.noCommits")}
</span>
) : git.upstream && git.ahead != null && git.behind != null ? (
<span
data-testid="lane-git-tracking"
title={git.upstream}
@@ -264,51 +285,76 @@ export function LaneGitBar({
/>
)}
<div className="ml-auto flex shrink-0 items-center">
<Btn
compact
label={t("gitBar.fetch")}
icon={<RefreshCw className="h-3.5 w-3.5" />}
busy={busy === "fetch"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.fetchHint")}
onClick={() => run("fetch")}
/>
<Btn
compact
label={t("gitBar.pull")}
icon={<ArrowDown className="h-3.5 w-3.5" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy || merging}
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
onClick={() => run("pull")}
/>
<Btn
compact
label={t("gitBar.push")}
icon={<Upload className="h-3.5 w-3.5" />}
busy={busy === "push"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pushHint")}
onClick={() => run("push")}
/>
<Btn
compact
label={t("gitBar.switch")}
icon={<GitBranch className="h-3.5 w-3.5" />}
disabled={!!busy || merging}
// A dirty tree is refused server-side; saying so before the click
// beats a red box after it.
title={
merging
? t("gitBar.merging")
: dirty > 0
? t("gitBar.switchDirty")
: t("gitBar.switchHint")
}
onClick={openSwitch}
/>
</div>
{/* A repo with no commit has nothing to push, fetch into or branch
from — the one next step is the commit box below. */}
{git.unborn ? (
<span className="ml-auto text-[10px] text-fg-muted">{t("gitBar.unbornHint")}</span>
) : (
<div className="ml-auto flex shrink-0 items-center">
<Btn
compact
label={t("gitBar.fetch")}
icon={<RefreshCw className="h-3.5 w-3.5" />}
busy={busy === "fetch"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.fetchHint")}
onClick={() => run("fetch")}
/>
<Btn
compact
label={t("gitBar.pull")}
icon={<ArrowDown className="h-3.5 w-3.5" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy || merging}
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
onClick={() => run("pull")}
/>
<Btn
compact
// Same `push -u <remote> HEAD` either way; a branch with no
// upstream is being published, not updated, so it says that.
label={git.hasRemote && !git.upstream ? t("gitBar.publishBranch") : t("gitBar.push")}
icon={<Upload className="h-3.5 w-3.5" />}
busy={busy === "push"}
disabled={!git.hasRemote || !!busy}
title={
git.hasRemote && !git.upstream
? t("gitBar.publishBranchHint")
: t("gitBar.pushHint")
}
onClick={() => run("push")}
/>
{!git.hasRemote && (
<Btn
compact
label={t("gitBar.publish")}
icon={<CloudUpload className="h-3.5 w-3.5" />}
disabled={!!busy}
title={t("gitBar.publishHint")}
onClick={() => {
setPublishing((v) => !v);
setError(null);
}}
/>
)}
<Btn
compact
label={t("gitBar.switch")}
icon={<GitBranch className="h-3.5 w-3.5" />}
disabled={!!busy || merging}
// A dirty tree is refused server-side; saying so before the click
// beats a red box after it.
title={
merging
? t("gitBar.merging")
: dirty > 0
? t("gitBar.switchDirty")
: t("gitBar.switchHint")
}
onClick={openSwitch}
/>
</div>
)}
</div>
{switching && (
@@ -406,6 +452,31 @@ export function LaneGitBar({
</div>
)}
{publishing && !git.hasRemote && !git.unborn && (
<div className="flex items-center gap-1.5">
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
}}
// The server decides which URLs are acceptable (https without a
// token, ssh, user@host:path); this only names the two usual ones.
placeholder={t("gitBar.publishUrl")}
aria-label={t("gitBar.publishUrl")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<Btn
label={t("gitBar.publishApply")}
icon={<Upload className="h-3 w-3" />}
busy={busy === "publish"}
disabled={!url.trim()}
title={t("gitBar.publishHint")}
onClick={() => void run("publish", { url: url.trim() })}
/>
</div>
)}
{login && (
<form
data-testid="lane-git-login"
@@ -305,6 +305,28 @@ describe("AddLaneModal — worktree mode (default)", () => {
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
});
it("refuses a worktree of a repo with no commits and steers to Repo mode", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: [],
current: "main",
worktrees: [],
});
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/empty");
expect(await screen.findByText(/commit first, then publish/i)).toBeInTheDocument();
await user.type(screen.getByLabelText("Title"), "demo");
// There is nothing to branch from, so the worktree cannot be created.
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Use as a Repo lane" }));
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
});
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
@@ -1,7 +1,8 @@
/**
* @file LaneGitBar.test.tsx
* @description Pins the lane git action row: the ahead/behind readout, which
* buttons a repo with no remote may press, that it offers no commit (the
* buttons a repo with no remote may press (and that it may publish to a typed URL),
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
* refuses it too), and that a failed action shows git's own words rather than
* a generic sentence.
@@ -24,6 +25,7 @@ function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}):
available: true,
branch: "feat/thing",
head: "9b3e74a",
unborn: false,
subject: "wip",
dirty: 2,
untracked: 1,
@@ -79,6 +81,65 @@ describe("LaneGitBar", () => {
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
});
it("shows an unborn repo's branch and asks for a first commit instead of a push", () => {
render(
<LaneGitBar
laneId={3}
git={facts({
branch: "main",
head: null,
unborn: true,
subject: "",
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
})}
onChanged={vi.fn()}
/>
);
expect(screen.getByTestId("lane-git-bar")).toHaveTextContent("no commits yet");
expect(screen.getByText(/Commit first, then publish/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Fetch|Pull|Push|Publish/ })).toBeNull();
});
it("publishes a repo with no remote to a typed URL", async () => {
const user = userEvent.setup();
const onChanged = vi.fn();
render(
<LaneGitBar
laneId={3}
git={facts({ hasRemote: false, upstream: null, ahead: null, behind: null })}
onChanged={onChanged}
/>
);
await user.click(screen.getByRole("button", { name: "Publish…" }));
await user.type(screen.getByLabelText(/Remote URL/), "git@host:owner/repo.git");
await user.click(screen.getByRole("button", { name: "Publish" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "publish", {
url: "git@host:owner/repo.git",
})
);
expect(onChanged).toHaveBeenCalled();
});
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
const user = userEvent.setup();
render(
<LaneGitBar
laneId={3}
git={facts({ upstream: null, ahead: null, behind: null })}
onChanged={vi.fn()}
/>
);
expect(screen.queryByRole("button", { name: /^Push$/ })).toBeNull();
await user.click(screen.getByRole("button", { name: "Publish branch" }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
});
it("loads the branch list on demand and refuses to switch while dirty", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);