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:
@@ -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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user