fix(lanes): removing a lane deletes its record only, never the worktree
A lane's `remove` tore down what the lane pointed at: `git worktree remove --force`, a branch delete, and a drop of the lane's own database. One mis-click on a dashboard row cost a whole branch's unpushed code — and the row is the only thing AgentHub actually owns. `remove` now stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/`, and deletes the row. The working tree, its branch, git's worktree records and the database stay exactly where they are, for the user to delete by hand when they mean to. That also fixes #28. Removal used to route through `assertDestroyable`, whose third check demands the cwd appear in `git worktree list`; a managed lane whose directory git no longer listed answered `ENOTWORKTREE` ("lane is not listed as a worktree in …") and could never be dropped from the dashboard at all. Removal no longer asks git anything, so no preflight blocker stops it, and the unpushed-commits force-gate now applies to `reset` alone — nothing `remove` does can discard a commit. The UI follows: one label for both lane kinds ("Delete lane record"), copy that says what survives, and no Force checkbox on removal. `removeWorktree`/`removeLaneData` remain in place as guarded primitives; no route calls them any more.
This commit is contained in:
@@ -673,18 +673,18 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(g(lane.cwd, "log", "-1", "--format=%s").trim(), "move head");
|
||||
});
|
||||
|
||||
it("remove tears down the worktree and the branch, and deletes the lane row", async () => {
|
||||
it("remove deletes the lane row only, leaving a managed worktree and its branch on disk", async () => {
|
||||
const lane = await createManagedLane("remove-managed");
|
||||
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
|
||||
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
|
||||
confirm: true,
|
||||
force: true,
|
||||
expect: destructiveExpect(preflight.body),
|
||||
});
|
||||
assert.equal(removed.status, 200);
|
||||
assert.equal(removed.body.ok, true);
|
||||
assert.equal(fs.existsSync(lane.cwd), false);
|
||||
assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), "");
|
||||
// The user's code is the user's: forgetting a lane may never cost a branch.
|
||||
assert.equal(fs.existsSync(lane.cwd), true);
|
||||
assert.equal(g(SRC, "branch", "--list", lane.branch).trim().endsWith(lane.branch), true);
|
||||
const missing = await request("GET", `/api/lanes/${lane.id}`);
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
@@ -719,7 +719,7 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(fs.readFileSync(adoptedFile, "utf8"), "real project contents\n");
|
||||
});
|
||||
|
||||
it("forgets an adopted lane with unpushed commits without force, while managed removal still requires it", async () => {
|
||||
it("forgets a lane with unpushed commits without force, adopted or managed — removal destroys nothing", async () => {
|
||||
const adoptedDir = path.join(ROOT, "adopted-unpushed");
|
||||
fs.mkdirSync(adoptedDir, { recursive: true });
|
||||
g(adoptedDir, "init");
|
||||
@@ -750,13 +750,14 @@ describe("destructive lane lifecycle actions", () => {
|
||||
`/api/lanes/${managed.id}/preflight?action=remove`
|
||||
);
|
||||
assert.ok(managedPreflight.body.unpushed > 0);
|
||||
const refused = await request("POST", `/api/lanes/${managed.id}/remove`, {
|
||||
const forgottenManaged = await request("POST", `/api/lanes/${managed.id}/remove`, {
|
||||
confirm: true,
|
||||
expect: destructiveExpect(managedPreflight.body),
|
||||
});
|
||||
assert.equal(refused.status, 409);
|
||||
assert.equal(refused.body.error.code, "EUNPUSHED");
|
||||
await request("DELETE", `/api/lanes/${managed.id}`);
|
||||
assert.equal(forgottenManaged.status, 200);
|
||||
// The unpushed commit is still there, on a branch nothing deleted.
|
||||
assert.equal(fs.existsSync(managed.cwd), true);
|
||||
assert.equal(g(managed.cwd, "log", "-1", "--format=%s").trim(), "unpushed managed work");
|
||||
});
|
||||
|
||||
it("requires a complete expect object and returns stale diagnostics", async () => {
|
||||
@@ -885,11 +886,33 @@ describe("destructive lane lifecycle actions", () => {
|
||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||
});
|
||||
|
||||
it("removes a lane whose worktree was deleted by hand, taking the prune path", async () => {
|
||||
// The design promises "the lane reports `missing` and only `remove` is
|
||||
// offered, taking the prune path". Before this, `remove` hit check 2, which
|
||||
// maps a non-existent path to EOUTSIDEROOT → 400, so the lane row could
|
||||
// never be removed through the destructive action at all.
|
||||
it("removes a managed lane whose directory git no longer lists as a worktree (issue #28)", async () => {
|
||||
// Reported as "Lane action failed: lane is not listed as a worktree in …":
|
||||
// a managed lane whose directory is a plain repo git never registered (or no
|
||||
// longer registers) as a worktree used to hit assertDestroyable's check 3 and
|
||||
// become unremovable. Removal no longer asks git anything.
|
||||
const lane = await createManagedLane("remove-not-a-worktree");
|
||||
g(SRC, "worktree", "remove", "--force", lane.cwd);
|
||||
fs.mkdirSync(lane.cwd, { recursive: true });
|
||||
g(lane.cwd, "init");
|
||||
g(lane.cwd, "config", "user.email", "test@example.com");
|
||||
g(lane.cwd, "config", "user.name", "Test User");
|
||||
fs.writeFileSync(path.join(lane.cwd, "keep.txt"), "user code\n");
|
||||
|
||||
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
|
||||
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
|
||||
confirm: true,
|
||||
expect: destructiveExpect(preflight.body),
|
||||
});
|
||||
assert.equal(removed.status, 200, JSON.stringify(removed.body));
|
||||
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404);
|
||||
assert.equal(fs.readFileSync(path.join(lane.cwd, "keep.txt"), "utf8"), "user code\n");
|
||||
});
|
||||
|
||||
it("removes a lane whose worktree was deleted by hand, leaving git's own records alone", async () => {
|
||||
// A missing directory changes nothing about `remove` any more: it drops the
|
||||
// dashboard's row and never touches git. Cleaning up git's stale worktree
|
||||
// entry is the user's own `git worktree prune`.
|
||||
const lane = await createManagedLane("remove-hand-deleted");
|
||||
fs.rmSync(lane.cwd, { recursive: true, force: true });
|
||||
|
||||
@@ -905,13 +928,8 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(removed.status, 200, JSON.stringify(removed.body));
|
||||
assert.equal(removed.body.ok, true);
|
||||
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404);
|
||||
// git's stale record and the lane's branch are both gone.
|
||||
const wt = require("../lib/worktree");
|
||||
assert.equal(
|
||||
(await wt.listWorktrees(SRC)).some((w) => w.path === lane.cwd),
|
||||
false
|
||||
);
|
||||
assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), "");
|
||||
// The branch survives — it is where the deleted directory's commits live.
|
||||
assert.equal(g(SRC, "branch", "--list", lane.branch).trim().endsWith(lane.branch), true);
|
||||
});
|
||||
|
||||
it("refuses a second start while the first run is still live, so no tmux session is orphaned", async () => {
|
||||
|
||||
@@ -546,13 +546,14 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
assert.match(loser.stdout, /Current preflight:/);
|
||||
});
|
||||
|
||||
it("removes a managed lane's worktree and directory with --yes", async () => {
|
||||
it("removes a managed lane's record with --yes, leaving its directory on disk", async () => {
|
||||
const lane = await ownLane("CLI remove managed");
|
||||
assert.equal(fs.existsSync(lane.cwd), true);
|
||||
const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, new RegExp(`Removed lane #${lane.id}`));
|
||||
assert.equal(fs.existsSync(lane.cwd), false);
|
||||
assert.match(r.stdout, /left for you to delete yourself/);
|
||||
assert.equal(fs.existsSync(lane.cwd), true);
|
||||
assert.equal((await get(`/api/lanes/${lane.id}`)).status, 404);
|
||||
});
|
||||
|
||||
|
||||
@@ -76,8 +76,9 @@ async function preflight(lane, action) {
|
||||
}
|
||||
}
|
||||
|
||||
// The database name this action would drop (reset unless --keep-db,
|
||||
// remove always) — echoed the same way `head`/`dirty` are, so the
|
||||
// The database name this action would drop (reset unless --keep-db; never
|
||||
// for remove, which forgets the lane and leaves its data in place — see the
|
||||
// remove branch in routes/lanes.js) — echoed the same way `head`/`dirty` are, so the
|
||||
// confirmation dialog names the destructive fact rather than leaving it a
|
||||
// surprise. Null when the lane has no slot yet or the profile declares no
|
||||
// DB_PREFIX — nothing has been derived to drop.
|
||||
|
||||
+17
-20
@@ -35,7 +35,6 @@ const {
|
||||
isGitRepo,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
removeWorktree,
|
||||
resetWorktree,
|
||||
resolveBase,
|
||||
slugify,
|
||||
@@ -53,7 +52,6 @@ const {
|
||||
requireProfile,
|
||||
provisionLane,
|
||||
resetLaneData,
|
||||
removeLaneData,
|
||||
} = require("../lib/lane-runtime");
|
||||
const { reapOrphanMcp, capOversizedLogs } = require("../lib/lane-gc");
|
||||
const { detectNode, scaffoldProfile } = require("../lib/lane-detect");
|
||||
@@ -1250,21 +1248,20 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found");
|
||||
|
||||
await stopLaneRun(lockedLane);
|
||||
// A running stack holds files open in the very directory reset and remove
|
||||
// are about to rewrite or delete, and its processes would outlive the lane
|
||||
// still bound to its ports. Stop it before touching git. Idempotent and a
|
||||
// no-op for a lane that was never brought up.
|
||||
// A running stack holds files open in the very directory `reset` is about
|
||||
// to rewrite, and either way its processes would outlive the lane still
|
||||
// bound to its ports. Stop it before touching git or dropping the row.
|
||||
// Idempotent and a no-op for a lane that was never brought up.
|
||||
if (action === "reset" || action === "remove") {
|
||||
await downLane(lanesLib.getLane(lane.id));
|
||||
}
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
const facts = await preflight(current, action);
|
||||
assertExpectedPreflight(action, facts, body.expect);
|
||||
if (
|
||||
(action === "reset" || (action === "remove" && current.kind === "managed")) &&
|
||||
facts.unpushed > 0 &&
|
||||
body.force !== true
|
||||
) {
|
||||
// Only `reset` can lose committed work now — `remove` forgets the row and
|
||||
// touches neither the worktree nor the branch, so unpushed commits are
|
||||
// nothing it can discard.
|
||||
if (action === "reset" && facts.unpushed > 0 && body.force !== true) {
|
||||
throw lifecycleError(
|
||||
"EUNPUSHED",
|
||||
`${action} requires force: true when commits are unpushed`
|
||||
@@ -1286,15 +1283,15 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
return { lane: lanesLib.getLane(current.id) };
|
||||
}
|
||||
if (action === "remove") {
|
||||
// Drop the lane's own database(s) before anything else — its state
|
||||
// directory (the drop-created marker) is about to be deleted too.
|
||||
const profile = resolveProfile(current);
|
||||
if (profile && current.slot) await removeLaneData(current, profile);
|
||||
// Forgetting an adopted lane only removes dashboard metadata. The
|
||||
// filesystem destroy guard is deliberately reached only for managed
|
||||
// worktrees, where removal can actually touch a directory.
|
||||
if (current.kind === "managed") await removeWorktree(current);
|
||||
// Runtime bookkeeping outlives the row otherwise: pid files and hook
|
||||
// `remove` forgets the lane, it does not destroy it. The worktree
|
||||
// directory, its branch and the lane's own database(s) are the USER's
|
||||
// data and outlive the dashboard's record of them — one mis-click here
|
||||
// used to cost a whole branch's unpushed code, and no dashboard row is
|
||||
// worth that. Deleting any of it is the user's own call, by hand
|
||||
// (`git worktree remove` / `git branch -d` / dropping the db).
|
||||
//
|
||||
// Runtime bookkeeping is the exception, because it is AgentHub's and
|
||||
// not the user's: pid files and hook
|
||||
// logs under .state/lane<slot>/ would be inherited by whichever lane
|
||||
// claims that slot next. Deleting the row is what frees the slot —
|
||||
// usedSlots() reads the table, so there is nothing else to release.
|
||||
|
||||
Reference in New Issue
Block a user