diff --git a/docs/API.md b/docs/API.md index 9d000c41..0a5ace46 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 } diff --git a/server/__tests__/git-clone.test.js b/server/__tests__/git-clone.test.js index 460d7fe3..fa98537e 100644 --- a/server/__tests__/git-clone.test.js +++ b/server/__tests__/git-clone.test.js @@ -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); diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 25fff54b..65b416d9 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -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 } : {}),