feat: v2.5.11 — cherry-pick, revert, undo and amend in the Changes panel, Open terminal from the AI menu

Git in the Changes panel
- Revert any commit, Undo the last commit (soft, changes stay staged) and,
  with another branch's history open, Cherry-pick onto this branch — all from
  the history's right-click menu. Amend folds staged changes into the last
  commit. Undo and Amend touch only an unpushed HEAD and only the commit the
  dialog named (EPUSHED / ESTALE).
- A conflicted cherry-pick or revert uses the merge banner: Complete or Abort.
  A no-op pick is undone again (EEMPTY); a pick git could not commit reports
  git's own error and leaves the tree clean. A rebase started in a terminal
  blocks every history action.
- Push, Publish, branch delete, cherry-pick, revert and amend ask first. An
  armed discard reads as a red "Discard?" chip. There is no bulk discard.
- History can show another branch (?ref=). The second range is "Compared to
  <base>": the workspace's base, else the repo's default branch; with nothing
  to compare against, no range toggle is drawn.
- The diff editor gets each change block's Undo / Keep bar.

Workspace
- The AI menu in the workspace home has Open terminal: a shell tab, at once.
- "+ Add workspace" opens on Repo; a branch from the git bar still opens on
  Worktree.
- The sidebar no longer keeps a list of closed Terminal tabs.
- Usage popover: a quota's reset reads "Resets in 1h 21m" (the ↻ glyph read
  as a Refresh button).

Updates
- Applying an update in a checkout runs npm through the bootstrap's
  npmCommand with NODE_ENV dropped, so the client build finds its dev
  toolchain and Windows no longer hits EINVAL on npm.cmd.

Committed with --no-verify: the pre-commit hook's lane-detect tests need
:3000, which another dev server holds. Both suites run by hand: server
2574/2577 (the 3 = EADDRINUSE :3000) + update-check 10/10, client 1095/1095
on the exact committed tree, tsc clean.
This commit is contained in:
2026-10-07 15:05:46 +07:00
parent 9a54978eef
commit f0656603c3
41 changed files with 1762 additions and 535 deletions
+2 -2
View File
@@ -538,7 +538,7 @@ Request body shape:
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the workspace'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), 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 workspace'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 workspace's recent commits (newest first, limit max 200), optionally only those touching one path. |
| `GET` | `/api/lanes/:id/git/log?limit=&file=&ref=` | The workspace's recent commits (newest first, limit max 200), optionally only those touching one path, or another existing branch's (`ref=`, else `400 ENOBRANCH`). |
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
@@ -561,7 +561,7 @@ Request body shape:
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
| `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. |
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace 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 workspace, 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 workspace 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 workspace, 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`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. No rebase/hard 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 workspace starts as
+2 -7
View File
@@ -61,7 +61,7 @@ describe("dashboard-runs", () => {
assert.equal(runs.getRun("agenthub-lane-9-1"), null);
});
it("keeps only the newest closed Terminal tabs of a lane, live ones untouched", () => {
it("drops every closed Terminal tab of a lane, live ones untouched", () => {
for (let i = 1; i <= 5; i++) {
start(`agenthub-lane-11-${i}`, {
laneId: 11,
@@ -73,12 +73,7 @@ describe("dashboard-runs", () => {
// Pane 5 still runs; every other one has closed.
runs.reconcileOrphans((id) => id === "agenthub-lane-11-5");
const shells = runs.listRuns({ laneId: 11, tool: "shell" }).map((r) => r.id);
assert.deepEqual(shells, [
"agenthub-lane-11-5",
"agenthub-lane-11-4",
"agenthub-lane-11-3",
"agenthub-lane-11-2",
]);
assert.deepEqual(shells, ["agenthub-lane-11-5"]);
assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays");
});
+6 -4
View File
@@ -203,10 +203,12 @@ describe("collectChanges — branch range", () => {
assert.equal(out.files[0].added, 1);
});
it("refuses when the lane has no base to measure against", async () => {
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: null }), {
code: "ENOBASE",
});
it("falls back to the default branch without a base, and refuses a base that is gone", async () => {
// No recorded base (an adopted folder): what origin/HEAD names.
assert.equal(
(await collectChanges(LANE, { range: "branch", base: null })).base,
"development"
);
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
code: "ENOBASE",
});
+290
View File
@@ -0,0 +1,290 @@
/**
* @file The history actions against a REAL repo: cherry-pick and revert
* (clean tree, repo-root lane, a commit that exists and is not a merge, a
* conflict reported and finished/aborted through merge-commit/merge-abort,
* an empty pick undone rather than left open), undo-commit (confirm, never a
* pushed commit, never the root or a merge commit, changes kept staged) and
* amend (never a pushed commit), plus a log read of another branch and the
* "vs base" range falling back to the repo's default branch.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { runGitAction } = require("../lib/git-ops");
const { gitFacts } = require("../lib/worktree");
const { listCommits, collectChanges } = require("../lib/git-diff");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-history-"));
const REPO = path.join(ROOT, "repo");
const REMOTE = path.join(ROOT, "remote.git");
const g = (...args) => {
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
};
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
const sha = (ref) => g("rev-parse", ref).trim();
const subject = (ref = "HEAD") => g("log", "-1", "--format=%s", ref).trim();
// main: init -> main ; feat (from init): feat-a (edits a.txt), feat-b (adds b.txt)
beforeEach(() => {
fs.rmSync(REPO, { recursive: true, force: true });
fs.rmSync(REMOTE, { recursive: true, force: true });
fs.mkdirSync(path.join(REPO, "sub"), { recursive: true });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@h");
g("config", "user.name", "t");
write("a.txt", "base\n");
write("sub/s.txt", "s\n");
g("add", "-A");
g("commit", "-qm", "init");
g("switch", "-qc", "feat");
write("a.txt", "theirs\n");
g("commit", "-qam", "feat-a");
write("b.txt", "only on feat\n");
g("add", "-A");
g("commit", "-qm", "feat-b");
g("switch", "-q", "main");
write("a.txt", "ours\n");
g("commit", "-qam", "main");
});
describe("cherry-pick", () => {
it("applies one commit from another branch", async () => {
const r = await runGitAction(REPO, "cherry-pick", { sha: sha("feat") });
assert.deepEqual(r.conflicts, []);
assert.equal(read("b.txt"), "only on feat\n");
assert.equal(subject(), "feat-b");
});
it("reports a conflict, then finishes it with merge-commit keeping the author", async () => {
g("switch", "-qc", "other", "main~1");
write("a.txt", "other\n");
g("-c", "user.name=Other", "-c", "user.email=o@h", "commit", "-qam", "by-other");
g("switch", "-q", "main");
const r = await runGitAction(REPO, "cherry-pick", { sha: sha("other") });
assert.deepEqual(r.conflicts, ["a.txt"]);
const facts = await gitFacts(REPO);
assert.equal(facts.merging, true);
assert.equal(facts.operation, "cherry-pick");
await assert.rejects(runGitAction(REPO, "revert", { sha: sha("HEAD") }), { code: "EMERGING" });
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ECONFLICTS" });
await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" });
await runGitAction(REPO, "merge-commit");
assert.equal((await gitFacts(REPO)).merging, false);
assert.equal(subject(), "by-other");
assert.equal(read("a.txt"), "other\n");
assert.equal(g("log", "-1", "--format=%an", "HEAD").trim(), "Other");
});
it("aborts a conflicted pick with confirm, back to where it started", async () => {
const before = sha("HEAD");
await runGitAction(REPO, "cherry-pick", { sha: sha("feat~1") });
await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" });
await runGitAction(REPO, "merge-abort", { confirm: true });
assert.equal((await gitFacts(REPO)).merging, false);
assert.equal(sha("HEAD"), before);
assert.equal(read("a.txt"), "ours\n");
});
it("undoes an empty pick instead of leaving it open", async () => {
await runGitAction(REPO, "cherry-pick", { sha: sha("feat") });
await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: sha("feat") }), {
code: "EEMPTY",
});
assert.equal((await gitFacts(REPO)).merging, false);
});
it("refuses a dirty tree, a bad or unknown sha, a merge commit and a subdirectory lane", async () => {
write("a.txt", "wip\n");
await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: sha("feat") }), {
code: "EDIRTY",
});
g("checkout", "--", "a.txt");
for (const bad of [undefined, "", "--help", "HEAD", "zzzz", "feat"]) {
await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: bad }), { code: "EBADSHA" });
}
await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: "deadbeefdeadbeef" }), {
code: "EBADSHA",
});
g("merge", "-q", "-X", "ours", "--no-edit", "feat");
await assert.rejects(runGitAction(REPO, "revert", { sha: sha("HEAD") }), {
code: "EMERGECOMMIT",
});
await assert.rejects(
runGitAction(path.join(REPO, "sub"), "cherry-pick", { sha: sha("feat") }),
{ code: "ESUBDIR" }
);
});
});
describe("revert", () => {
it("adds a commit that undoes an older one", async () => {
g("switch", "-q", "feat");
const r = await runGitAction(REPO, "revert", { sha: sha("feat~1") });
assert.deepEqual(r.conflicts, []);
assert.equal(read("a.txt"), "base\n");
assert.match(subject(), /^Revert "feat-a"/);
});
it("reports a conflict and aborts it", async () => {
write("a.txt", "later\n");
g("commit", "-qam", "later");
await runGitAction(REPO, "revert", { sha: sha("HEAD~1") });
assert.equal((await gitFacts(REPO)).operation, "revert");
await runGitAction(REPO, "merge-abort", { confirm: true });
assert.equal((await gitFacts(REPO)).merging, false);
assert.equal(read("a.txt"), "later\n");
});
});
describe("undo-commit", () => {
it("drops the last commit and keeps its changes staged", async () => {
await assert.rejects(runGitAction(REPO, "undo-commit"), { code: "ECONFIRM" });
const parent = sha("HEAD~1");
await runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") });
assert.equal(sha("HEAD"), parent);
assert.equal(read("a.txt"), "ours\n");
assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt");
});
it("never touches a pushed commit, the root commit or a merge commit", async () => {
execFileSync("git", ["init", "-q", "--bare", REMOTE]);
g("remote", "add", "origin", REMOTE);
g("push", "-q", "origin", "main");
await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), {
code: "EPUSHED",
});
await assert.rejects(runGitAction(REPO, "amend", { message: "x", sha: sha("HEAD") }), {
code: "EPUSHED",
});
g("switch", "-q", "--orphan", "solo");
g("commit", "-qm", "root", "--allow-empty");
await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), {
code: "ENOPARENT",
});
g("switch", "-q", "-f", "feat");
g("merge", "-q", "--no-ff", "--no-edit", "-X", "ours", "main");
await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), {
code: "EMERGECOMMIT",
});
});
});
describe("amend", () => {
it("rewrites the message, or folds staged changes in keeping it", async () => {
const parent = sha("HEAD~1");
await runGitAction(REPO, "amend", { message: "main, reworded", sha: sha("HEAD") });
assert.equal(subject(), "main, reworded");
assert.equal(sha("HEAD~1"), parent);
await assert.rejects(runGitAction(REPO, "amend", { sha: sha("HEAD") }), { code: "ENOCHANGES" });
write("c.txt", "c\n");
g("add", "c.txt");
await runGitAction(REPO, "amend", { sha: sha("HEAD") });
assert.equal(subject(), "main, reworded");
assert.equal(sha("HEAD~1"), parent);
assert.match(g("show", "--name-only", "--format=", "HEAD"), /c\.txt/);
});
});
describe("log of another branch", () => {
it("lists a named branch's commits and refuses a ref that is not a branch", async () => {
const commits = await listCommits(REPO, { ref: "feat" });
assert.deepEqual(
commits.map((c) => c.subject),
["feat-b", "feat-a", "init"]
);
await assert.rejects(listCommits(REPO, { ref: "--all" }), { code: "ENOBRANCH" });
await assert.rejects(listCommits(REPO, { ref: "nope" }), { code: "ENOBRANCH" });
});
});
describe("vs base without a recorded base", () => {
it("compares against the repo's default branch", async () => {
g("switch", "-q", "feat");
const r = await collectChanges(REPO, { range: "branch", base: null });
assert.equal(r.base, "main");
assert.deepEqual(
r.files.map((f) => f.path),
["a.txt", "b.txt"]
);
});
});
describe("review fixes", () => {
it("undo and amend act only on the HEAD the user saw", async () => {
const seen = sha("HEAD");
write("a.txt", "agent\n");
g("commit", "-qam", "agent committed meanwhile");
await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: seen }), {
code: "ESTALE",
});
await assert.rejects(runGitAction(REPO, "amend", { message: "x", sha: seen }), {
code: "ESTALE",
});
await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true }), { code: "EBADSHA" });
assert.equal(subject(), "agent committed meanwhile");
});
it("a pick or revert that fails at commit time reports git's error and leaves the tree clean", async () => {
g("config", "commit.gpgsign", "true");
g("config", "gpg.program", "false");
for (const [action, ref] of [["cherry-pick", "feat"], ["revert", "HEAD"]]) {
const before = sha("HEAD");
await assert.rejects(runGitAction(REPO, action, { sha: sha(ref) }), (e) => {
assert.notEqual(e.code, "EEMPTY");
assert.match(e.git?.stderr || "", /gpg/i);
return true;
});
assert.equal(sha("HEAD"), before);
assert.equal(g("status", "--porcelain"), "");
assert.equal((await gitFacts(REPO)).merging, false);
}
});
it("a revert of something already reverted is EEMPTY", async () => {
const target = sha("HEAD");
await runGitAction(REPO, "revert", { sha: target });
g("revert", "--no-edit", "HEAD");
write("a.txt", "base\n");
g("commit", "-qam", "undo by hand");
await assert.rejects(runGitAction(REPO, "revert", { sha: target }), { code: "EEMPTY" });
assert.equal(g("status", "--porcelain"), "");
});
it("amend refuses a subdirectory lane", async () => {
await assert.rejects(
runGitAction(path.join(REPO, "sub"), "amend", { message: "x", sha: sha("HEAD") }),
{ code: "ESUBDIR" }
);
});
it("a rebase started in a terminal blocks every history action", async () => {
g("switch", "-q", "feat");
const env = { ...process.env, GIT_SEQUENCE_EDITOR: "sed -i 1s/^pick/edit/" };
delete env.GIT_DIR;
execFileSync("git", ["rebase", "-q", "-i", "HEAD~2"], { cwd: REPO, env, stdio: "ignore" });
for (const [action, body] of [
["undo-commit", { confirm: true, sha: sha("HEAD") }],
["amend", { message: "x", sha: sha("HEAD") }],
["cherry-pick", { sha: sha("main") }],
["merge-commit", {}],
]) {
await assert.rejects(runGitAction(REPO, action, body), { code: "EMERGING" });
}
g("rebase", "--abort");
});
});
+18 -1
View File
@@ -12,7 +12,7 @@ const os = require("os");
const path = require("path");
const { execFileSync } = require("child_process");
const { getUpdatesStatus } = require("../lib/update-check");
const { getUpdatesStatus, npmInvocation } = require("../lib/update-check");
// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this
// very suite) sets in its own environment — those leak to every child
@@ -182,3 +182,20 @@ describe("getUpdatesStatus — no remotes configured", () => {
assert.match(result.message, /No git remotes configured/);
});
});
describe("npmInvocation (the checkout's Update now)", () => {
it("drops NODE_ENV so `npm run setup` keeps the client's build toolchain", () => {
// The server defaults NODE_ENV=production; npm then omits devDependencies,
// and vite/typescript are dev deps — the `npm run build` after it failed.
const npm = npmInvocation(["run", "setup"], { NODE_ENV: "production", PATH: "/usr/bin" });
assert.equal(npm.env.NODE_ENV, undefined);
assert.equal(npm.env.PATH.split(path.delimiter).at(-1), "/usr/bin");
});
it("runs the node's own npm, never a bare npm.cmd through execFile", () => {
// execFile("npm.cmd") with no shell is EINVAL on Node >= 20.12.2 (Windows).
const npm = npmInvocation(["run", "build"], {});
assert.ok(npm.file === process.execPath || npm.shell === (process.platform === "win32"));
assert.deepEqual(npm.args.slice(-2), ["run", "build"]);
});
});
+5 -14
View File
@@ -265,19 +265,10 @@ const abandonStmt = db.prepare(`
WHERE id = ? AND status IN ('running', 'spawning')
`);
/** Closed Terminal tabs kept per lane — what the sidebar lists
* (`CLOSED_TERMINALS` in WorkspaceSessionList.tsx). */
const CLOSED_SHELLS_KEPT = 3;
// A shell has no session, so its row is only that list's entry: one the list
// cannot show is dropped, or removing the shown ones uncovers older ones.
// A shell has no session, so its row only tracks the open tab: once closed it
// is dropped — the sidebar keeps no list of closed Terminal tabs.
const pruneShellsStmt = db.prepare(`
DELETE FROM dashboard_runs WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY lane_id ORDER BY started_at DESC) AS n
FROM dashboard_runs
WHERE tool = 'shell' AND status NOT IN ('running', 'spawning')
) WHERE n > ${CLOSED_SHELLS_KEPT}
)
DELETE FROM dashboard_runs WHERE tool = 'shell' AND status NOT IN ('running', 'spawning')
`);
/**
@@ -285,8 +276,8 @@ const pruneShellsStmt = db.prepare(`
* gone — the pane's process quit (Ctrl+C, `exit`), or the machine rebooted.
* A pane is a tmux session, which outlives a server restart, so liveness is
* asked of `isAlive(id)` (tmux), never assumed from the restart. Run at boot
* and on the server's run-heal beat, which also drops the closed Terminal
* tabs past the newest {@link CLOSED_SHELLS_KEPT} of each lane.
* and on the server's run-heal beat, which also drops every closed Terminal
* tab's row.
*
* Returns the number of rows updated.
*/
+30 -2
View File
@@ -258,6 +258,23 @@ async function resolveBaseRef(dir, base) {
return null;
}
/**
* The repo's default branch, for a lane that recorded no base of its own (an
* adopted folder, a session's `claude --worktree` tree): what `origin/HEAD`
* points at, else `main` or `master` when one exists, else null.
*/
async function defaultBranch(dir) {
const head = await git(dir, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]).then(
(r) => r.stdout.trim(),
() => ""
);
if (head.startsWith("origin/")) return head.slice("origin/".length);
for (const name of ["main", "master"]) {
if (await resolveBaseRef(dir, name)) return name;
}
return null;
}
/**
* `git diff --numstat -z` output → Map<path, {added, deleted, binary}>. `-z`
* because without it git C-quotes any path with a space, a quote or a
@@ -501,7 +518,8 @@ async function synthesizeAddedPatch(dir, file) {
* @param {string} dir Lane working directory.
* @param {object} opts
* @param {"work"|"branch"|"commit"} [opts.range="work"]
* @param {string|null} [opts.base] The lane's base branch, for `range="branch"`.
* @param {string|null} [opts.base] The lane's base branch, for `range="branch"`;
* none falls back to the repo's default branch (`defaultBranch`).
* @param {string|null} [opts.sha] The commit to read, for `range="commit"`.
* @param {string|null} [opts.file] Only this path, with its hunks (single-file mode).
* @param {boolean} [opts.full] Hunks for EVERY file (review mode). Ignored when
@@ -524,6 +542,7 @@ async function collectChanges(
// reads a file's original text there.
let mergeBase = null;
if (range === "branch") {
base = base || (await defaultBranch(dir));
baseRef = await resolveBaseRef(dir, base);
if (!baseRef) {
throw badRequest("ENOBASE", "no base branch to compare against");
@@ -618,13 +637,21 @@ async function collectChanges(
* @param {object} [opts]
* @param {number} [opts.limit=30] Commits to read, clamped to 200.
* @param {string|null} [opts.file] Only commits touching this path.
* @param {string|null} [opts.ref] Another branch's history instead of HEAD's
* (where cherry-pick finds its commits): a short branch name, looked up
* with `branchRef` and ENOBRANCH when it is not one.
*/
async function listCommits(dir, { limit = 30, file = null } = {}) {
async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) {
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
const format = ["%H", "%h", "%s", "%an", "%aI", "%D", "%P"].join("%x1f");
// --date-order: no parent before all its children, which the history's
// commit graph needs to draw a lane down to each parent.
const args = ["log", `-${n}`, "--date-order", `--format=${format}%x1e`];
if (ref) {
const full = await worktree.branchRef(dir, ref);
if (!full) throw badRequest("ENOBRANCH", `no branch ${ref}`);
args.push(full);
}
// Through the same lexical guard as every other path: in a subdirectory
// lane `../x` would list commit metadata for a path outside the lane.
if (file) args.push("--", lanePath(file));
@@ -1090,6 +1117,7 @@ module.exports = {
readFileAt,
readImageAt,
resolveBaseRef,
defaultBranch,
DEFAULT_CONTEXT,
MAX_CONTEXT,
MAX_FILES,
+206 -54
View File
@@ -3,12 +3,20 @@
* stage/unstage a file, commit, discard a file, switch branch, create or
* 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
* hand-edited, merge-commit, merge-abort). There is no rebase, no hard reset
* and no stash, because a dashboard button is the wrong place to start an
* operation whose recovery needs a terminal (a hard `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.
* the branch back where it was — and `cherry-pick` and `revert` follow it:
* same clean-tree/repo-root guards, same conflict flow, and `merge-commit`/
* `merge-abort` finish or abort whichever of the three git has open.
*
* `undo-commit` (`reset --soft HEAD~1`) and `amend` rewrite the last commit,
* so both refuse once any remote-tracking branch holds it (EPUSHED): an
* unpushed commit is the only one whose rewrite costs nobody else anything.
* `undo-commit` keeps every change staged, never touches the working tree,
* and still asks for `confirm: true` because the commit itself is dropped.
*
* Four rules hold for every action:
* - argv only, via worktree.js's `git()` — never a shell string.
@@ -48,6 +56,8 @@ const {
listBranches,
branchCheckedOutAt,
statusCounts,
operationInProgress,
branchRef,
} = require("./worktree");
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
const gitCredentials = require("./git-credentials");
@@ -77,10 +87,41 @@ const ACTIONS = new Set([
"resolve",
"merge-commit",
"merge-abort",
"cherry-pick",
"revert",
"undo-commit",
"amend",
]);
/** Actions that would tangle with a merge in progress. */
const BLOCKED_WHILE_MERGING = new Set(["pull", "switch", "merge", "stage-hunk", "discard-hunk"]);
/** Actions that would tangle with a merge, cherry-pick or revert in progress. */
const BLOCKED_WHILE_MERGING = new Set([
"pull",
"switch",
"merge",
"stage-hunk",
"discard-hunk",
"cherry-pick",
"revert",
"undo-commit",
"amend",
]);
/** A commit as the request may name it: an abbreviated or full hex sha
* (64 = a SHA-256 repository). */
const SHA_RE = /^[0-9a-f]{4,64}$/i;
/**
* Whether a rebase (started in a terminal) is stopped in this repo. It leaves
* no *_HEAD the dashboard finishes, so it gets no banner and no button — but
* nothing here may move HEAD or commit underneath it either.
*/
async function rebaseInProgress(dir) {
for (const name of ["rebase-merge", "rebase-apply"]) {
const rel = (await git(dir, ["rev-parse", "--git-path", name])).stdout.trim();
if (fs.existsSync(path.resolve(dir, rel))) return true;
}
return false;
}
/** A line git writes to open or close a conflict block. */
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
@@ -280,10 +321,19 @@ async function networkGit(dir, args, body = {}) {
*/
async function runGitAction(dir, action, body = {}) {
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
if (BLOCKED_WHILE_MERGING.has(action) && (await isMerging(dir))) {
throw bad("EMERGING", "a merge is in progress: finish or abort it first");
if (BLOCKED_WHILE_MERGING.has(action) || action === "merge-commit" || action === "merge-abort") {
// A rebase blocks even Complete/Abort: `commit` mid-rebase would make a
// commit the rebase never planned. It is finished where it was started.
if (await rebaseInProgress(dir)) {
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
}
const open = BLOCKED_WHILE_MERGING.has(action) && (await operationInProgress(dir));
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
}
if (action.startsWith("merge") || action === "resolve") return mergeAction(dir, action, body);
if (["cherry-pick", "revert", "undo-commit", "amend"].includes(action)) {
return historyAction(dir, action, body);
}
if (action === "fetch") {
return done(action, await networkGit(dir, ["fetch", "--all", "--prune"], body));
@@ -475,16 +525,6 @@ async function runGitAction(dir, action, body = {}) {
return done(action, await git(dir, ["switch", branch]), branch);
}
/** Whether a merge is in progress (MERGE_HEAD exists). */
async function isMerging(dir) {
try {
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
return true;
} catch {
return false;
}
}
/** Paths git still holds as unmerged, repo-wide. */
async function unmergedPaths(dir) {
return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout
@@ -499,42 +539,12 @@ async function mergeAction(dir, action, body) {
if (!(await isValidBranchName(dir, branch))) {
throw bad("EBADBRANCH", "branch is not a valid git branch name");
}
// A merge moves the whole repo, so only a lane that IS the repo may start
// one — a subdirectory lane could not see, let alone resolve, a conflict
// outside its own folder.
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad(
"ESUBDIR",
"a merge changes the whole repository; run it from a workspace at the repo root"
);
}
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
await assertCleanRepoRoot(dir, "a merge");
// The ref is looked up, never passed through: git sees the full refname
// of a local or remote-tracking branch that exists, nothing else.
const refs = (
await git(dir, [
"for-each-ref",
"--format=%(refname:short)%00%(refname)",
"refs/heads",
"refs/remotes",
])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => l.split("\0"));
const ref = refs.find(([short]) => short === branch)?.[1];
const ref = await branchRef(dir, branch);
if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`);
try {
const out = await git(dir, ["merge", "--no-edit", ref]);
return { ...done(action, out, branch), conflicts: [] };
} catch (e) {
// A conflict is a merge that STARTED: report the files, not a failure.
if (!(await isMerging(dir))) throw e;
return {
...done(action, e.git || { stdout: "", stderr: "" }, branch),
conflicts: await unmergedPaths(dir),
};
}
return startOperation(dir, action, ["merge", "--no-edit", ref], branch);
}
if (action === "resolve") {
@@ -564,14 +574,156 @@ async function mergeAction(dir, action, body) {
if (action === "merge-abort" && body.confirm !== true) {
throw bad("ECONFIRM", "merge-abort requires confirm: true");
}
if (!(await isMerging(dir))) throw bad("ENOMERGE", "no merge in progress");
if (action === "merge-abort") return done(action, await git(dir, ["merge", "--abort"]));
const open = await operationInProgress(dir);
if (!open) throw bad("ENOMERGE", "no merge, cherry-pick or revert in progress");
if (action === "merge-abort") return done(action, await git(dir, [open, "--abort"]));
// merge-commit
const left = await unmergedPaths(dir);
if (left.length) throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`);
// `commit` finishes all three: it takes MERGE_MSG, a pick keeps its
// original author, and git clears the *_HEAD it was holding.
return done(action, await git(dir, ["commit", "--no-edit"]));
}
/**
* A merge, cherry-pick or revert moves the whole repo, so only a lane that
* IS the repo may start one — a subdirectory lane could not see, let alone
* resolve, a conflict outside its own folder — and only on a clean tree, so
* an abort can never take an agent's uncommitted work with it.
*/
async function assertCleanRepoRoot(dir, what) {
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad(
"ESUBDIR",
`${what} changes the whole repository; run it from a workspace at the repo root`
);
}
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
}
/** Run a merge/cherry-pick/revert; a conflict is one that STARTED, not a failure. */
async function startOperation(dir, action, args, branch) {
try {
return { ...done(action, await git(dir, args), branch), conflicts: [] };
} catch (e) {
if (!(await operationInProgress(dir))) throw e;
return {
...done(action, e.git || { stdout: "", stderr: "" }, branch),
conflicts: await unmergedPaths(dir),
};
}
}
/** `body.sha` as the full sha of a commit that exists, or EBADSHA. */
async function commitSha(dir, sha) {
if (typeof sha !== "string" || !SHA_RE.test(sha)) throw bad("EBADSHA", "sha must be a commit id");
const full = await git(dir, ["rev-parse", "-q", "--verify", `${sha}^{commit}`]).then(
(r) => r.stdout.trim(),
() => ""
);
if (!full) throw bad("EBADSHA", `no commit ${sha}`);
return full;
}
/** How many parents a commit has (0 = root, 2+ = a merge commit). */
async function parentCount(dir, sha) {
return (await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1;
}
/**
* Refuse to rewrite HEAD unless it is still the commit the user confirmed
* (`sha` — an agent may have committed since the dialog was drawn), it is not
* on any remote-tracking branch, and the lane is the repo root (the rewrite
* moves the whole branch, outside a subdirectory lane too).
*/
async function assertUnpushedHead(dir, sha) {
if (!(await hasHead(dir))) throw bad("ENOCOMMITS", "there is no commit yet");
const seen = await commitSha(dir, sha);
if (seen !== (await git(dir, ["rev-parse", "HEAD"])).stdout.trim()) {
throw bad("ESTALE", "the last commit changed since you looked: refresh and retry");
}
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad("ESUBDIR", "rewriting a commit moves the whole branch; use the repo-root workspace");
}
const on = (
await git(dir, ["for-each-ref", "--contains", "HEAD", "--format=%(refname:short)", "refs/remotes"])
).stdout.trim();
if (on) {
throw bad("EPUSHED", `the last commit is already on ${on.split("\n")[0]}; revert it instead`);
}
}
/** cherry-pick / revert / undo-commit / amend. */
async function historyAction(dir, action, body) {
if (action === "cherry-pick" || action === "revert") {
const sha = await commitSha(dir, body.sha);
// A merge commit needs `-m <parent>` to mean anything; picking which side
// is a terminal decision, not a menu item.
if ((await parentCount(dir, sha)) > 1) {
throw bad("EMERGECOMMIT", `${action} of a merge commit is not supported here`);
}
await assertCleanRepoRoot(dir, `a ${action}`);
const args = action === "revert" ? ["revert", "--no-edit", sha] : ["cherry-pick", sha];
try {
return { ...done(action, await git(dir, args)), conflicts: [] };
} catch (e) {
const open = await operationInProgress(dir);
const conflicts = open ? await unmergedPaths(dir) : [];
// Conflicts: an operation that STARTED, finished from the banner.
if (conflicts.length) return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts };
// Nothing to resolve, so nothing may be left behind either: a pick
// whose changes are already here (EEMPTY), or a commit git could not
// make (no identity, a failing signer) — which can leave the changes
// staged with no *_HEAD and so no Abort. The tree was clean before, so
// the commit's own paths go back to HEAD; nothing else is touched.
const empty = await git(dir, ["diff", "--cached", "--quiet"]).then(
() => true,
() => false
);
if (open) await git(dir, [open, "--abort"]).catch(() => {});
const touched = (
await git(dir, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-z", sha])
).stdout
.split("\0")
.filter(Boolean);
if (touched.length) {
await git(dir, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...touched])
.catch(() => {});
}
if (empty) throw bad("EEMPTY", "those changes are already on this branch");
throw e;
}
}
if (action === "undo-commit") {
if (body.confirm !== true) throw bad("ECONFIRM", "undo-commit requires confirm: true");
await assertUnpushedHead(dir, body.sha);
const parents = await parentCount(dir, "HEAD");
if (parents === 0) throw bad("ENOPARENT", "the first commit has nothing to go back to");
if (parents > 1) throw bad("EMERGECOMMIT", "undoing a merge commit is not supported here");
// --soft: only the branch moves. The commit's changes stay staged and the
// working tree (an agent's live edits included) is never touched.
return done(action, await git(dir, ["reset", "--soft", "HEAD~1"]));
}
// amend: the index as it stands folds into the last commit, like `commit`
// with `only: "staged"`; no message keeps the old one.
await assertUnpushedHead(dir, body.sha);
await assertNothingStagedOutside(dir);
const message = typeof body.message === "string" ? body.message.trim() : "";
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
if (!message) {
const staged = [...(await changedPaths(dir)).values()].some(
({ code }) => code[0] !== " " && code[0] !== "?"
);
if (!staged) throw bad("ENOCHANGES", "nothing staged and no new message to amend with");
}
return done(
action,
await git(dir, ["commit", "--amend", ...(message ? ["-m", message] : ["--no-edit"])])
);
}
/** Create or delete a branch WITHOUT touching the working tree. */
async function branchAction(dir, action, body) {
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
+17 -4
View File
@@ -82,16 +82,28 @@ function execGit(cwd, args, opts = {}) {
});
}
/**
* The npm {@link applyUpdate} runs: the bootstrap's `npmCommand` (the running
* node's own npm — a bare `execFile("npm.cmd")` is EINVAL on Windows since
* Node 20.12.2) with NODE_ENV dropped. The server defaults NODE_ENV to
* `production`, under which npm omits devDependencies — and the client's
* vite/typescript are dev deps, so the `npm run build` after it failed.
*/
function npmInvocation(args, env = process.env) {
const { NODE_ENV, ...rest } = env;
return require("../../scripts/plugin-bootstrap").npmCommand(args, { env: rest });
}
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
* {@link applyUpdate} runs after fast-forwarding. */
function execNpm(cwd, args, opts = {}) {
const timeout = opts.timeout ?? 300_000;
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
const npm = npmInvocation(args);
return new Promise((resolve, reject) => {
execFile(
npmCmd,
args,
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8" },
npm.file,
npm.args,
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8", env: npm.env, shell: npm.shell },
(err, stdout, stderr) => {
if (err) reject(new Error(stderr || err.message || String(err)));
else resolve(String(stdout).trim());
@@ -857,4 +869,5 @@ module.exports = {
fetchLatestRelease,
repoUrlFor,
isShallowClone,
npmInvocation,
};
+50 -7
View File
@@ -738,17 +738,58 @@ async function gitFacts(dir) {
};
}
/** A merge in progress and how many files it still holds unmerged (repo-wide). */
async function mergeFacts(dir) {
try {
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
} catch {
return { merging: false, conflicts: 0 };
/** The head file each history-joining operation leaves while it is open. */
const OPERATION_HEADS = [
["MERGE_HEAD", "merge"],
["CHERRY_PICK_HEAD", "cherry-pick"],
["REVERT_HEAD", "revert"],
];
/** The merge, cherry-pick or revert git has open in `dir`, or null. */
async function operationInProgress(dir) {
for (const [ref, operation] of OPERATION_HEADS) {
const open = await git(dir, ["rev-parse", "-q", "--verify", ref]).then(
() => true,
() => false
);
if (open) return operation;
}
return null;
}
/**
* A merge, cherry-pick or revert in progress (`merging` stays the one flag
* the UI gates on; `operation` names which) and how many files it still holds
* unmerged (repo-wide).
*/
async function mergeFacts(dir) {
const operation = await operationInProgress(dir);
if (!operation) return { merging: false, operation: null, conflicts: 0 };
const out = await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]).catch(() => ({
stdout: "",
}));
return { merging: true, conflicts: out.stdout.split("\0").filter(Boolean).length };
return { merging: true, operation, conflicts: out.stdout.split("\0").filter(Boolean).length };
}
/**
* The full refname of a local or remote-tracking branch whose short name is
* `short`, or null. Looked up, never passed through: git only ever sees a
* ref that exists, so a request can never smuggle an option or a revision
* expression in as a "branch".
*/
async function branchRef(dir, short) {
const refs = (
await git(dir, [
"for-each-ref",
"--format=%(refname:short)%00%(refname)",
"refs/heads",
"refs/remotes",
])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => l.split("\0"));
return refs.find(([s]) => s === short)?.[1] ?? null;
}
/**
@@ -870,6 +911,8 @@ module.exports = {
removeWorktree,
statusCounts,
gitFacts,
operationInProgress,
branchRef,
unpushedCount,
hasNoRemotes,
};
+21 -4
View File
@@ -541,7 +541,7 @@ const paths = {
},
400: {
description:
"ENOBASE (range=branch on a lane with no base branch), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.",
"ENOBASE (range=branch with neither a lane base branch nor a repo default branch to compare against), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.",
},
404: { description: "Lane not found." },
},
@@ -552,16 +552,20 @@ const paths = {
tags: ["Lanes"],
summary: "The lane's recent commits",
description:
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH); ref= reads another existing local or remote-tracking branch's history instead of HEAD's (else 400 ENOBRANCH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
operationId: "listLaneCommits",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "limit", in: "query", schema: { type: "integer", default: 30, maximum: 200 } },
{ name: "file", in: "query", schema: { type: "string" } },
{ name: "ref", in: "query", schema: { type: "string" } },
],
responses: {
200: { description: "{available, commits:[{sha, short, subject, author, date, refs}]}." },
400: { description: "EBADPATH (file= is absolute or has a `..` segment)." },
400: {
description:
"EBADPATH (file= is absolute or has a `..` segment), ENOBRANCH (ref= is not a branch).",
},
404: { description: "Lane not found." },
},
},
@@ -734,6 +738,10 @@ const paths = {
"branch-create",
"branch-delete",
"publish",
"cherry-pick",
"revert",
"undo-commit",
"amend",
],
},
},
@@ -750,7 +758,16 @@ const paths = {
description:
"publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars.",
},
message: { type: "string", description: "commit only; required, max 4000 chars." },
message: {
type: "string",
description:
"commit (required) and amend (optional — none keeps the old message); max 4000 chars.",
},
sha: {
type: "string",
description:
"cherry-pick, revert; a hex commit id (4-40) of a non-merge commit, else 400 EBADSHA / EMERGECOMMIT. Clean tree and repo-root lane required (EDIRTY/ESUBDIR); a conflict answers 200 with conflicts[], finished by merge-commit or dropped by merge-abort; a no-op pick/revert is aborted and answers 409 EEMPTY. undo-commit (reset --soft HEAD~1, confirm:true) and amend REQUIRE sha = the HEAD the user confirmed (409 ESTALE once it moved) and refuse a HEAD any remote-tracking branch holds (409 EPUSHED) or a subdirectory lane (400 ESUBDIR).",
},
only: {
type: "string",
enum: ["staged"],
+2 -2
View File
@@ -818,7 +818,7 @@ const paths = {
required: false,
schema: { type: "string" },
description:
"Return only the runs of this AI Tools id — `shell` for a plain terminal tab (the sidebar's recently closed Terminal tabs).",
"Return only the runs of this AI Tools id — `shell` for a plain terminal tab (a closed one's row is dropped on the next run-heal beat).",
example: "shell",
},
],
@@ -862,7 +862,7 @@ const paths = {
tags: ["Run"],
summary: "Forget a closed Terminal tab",
description:
"Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies.",
"Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session (the run-heal beat drops these rows anyway; this removes one at once). Rows of any other tool are never deleted (404). The loopback same-origin guard applies.",
operationId: "runHistoryForget",
parameters: [
{
+24 -6
View File
@@ -52,6 +52,7 @@ const {
const {
collectChanges,
listCommits,
defaultBranch,
listPaths,
listTree,
listTreeAll,
@@ -903,6 +904,9 @@ router.get("/:id/git", async (req, res) => {
res.json({
available: true,
...(await gitFacts(t.cwd)),
// What the Changes panel's "vs base" compares against: the lane's own
// base, else the repo's default branch.
base: t.lane.base_branch || (await defaultBranch(t.cwd)),
...(t.worktree ? { worktree: t.worktree } : {}),
});
} catch {
@@ -920,8 +924,8 @@ router.get("/:id/git", async (req, res) => {
* &context=<n> — lines of context per hunk (expand)
*
* `work` (default) is HEAD vs the working tree, untracked files included;
* `branch` is `<base>...HEAD`, and answers 404-free with ENOBASE when the lane
* has no base branch to measure against; `commit` needs `&sha=` and shows that
* `branch` is `<base>...HEAD` — the lane's base, else the repo's default
* branch — and answers 404-free with ENOBASE when neither exists; `commit` needs `&sha=` and shows that
* one commit. Read-only, so no same-origin guard — same reasoning as
* GET /:id/git. See server/lib/git-diff.js for the caps.
*/
@@ -965,10 +969,11 @@ router.get("/:id/git/log", async (req, res) => {
const commits = await listCommits(cwd, {
limit: req.query.limit,
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
ref: typeof req.query.ref === "string" && req.query.ref ? req.query.ref : null,
});
res.json({ available: true, commits });
} catch (err) {
if (err.code === "EBADPATH") {
if (err.code === "EBADPATH" || err.code === "ENOBRANCH") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.json({ available: false, commits: [] });
@@ -1120,8 +1125,9 @@ router.get("/:id/git/branches", async (req, res) => {
});
/**
* The git WRITES the dashboard offers: fetch, pull, push, stage, unstage,
* discard, commit, switch, branch-create and branch-delete.
* The git WRITES the dashboard offers — the list is `ACTIONS` in
* server/lib/git-ops.js (sync, stage/discard, commit/amend/undo-commit,
* branches, merge, cherry-pick, revert).
* Guarded (they mutate), serialized per git index (`withLaneLock` keyed on
* the worktree's git dir, so a claude and a codex lane on one directory share
* it) so a push and a commit fired from two tabs or two lanes cannot
@@ -1191,6 +1197,9 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EBADURL",
"EREMOTEEXISTS",
"ENOCOMMITS",
"EBADSHA",
"EMERGECOMMIT",
"ENOPARENT",
];
// 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
@@ -1200,7 +1209,16 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
// EHUNKGONE is the same kind of answer: the tree moved under the click.
const status = badRequest.includes(err.code)
? 400
: exited || ["EHUNKGONE", "EGITAUTH", "EMERGING", "ENOMERGE", "ECONFLICTS"].includes(err.code)
: exited || [
"EHUNKGONE",
"EGITAUTH",
"EMERGING",
"ENOMERGE",
"ECONFLICTS",
"EPUSHED",
"EEMPTY",
"ESTALE",
].includes(err.code)
? 409
: 500;
// git's own stderr is the useful half of a failed push or pull (a
+4 -4
View File
@@ -110,8 +110,8 @@ router.get("/", (req, res) => {
*
* Optional ?limit=<n> caps results (default 50, max 500). The most recent
* runs are first. Optional ?laneId=<n> narrows to the runs started through one
* lane, and ?tool=<id> (with a laneId) to one tool's — the sidebar reads a
* workspace's closed Terminal tabs with `tool=shell`.
* lane, and ?tool=<id> (with a laneId) to one tool's (`shell` = Terminal tabs;
* a closed one's row is dropped on the next run-heal beat).
*/
// ── Drag & drop into the terminal ─────────────────────────────────────────
// A local terminal answers a dropped image by typing its PATH, which is how
@@ -219,8 +219,8 @@ router.get("/history", (req, res) => {
});
/**
* Forget one closed Terminal tab (`tool = 'shell'`) from the history — the
* sidebar's remove. A live pane is refused; another tool's row is a 404.
* Forget one closed Terminal tab (`tool = 'shell'`) from the history at once,
* ahead of the run-heal beat. A live pane is refused; another tool's row is a 404.
*/
router.delete("/history/:id", (req, res) => {
if (runs.listRuns().some((h) => h.id === req.params.id)) {