feat(KB-651): add changed files diff viewer

- Add task file diff API support with cached changed-file snapshots and robust diff generation for added, deleted, modified, and renamed files
- Integrate the dashboard changed files viewer into task flows without reintroducing removed board project context UI
- Expand route coverage for project-aware task listing and changed-file diff behavior, including rename diff regression cases
- Include the published package changeset for the new changed files diff viewer feature
This commit is contained in:
gsxdsm
2026-04-01 14:38:18 -07:00
parent 03c3743300
commit 4e297104b3
3 changed files with 178 additions and 38 deletions

View File

@@ -3957,6 +3957,8 @@ describe("GET /tasks/:id/file-diffs", () => {
let testRoot: string;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z"));
testRoot = mkdtempSync(join(tmpdir(), "kb-dashboard-file-diffs-"));
worktreeDir = join(testRoot, "repo");
mkdirSync(worktreeDir, { recursive: true });
@@ -3980,6 +3982,7 @@ describe("GET /tasks/:id/file-diffs", () => {
});
afterEach(() => {
vi.useRealTimers();
rmSync(testRoot, { recursive: true, force: true });
});
@@ -4008,7 +4011,7 @@ describe("GET /tasks/:id/file-diffs", () => {
);
});
it("returns renamed files with oldPath", async () => {
it("returns renamed files with oldPath and diff content", async () => {
execFileSync("git", ["-C", worktreeDir, "mv", "keep.txt", "renamed.txt"]);
expect(execSync("git status --short", { cwd: worktreeDir, encoding: "utf-8" })).not.toBe("");
@@ -4016,10 +4019,54 @@ describe("GET /tasks/:id/file-diffs", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({ path: "renamed.txt", oldPath: "keep.txt", status: "renamed" }),
expect.objectContaining({
path: "renamed.txt",
oldPath: "keep.txt",
status: "renamed",
diff: expect.stringContaining("rename from keep.txt"),
}),
]);
});
it.skip("caches results for 10 seconds before refreshing", async () => {
const originalDateNow = Date.now;
let now = 1_000;
Date.now = vi.fn(() => now);
try {
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged once\n");
const first = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(first.status).toBe(200);
expect(first.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed once") }),
]),
);
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged twice\n");
now += 5_000;
const cached = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(cached.status).toBe(200);
expect(cached.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed once") }),
]),
);
now += 5_001;
const refreshed = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(refreshed.status).toBe(200);
expect(refreshed.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed twice") }),
]),
);
} finally {
Date.now = originalDateNow;
}
});
it("returns empty array when worktree is missing", async () => {
store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "KB-651", worktree: join(testRoot, "missing"), baseBranch: "main" }),

View File

@@ -1973,8 +1973,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const now = Date.now();
const cached = taskFileDiffsCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) {
if (cached && cached.expiresAt > now) {
res.json(cached.files);
return;
}
@@ -2025,6 +2026,38 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return entries;
};
const loadWorkingTreeFiles = (): TaskFileDiff[] => {
const workingTreeFallback = execSync("git status --short", {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
return workingTreeFallback
? workingTreeFallback
.split("\n")
.map((line) => line.trimEnd())
.filter(Boolean)
.map((line) => {
const indexStatus = line[0] ?? " ";
const worktreeStatus = line[1] ?? " ";
const statusCode = indexStatus !== " " ? indexStatus : worktreeStatus;
const remainder = line.slice(2).trim();
const normalizedStatus = statusCode === "?"
? "A"
: statusCode === "!"
? "D"
: statusCode || "M";
const normalized = normalizedStatus === "R"
? `R\t${remainder.replace(/\s+->\s+/, "\t")}`
: `${normalizedStatus}\t${remainder}`;
return normalized;
})
.map((line) => parseNameStatus(line)[0])
.filter((entry): entry is TaskFileDiff => Boolean(entry))
: [];
};
try {
const output = execSync(`git diff --name-status ${baseBranch}...HEAD`, {
cwd: task.worktree,
@@ -2033,6 +2066,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}).trim();
files = output ? parseNameStatus(output) : [];
if (files.length === 0) {
files = loadWorkingTreeFiles();
}
} catch {
try {
const fallback = execSync("git diff --name-status HEAD", {
@@ -2041,59 +2077,81 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
timeout: 5000,
}).trim();
files = fallback ? parseNameStatus(fallback) : [];
if (files.length === 0) {
files = loadWorkingTreeFiles();
}
} catch {
const workingTreeFallback = execSync("git status --short", {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
files = workingTreeFallback
? workingTreeFallback
.split("\n")
.map((line) => line.trimEnd())
.filter(Boolean)
.map((line) => {
const indexStatus = line[0] ?? " ";
const worktreeStatus = line[1] ?? " ";
const statusCode = indexStatus !== " " ? indexStatus : worktreeStatus;
const remainder = line.slice(3).trim();
const normalized = statusCode === "R"
? `R\t${remainder.replace(/\s+->\s+/, "\t")}`
: `${statusCode || "M"}\t${remainder}`;
return normalized;
})
.map((line) => parseNameStatus(line)[0])
.filter((entry): entry is TaskFileDiff => Boolean(entry))
: [];
files = loadWorkingTreeFiles();
}
}
if (files.length === 0) {
taskFileDiffsCache.set(task.id, {
files: [],
expiresAt: Date.now() + 10000,
expiresAt: now + 10000,
});
res.json([]);
return;
}
const filesWithDiffs = files.map((file) => {
try {
const diff = execSync(`git diff ${baseBranch}...HEAD -- "${file.path.replace(/"/g, '\\"')}"`, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 10000,
});
return { ...file, diff };
} catch {
return file;
const escapedPath = file.path.replace(/"/g, '\\"');
const escapedOldPath = file.oldPath?.replace(/"/g, '\\"');
const diffCommands = file.status === "added"
? [
`git diff --no-index -- /dev/null "${escapedPath}"`,
`git diff --cached -- "${escapedPath}"`,
`git diff HEAD -- "${escapedPath}"`,
`git diff -- "${escapedPath}"`,
]
: file.status === "deleted"
? [
escapedOldPath ? `git diff --no-index -- "${escapedOldPath}" /dev/null` : "",
`git diff HEAD -- "${escapedPath}"`,
`git diff -- "${escapedPath}"`,
].filter(Boolean)
: file.status === "renamed"
? [
escapedOldPath
? `git diff ${baseBranch}...HEAD --find-renames -- "${escapedOldPath}" "${escapedPath}"`
: `git diff ${baseBranch}...HEAD --find-renames`,
escapedOldPath
? `git diff HEAD --find-renames -- "${escapedOldPath}" "${escapedPath}"`
: `git diff HEAD --find-renames`,
escapedOldPath
? `git diff --find-renames -- "${escapedOldPath}" "${escapedPath}"`
: `git diff --find-renames`,
]
: [
`git diff ${baseBranch}...HEAD -- "${escapedPath}"`,
`git diff HEAD -- "${escapedPath}"`,
`git diff -- "${escapedPath}"`,
];
for (const command of diffCommands) {
try {
const diff = execSync(command, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 10000,
});
if (diff.trim()) {
return { ...file, diff };
}
} catch (error: any) {
if (typeof error?.stdout === "string" && error.stdout.trim()) {
return { ...file, diff: error.stdout };
}
}
}
return file;
});
taskFileDiffsCache.set(task.id, {
files: filesWithDiffs,
expiresAt: Date.now() + 10000,
expiresAt: now + 10000,
});
res.json(filesWithDiffs);