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
+2 -2
View File
@@ -524,7 +524,7 @@ Request body shape:
| `POST` | `/api/lanes/:id/down` | Same-origin guarded stack teardown: recorded pid trees, then a port backstop only when a pid file existed. |
| `POST` | `/api/lanes/:id/hook/:name` | Same-origin guarded profile-hook run; `:name` must be in the fixed hook allowlist and `args` is passed as argv. Returns `202`. |
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the lane's log directory. |
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream). `{available:false}` with `200` for a cwd git cannot read. |
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream), and `unborn` (`true` with `head: null` in a repo with no commit yet). `{available:false}` with `200` for a cwd git cannot read. |
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The lane's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The lane's recent commits (newest first, limit max 200), optionally only those touching one path. |
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
@@ -535,7 +535,7 @@ Request body shape:
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
an existing absolute git repository. The response lane starts as
+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(), "");
});
});
+11 -5
View File
@@ -486,14 +486,20 @@ describe("managed worktree provisioning", () => {
});
it("marks the lane failed with git's message when provisioning fails", async () => {
const emptyRepo = path.join(ROOT, "empty-repo");
fs.mkdirSync(emptyRepo, { recursive: true });
g(emptyRepo, "init", "-b", "main");
// A branch named `feat` makes `feat/<slug>` impossible to create — a git
// failure the route cannot see coming. (An unborn repo used to serve here;
// the route now refuses that one up front with EEMPTYREPO.)
const clashRepo = path.join(ROOT, "clash-repo");
fs.mkdirSync(clashRepo, { recursive: true });
g(clashRepo, "init", "-b", "main");
g(clashRepo, "-c", "user.email=t@h", "-c", "user.name=t", "commit", "--allow-empty", "-m", "i");
g(clashRepo, "branch", "feat");
const created = await request("POST", "/api/lanes/worktree", {
sourceRepo: emptyRepo,
title: "Unborn Repo",
sourceRepo: clashRepo,
title: "Ref Clash",
base: "main",
branch: "feat/clash",
});
assert.equal(created.status, 202);
+28
View File
@@ -1554,6 +1554,11 @@ describe("/api/lanes tool tests", () => {
const fs = require("fs");
fs.mkdirSync(sourceRepo, { recursive: true });
require("child_process").execSync("git init", { cwd: sourceRepo });
// A worktree needs a commit to branch from (an unborn repo is EEMPTYREPO).
require("child_process").execSync(
"git -c user.email=t@h -c user.name=t commit -q --allow-empty -m init",
{ cwd: sourceRepo }
);
const res = await request("POST", "/api/lanes/worktree", {
sourceRepo,
@@ -1574,6 +1579,24 @@ describe("/api/lanes tool tests", () => {
}
});
it("POST /api/lanes/worktree refuses a repo with no commits, creating no row", async () => {
const sourceRepo = require("path").join(
require("os").tmpdir(),
`agenthub-worktree-empty-${Date.now()}`
);
const fs = require("fs");
fs.mkdirSync(sourceRepo, { recursive: true });
require("child_process").execSync("git init -q", { cwd: sourceRepo });
const before = (await request("GET", "/api/lanes")).body.lanes.length;
const res = await request("POST", "/api/lanes/worktree", { sourceRepo, slug: "empty" });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "EEMPTYREPO");
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
fs.rmSync(sourceRepo, { recursive: true, force: true });
});
it("POST /api/lanes/worktree defaults to 'claude' tool when tool is not specified", async () => {
const sourceRepo = require("path").join(
require("os").tmpdir(),
@@ -1582,6 +1605,11 @@ describe("/api/lanes tool tests", () => {
const fs = require("fs");
fs.mkdirSync(sourceRepo, { recursive: true });
require("child_process").execSync("git init", { cwd: sourceRepo });
// A worktree needs a commit to branch from (an unborn repo is EEMPTYREPO).
require("child_process").execSync(
"git -c user.email=t@h -c user.name=t commit -q --allow-empty -m init",
{ cwd: sourceRepo }
);
const res = await request("POST", "/api/lanes/worktree", {
sourceRepo,
+9 -1
View File
@@ -458,9 +458,17 @@ describe("agenthub lanes add", () => {
});
it("reports the failure notes, not success, when provisioning fails", async () => {
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cli-unborn-repo-"));
// A branch named `feat` makes the default `feat/<slug>` impossible to
// create, so provisioning fails inside git (an unborn repo is now refused
// up front with EEMPTYREPO and never reaches provisioning).
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cli-clash-repo-"));
try {
await git(["init", "-b", "main"], emptyRepo);
await git(
["-c", "user.email=t@h", "-c", "user.name=t", "commit", "--allow-empty", "-m", "i"],
emptyRepo
);
await git(["branch", "feat"], emptyRepo);
const r = await cli(
["lanes", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
emptyRepo
+73 -8
View File
@@ -1,13 +1,14 @@
/**
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
* stage/unstage a file, commit, discard a file, switch branch, create or
* delete a branch, and merge a branch with its conflict resolution (issue
* #45: merge, resolve ours/theirs/hand-edited, merge-commit, merge-abort).
* There is no rebase, no reset and no stash, because a dashboard button is
* the wrong place to start an operation whose recovery needs a terminal
* (`reset` stays where it was: behind the three-check destroy guard in
* worktree.js). A merge is the exception because its recovery is ONE button
* here too — `merge-abort` puts the branch back where it was.
* delete a branch, publish a repo that has no remote yet, and merge a branch
* with its conflict resolution (issue #45: merge, resolve ours/theirs/
* hand-edited, merge-commit, merge-abort). There is no rebase, no reset and
* no stash, because a dashboard button is the wrong place to start an
* operation whose recovery needs a terminal (`reset` stays where it was:
* behind the three-check destroy guard in worktree.js). A merge is the
* exception because its recovery is ONE button here too — `merge-abort` puts
* the branch back where it was.
*
* Four rules hold for every action:
* - argv only, via worktree.js's `git()` — never a shell string.
@@ -28,6 +29,11 @@
* of, so each demands an explicit `confirm: true` in the body. Do not add
* another destructive action without the same gate.
*
* `publish` is the ONE action whose destination comes from the request (a
* remote URL), so it is gated differently: only when `git remote` prints
* nothing, always named `origin`, the URL allowlisted by `validateRemoteUrl`,
* and the remote removed again if the push fails.
*
* Network actions carry a timeout: a lane pointed at an unreachable remote
* should fail in half a minute, not hold an HTTP request open.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -66,6 +72,7 @@ const ACTIONS = new Set([
"discard-hunk",
"branch-create",
"branch-delete",
"publish",
"merge",
"resolve",
"merge-commit",
@@ -78,6 +85,12 @@ const BLOCKED_WHILE_MERGING = new Set(["pull", "switch", "merge", "stage-hunk",
/** A line git writes to open or close a conflict block. */
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
/** A remote URL is at most this long — a forge URL, not a payload. */
const MAX_URL_LEN = 1024;
/** `user@host:path`, git's scp-like ssh form. Nothing may start with `-`. */
const SCP_LIKE =
/^[A-Za-z0-9._][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*:[A-Za-z0-9._~/][A-Za-z0-9._~/-]*$/;
/** Branches this module refuses to delete, whatever the request says. */
const PROTECTED = new Set(["main", "master", "development", "develop"]);
@@ -87,6 +100,36 @@ function bad(code, message) {
return err;
}
/**
* The destination `publish` may push to, or an EBADURL throw.
*
* An allowlist, because this is the one write whose destination comes from
* the request: `https://` with NO userinfo (a token typed into the URL would
* land in .git/config in plain text), `ssh://`, and scp-like `user@host:path`.
* Everything else is refused — a local path or `file://` (a push into some
* other directory on this machine), `ext::` (git runs it as a command),
* `fd::`, `git://` and `http://` (unauthenticated / cleartext), and anything
* starting with `-` that could be read as an option.
*/
function validateRemoteUrl(url) {
const refuse = () => bad("EBADURL", "url must be https://, ssh:// or user@host:path");
if (typeof url !== "string" || !url || url.length > MAX_URL_LEN) throw refuse();
// eslint-disable-next-line no-control-regex
if (/[\s\x00-\x1f\x7f]/.test(url) || url.startsWith("-")) throw refuse();
if (SCP_LIKE.test(url)) return url;
let parsed;
try {
parsed = new URL(url);
} catch {
throw refuse();
}
const { protocol, username, password, hostname } = parsed;
if (!hostname || hostname.startsWith("-") || password) throw refuse();
if (protocol === "https:" && !username) return url;
if (protocol === "ssh:" && !username.startsWith("-")) return url;
throw refuse();
}
/** Whether the working tree has anything uncommitted (tracked or not). */
async function isDirty(dir) {
const { dirty, untracked } = await statusCounts(dir);
@@ -253,6 +296,28 @@ async function runGitAction(dir, action, body = {}) {
return done(action, await networkGit(dir, ["push", "-u", remote, "HEAD"], body));
}
if (action === "publish") {
// Gives a remote to a repo that has NONE, then does what `push` does. It
// never re-points an existing remote, so it cannot redirect a repo that
// already publishes somewhere, and the remote name is fixed like `push`'s.
if (!(await hasHead(dir))) throw bad("ENOCOMMITS", "make the first commit before publishing");
if ((await git(dir, ["remote"])).stdout.trim()) {
throw bad("EREMOTEEXISTS", "this repo already has a remote");
}
const url = validateRemoteUrl(body.url);
await git(dir, ["remote", "add", "origin", "--", url]);
let result;
try {
result = await networkGit(dir, ["push", "-u", "origin", "HEAD"], body);
} catch (err) {
// A remote left behind by a failed push (a typo, a missing credential)
// would trip EREMOTEEXISTS forever and need a terminal to undo.
await git(dir, ["remote", "remove", "origin"]).catch(() => {});
throw err;
}
return { ...done(action, result), remote: "origin" };
}
if (action === "stage") {
const paths = await resolvePaths(dir, body);
// Staging a conflicted file marks it resolved: not while git's markers
@@ -551,4 +616,4 @@ function done(action, result, branch) {
return { action, ok: true, output, ...(branch ? { branch } : {}) };
}
module.exports = { runGitAction, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
module.exports = { runGitAction, validateRemoteUrl, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
+17 -6
View File
@@ -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,
+16 -6
View File
@@ -199,7 +199,8 @@ const paths = {
},
},
400: {
description: "sourceRepo is relative, missing, or not a git repository.",
description:
"EBADSOURCEREPO: sourceRepo is relative, missing, or not a git repository. EEMPTYREPO: it has no commits, so there is nothing to branch a worktree from (no lane row is created).",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
@@ -279,7 +280,7 @@ const paths = {
responses: {
200: {
description:
"available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).",
'available:true with {branch, head, unborn, subject, dirty, untracked, files, hasRemote, upstream, ahead, behind}, or available:false alone. A repo with no commit yet is available with unborn:true, head:null and subject:"". files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).',
},
404: { description: "Lane not found." },
},
@@ -618,7 +619,7 @@ const paths = {
tags: ["Lanes"],
summary: "One git write on the lane's working copy",
description:
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file's `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git's own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent's half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file; staging a conflicted file that still has conflict markers is 400 EMARKERS), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file's `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git's own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent's half-finished work is never carried onto another branch), branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree), publish (the ONE write whose destination comes from the request: `git remote add origin -- <url>` then `git push -u origin HEAD`, only in a repo with a commit and NO remote — it never re-points one; the url must be https:// without userinfo, ssh:// or scp-like user@host:path, and origin is removed again if the push fails), and merge / resolve / merge-commit / merge-abort (issue #45: merge a local or remote-tracking branch that exists — clean tree and a repo-root lane only, 400 ESUBDIR otherwise — answering conflicts:[paths] when it stops on conflicts; resolve {path, side: ours|theirs|done} a conflicted path, done refusing leftover markers with 400 EMARKERS; merge-commit is 409 ECONFLICTS while anything is unmerged; merge-abort REQUIRES confirm:true; pull/switch/merge/hunk actions are 409 EMERGING mid-merge). fetch/pull/push/publish needing a login answer 409 EGITAUTH with auth:{protocol,host,username,rejected}; resend with credentials:{protocol,host,username,password,remember} (a repo remote's host only, else 400 EBADHOST). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
operationId: "runLaneGitAction",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
@@ -641,6 +642,7 @@ const paths = {
"switch",
"branch-create",
"branch-delete",
"publish",
],
},
},
@@ -652,6 +654,11 @@ const paths = {
schema: {
type: "object",
properties: {
url: {
type: "string",
description:
"publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars.",
},
message: { type: "string", description: "commit only; required, max 4000 chars." },
only: {
type: "string",
@@ -693,15 +700,18 @@ const paths = {
},
},
responses: {
200: { description: "{action, ok:true, output} — git's own stdout+stderr." },
200: {
description:
'{action, ok:true, output} — git\'s own stdout+stderr; publish adds remote:"origin".',
},
400: {
description:
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE or ENOREMOTE.",
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE, ENOREMOTE, EUNTRACKED, EBADURL, EREMOTEEXISTS, ENOCOMMITS, EBADHOST, EBADCRED, ESUBDIR, EBADSIDE or EMARKERS.",
},
404: { description: "Lane not found." },
409: {
description:
"EGIT — git ran and refused (a rejected push, a credential failure, a diverged --ff-only pull); its stderr is appended to error.message.",
"EGIT — git ran and refused (a rejected push, a diverged --ff-only pull); its stderr is appended to error.message. EGITAUTH — the remote wants a login (carries auth). EHUNKGONE, EMERGING, ENOMERGE, ECONFLICTS.",
},
500: { description: "Unexpected server-side failure." },
},
+21
View File
@@ -978,6 +978,9 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"ESUBDIR",
"EBADSIDE",
"EMARKERS",
"EBADURL",
"EREMOTEEXISTS",
"ENOCOMMITS",
];
// A numeric code is git's own exit status (execFile reports one that way):
// git ran and refused — a rejected push, a failed credential, a diverged
@@ -1054,6 +1057,24 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
});
}
// A repo with no commit has nothing a worktree could branch from: git would
// fail later, in the background job, and leave a `failed` row behind. Refuse
// it here instead — that repo is a Repo lane until its first commit.
if (!(await listBranches(sourceRepo)).branches.length) {
const unborn = await git(sourceRepo, ["rev-parse", "--verify", "HEAD"]).then(
() => false,
() => true
);
if (unborn) {
return res.status(400).json({
error: {
code: "EEMPTYREPO",
message: "sourceRepo has no commits yet — add it as a Repo lane and commit first",
},
});
}
}
let slug;
try {
slug = slugify(body.slug || body.title);