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:
+73
-8
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user