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:
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user