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
+129 -1
View File
@@ -23,7 +23,7 @@ const {
parseUnifiedDiff,
readFileAt,
} = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { runGitAction, validateRemoteUrl } = require("../lib/git-ops");
const { gitFacts } = require("../lib/worktree");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gitdiff-"));
@@ -660,3 +660,131 @@ describe("runGitAction — one hunk at a time", () => {
);
});
});
describe("an unborn repo and `publish`", () => {
const EMPTY = path.join(ROOT, "empty");
const URL_OK = "https://example.test/owner/r.git";
/** A `git init`ed repo with no commit and no remote. */
function unborn() {
fs.rmSync(EMPTY, { recursive: true, force: true });
g(ROOT, "init", "-q", "-b", "main", EMPTY);
g(EMPTY, "config", "user.email", "t@h");
g(EMPTY, "config", "user.name", "t");
}
function firstCommit() {
write(EMPTY, "x.txt", "x\n");
gc(EMPTY, "add", "-A");
gc(EMPTY, "commit", "-qm", "first");
}
beforeEach(() => {
freshFixture();
unborn();
});
it("reports an unborn repo instead of failing to read it", async () => {
write(EMPTY, "x.txt", "x\n");
const facts = await gitFacts(EMPTY);
assert.equal(facts.unborn, true);
assert.equal(facts.head, null);
assert.equal(facts.branch, "main");
assert.equal(facts.subject, "");
assert.equal(facts.untracked, 1);
assert.equal(facts.hasRemote, false);
assert.equal((await gitFacts(LANE)).unborn, false);
});
it("makes the first commit of an unborn repo", async () => {
write(EMPTY, "x.txt", "x\n");
const out = await runGitAction(EMPTY, "commit", { message: "first" });
assert.equal(out.ok, true);
assert.equal((await gitFacts(EMPTY)).unborn, false);
});
it("refuses to publish before the first commit", async () => {
await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK }), {
code: "ENOCOMMITS",
});
});
it("refuses to publish a repo that already has a remote", async () => {
await assert.rejects(() => runGitAction(LANE, "publish", { url: URL_OK }), {
code: "EREMOTEEXISTS",
});
assert.equal(g(LANE, "remote", "get-url", "origin").trim(), ORIGIN);
});
it("refuses every URL outside https-without-userinfo, ssh:// and scp-like", async () => {
firstCommit();
for (const url of [
ORIGIN, // a local path
"/tmp/x",
"../sibling.git",
"file:///tmp/x.git",
"ext::sh -c id",
"ext::sh",
"fd::3",
"git://host/r.git",
"http://host/r.git",
"-oProxyCommand=x",
"--upload-pack=x",
"https://u:p@host/r.git",
"https://token@host/r.git",
"https://host/r .git",
"https://host/r.git\n",
"ssh://-oProxyCommand=x/r.git",
"-git@host:r.git",
"",
42,
undefined,
]) {
await assert.rejects(
() => runGitAction(EMPTY, "publish", { url }),
{ code: "EBADURL" },
`accepted ${JSON.stringify(url)}`
);
}
assert.equal(g(EMPTY, "remote").trim(), "");
});
it("accepts https, ssh:// and scp-like URLs", () => {
for (const url of [
URL_OK,
"https://host:8443/owner/r",
"ssh://git@host:2222/owner/r.git",
"ssh://host/r.git",
"git@github.com:owner/r.git",
]) {
assert.equal(validateRemoteUrl(url), url);
}
});
it("adds origin and pushes HEAD with an upstream", async () => {
firstCommit();
const bare = path.join(ROOT, "published.git");
g(ROOT, "init", "-q", "--bare", bare);
// Repo-local insteadOf maps the https URL onto the bare fixture, so the
// real https branch of the allowlist is exercised without a network.
g(EMPTY, "config", `url.${bare}.insteadOf`, URL_OK);
const out = await runGitAction(EMPTY, "publish", { url: URL_OK });
assert.equal(out.ok, true);
assert.equal(out.remote, "origin");
// `remote add origin -- <url>` stores the URL itself, not "--".
assert.equal(g(EMPTY, "config", "remote.origin.url").trim(), URL_OK);
assert.equal(g(bare, "rev-parse", "main").trim(), g(EMPTY, "rev-parse", "HEAD").trim());
const facts = await gitFacts(EMPTY);
assert.equal(facts.upstream, "origin/main");
assert.equal(facts.ahead, 0);
});
it("removes the remote again when the push fails", async () => {
firstCommit();
g(EMPTY, "config", `url.${path.join(ROOT, "nowhere.git")}.insteadOf`, URL_OK);
await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK }));
assert.equal(g(EMPTY, "remote").trim(), "");
});
});