feat(lanes): name the files behind a lane's uncommitted counts

GET /api/lanes/:id/git now returns `files`, the raw `git status --porcelain`
lines the dirty/untracked counts were already parsed from, capped at 20 so a
lane mid-`npm install` cannot ship thousands of paths through a polled
endpoint. The lane card hovers them as the tooltip on its
"N modified · N untracked" text, appending "… +N" when the cap bit.

No new subprocess and no new endpoint — statusCounts was counting those lines
and throwing them away.
This commit is contained in:
2026-09-17 13:19:07 +07:00
parent 2fe1dcd46f
commit af2ad63a18
8 changed files with 46 additions and 8 deletions
+2
View File
@@ -100,6 +100,8 @@ describe("worktree", () => {
assert.equal(s.dirty, 1);
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
assert.match(s.head, /^[0-9a-f]{7,40}$/);
// The same lines the counts came from, so a caller can name the files.
assert.deepEqual(s.files.sort(), [" M README.md", "?? scratch.txt"]);
// The fixture has no remotes. Given the lane's base branch, unpushedCount
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
assert.equal(await wt.unpushedCount(dir, "main"), 0);
+11 -3
View File
@@ -16,6 +16,8 @@ const execFileAsync = promisify(execFile);
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
const PROTECTED_BRANCHES = new Set(["main", "master"]);
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
const STATUS_FILE_CAP = 20;
/**
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
@@ -549,11 +551,16 @@ async function removeWorktree(lane) {
/**
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
* Lines starting with ?? are untracked; others are dirty.
*
* `files` carries the same lines back verbatim so a caller can show WHICH
* files the counts mean, capped because this rides a polled endpoint and a
* lane mid-`npm install` can report thousands.
*/
async function statusCounts(dir) {
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
let dirty = 0;
let untracked = 0;
const files = [];
for (const line of result.stdout.split("\n")) {
if (!line.trim()) continue;
@@ -562,13 +569,14 @@ async function statusCounts(dir) {
} else {
dirty++;
}
if (files.length < STATUS_FILE_CAP) files.push(line.trimEnd());
}
// Get short commit hash
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
const head = headResult.stdout.trim();
return { dirty, untracked, head };
return { dirty, untracked, head, files };
}
/**
@@ -581,10 +589,10 @@ async function statusCounts(dir) {
* caller shows what git says rather than inventing a nicer word for it.
*/
async function gitFacts(dir) {
const { dirty, untracked, head } = await statusCounts(dir);
const { dirty, untracked, head, files } = await statusCounts(dir);
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
return { branch, head, subject, dirty, untracked };
return { branch, head, subject, dirty, untracked, files };
}
/**
+1 -1
View File
@@ -274,7 +274,7 @@ const paths = {
responses: {
200: {
description:
"available:true with {branch, head, subject, dirty, untracked}, or available:false alone.",
"available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files holds the raw `git status --porcelain` lines behind those counts, capped at 20; the counts themselves are never capped.",
},
404: { description: "Lane not found." },
},