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:
@@ -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();
|
||||
|
||||
@@ -5,7 +5,52 @@ import { createHaiAgent } from "./pi.js";
|
||||
import type { WorktreePool } from "./worktree-pool.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
|
||||
const MERGE_SYSTEM_PROMPT = `You are a merge agent for "hai", an AI-orchestrated task board.
|
||||
/**
|
||||
* Build the merge system prompt. When `includeTaskId` is true (default),
|
||||
* the commit format uses `<type>(<scope>): <summary>` where scope is the
|
||||
* task ID. When false, it uses `<type>: <summary>` with no scope.
|
||||
*/
|
||||
function buildMergeSystemPrompt(includeTaskId: boolean): string {
|
||||
const commitFormat = includeTaskId
|
||||
? `\`\`\`
|
||||
git commit -m "<type>(<scope>): <summary>" -m "<body>"
|
||||
\`\`\`
|
||||
|
||||
Message format:
|
||||
- **Type:** feat, fix, refactor, docs, test, chore
|
||||
- **Scope:** the task ID (e.g., HAI-001)
|
||||
- **Summary:** one line describing what the squash brings in (imperative mood)
|
||||
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
|
||||
|
||||
Example:
|
||||
\`\`\`
|
||||
git commit -m "feat(HAI-003): add user profile page" -m "- Add /profile route with avatar upload
|
||||
- Create ProfileCard and EditProfileForm components
|
||||
- Add profile image resizing via sharp
|
||||
- Update nav bar with profile link
|
||||
- Add profile e2e tests"
|
||||
\`\`\``
|
||||
: `\`\`\`
|
||||
git commit -m "<type>: <summary>" -m "<body>"
|
||||
\`\`\`
|
||||
|
||||
Message format:
|
||||
- **Type:** feat, fix, refactor, docs, test, chore
|
||||
- **Summary:** one line describing what the squash brings in (imperative mood)
|
||||
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
|
||||
|
||||
Do NOT include a scope in the commit message type.
|
||||
|
||||
Example:
|
||||
\`\`\`
|
||||
git commit -m "feat: add user profile page" -m "- Add /profile route with avatar upload
|
||||
- Create ProfileCard and EditProfileForm components
|
||||
- Add profile image resizing via sharp
|
||||
- Update nav bar with profile link
|
||||
- Add profile e2e tests"
|
||||
\`\`\``;
|
||||
|
||||
return `You are a merge agent for "hai", an AI-orchestrated task board.
|
||||
|
||||
Your job is to finalize a squash merge: resolve any conflicts and write a good commit message.
|
||||
All changes from the branch are squashed into a single commit.
|
||||
@@ -23,27 +68,11 @@ If there are merge conflicts:
|
||||
After all conflicts are resolved (or if there were none), write and execute the squash commit.
|
||||
|
||||
Look at the branch commits and diff to understand what was done, then run:
|
||||
\`\`\`
|
||||
git commit -m "<type>(<scope>): <summary>" -m "<body>"
|
||||
\`\`\`
|
||||
|
||||
Message format:
|
||||
- **Type:** feat, fix, refactor, docs, test, chore
|
||||
- **Scope:** the task ID (e.g., HAI-001)
|
||||
- **Summary:** one line describing what the squash brings in (imperative mood)
|
||||
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
|
||||
|
||||
Example:
|
||||
\`\`\`
|
||||
git commit -m "feat(HAI-003): add user profile page" -m "- Add /profile route with avatar upload
|
||||
- Create ProfileCard and EditProfileForm components
|
||||
- Add profile image resizing via sharp
|
||||
- Update nav bar with profile link
|
||||
- Add profile e2e tests"
|
||||
\`\`\`
|
||||
${commitFormat}
|
||||
|
||||
Do NOT use generic messages like "merge branch" or "resolve conflicts".
|
||||
Base the message on the ACTUAL work done in the branch commits.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any non-done task (other than `excludeTaskId`) references the given
|
||||
@@ -114,7 +143,11 @@ export async function aiMergeTask(
|
||||
console.warn(`[merger] ${taskId}: no worktree path set — skipping worktree cleanup`);
|
||||
}
|
||||
|
||||
// 2. Check branch exists
|
||||
// 2. Read settings early (reused later for recycleWorktrees)
|
||||
const settings = await store.getSettings();
|
||||
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
||||
|
||||
// 3. Check branch exists
|
||||
try {
|
||||
execSync(`git rev-parse --verify "${branch}"`, {
|
||||
cwd: rootDir,
|
||||
@@ -200,7 +233,7 @@ export async function aiMergeTask(
|
||||
|
||||
const { session } = await createHaiAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: MERGE_SYSTEM_PROMPT,
|
||||
systemPrompt: buildMergeSystemPrompt(includeTaskId),
|
||||
tools: "coding",
|
||||
onText: agentLogger.onText,
|
||||
onToolStart: agentLogger.onToolStart,
|
||||
@@ -219,8 +252,9 @@ export async function aiMergeTask(
|
||||
if (staged !== "0") {
|
||||
console.log("[merger] Agent didn't commit — committing with fallback message");
|
||||
const escapedLog = commitLog.replace(/"/g, '\\"');
|
||||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||
execSync(
|
||||
`git commit -m "feat(${taskId}): merge ${branch}" -m "${escapedLog}"`,
|
||||
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
|
||||
{ cwd: rootDir, stdio: "pipe" },
|
||||
);
|
||||
}
|
||||
@@ -255,7 +289,7 @@ export async function aiMergeTask(
|
||||
if (otherUser) {
|
||||
console.log(`[merger] Worktree retained — still needed by ${otherUser}`);
|
||||
result.worktreeRemoved = false;
|
||||
} else if (options.pool && (await store.getSettings()).recycleWorktrees) {
|
||||
} else if (options.pool && settings.recycleWorktrees) {
|
||||
options.pool.release(worktreePath);
|
||||
result.worktreeRemoved = false;
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user