feat(HAI-094): add includeTaskIdInCommit setting to control commit scope

- Add includeTaskIdInCommit boolean to Settings type with default true
- Refactor merger to dynamically build system prompt based on setting
- Update fallback commit message to respect the toggle
- Add checkbox to Settings UI under Merge section
- Add unit tests for merger and SettingsModal behavior
This commit is contained in:
Dustin Byrne
2026-03-26 19:29:45 -04:00
parent 121b509686
commit 30593e5f6b
5 changed files with 211 additions and 24 deletions

View File

@@ -185,6 +185,113 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
});
});
describe("aiMergeTask — includeTaskIdInCommit setting", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("includes task ID in system prompt by default (includeTaskIdInCommit: true)", async () => {
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "HAI-050");
const agentCall = mockedCreateHaiAgent.mock.calls[0][0] as any;
expect(agentCall.systemPrompt).toContain("<type>(<scope>): <summary>");
expect(agentCall.systemPrompt).toContain("the task ID");
});
it("omits task ID scope in system prompt when includeTaskIdInCommit is false", async () => {
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
includeTaskIdInCommit: false,
});
await aiMergeTask(store, "/tmp/root", "HAI-050");
const agentCall = mockedCreateHaiAgent.mock.calls[0][0] as any;
expect(agentCall.systemPrompt).toContain("<type>: <summary>");
expect(agentCall.systemPrompt).not.toContain("<type>(<scope>): <summary>");
expect(agentCall.systemPrompt).toContain("Do NOT include a scope");
});
it("fallback commit includes task ID when includeTaskIdInCommit is true", async () => {
// Make staged check return "1" so fallback is triggered
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached")) return "1" as any;
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "HAI-050");
const commitCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("git commit"),
);
expect(commitCall).toBeDefined();
expect(String(commitCall![0])).toContain("feat(HAI-050):");
});
it("fallback commit omits task ID when includeTaskIdInCommit is false", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached")) return "1" as any;
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
includeTaskIdInCommit: false,
});
await aiMergeTask(store, "/tmp/root", "HAI-050");
const commitCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("git commit"),
);
expect(commitCall).toBeDefined();
expect(String(commitCall![0])).toContain("feat: merge");
expect(String(commitCall![0])).not.toContain("feat(HAI-050)");
});
});
describe("aiMergeTask — agent log persistence", () => {
beforeEach(() => {
vi.clearAllMocks();