fix: exclude .gsd/ from pre-switch auto-commits to prevent squash merge conflicts (#143)

Pre-switch auto-commits were including .gsd/ planning artifacts (roadmaps, STATE.md)
on both sides of a branch switch, causing reliable merge conflicts when squash-merging
slices back to main. Now pre-switch auto-commits exclude the entire .gsd/ directory,
while post-task auto-commits continue to include them normally.

Also restores VALID_BRANCH_NAME export removed in a prior merge conflict resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-12 21:11:26 -06:00
parent 7a1eac6af3
commit db2a409d7d
2 changed files with 57 additions and 7 deletions

View file

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

View file

@ -453,6 +453,49 @@ async function main(): Promise<void> {
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 ===");