feat(lanes): Add Lane repo/worktree mode toggle, manual branch, folder browse

- Repo mode adopts a directory as-is via /lanes/ensure (no worktree, no
  branch fields) - the right choice for a main repo you want stage
  detection on. Worktree mode (default) keeps the existing provisioning
  flow but now requires a manually-typed branch name instead of deriving
  one from the title.
- POST /lanes/worktree accepts an optional `branch`, validated via
  `git check-ref-format --branch`; omitting it preserves the CLI's
  existing auto-derived-branch behavior.
- New GET /lanes/browse lists a directory's immediate subdirectories,
  backing a small folder-browse modal on both path fields - browsers
  cannot expose an absolute path from a native picker, so this is
  server-backed instead, consistent with the tool's local-first model.
This commit is contained in:
2026-08-06 16:03:49 +07:00
parent 9f13769fb4
commit 78f6e1be8e
10 changed files with 594 additions and 72 deletions
+62
View File
@@ -488,6 +488,68 @@ describe("managed worktree provisioning", () => {
const missing = await request("GET", `/api/lanes/${lane.id}`);
assert.equal(missing.status, 404);
});
it("uses a caller-supplied branch name instead of deriving one from the slug", async () => {
const created = await request("POST", "/api/lanes/worktree", {
sourceRepo: SRC,
title: "Custom Branch",
base: "main",
branch: "custom/my-branch",
});
assert.equal(created.status, 202);
assert.equal(created.body.lane.branch, "custom/my-branch");
const lane = await waitForProvisioning(created.body.lane.id);
assert.equal(lane.status, "idle");
assert.equal(g(lane.cwd, "branch", "--show-current").trim(), "custom/my-branch");
});
it("rejects an invalid caller-supplied branch name before creating anything", async () => {
const response = await request("POST", "/api/lanes/worktree", {
sourceRepo: SRC,
title: "Bad Branch",
base: "main",
branch: "not a valid branch..name",
});
assert.equal(response.status, 400);
assert.equal(response.body.error.code, "EBADBRANCH");
});
});
describe("GET /api/lanes/browse", () => {
it("lists a directory's immediate subdirectories, marking git repos", async () => {
const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
assert.equal(response.status, 200);
assert.equal(response.body.path, ROOT);
const names = response.body.entries.map((e) => e.name);
assert.ok(names.includes("src-repo"));
const srcRepoEntry = response.body.entries.find((e) => e.name === "src-repo");
assert.equal(srcRepoEntry.isGitRepo, true);
});
it("reports the parent directory, or null at the filesystem root", async () => {
const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
assert.equal(response.body.parent, path.dirname(ROOT));
const rootResponse = await request("GET", "/api/lanes/browse?path=/");
assert.equal(rootResponse.body.parent, null);
});
it("rejects a path that does not exist or is not a directory", async () => {
const missing = await request(
"GET",
`/api/lanes/browse?path=${encodeURIComponent(path.join(ROOT, "does-not-exist"))}`
);
assert.equal(missing.status, 400);
assert.equal(missing.body.error.code, "ENOTFOUND");
const filePath = path.join(ROOT, "a-file.txt");
fs.writeFileSync(filePath, "hi\n");
const notADir = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(filePath)}`);
assert.equal(notADir.status, 400);
assert.equal(notADir.body.error.code, "ENOTADIR");
});
});
describe("destructive lane lifecycle actions", () => {