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:
2026-09-18 09:44:17 +07:00
parent 75a2de60b2
commit 0004cf7036
13 changed files with 136 additions and 103 deletions
+39 -21
View File
@@ -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 () => {