fix(lanes): cloning into a deleted workspace's folder re-attaches it instead of failing UNIQUE

A workspace record outlives its folder (deleted from the File Explorer or a
shell). Cloning the same repo into that path again ran `git clone`, then
createLane hit idx_lanes_cwd_tool and answered 500 "UNIQUE constraint failed:
lanes.cwd, lanes.tool", leaving the new clone with no workspace on it.

POST /api/lanes/clone now looks for the caller's own workspace at exactly
that folder and tool and answers 200 { lane, created: false, path }, like
POST /ensure. Another user's workspace there is still refused before the
clone (folderTakenByOther), and a nested path still makes its own workspace.
This commit is contained in:
2026-10-23 16:26:56 +07:00
parent 572d615d4e
commit 65a001dad4
3 changed files with 31 additions and 1 deletions
+1
View File
@@ -405,6 +405,7 @@ closing the dialog then only hides it behind a progress chip (click to reopen)
```
POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? }, partial?, progressToken? }
→ 201 { lane, created: true, path }
→ 200 { lane, created: false, path } (the user's own workspace already at that folder + tool, its folder deleted since)
GET /api/lanes/clone/progress/:token
→ 200 { state: queued|running|done|failed, position, running, waiting, limit,
phase: remote|receiving|resolving|checkout|other|null, label, percent, detail }
+19
View File
@@ -143,6 +143,25 @@ describe("POST /api/lanes/clone", () => {
assert.equal(again.body.error.code, "EEXISTS");
});
it("re-attaches a workspace whose folder was deleted instead of failing UNIQUE", async () => {
const first = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "gone",
});
assert.equal(first.status, 201, JSON.stringify(first.body));
fs.rmSync(path.join(WORK, "gone"), { recursive: true, force: true });
const again = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "gone",
});
assert.equal(again.status, 200, JSON.stringify(again.body));
assert.equal(again.body.created, false);
assert.equal(again.body.lane.id, first.body.lane.id);
assert.ok(fs.existsSync(path.join(WORK, "gone", "README.md")));
});
it("clones over ssh with the user's key, kept in the clone's config", async () => {
const made = await request("POST", "/api/me/git/ssh-key", {});
assert.equal(made.status, 201);
+11 -1
View File
@@ -886,9 +886,19 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
});
if (!done) return;
const { target, name, ownerId } = done;
const cwd = lanesLib.canonicalCwd(target);
// A workspace can outlive its folder (deleted from the File Explorer or a
// shell): cloning into that path again re-attaches the user's own record
// rather than tripping idx_lanes_cwd_tool. Another user's was refused above.
const strip = (p) => p.replace(/[\\/]+$/, "");
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
if (found && strip(found.cwd) === strip(cwd) && scope.canSeeLane(req, found.id)) {
broadcastLane(found.id);
return res.status(200).json({ lane: payload(found), created: false, path: target });
}
try {
const lane = lanesLib.createLane({
cwd: lanesLib.canonicalCwd(target),
cwd,
owner_id: ownerId,
title: typeof body.title === "string" && body.title.trim() ? body.title.trim() : name,
...(body.tool ? { tool: body.tool } : {}),