diff --git a/docs-internal/git-strategy.md b/docs-internal/git-strategy.md index 40576256f..c8274b7d0 100644 --- a/docs-internal/git-strategy.md +++ b/docs-internal/git-strategy.md @@ -36,10 +36,10 @@ Use this for hot-reload workflows where file isolation breaks dev tooling (e.g., main ───────────────────────────────────────────────────────── │ ↑ └── milestone/M001 (worktree) ────────────────────────┘ - commit: feat(S01/T01): core types - commit: feat(S01/T02): markdown parser - commit: feat(S01/T03): file writer - commit: docs(M001/S01): workflow docs + commit: feat: core types + commit: feat: markdown parser + commit: feat: file writer + commit: docs: workflow docs ... → squash-merged to main as single commit ``` @@ -56,13 +56,13 @@ With [parallel orchestration](./parallel-orchestration.md) enabled, multiple mil main ────────────────────────────────────────────────────────── │ ↑ ↑ ├── milestone/M002 (worktree) ─────────┘ │ - │ commit: feat(S01/T01): auth types │ - │ commit: feat(S01/T02): JWT middleware │ + │ commit: feat: auth types │ + │ commit: feat: JWT middleware │ │ → squash-merged first │ │ │ └── milestone/M003 (worktree) ────────────────────────┘ - commit: feat(S01/T01): dashboard layout - commit: feat(S01/T02): chart components + commit: feat: dashboard layout + commit: feat: chart components → squash-merged second ``` @@ -75,13 +75,16 @@ Each worktree operates on its own branch with its own commit history. Merges hap ### Commit Format -Commits use conventional commit format with scope: +Commits use conventional commit format with GSD metadata in trailers: ``` -feat(S01/T01): core type definitions -feat(S01/T02): markdown parser for plan files -fix(M001/S03): bug fixes and doc corrections -docs(M001/S04): workflow documentation +feat: core type definitions + +GSD-Task: M001/S01/T01 + +feat: markdown parser for plan files + +GSD-Task: M001/S01/T02 ``` ## Worktree Management diff --git a/mintlify-docs/guides/git-strategy.mdx b/mintlify-docs/guides/git-strategy.mdx index 31a755307..67ce24742 100644 --- a/mintlify-docs/guides/git-strategy.mdx +++ b/mintlify-docs/guides/git-strategy.mdx @@ -37,9 +37,9 @@ Work happens in the project root on a `milestone/` branch. No worktree is c main ───────────────────────────────────────────────────────── │ ↑ └── milestone/M001 (worktree) ────────────────────────┘ - commit: feat(S01/T01): core types - commit: feat(S01/T02): markdown parser - commit: feat(S01/T03): file writer + commit: feat: core types + commit: feat: markdown parser + commit: feat: file writer → squash-merged to main as single commit ``` @@ -61,13 +61,16 @@ Merges happen sequentially to avoid conflicts. ### Commit format -Conventional commit format with scope: +Conventional commit format with GSD metadata in trailers: ``` -feat(S01/T01): core type definitions -feat(S01/T02): markdown parser for plan files -fix(M001/S03): bug fixes and doc corrections -docs(M001/S04): workflow documentation +feat: core type definitions + +GSD-Task: M001/S01/T01 + +feat: markdown parser for plan files + +GSD-Task: M001/S01/T02 ``` ## Workflow modes diff --git a/src/resources/extensions/github-sync/tests/commit-linking.test.ts b/src/resources/extensions/github-sync/tests/commit-linking.test.ts index 60dc2f0b5..d1d85eab3 100644 --- a/src/resources/extensions/github-sync/tests/commit-linking.test.ts +++ b/src/resources/extensions/github-sync/tests/commit-linking.test.ts @@ -10,7 +10,8 @@ describe("commit linking", () => { issueNumber: 43, }); assert.ok(msg.includes("Resolves #43"), "should include Resolves trailer"); - assert.ok(msg.startsWith("feat(S01/T02):"), "subject line unchanged"); + assert.ok(msg.startsWith("feat:"), "subject line has no scope"); + assert.ok(msg.includes("GSD-Task: S01/T02"), "GSD-Task trailer present"); }); it("includes both key files and Resolves #N", () => { @@ -22,10 +23,13 @@ describe("commit linking", () => { }); assert.ok(msg.includes("- src/auth.ts"), "key files present"); assert.ok(msg.includes("Resolves #43"), "Resolves trailer present"); - // Resolves should come after key files + assert.ok(msg.includes("GSD-Task: S01/T02"), "GSD-Task trailer present"); + // GSD-Task should come after key files but before Resolves const keyFilesIdx = msg.indexOf("- src/auth.ts"); + const taskIdx = msg.indexOf("GSD-Task: S01/T02"); const resolvesIdx = msg.indexOf("Resolves #43"); - assert.ok(resolvesIdx > keyFilesIdx, "Resolves after key files"); + assert.ok(taskIdx > keyFilesIdx, "GSD-Task after key files"); + assert.ok(resolvesIdx > taskIdx, "Resolves after GSD-Task"); }); it("no Resolves trailer when issueNumber is not set", () => { @@ -34,6 +38,6 @@ describe("commit linking", () => { taskTitle: "implement auth", }); assert.ok(!msg.includes("Resolves"), "no Resolves when no issueNumber"); - assert.ok(!msg.includes("\n"), "no body when no issueNumber or keyFiles"); + assert.ok(msg.includes("GSD-Task: S01/T02"), "GSD-Task trailer still present"); }); }); diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index bd21addbf..1aa4471ad 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -47,6 +47,16 @@ import { import { hasPendingCaptures, loadPendingCaptures } from "./captures.js"; import { debugLog } from "./debug-logger.js"; import type { AutoSession } from "./auto/session.js"; + +/** Unit types that only touch `.gsd/` internal state files (no code changes). + * Auto-commit is skipped for these — their state files are picked up by the + * next actual task commit via `smartStage()`. */ +const LIFECYCLE_ONLY_UNITS = new Set([ + "research-milestone", "discuss-milestone", "plan-milestone", + "validate-milestone", "research-slice", "plan-slice", + "replan-slice", "complete-slice", "run-uat", + "reassess-roadmap", "rewrite-docs", +]); import { updateProgressWidget as _updateProgressWidget, updateSliceProgressCache, @@ -279,9 +289,14 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV // `git worktree remove --force` during teardown. _resetHasChangesCache(); - const commitMsg = autoCommitCurrentBranch(s.basePath, s.currentUnit.type, s.currentUnit.id, taskContext); - if (commitMsg) { - ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info"); + // Skip auto-commit for lifecycle-only units (#2553) — they only touch + // `.gsd/` internal state files. Those files are picked up by the next + // actual task commit via smartStage(). + if (!LIFECYCLE_ONLY_UNITS.has(s.currentUnit.type)) { + const commitMsg = autoCommitCurrentBranch(s.basePath, s.currentUnit.type, s.currentUnit.id, taskContext); + if (commitMsg) { + ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info"); + } } } catch (e) { debugLog("postUnit", { phase: "auto-commit", error: String(e) }); diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index c2e00a67d..e91c67009 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -1058,13 +1058,15 @@ export function mergeMilestoneToMain( if (titleMatch) milestoneTitle = titleMatch[1].trim(); } milestoneTitle = milestoneTitle || milestoneId; - const subject = `feat(${milestoneId}): ${milestoneTitle}`; + const subject = `feat: ${milestoneTitle}`; let body = ""; if (completedSlices.length > 0) { const sliceLines = completedSlices .map((s) => `- ${s.id}: ${s.title}`) .join("\n"); - body = `\n\nCompleted slices:\n${sliceLines}\n\nBranch: ${milestoneBranch}`; + body = `\n\nCompleted slices:\n${sliceLines}\n\nGSD-Milestone: ${milestoneId}\nBranch: ${milestoneBranch}`; + } else { + body = `\n\nGSD-Milestone: ${milestoneId}\nBranch: ${milestoneBranch}`; } const commitMessage = subject + body; diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 29cddd10f..9f17574e5 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -102,23 +102,25 @@ export interface TaskCommitContext { /** * Build a meaningful conventional commit message from task execution context. - * Format: `{type}({sliceId}/{taskId}): {description}` + * Format: `{type}: {description}` (clean conventional commit — no GSD IDs in subject). + * + * GSD metadata is placed in a `GSD-Task:` git trailer at the end of the body, + * following the same convention as `Signed-off-by:` or `Co-Authored-By:`. * * The description is the task summary one-liner if available (it describes * what was actually built), falling back to the task title (what was planned). */ export function buildTaskCommitMessage(ctx: TaskCommitContext): string { - const scope = ctx.taskId; // e.g. "S01/T02" or just "T02" const description = ctx.oneLiner || ctx.taskTitle; const type = inferCommitType(ctx.taskTitle, ctx.oneLiner); - // Truncate description to ~72 chars for subject line - const maxDescLen = 68 - type.length - scope.length; + // Truncate description to ~72 chars for subject line (full budget without scope) + const maxDescLen = 70 - type.length; const truncated = description.length > maxDescLen ? description.slice(0, maxDescLen - 1).trimEnd() + "…" : description; - const subject = `${type}(${scope}): ${truncated}`; + const subject = `${type}: ${truncated}`; // Build body with key files if available const bodyParts: string[] = []; @@ -131,15 +133,14 @@ export function buildTaskCommitMessage(ctx: TaskCommitContext): string { bodyParts.push(fileLines); } + // Trailers: GSD-Task first, then Resolves + bodyParts.push(`GSD-Task: ${ctx.taskId}`); + if (ctx.issueNumber) { bodyParts.push(`Resolves #${ctx.issueNumber}`); } - if (bodyParts.length > 0) { - return `${subject}\n\n${bodyParts.join("\n\n")}`; - } - - return subject; + return `${subject}\n\n${bodyParts.join("\n\n")}`; } /** @@ -538,7 +539,7 @@ export class GitServiceImpl { const message = taskContext ? buildTaskCommitMessage(taskContext) - : `chore(${unitId}): auto-commit after ${unitType}`; + : `chore: auto-commit after ${unitType}\n\nGSD-Unit: ${unitId}`; nativeCommit(this.basePath, message, { allowEmpty: false }); return message; } diff --git a/src/resources/extensions/gsd/tests/auto-stash-merge.test.ts b/src/resources/extensions/gsd/tests/auto-stash-merge.test.ts index 40a732acc..5152ba930 100644 --- a/src/resources/extensions/gsd/tests/auto-stash-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-stash-merge.test.ts @@ -76,7 +76,7 @@ test("#2151 bug 1: auto-stash unblocks merge when unrelated files are dirty", () // Should succeed — the dirty README.md is auto-stashed before merge. const result = mergeMilestoneToMain(repo, "M200", roadmap); - assert.ok(result.commitMessage.includes("feat(M200)"), "merge succeeds with dirty unrelated file"); + assert.ok(result.commitMessage.includes("feat:") && result.commitMessage.includes("GSD-Milestone: M200"), "merge succeeds with dirty unrelated file"); assert.ok(existsSync(join(repo, "stash-test.ts")), "milestone code merged to main"); // Verify the dirty file was restored (stash popped). diff --git a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts index 86b4e5b18..bb143a8c4 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts @@ -160,15 +160,17 @@ describe("auto-worktree-milestone-merge", () => { const result = mergeMilestoneToMain(repo, "M020", roadmap); - assert.match(result.commitMessage, /^feat\(M020\):/, "subject has conventional commit prefix"); + assert.match(result.commitMessage, /^feat:/, "subject has conventional commit prefix without milestone ID"); assert.ok(result.commitMessage.includes("Backend foundation"), "subject includes milestone title"); assert.ok(result.commitMessage.includes("- S01: Core API"), "body lists S01"); assert.ok(result.commitMessage.includes("- S02: Error handling"), "body lists S02"); assert.ok(result.commitMessage.includes("- S03: Logging infra"), "body lists S03"); + assert.ok(result.commitMessage.includes("GSD-Milestone: M020"), "body has GSD-Milestone trailer"); assert.ok(result.commitMessage.includes("Branch: milestone/M020"), "body has branch metadata"); const gitMsg = run("git log -1 --format=%B main", repo).trim(); - assert.match(gitMsg, /^feat\(M020\):/, "git commit message starts with feat(M020):"); + assert.match(gitMsg, /^feat:/, "git commit message starts with feat:"); + assert.ok(gitMsg.includes("GSD-Milestone: M020"), "git commit has GSD-Milestone trailer"); assert.ok(gitMsg.includes("- S01: Core API"), "git commit body has S01"); }); @@ -213,11 +215,11 @@ describe("auto-worktree-milestone-merge", () => { const result = mergeMilestoneToMain(repo, "M040", roadmap); const mainLog = run("git log --oneline main", repo); - assert.ok(mainLog.includes("feat(M040)"), "milestone commit on main"); + assert.ok(mainLog.includes("feat:"), "milestone commit on main"); run("git push origin main", repo); const remoteLog = run("git log --oneline main", bareDir); - assert.ok(remoteLog.includes("feat(M040)"), "milestone commit reachable on remote after manual push"); + assert.ok(remoteLog.includes("feat:"), "milestone commit reachable on remote after manual push"); assert.strictEqual(typeof result.pushed, "boolean", "pushed flag remains boolean"); }); @@ -248,7 +250,7 @@ describe("auto-worktree-milestone-merge", () => { let threw = false; try { const result = mergeMilestoneToMain(repo, "M050", roadmap); - assert.ok(result.commitMessage.includes("feat(M050)"), "merge commit created despite .gsd conflict"); + assert.ok(result.commitMessage.includes("feat:") && result.commitMessage.includes("GSD-Milestone: M050"), "merge commit created despite .gsd conflict"); } catch (err) { threw = true; } @@ -274,7 +276,7 @@ describe("auto-worktree-milestone-merge", () => { let threw = false; try { const result = mergeMilestoneToMain(repo, "M060", roadmap); - assert.ok(result.commitMessage.includes("feat(M060)"), "merge commit created"); + assert.ok(result.commitMessage.includes("feat:") && result.commitMessage.includes("GSD-Milestone: M060"), "merge commit created"); } catch (err) { threw = true; } @@ -312,7 +314,7 @@ describe("auto-worktree-milestone-merge", () => { let errMsg = ""; try { const result = mergeMilestoneToMain(dir, "M070", roadmap); - assert.ok(result.commitMessage.includes("feat(M070)"), "merge commit created on master"); + assert.ok(result.commitMessage.includes("feat:") && result.commitMessage.includes("GSD-Milestone: M070"), "merge commit created on master"); } catch (err) { threw = true; errMsg = err instanceof Error ? err.message : String(err); @@ -392,7 +394,7 @@ describe("auto-worktree-milestone-merge", () => { let threw = false; try { const result = mergeMilestoneToMain(repo, "M090", roadmap); - assert.ok(result.commitMessage.includes("feat(M090)"), "#1738 merge succeeds after cleaning synced dirs"); + assert.ok(result.commitMessage.includes("feat:") && result.commitMessage.includes("GSD-Milestone: M090"), "#1738 merge succeeds after cleaning synced dirs"); } catch (err: unknown) { threw = true; } @@ -419,7 +421,7 @@ describe("auto-worktree-milestone-merge", () => { let threw = false; try { const result = mergeMilestoneToMain(repo, "M100", roadmap); - assert.ok(result.commitMessage.includes("feat(M100)"), "#2151: merge succeeds after stashing dirty files"); + assert.ok(result.commitMessage.includes("feat:") && result.commitMessage.includes("GSD-Milestone: M100"), "#2151: merge succeeds after stashing dirty files"); } catch { threw = true; } @@ -519,7 +521,7 @@ describe("auto-worktree-milestone-merge", () => { let errMsg = ""; try { const result = mergeMilestoneToMain(repo, "M140", roadmap); - assert.ok(result.commitMessage.includes("feat(M140)"), "merge commit created"); + assert.ok(result.commitMessage.includes("feat:") && result.commitMessage.includes("GSD-Milestone: M140"), "merge commit created"); } catch (err) { threw = true; errMsg = err instanceof Error ? err.message : String(err); @@ -589,7 +591,7 @@ describe("auto-worktree-milestone-merge", () => { assert.ok(existsSync(squashMsgPath), "SQUASH_MSG planted before merge"); const result = mergeMilestoneToMain(repo, "M160", roadmap); - assert.ok(result.commitMessage.includes("feat(M160)"), "merge commit created"); + assert.ok(result.commitMessage.includes("feat:") && result.commitMessage.includes("GSD-Milestone: M160"), "merge commit created"); assert.ok(!existsSync(squashMsgPath), "#1853: SQUASH_MSG must not persist after successful squash-merge"); }); @@ -609,7 +611,7 @@ describe("auto-worktree-milestone-merge", () => { ]); const result = mergeMilestoneToMain(repo, "M170", roadmap); - assert.ok(result.commitMessage.includes("feat(M170)"), "merge commit created"); + assert.ok(result.commitMessage.includes("feat:") && result.commitMessage.includes("GSD-Milestone: M170"), "merge commit created"); assert.ok( existsSync(join(repo, "uncommitted-agent-code.ts")), diff --git a/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts b/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts index c99ca45a9..6794a6ea9 100644 --- a/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +++ b/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts @@ -252,7 +252,7 @@ describe('feature-branch-lifecycle-integration', async () => { // Exactly one new commit on feature branch (the squash merge) const featureLog = run(`git log --oneline ${featureBranch}`, repo); assert.ok( - featureLog.includes(`feat(${milestoneId})`), + featureLog.includes("feat:"), "feature branch has milestone merge commit", ); diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index 0cfd47386..88809f709 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -215,10 +215,12 @@ describe('git-service', async () => { oneLiner: "Added JWT-based auth with refresh token rotation", keyFiles: ["src/auth.ts", "src/middleware/jwt.ts"], }); - assert.ok(msg.startsWith("feat(S01/T02):"), "message starts with type(scope)"); + assert.ok(msg.startsWith("feat:"), "message starts with type: (no scope)"); + assert.ok(!msg.includes("(S01/T02)"), "no GSD ID in subject line"); assert.ok(msg.includes("JWT-based auth"), "message includes one-liner content"); assert.ok(msg.includes("- src/auth.ts"), "message body includes key files"); assert.ok(msg.includes("- src/middleware/jwt.ts"), "message body includes second key file"); + assert.ok(msg.includes("GSD-Task: S01/T02"), "GSD-Task trailer in body"); }); { @@ -226,9 +228,9 @@ describe('git-service', async () => { taskId: "S02/T01", taskTitle: "fix login redirect bug", }); - assert.ok(msg.startsWith("fix(S02/T01):"), "infers fix type from title"); + assert.ok(msg.startsWith("fix:"), "infers fix type from title"); assert.ok(msg.includes("fix login redirect bug"), "uses task title when no one-liner"); - assert.ok(!msg.includes("\n"), "no body when no key files"); + assert.ok(msg.includes("GSD-Task: S02/T01"), "GSD-Task trailer present"); } { @@ -237,7 +239,8 @@ describe('git-service', async () => { taskTitle: "add tests", oneLiner: "Unit tests for auth module with coverage", }); - assert.ok(msg.startsWith("test(S01/T03):"), "infers test type"); + assert.ok(msg.startsWith("test:"), "infers test type"); + assert.ok(msg.includes("GSD-Task: S01/T03"), "GSD-Task trailer present"); } // ─── RUNTIME_EXCLUSION_PATHS ─────────────────────────────────────────── @@ -478,10 +481,10 @@ describe('git-service', async () => { // Without task context, autoCommit uses generic chore message const msg = svc.autoCommit("task", "T01"); - assert.deepStrictEqual(msg, "chore(T01): auto-commit after task", "autoCommit returns generic format without task context"); + assert.deepStrictEqual(msg, "chore: auto-commit after task\n\nGSD-Unit: T01", "autoCommit returns generic format with trailer"); const log = run("git log --oneline -1", repo); - assert.ok(log.includes("chore(T01): auto-commit after task"), "generic commit message is in git log"); + assert.ok(log.includes("chore: auto-commit after task"), "generic commit message is in git log"); // With task context, autoCommit uses meaningful message createFile(repo, "src/auth.ts", "export function login() {}"); @@ -492,8 +495,9 @@ describe('git-service', async () => { keyFiles: ["src/auth.ts"], }); assert.ok(msg2 !== null, "autoCommit with task context returns a message"); - assert.ok(msg2!.startsWith("feat(S01/T02):"), "meaningful commit uses feat type and scope"); + assert.ok(msg2!.startsWith("feat:"), "meaningful commit uses feat type without scope"); assert.ok(msg2!.includes("JWT-based auth"), "meaningful commit includes one-liner content"); + assert.ok(msg2!.includes("GSD-Task: S01/T02"), "meaningful commit has GSD-Task trailer"); rmSync(repo, { recursive: true, force: true }); }); @@ -1295,7 +1299,12 @@ describe('git-service', async () => { issueNumber: 42, }); assert.ok(msg.includes("Resolves #42"), "buildTaskCommitMessage includes Resolves #N trailer when issueNumber is set"); - assert.ok(msg.startsWith("fix(S01/T03):"), "buildTaskCommitMessage infers fix type"); + assert.ok(msg.startsWith("fix:"), "buildTaskCommitMessage infers fix type"); + assert.ok(msg.includes("GSD-Task: S01/T03"), "GSD-Task trailer present"); + // GSD-Task should come before Resolves + const taskIdx = msg.indexOf("GSD-Task: S01/T03"); + const resolvesIdx = msg.indexOf("Resolves #42"); + assert.ok(taskIdx < resolvesIdx, "GSD-Task trailer before Resolves trailer"); }); { @@ -1305,6 +1314,7 @@ describe('git-service', async () => { taskTitle: "add dashboard widget", }); assert.ok(!msg.includes("Resolves"), "buildTaskCommitMessage omits Resolves trailer when issueNumber is absent"); + assert.ok(msg.includes("GSD-Task: S01/T04"), "GSD-Task trailer still present"); } // ─── runPreMergeCheck: skips when no package.json ──────────────────────── diff --git a/src/resources/extensions/gsd/tests/parallel-merge.test.ts b/src/resources/extensions/gsd/tests/parallel-merge.test.ts index 9b46cae6e..9283a64c5 100644 --- a/src/resources/extensions/gsd/tests/parallel-merge.test.ts +++ b/src/resources/extensions/gsd/tests/parallel-merge.test.ts @@ -168,7 +168,7 @@ test("formatMergeResults — empty results", () => { test("formatMergeResults — successful merge", () => { const results: MergeResult[] = [ - { milestoneId: "M001", success: true, commitMessage: "feat(M001): Auth", pushed: true }, + { milestoneId: "M001", success: true, commitMessage: "feat: Auth\n\nGSD-Milestone: M001\nBranch: milestone/M001", pushed: true }, ]; const output = formatMergeResults(results); assert.ok(output.includes("M001")); @@ -178,7 +178,7 @@ test("formatMergeResults — successful merge", () => { test("formatMergeResults — successful merge without push", () => { const results: MergeResult[] = [ - { milestoneId: "M001", success: true, commitMessage: "feat(M001): Auth", pushed: false }, + { milestoneId: "M001", success: true, commitMessage: "feat: Auth\n\nGSD-Milestone: M001\nBranch: milestone/M001", pushed: false }, ]; const output = formatMergeResults(results); assert.ok(output.includes("merged successfully")); @@ -213,7 +213,7 @@ test("formatMergeResults — generic failure without conflict files", () => { test("formatMergeResults — mixed results", () => { const results: MergeResult[] = [ - { milestoneId: "M001", success: true, commitMessage: "feat(M001): OK", pushed: false }, + { milestoneId: "M001", success: true, commitMessage: "feat: OK\n\nGSD-Milestone: M001\nBranch: milestone/M001", pushed: false }, { milestoneId: "M002", success: false, error: "conflict", conflictFiles: ["a.ts"] }, ]; const output = formatMergeResults(results); diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index 4784d9b4f..a1722132d 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -661,7 +661,7 @@ async function handleMerge( // --- Deterministic merge path (preferred) --- // Try a direct squash-merge first. Only fall back to LLM on conflict. const commitType = inferCommitType(name); - const commitMessage = `${commitType}(${name}): merge worktree ${name}`; + const commitMessage = `${commitType}: merge worktree ${name}\n\nGSD-Worktree: ${name}`; // Reconcile worktree DB into main DB before squash merge const wtDbPath = join(worktreePath(basePath, name), ".gsd", "gsd.db"); diff --git a/src/worktree-cli.ts b/src/worktree-cli.ts index 0ad371eef..70abba856 100644 --- a/src/worktree-cli.ts +++ b/src/worktree-cli.ts @@ -207,7 +207,7 @@ async function doMerge(ext: ExtensionModules, basePath: string, name: string): P } const commitType = ext.inferCommitType(name) - const commitMessage = `${commitType}(${name}): merge worktree ${name}` + const commitMessage = `${commitType}: merge worktree ${name}\n\nGSD-Worktree: ${name}` process.stderr.write(`\nMerging ${chalk.bold.cyan(name)} → ${chalk.magenta(ext.nativeDetectMainBranch(basePath))}\n`) process.stderr.write(chalk.dim(` ${status.filesChanged} files, ${chalk.green(`+${status.linesAdded}`)} ${chalk.red(`-${status.linesRemoved}`)}\n\n`))