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:
@@ -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(), "");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user