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
+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,
};