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
+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() : "";