feat(git): switch with uncommitted changes (carry / shelve), Create & switch, and shelves with Restore/Delete
- switch takes changes: carry (plain git switch, 409 ECARRYCONFLICT with files) or shelve (shelf first, re-applied if the switch fails) - branch-create switch: true runs git switch -c, the changes come along - shelve / unshelve / shelf-drop + GET /git/shelves: only agenthub-shelf: stash entries, addressed by sha, repo root only, never mid-operation; a clean restore drops the shelf, a conflicted one keeps it - 409 EAGENTRUNNING until confirm: true while the workspace's agent is working - git bar: Bring my changes / Shelve and switch choice, Create & switch, restore offer after a switch; Changes panel: Shelve (n) and the Shelves list - fix: after a switch the Changes panel showed the old branch — the post-write re-read joined an in-flight GET started before the write (request() dedupe); writes now close in-flight GETs, and every git write fires agenthub:git-changed so every view of the workspace re-reads
This commit is contained in:
+385
-23
@@ -5,10 +5,24 @@
|
||||
* .gitignore (`ignore`), 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 hard reset
|
||||
* and no stash, because a dashboard button is the wrong place to start an
|
||||
* hand-edited, merge-commit, merge-abort). There is no rebase and no hard
|
||||
* reset, 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
|
||||
* was: behind the three-check destroy guard in worktree.js).
|
||||
*
|
||||
* Shelves (`shelve`, `unshelve`, `shelf-drop`, and `switch` with
|
||||
* `changes: "carry" | "shelve"`) are git stash under the hood, allowed
|
||||
* because their recovery is a button too: every shelf is listed
|
||||
* (`listShelves`, only entries whose message carries SHELF_PREFIX — any
|
||||
* other stash is the user's and never shown or touched) with Restore and
|
||||
* Delete, addressed by its commit sha (never `stash@{n}`, which a terminal
|
||||
* stash would shift), and nothing drops one except a clean restore or a
|
||||
* Delete with `confirm: true`. They are repo-wide, so repo root only
|
||||
* (ESUBDIR), never during a merge/cherry-pick/revert/rebase (EMERGING), and
|
||||
* a restore needs a clean tree (EDIRTY). A conflicted restore keeps its
|
||||
* shelf and leaves the unmerged files to `resolve`. A write that moves
|
||||
* files under an agent the route reports as working asks for
|
||||
* `confirm: true` first (EAGENTRUNNING). A merge is the
|
||||
* exception because its recovery is ONE button here too — `merge-abort` puts
|
||||
* 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`/
|
||||
@@ -30,13 +44,15 @@
|
||||
* - a caller-supplied path is never trusted as a path: it must be one
|
||||
* `git status` is reporting as changed right now (`resolvePaths`), so no
|
||||
* `..`, no absolute path and no untouched file is reachable.
|
||||
* - `pull` is `--ff-only` and `switch` demands a clean tree, so neither can
|
||||
* invent a merge commit or a conflict underneath a running agent. A merge
|
||||
* - `pull` is `--ff-only` and `switch` demands a clean tree unless the
|
||||
* request names what happens to the changes (carry them, or shelve them),
|
||||
* so neither can invent a merge commit or a conflict underneath a running
|
||||
* agent. A merge
|
||||
* happens only when asked for by name (`merge`), on a clean tree, from a
|
||||
* lane that IS the repo root (a merge is repo-wide), one at a time.
|
||||
*
|
||||
* `discard`, `discard-hunk` and `merge-abort` throw away work git has no copy
|
||||
* of, so each demands an explicit `confirm: true` in the body. Do not add
|
||||
* `discard`, `discard-hunk`, `merge-abort` and `shelf-drop` throw away work
|
||||
* git has no other copy 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
|
||||
@@ -94,6 +110,9 @@ const ACTIONS = new Set([
|
||||
"undo-commit",
|
||||
"amend",
|
||||
"ignore",
|
||||
"shelve",
|
||||
"unshelve",
|
||||
"shelf-drop",
|
||||
]);
|
||||
|
||||
/** Actions that would tangle with a merge, cherry-pick or revert in progress. */
|
||||
@@ -107,6 +126,8 @@ const BLOCKED_WHILE_MERGING = new Set([
|
||||
"revert",
|
||||
"undo-commit",
|
||||
"amend",
|
||||
"shelve",
|
||||
"unshelve",
|
||||
]);
|
||||
|
||||
/** A commit as the request may name it: an abbreviated or full hex sha
|
||||
@@ -324,12 +345,17 @@ async function networkGit(dir, args, body = {}) {
|
||||
* @param {string} dir The lane's cwd.
|
||||
* @param {string} action One of ACTIONS.
|
||||
* @param {object} [body] Action input: `{message}` for commit,
|
||||
* `{branch, create}` for switch. Ignored otherwise.
|
||||
* `{branch, create, changes}` for switch, `{note, paths}` for shelve,
|
||||
* `{sha}` for unshelve/shelf-drop, and so on.
|
||||
* @param {{agentWorking?: boolean}} [opts] What the route knows about the
|
||||
* lane: `agentWorking` makes every write that moves files under a
|
||||
* working agent (shelve, unshelve, a switch carrying or shelving changes)
|
||||
* demand `confirm: true` (EAGENTRUNNING).
|
||||
* @returns {Promise<{action, ok, output, branch?}>} `output` is git's own
|
||||
* stdout+stderr, shown verbatim — a push rejection or a credential failure
|
||||
* says more in git's words than in ours.
|
||||
*/
|
||||
async function runGitAction(dir, action, body = {}) {
|
||||
async function runGitAction(dir, action, body = {}, opts = {}) {
|
||||
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
|
||||
if (BLOCKED_WHILE_MERGING.has(action) || action === "merge-commit" || action === "merge-abort") {
|
||||
// A rebase blocks even Complete/Abort: `commit` mid-rebase would make a
|
||||
@@ -516,29 +542,133 @@ async function runGitAction(dir, action, body = {}) {
|
||||
return done(action, await git(dir, ["commit", "-m", message]));
|
||||
}
|
||||
|
||||
if (action === "shelve") {
|
||||
await assertRepoRootFor(dir, "a shelf");
|
||||
if (!(await isDirty(dir))) throw bad("ENOCHANGES", "nothing to shelve");
|
||||
const paths =
|
||||
Array.isArray(body.paths) && body.paths.length ? await resolvePaths(dir, body) : null;
|
||||
assertAgentConfirmed(opts, body, "shelving");
|
||||
const shelf = await shelve(dir, {
|
||||
note: body.note,
|
||||
// A rename's original path rides along, as in `discard`.
|
||||
paths:
|
||||
paths &&
|
||||
paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path])),
|
||||
}).catch((e) => {
|
||||
if (!e.shelf) throw e;
|
||||
throw Object.assign(
|
||||
bad(
|
||||
"ESHELVED",
|
||||
`git failed after saving your changes on the shelf ${e.shelf.sha.slice(0, 7)}`
|
||||
),
|
||||
{ git: e.git, shelf: e.shelf }
|
||||
);
|
||||
});
|
||||
return { ...done(action, shelf.result), shelf: shelf.entry };
|
||||
}
|
||||
|
||||
if (action === "unshelve" || action === "shelf-drop") {
|
||||
await assertRepoRootFor(dir, "a shelf");
|
||||
if (action === "shelf-drop") {
|
||||
// A shelf is the only copy of the work in it: same gate as discard.
|
||||
if (body.confirm !== true) throw bad("ECONFIRM", "shelf-drop requires confirm: true");
|
||||
const entry = await findShelf(dir, body.sha);
|
||||
return { ...done(action, await dropShelf(dir, entry.sha)), sha: entry.sha };
|
||||
}
|
||||
const entry = await findShelf(dir, body.sha);
|
||||
// A restore never mixes with new work: on a clean tree its result is
|
||||
// exactly the shelf, and a conflict can only be between it and HEAD.
|
||||
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
|
||||
assertAgentConfirmed(opts, body, "restoring a shelf");
|
||||
return applyShelf(dir, entry);
|
||||
}
|
||||
|
||||
if (action === "ignore") return ignorePaths(dir, body);
|
||||
|
||||
if (action === "branch-create" || action === "branch-delete") {
|
||||
return branchAction(dir, action, body);
|
||||
}
|
||||
|
||||
// switch
|
||||
return switchAction(dir, body, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* `switch`. With no `changes` a dirty tree is refused, as it always was: git
|
||||
* happily carries unrelated edits across a switch, which would silently move
|
||||
* an agent's half-finished work onto another branch unless the user asked
|
||||
* for exactly that. `changes: "carry"` asks for it (a plain `git switch`,
|
||||
* which still refuses when a carried file would be overwritten —
|
||||
* ECARRYCONFLICT with the files), `changes: "shelve"` puts the changes on a
|
||||
* shelf first and restores it at once if the switch then fails. Never
|
||||
* --force, --merge or --discard-changes. A successful switch lists the
|
||||
* TARGET branch's shelves, so the UI can offer them back — asking, never
|
||||
* applying one on its own.
|
||||
*/
|
||||
async function switchAction(dir, body, opts) {
|
||||
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
|
||||
if (!branch) throw bad("EBADBRANCH", "branch is required");
|
||||
if (!(await isValidBranchName(dir, branch))) {
|
||||
throw bad("EBADBRANCH", "branch is not a valid git branch name");
|
||||
}
|
||||
if (await isDirty(dir)) {
|
||||
// git would refuse anyway for conflicting files, but it happily carries
|
||||
// unrelated edits across a switch — which silently moves an agent's
|
||||
// half-finished work onto another branch. Refuse the whole case.
|
||||
throw bad("EDIRTY", "working tree has uncommitted changes");
|
||||
const changes = body.changes;
|
||||
if (changes !== undefined && changes !== "carry" && changes !== "shelve") {
|
||||
throw bad("EBADCHANGES", "changes must be carry or shelve");
|
||||
}
|
||||
const dirty = await isDirty(dir);
|
||||
if (dirty && !changes) throw bad("EDIRTY", "working tree has uncommitted changes");
|
||||
// Every refusal that needs no switch attempt comes BEFORE a shelf is made,
|
||||
// so a refused switch never leaves the user's work moved aside.
|
||||
const { argv, target } = await switchArgv(dir, branch, body);
|
||||
if (dirty && changes === "shelve") await assertRepoRootFor(dir, "a shelf");
|
||||
if (dirty) assertAgentConfirmed(opts, body, "switching with uncommitted changes");
|
||||
|
||||
let shelved = null;
|
||||
if (dirty && changes === "shelve") {
|
||||
const note = typeof body.note === "string" && body.note.trim() ? body.note : "";
|
||||
try {
|
||||
shelved = (await shelve(dir, { note })).entry;
|
||||
} catch (e) {
|
||||
// Saved but failed half way: put it back before reporting.
|
||||
if (e.shelf) await restoreOrReport(dir, e.shelf, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await git(dir, argv);
|
||||
} catch (e) {
|
||||
// Back where the user was: the shelf goes straight back on.
|
||||
if (shelved) await restoreOrReport(dir, shelved, e);
|
||||
throw carryConflict(e) || e;
|
||||
}
|
||||
const shelves = (await listShelves(dir).catch(() => [])).filter((s) => s.branch === target);
|
||||
return { ...done("switch", result, target), ...(shelved ? { shelved } : {}), shelves };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-apply a shelf a failed switch made, so the user is back where they
|
||||
* were. If even that fails the work is still on the shelf, and the answer
|
||||
* (ESHELVED, 409) says which one rather than leaving the user to find it.
|
||||
*/
|
||||
async function restoreOrReport(dir, shelf, cause) {
|
||||
const back = await applyShelf(dir, shelf).catch((err) => err);
|
||||
if (!(back instanceof Error) && !back.kept) return;
|
||||
throw Object.assign(
|
||||
bad(
|
||||
"ESHELVED",
|
||||
`git failed and your changes are on the shelf ${shelf.sha.slice(0, 7)}: restore them from the Shelves list`
|
||||
),
|
||||
{ git: cause.git, shelf }
|
||||
);
|
||||
}
|
||||
|
||||
/** The `git switch` argv for `branch`, after every check that needs no switch. */
|
||||
async function switchArgv(dir, branch, body) {
|
||||
const { branches } = await listBranches(dir);
|
||||
const exists = branches.includes(branch);
|
||||
if (body.create) {
|
||||
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
|
||||
return done(action, await git(dir, ["switch", "-c", branch]), branch);
|
||||
return { argv: ["switch", "-c", branch], target: branch };
|
||||
}
|
||||
if (!exists) {
|
||||
// A branch only a remote has (fetched, never checked out here): its local
|
||||
@@ -559,15 +689,220 @@ async function runGitAction(dir, action, body = {}) {
|
||||
}
|
||||
throw bad("ENOBRANCH", `branch ${branch} does not exist locally or on a remote`);
|
||||
}
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["switch", "-c", pick.branch, "--track", `refs/remotes/${pick.name}`]),
|
||||
pick.branch
|
||||
);
|
||||
return {
|
||||
argv: ["switch", "-c", pick.branch, "--track", `refs/remotes/${pick.name}`],
|
||||
target: pick.branch,
|
||||
};
|
||||
}
|
||||
const busyAt = await branchCheckedOutAt(dir, branch);
|
||||
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
|
||||
return done(action, await git(dir, ["switch", branch]), branch);
|
||||
return { argv: ["switch", branch], target: branch };
|
||||
}
|
||||
|
||||
/**
|
||||
* git's "would be overwritten by checkout" refusal as ECARRYCONFLICT carrying
|
||||
* the files it named (tracked edits and untracked files alike), or null.
|
||||
*/
|
||||
function carryConflict(e) {
|
||||
const stderr = e?.git?.stderr || "";
|
||||
if (!/would be overwritten by checkout/.test(stderr)) return null;
|
||||
const files = [];
|
||||
let inList = false;
|
||||
for (const line of stderr.split("\n")) {
|
||||
if (/would be overwritten by checkout/.test(line)) inList = true;
|
||||
else if (inList && /^\s/.test(line) && line.trim()) files.push(line.trim());
|
||||
else inList = false;
|
||||
}
|
||||
return Object.assign(
|
||||
bad("ECARRYCONFLICT", "your changes would be overwritten by the switch: shelve them instead"),
|
||||
{ files, git: e.git }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a write that moves files under an agent the lane reports as working,
|
||||
* unless the request says the user saw that and confirmed (`confirm: true`).
|
||||
*/
|
||||
function assertAgentConfirmed(opts, body, what) {
|
||||
if (opts?.agentWorking && body.confirm !== true) {
|
||||
throw bad(
|
||||
"EAGENTRUNNING",
|
||||
`an agent is working in this workspace: confirm ${what} under it (confirm: true)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A stash is repo-wide, so only a lane at the repo root may shelve or restore. */
|
||||
async function assertRepoRootFor(dir, what) {
|
||||
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
|
||||
throw bad(
|
||||
"ESUBDIR",
|
||||
`${what} holds changes of the whole repository; use a workspace at the repo root`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** What marks a stash entry as one AgentHub made — the only ones it lists or touches. */
|
||||
const SHELF_PREFIX = "agenthub-shelf: ";
|
||||
const SHELF_SEP = " · ";
|
||||
const MAX_NOTE_LEN = 200;
|
||||
|
||||
/**
|
||||
* Every shelf in the repo, newest first: `{ref, sha, branch, note, createdAt,
|
||||
* files: [{path, status}]}`. Only entries whose message carries SHELF_PREFIX
|
||||
* are listed — any other stash is the user's own and never shown or touched.
|
||||
* A write addresses an entry by `sha`, never by its `stash@{n}`, which a
|
||||
* `git stash` run in a terminal meanwhile would shift.
|
||||
*/
|
||||
async function listShelves(dir) {
|
||||
const out = await git(dir, ["stash", "list", "--format=%gd%x00%H%x00%gs"]).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
const shelves = [];
|
||||
for (const line of out.stdout.split("\n")) {
|
||||
if (!line) continue;
|
||||
const [ref, sha, subject = ""] = line.split("\0");
|
||||
const at = subject.indexOf(SHELF_PREFIX);
|
||||
if (at < 0 || !SHA_RE.test(sha || "")) continue;
|
||||
const [branch, createdAt, ...rest] = subject.slice(at + SHELF_PREFIX.length).split(SHELF_SEP);
|
||||
shelves.push({
|
||||
ref,
|
||||
sha,
|
||||
branch: branch || "",
|
||||
note: rest.join(SHELF_SEP),
|
||||
createdAt: createdAt || null,
|
||||
files: await shelfFiles(dir, sha),
|
||||
});
|
||||
}
|
||||
return shelves;
|
||||
}
|
||||
|
||||
/** The files a shelf holds: its tracked changes, plus its untracked files as `??`. */
|
||||
async function shelfFiles(dir, sha) {
|
||||
const files = [];
|
||||
const none = () => ({ stdout: "" });
|
||||
const tracked = await git(dir, [
|
||||
"diff",
|
||||
"--no-renames",
|
||||
"--name-status",
|
||||
"-z",
|
||||
`${sha}^1`,
|
||||
sha,
|
||||
]).catch(none);
|
||||
const parts = tracked.stdout.split("\0").filter(Boolean);
|
||||
for (let i = 0; i + 1 < parts.length; i += 2)
|
||||
files.push({ path: parts[i + 1], status: parts[i] });
|
||||
const untracked = await git(dir, ["ls-tree", "-r", "-z", "--name-only", `${sha}^3`]).catch(none);
|
||||
for (const p of untracked.stdout.split("\0").filter(Boolean)) {
|
||||
files.push({ path: p, status: "??" });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** The shelf named by `sha` (full or abbreviated), or EBADSHA / ENOSHELF. */
|
||||
async function findShelf(dir, sha) {
|
||||
if (typeof sha !== "string" || !SHA_RE.test(sha)) throw bad("EBADSHA", "sha must be a shelf id");
|
||||
const want = sha.toLowerCase();
|
||||
const entry = (await listShelves(dir)).find((s) => s.sha.startsWith(want));
|
||||
if (!entry) throw bad("ENOSHELF", `no shelf ${sha}`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* `git stash push --include-untracked` under an AgentHub message — the
|
||||
* current branch, the time and an optional one-line note — of everything or
|
||||
* of exactly `paths` (already checked by `resolvePaths`).
|
||||
*/
|
||||
async function shelve(dir, { note, paths } = {}) {
|
||||
const branch =
|
||||
(
|
||||
await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" }))
|
||||
).stdout.trim() || "(detached)";
|
||||
const text =
|
||||
typeof note === "string"
|
||||
? note
|
||||
.replace(/[\r\n\t]+/g, " ")
|
||||
.trim()
|
||||
.slice(0, MAX_NOTE_LEN)
|
||||
: "";
|
||||
const message = `${SHELF_PREFIX}${branch}${SHELF_SEP}${new Date().toISOString()}${
|
||||
text ? `${SHELF_SEP}${text}` : ""
|
||||
}`;
|
||||
const before = await stashTop(dir);
|
||||
// A stash can fail AFTER it saved the entry (its internal reset could not
|
||||
// lock a ref): the work is then on a shelf, and the error says which one.
|
||||
const saved = async (e) => {
|
||||
const top = await stashTop(dir);
|
||||
if (!top || top === before) return e;
|
||||
const entry = (await listShelves(dir)).find((s) => s.sha === top);
|
||||
return entry ? Object.assign(e, { shelf: entry }) : e;
|
||||
};
|
||||
// NOT the literal-pathspec `git` wrapper: under GIT_LITERAL_PATHSPECS a
|
||||
// stash saves the untracked files but never cleans them off the tree (its
|
||||
// internal clean uses a magic pathspec), so each path is made literal by
|
||||
// its own `:(literal)` prefix instead.
|
||||
const result = await rawGit(dir, [
|
||||
"stash",
|
||||
"push",
|
||||
"--include-untracked",
|
||||
"-m",
|
||||
message,
|
||||
...(paths && paths.length ? ["--", ...paths.map((p) => `:(literal)${p}`)] : []),
|
||||
]).catch(async (e) => {
|
||||
throw await saved(e);
|
||||
});
|
||||
const top = await stashTop(dir);
|
||||
// "No local changes to save" exits 0 and makes nothing.
|
||||
if (!top || top === before) throw bad("ENOCHANGES", "nothing to shelve");
|
||||
const entry = (await listShelves(dir)).find((s) => s.sha === top);
|
||||
return { result, entry };
|
||||
}
|
||||
|
||||
/** The newest stash entry's commit, or "". */
|
||||
async function stashTop(dir) {
|
||||
return (
|
||||
await git(dir, ["rev-parse", "-q", "--verify", "refs/stash"]).catch(() => ({ stdout: "" }))
|
||||
).stdout.trim();
|
||||
}
|
||||
|
||||
/** `git stash drop` of the entry whose commit is `sha`, its index looked up at drop time. */
|
||||
async function dropShelf(dir, sha) {
|
||||
const entry = (await listShelves(dir)).find((s) => s.sha === sha);
|
||||
if (!entry) throw bad("ENOSHELF", `no shelf ${sha}`);
|
||||
return git(dir, ["stash", "drop", "-q", entry.ref]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a shelf back on a clean tree. `--index` first, so what was staged comes
|
||||
* back staged; when only the index cannot be rebuilt git refuses before it
|
||||
* touches the tree, and the shelf is applied without it. A clean apply drops
|
||||
* the entry; a conflicted one KEEPS it and leaves the unmerged files to the
|
||||
* `resolve` flow (ours = the branch, theirs = the shelf).
|
||||
*/
|
||||
async function applyShelf(dir, entry) {
|
||||
const kept = (e, conflicts) => ({
|
||||
...done("unshelve", e.git || { stdout: "", stderr: "" }),
|
||||
sha: entry.sha,
|
||||
conflicts,
|
||||
kept: true,
|
||||
});
|
||||
let result;
|
||||
try {
|
||||
result = await git(dir, ["stash", "apply", "--index", entry.sha]);
|
||||
} catch (e) {
|
||||
const conflicts = await unmergedPaths(dir);
|
||||
if (conflicts.length) return kept(e, conflicts);
|
||||
if (await isDirty(dir)) throw e;
|
||||
try {
|
||||
result = await git(dir, ["stash", "apply", entry.sha]);
|
||||
} catch (e2) {
|
||||
const left = await unmergedPaths(dir);
|
||||
if (!left.length) throw e2;
|
||||
return kept(e2, left);
|
||||
}
|
||||
}
|
||||
await dropShelf(dir, entry.sha);
|
||||
return { ...done("unshelve", result), sha: entry.sha, conflicts: [], kept: false };
|
||||
}
|
||||
|
||||
/** Paths git still holds as unmerged, repo-wide. */
|
||||
@@ -986,6 +1321,25 @@ async function branchAction(dir, action, body) {
|
||||
if (base && !(await isValidBranchName(dir, base))) {
|
||||
throw bad("EBADBRANCH", "base is not a valid git ref name");
|
||||
}
|
||||
if (body.switch === true) {
|
||||
// Create & switch: the uncommitted changes come along (git never
|
||||
// refuses that from HEAD; from another base a clash is ECARRYCONFLICT).
|
||||
// The working tree is left as it is, so no running-agent confirm.
|
||||
if (await rebaseInProgress(dir)) {
|
||||
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
|
||||
}
|
||||
const open = await operationInProgress(dir);
|
||||
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
|
||||
try {
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["switch", "-c", branch, ...(base ? [base] : [])]),
|
||||
branch
|
||||
);
|
||||
} catch (e) {
|
||||
throw carryConflict(e) || e;
|
||||
}
|
||||
}
|
||||
// No switch: creating a branch to spawn a sibling worktree lane from must
|
||||
// not move the branch the agent in this pane is standing on.
|
||||
return done(action, await git(dir, ["branch", branch, ...(base ? [base] : [])]), branch);
|
||||
@@ -1024,4 +1378,12 @@ function done(action, result, branch) {
|
||||
return { action, ok: true, output, ...(branch ? { branch } : {}) };
|
||||
}
|
||||
|
||||
module.exports = { runGitAction, validateRemoteUrl, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
|
||||
module.exports = {
|
||||
runGitAction,
|
||||
listShelves,
|
||||
validateRemoteUrl,
|
||||
ACTIONS,
|
||||
PROTECTED,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
SHELF_PREFIX,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user