From 66196b4a4f1225f0e241caab85102e347b98c9c1 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 13 Mar 2026 09:11:27 -0600 Subject: [PATCH] fix: add configurable merge_strategy preference for slice completion (#167) Squash merge was hardcoded, causing auto-mode to hard-stop when conflicts arose from long-lived branches or frequently-changing .gsd/* artifacts. Add git.merge_strategy preference ("squash" | "merge", default: squash). "merge" uses --no-ff which preserves branch history and handles conflicts from divergent branches more gracefully. Users hitting repeated squash merge failures can set merge_strategy: merge in .gsd/preferences.md. Co-Authored-By: Claude Opus 4.6 --- src/resources/extensions/gsd/git-service.ts | 27 ++++++++++++++------- src/resources/extensions/gsd/preferences.ts | 8 ++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index fed411f75..4b6c6cb08 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -27,6 +27,7 @@ export interface GitPreferences { pre_merge_check?: boolean | string; commit_type?: string; main_branch?: string; + merge_strategy?: "squash" | "merge"; } export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/; @@ -526,25 +527,33 @@ export class GitServiceImpl { // Pull latest main before merging to avoid conflicts from remote changes this.git(["pull", "--rebase", "origin", mainBranch], { allowFailure: true }); - // Squash merge — abort cleanly on conflict so the working tree is never - // left in a half-merged state (see: merge-bug-fix). + // Merge slice branch — strategy is configurable via git.merge_strategy + // preference. Default: "squash" (preserves existing behavior). + // "merge" uses --no-ff which is more resilient to conflicts from + // long-lived branches or frequently-changing .gsd/* artifacts. + const strategy = this.prefs.merge_strategy ?? "squash"; + const mergeArgs = strategy === "merge" + ? ["merge", "--no-ff", "-m", message, branch] + : ["merge", "--squash", branch]; + try { - this.git(["merge", "--squash", branch]); + this.git(mergeArgs); } catch (mergeError) { - // git merge --squash exits non-zero on conflict. The working tree now - // has conflict markers and a dirty index. Reset to restore a clean state. + // Merge exits non-zero on conflict. Reset to restore a clean state. this.git(["reset", "--hard", "HEAD"], { allowFailure: true }); const msg = mergeError instanceof Error ? mergeError.message : String(mergeError); throw new Error( - `Squash-merge of "${branch}" into "${mainBranch}" failed with conflicts. ` + + `${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" failed with conflicts. ` + `Working tree has been reset to a clean state. ` + - `Resolve manually: git checkout ${mainBranch} && git merge --squash ${branch}\n` + + `Resolve manually: git checkout ${mainBranch} && git merge ${strategy === "merge" ? "--no-ff" : "--squash"} ${branch}\n` + `Original error: ${msg}`, ); } - // Commit with rich message via stdin pipe - this.git(["commit", "-F", "-"], { input: message }); + // Squash merge needs a separate commit; --no-ff merge already committed + if (strategy === "squash") { + this.git(["commit", "-F", "-"], { input: message }); + } // Delete the merged branch this.git(["branch", "-D", branch]); diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 6b15674be..02cf905f5 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -702,6 +702,14 @@ function validatePreferences(preferences: GSDPreferences): { errors.push(`git.commit_type must be one of: feat, fix, refactor, docs, test, chore, perf, ci, build, style`); } } + if (g.merge_strategy !== undefined) { + const validStrategies = new Set(["squash", "merge"]); + if (typeof g.merge_strategy === "string" && validStrategies.has(g.merge_strategy)) { + git.merge_strategy = g.merge_strategy as "squash" | "merge"; + } else { + errors.push("git.merge_strategy must be one of: squash, merge"); + } + } if (g.main_branch !== undefined) { if (typeof g.main_branch === "string" && g.main_branch.trim() !== "" && VALID_BRANCH_NAME.test(g.main_branch)) { git.main_branch = g.main_branch;