feat: squash merge for clean one-commit-per-task history

- git merge --squash replaces git merge --no-ff
- Merge agent prompt updated for squash flow
- Fallback detection uses staged changes instead of MERGE_HEAD
- Abort uses git reset --merge instead of git merge --abort
- Store fallback merge also squashes + commits
This commit is contained in:
Dustin Byrne
2026-03-25 22:02:33 -04:00
parent 005e504565
commit 69ff2f7aa0
2 changed files with 27 additions and 25 deletions

View File

@@ -494,22 +494,26 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// 2. Merge the branch // 2. Merge the branch
try { try {
execSync(`git merge "${branch}" --no-edit`, { execSync(`git merge --squash "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
execSync(`git commit --no-edit -m "feat(${id}): merge ${branch}"`, {
cwd: this.rootDir, cwd: this.rootDir,
stdio: "pipe", stdio: "pipe",
}); });
result.merged = true; result.merged = true;
} catch (err: any) { } catch (err: any) {
// Merge conflict — abort and report // Squash conflict — reset and report
try { try {
execSync("git merge --abort", { cwd: this.rootDir, stdio: "pipe" }); execSync("git reset --merge", { cwd: this.rootDir, stdio: "pipe" });
} catch { } catch {
// already clean // already clean
} }
throw new Error( throw new Error(
`Merge conflict merging '${branch}'. Resolve manually:\n` + `Merge conflict merging '${branch}'. Resolve manually:\n` +
` cd ${this.rootDir}\n` + ` cd ${this.rootDir}\n` +
` git merge ${branch}\n` + ` git merge --squash ${branch}\n` +
` # resolve conflicts, then: hai task move ${id} done`, ` # resolve conflicts, then: hai task move ${id} done`,
); );
} }

View File

@@ -6,7 +6,8 @@ import { createHaiAgent } from "./pi.js";
const MERGE_SYSTEM_PROMPT = `You are a merge agent for "hai", an AI-orchestrated task board. const MERGE_SYSTEM_PROMPT = `You are a merge agent for "hai", an AI-orchestrated task board.
Your job is to finalize a git merge: resolve any conflicts and write a good commit message. 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.
## Conflict resolution ## Conflict resolution
If there are merge conflicts: If there are merge conflicts:
@@ -18,22 +19,22 @@ If there are merge conflicts:
6. Do NOT change anything beyond what's needed to resolve the conflict 6. Do NOT change anything beyond what's needed to resolve the conflict
## Commit message ## Commit message
After all conflicts are resolved (or if there were none), write and execute the merge commit. 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: Look at the branch commits and diff to understand what was done, then run:
\`\`\` \`\`\`
git commit --no-edit -m "<type>(<scope>): <summary>" -m "<body>" git commit -m "<type>(<scope>): <summary>" -m "<body>"
\`\`\` \`\`\`
Message format: Message format:
- **Type:** feat, fix, refactor, docs, test, chore - **Type:** feat, fix, refactor, docs, test, chore
- **Scope:** the task ID (e.g., HAI-001) - **Scope:** the task ID (e.g., HAI-001)
- **Summary:** one line describing what the merge brings in (imperative mood) - **Summary:** one line describing what the squash brings in (imperative mood)
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- " - **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
Example: Example:
\`\`\` \`\`\`
git commit --no-edit -m "feat(HAI-003): add user profile page" -m "- Add /profile route with avatar upload git commit -m "feat(HAI-003): add user profile page" -m "- Add /profile route with avatar upload
- Create ProfileCard and EditProfileForm components - Create ProfileCard and EditProfileForm components
- Add profile image resizing via sharp - Add profile image resizing via sharp
- Update nav bar with profile link - Update nav bar with profile link
@@ -41,7 +42,7 @@ git commit --no-edit -m "feat(HAI-003): add user profile page" -m "- Add /profil
\`\`\` \`\`\`
Do NOT use generic messages like "merge branch" or "resolve conflicts". Do NOT use generic messages like "merge branch" or "resolve conflicts".
Base the message on the ACTUAL work done in the commits.`; Base the message on the ACTUAL work done in the branch commits.`;
export interface MergerOptions { export interface MergerOptions {
/** Called with agent text output */ /** Called with agent text output */
@@ -113,7 +114,7 @@ export async function aiMergeTask(
// 4. Start the merge (--no-commit so the agent controls the message) // 4. Start the merge (--no-commit so the agent controls the message)
let hasConflicts = false; let hasConflicts = false;
try { try {
execSync(`git merge "${branch}" --no-commit --no-ff`, { execSync(`git merge --squash "${branch}"`, {
cwd: rootDir, cwd: rootDir,
stdio: "pipe", stdio: "pipe",
}); });
@@ -129,7 +130,7 @@ export async function aiMergeTask(
if (!hasConflicts) { if (!hasConflicts) {
// Not conflicts — some other merge failure. Abort and throw. // Not conflicts — some other merge failure. Abort and throw.
try { try {
execSync("git merge --abort", { cwd: rootDir, stdio: "pipe" }); execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch { /* */ } } catch { /* */ }
throw new Error(`Merge failed for branch '${branch}'`); throw new Error(`Merge failed for branch '${branch}'`);
} }
@@ -137,7 +138,7 @@ export async function aiMergeTask(
if (e.message.includes("Merge failed")) throw e; if (e.message.includes("Merge failed")) throw e;
// git diff itself failed — abort // git diff itself failed — abort
try { try {
execSync("git merge --abort", { cwd: rootDir, stdio: "pipe" }); execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch { /* */ } } catch { /* */ }
throw new Error(`Merge failed for branch '${branch}'`); throw new Error(`Merge failed for branch '${branch}'`);
} }
@@ -162,20 +163,17 @@ export async function aiMergeTask(
const prompt = buildMergePrompt(taskId, branch, commitLog, diffStat, hasConflicts); const prompt = buildMergePrompt(taskId, branch, commitLog, diffStat, hasConflicts);
await session.prompt(prompt); await session.prompt(prompt);
// 6. Verify the commit happened — if MERGE_HEAD still exists, agent didn't commit // 6. Verify the commit happened — if there are still staged changes, agent didn't commit
let needsFallback = false; const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
try { cwd: rootDir,
execSync("git rev-parse MERGE_HEAD", { cwd: rootDir, stdio: "pipe" }); encoding: "utf-8",
// If we get here, MERGE_HEAD exists = still uncommitted }).trim();
needsFallback = true;
} catch {
// MERGE_HEAD doesn't exist = commit was made successfully
}
if (needsFallback) { if (staged !== "0") {
console.log("[merger] Agent didn't commit — committing with fallback message"); console.log("[merger] Agent didn't commit — committing with fallback message");
const escapedLog = commitLog.replace(/"/g, '\\"');
execSync( execSync(
`git commit --no-edit -m "feat(${taskId}): merge ${branch}" -m "${commitLog}"`, `git commit -m "feat(${taskId}): merge ${branch}" -m "${escapedLog}"`,
{ cwd: rootDir, stdio: "pipe" }, { cwd: rootDir, stdio: "pipe" },
); );
} }
@@ -185,7 +183,7 @@ export async function aiMergeTask(
// Agent failed — try to abort the merge // Agent failed — try to abort the merge
console.error(`[merger] Agent failed: ${err.message}`); console.error(`[merger] Agent failed: ${err.message}`);
try { try {
execSync("git merge --abort", { cwd: rootDir, stdio: "pipe" }); execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch { /* */ } } catch { /* */ }
throw new Error(`AI merge failed for ${taskId}: ${err.message}`); throw new Error(`AI merge failed for ${taskId}: ${err.message}`);
} finally { } finally {