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:
parent
a909b009fa
commit
2c82923ca9
13 changed files with 108 additions and 68 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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`))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue