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", () => {
+17 -5
View File
@@ -78,6 +78,21 @@ async function isGitRepo(dir) {
}
}
/**
* Whether `name` is a legal git branch name, per git's own rules rather than
* a hand-rolled regex. `cwd` need not be `sourceRepo` specifically — the
* check is not repo-dependent — but `git()` requires a directory to run in.
*/
async function isValidBranchName(cwd, name) {
if (typeof name !== "string" || !name) return false;
try {
await git(cwd, ["check-ref-format", "--branch", name]);
return true;
} catch {
return false;
}
}
/**
* List a repo's local branches plus its current HEAD branch, so a caller can
* offer a real picker instead of asking someone to remember a branch name.
@@ -85,11 +100,7 @@ async function isGitRepo(dir) {
* can actually check a new worktree out onto without a fetch first.
*/
async function listBranches(sourceRepo) {
const result = await git(sourceRepo, [
"for-each-ref",
"--format=%(refname:short)",
"refs/heads",
]);
const result = await git(sourceRepo, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]);
const branches = result.stdout
.split("\n")
.map((line) => line.trim())
@@ -643,6 +654,7 @@ module.exports = {
LANES_ROOT,
git,
isGitRepo,
isValidBranchName,
listBranches,
resolveBase,
slugify,
+55 -2
View File
@@ -10,6 +10,7 @@
const { Router } = require("express");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
@@ -25,6 +26,7 @@ const {
addWorktree,
gitFacts,
isGitRepo,
isValidBranchName,
listBranches,
removeWorktree,
resetWorktree,
@@ -193,6 +195,47 @@ router.post("/gc", sameOriginGuard, (req, res) => {
}
});
/**
* Directory listing for the Add Lane modal's folder browser — browsers cannot
* expose absolute filesystem paths from a native picker, so path selection is
* done by browsing server-side instead. Read-only; registered ahead of
* "/:id" so the literal "browse" segment is never captured as a lane id.
*/
router.get("/browse", (req, res) => {
const raw =
typeof req.query.path === "string" && req.query.path.trim() ? req.query.path : os.homedir();
const resolved = path.resolve(raw);
let stat;
try {
stat = fs.statSync(resolved);
} catch {
return res.status(400).json({ error: { code: "ENOTFOUND", message: "path does not exist" } });
}
if (!stat.isDirectory()) {
return res
.status(400)
.json({ error: { code: "ENOTADIR", message: "path is not a directory" } });
}
let entries = [];
try {
entries = fs
.readdirSync(resolved, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => {
const full = path.join(resolved, e.name);
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
})
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
// An unreadable entry mid-listing is skipped, not a request failure.
}
const parent = path.dirname(resolved) === resolved ? null : path.dirname(resolved);
res.json({ path: resolved, parent, entries });
});
router.get("/:id", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
@@ -503,8 +546,18 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
});
}
const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/";
const branch = `${branchPrefix}${slug}`;
let branch;
if (body.branch !== undefined) {
if (!(await isValidBranchName(resolvedSourceRepo, body.branch))) {
return res.status(400).json({
error: { code: "EBADBRANCH", message: "branch is not a valid git branch name" },
});
}
branch = body.branch;
} else {
const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/";
branch = `${branchPrefix}${slug}`;
}
let lane;
try {
lane = lanesLib.createLane({