refactor: move GSD metadata from commit subject scopes to git trailers

Remove GSD planning IDs (milestone/slice/task) from conventional commit
subject lines and place them in machine-parseable git trailers instead.
Skip auto-commits for lifecycle-only unit types that only touch .gsd/ files.

Resolves gsd-build/gsd-2#2553

Co-authored-by: glittercowboy <186001655+glittercowboy@users.noreply.github.com>
Agent-Logs-Url: https://github.com/gsd-build/gsd-2/sessions/250b4775-2d82-4329-9ccc-504b857428da
This commit is contained in:
copilot-swe-agent[bot] 2026-03-25 22:56:48 +00:00
parent a909b009fa
commit 2c82923ca9
13 changed files with 108 additions and 68 deletions

View file

@ -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

View file

@ -37,9 +37,9 @@ Work happens in the project root on a `milestone/<MID>` 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

View file

@ -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");
});
});

View file

@ -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) });

View file

@ -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;

View file

@ -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;
}

View file

@ -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).

View file

@ -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")),

View file

@ -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",
);

View file

@ -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 ────────────────────────

View file

@ -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);

View file

@ -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");

View file

@ -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`))