diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 2791dce49..edf11fdd0 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -28,6 +28,8 @@ export interface GitPreferences { commit_type?: string; } +export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/; + export interface CommitOptions { message: string; allowEmpty?: boolean; @@ -123,9 +125,11 @@ export class GitServiceImpl { /** * Smart staging: `git add -A` excluding GSD runtime paths via pathspec. * Falls back to plain `git add -A` if the exclusion pathspec fails. + * @param extraExclusions Additional pathspec exclusions beyond RUNTIME_EXCLUSION_PATHS. */ - private smartStage(): void { - const excludes = RUNTIME_EXCLUSION_PATHS.map(p => `':(exclude)${p}'`); + private smartStage(extraExclusions: readonly string[] = []): void { + const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions]; + const excludes = allExclusions.map(p => `':(exclude)${p}'`); const args = ["add", "-A", "--", ".", ...excludes]; try { this.git(args); @@ -157,13 +161,14 @@ export class GitServiceImpl { /** * Auto-commit dirty working tree with a conventional chore message. * Returns the commit message on success, or null if nothing to commit. + * @param extraExclusions Additional paths to exclude from staging (e.g. [".gsd/"] for pre-switch commits). */ - autoCommit(unitType: string, unitId: string): string | null { + autoCommit(unitType: string, unitId: string, extraExclusions: readonly string[] = []): string | null { // Quick check: is there anything dirty at all? const status = this.git(["status", "--short"], { allowFailure: true }); if (!status) return null; - this.smartStage(); + this.smartStage(extraExclusions); // After smart staging, check if anything was actually staged // (all changes might have been runtime files that got excluded) @@ -297,8 +302,9 @@ export class GitServiceImpl { } } - // Auto-commit dirty state via smart staging before checkout - this.autoCommit("pre-switch", current); + // Auto-commit dirty state via smart staging before checkout. + // Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts. + this.autoCommit("pre-switch", current, [".gsd/"]); this.git(["checkout", branch]); return created; @@ -312,7 +318,8 @@ export class GitServiceImpl { const current = this.getCurrentBranch(); if (current === mainBranch) return; - this.autoCommit("pre-switch", current); + // Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts. + this.autoCommit("pre-switch", current, [".gsd/"]); this.git(["checkout", mainBranch]); } diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index d5e70d9fe..f0096084f 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -453,6 +453,49 @@ async function main(): Promise { rmSync(repo, { recursive: true, force: true }); } + // ─── GitServiceImpl: autoCommit with extraExclusions ─────────────────── + + console.log("\n=== GitServiceImpl: autoCommit with extraExclusions ==="); + + { + const repo = initTempRepo(); + const svc = new GitServiceImpl(repo); + + // Create both a .gsd/ planning file and a regular source file + createFile(repo, ".gsd/milestones/M001/M001-ROADMAP.md", "- [x] S01"); + createFile(repo, "src/feature.ts", "export const y = 2;"); + + // Auto-commit with .gsd/ excluded (simulates pre-switch) + const msg = svc.autoCommit("pre-switch", "main", [".gsd/"]); + assertEq(msg, "chore(main): auto-commit after pre-switch", "pre-switch autoCommit with .gsd/ exclusion commits"); + + // Verify .gsd/ file was NOT committed + const show = run("git show --stat HEAD", repo); + assert(!show.includes("ROADMAP"), ".gsd/ files excluded from pre-switch auto-commit"); + assert(show.includes("feature.ts"), "non-.gsd/ files included in pre-switch auto-commit"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── GitServiceImpl: autoCommit extraExclusions — only .gsd/ dirty ──── + + console.log("\n=== GitServiceImpl: autoCommit extraExclusions — only .gsd/ dirty ==="); + + { + const repo = initTempRepo(); + const svc = new GitServiceImpl(repo); + + // Create only .gsd/ planning files + createFile(repo, ".gsd/milestones/M001/M001-ROADMAP.md", "- [x] S01"); + createFile(repo, ".gsd/STATE.md", "state content"); + + // Auto-commit with .gsd/ excluded — nothing else to commit + const result = svc.autoCommit("pre-switch", "main", [".gsd/"]); + assertEq(result, null, "autoCommit returns null when only .gsd/ files are dirty and excluded"); + + rmSync(repo, { recursive: true, force: true }); + } + // ─── GitServiceImpl: commit returns null when nothing staged ─────────── console.log("\n=== GitServiceImpl: commit empty ===");