fix: abort squash-merge on conflict and stop auto-mode instead of looping (#merge-bug-fix)

mergeSliceToMain now runs git reset --hard if git merge --squash fails,
restoring a clean working tree instead of leaving conflict markers.

The merge guard catch block in auto.ts now:
1. Detects leftover conflicted state (UU/AA/UD in porcelain status)
2. Resets the working tree if conflicts remain
3. Stops auto-mode with a clear error instead of continuing with
   corrupted .gsd/ state files that cause an infinite dispatch loop

Also fixes conflict markers in loader.ts, logo.ts, and postinstall.js
that were baked into main from a prior bad merge resolution.
This commit is contained in:
Lex Christopherson 2026-03-12 15:18:06 -06:00
parent 63f9a84e8a
commit 39f0df45d5
5 changed files with 109 additions and 24 deletions

View file

@ -29,10 +29,32 @@ function run(cmd, options = {}) {
// ---------------------------------------------------------------------------
process.stdout.write = process.stderr.write.bind(process.stderr)
// ---------------------------------------------------------------------------
// ASCII banner — printed before clack UI for brand recognition
// ---------------------------------------------------------------------------
const cyan = '\x1b[36m'
const dim = '\x1b[2m'
const reset = '\x1b[0m'
const banner =
'\n' +
cyan +
' ██████╗ ███████╗██████╗ \n' +
' ██╔════╝ ██╔════╝██╔══██╗\n' +
' ██║ ███╗███████╗██║ ██║\n' +
' ██║ ██║╚════██║██║ ██║\n' +
' ╚██████╔╝███████║██████╔╝\n' +
' ╚═════╝ ╚══════╝╚═════╝ ' +
reset + '\n' +
'\n' +
` Get Shit Done ${dim}v${pkg.version}${reset}\n`
// ---------------------------------------------------------------------------
// Main — wrapped in async IIFE, with graceful fallback if clack fails
// ---------------------------------------------------------------------------
;(async () => {
process.stderr.write(banner)
let p, pc
try {
@ -40,15 +62,14 @@ process.stdout.write = process.stderr.write.bind(process.stderr)
pc = (await import('picocolors')).default
} catch {
// Clack or picocolors unavailable — fall back to minimal output
process.stderr.write(`\n GSD v${pkg.version} installed.\n Run gsd to get started.\n\n`)
process.stderr.write(` Run gsd to get started.\n\n`)
await run('npx patch-package')
const args = os.platform() === 'linux' ? '--with-deps' : ''
await run(`npx playwright install chromium ${args}`)
await run('npx playwright install chromium')
return
}
// --- Branded intro -------------------------------------------------------
p.intro(pc.bgCyan(pc.black(' gsd ')) + ' ' + pc.dim(`v${pkg.version}`))
p.intro('Setup')
const results = []
const s = p.spinner()
@ -68,18 +89,28 @@ process.stdout.write = process.stderr.write.bind(process.stderr)
}
// --- Step 2: Playwright browser ------------------------------------------
// Avoid --with-deps: install scripts should not block on interactive sudo
// prompts. If Linux libs are missing, suggest the explicit follow-up.
s.start('Setting up browser tools…')
const pwArgs = os.platform() === 'linux' ? ' --with-deps' : ''
const pwResult = await run(`npx playwright install chromium${pwArgs}`)
const pwResult = await run('npx playwright install chromium')
if (pwResult.ok) {
s.stop('Browser tools ready')
results.push({ label: 'Browser tools ready', ok: true })
} else {
s.stop(pc.yellow('Browser tools — skipped (non-fatal)'))
results.push({
label: 'Browser tools unavailable — run ' + pc.cyan('npx playwright install chromium'),
ok: false,
})
const output = `${pwResult.stdout ?? ''}${pwResult.stderr ?? ''}`
if (os.platform() === 'linux' && output.includes('Host system is missing dependencies to run browsers.')) {
s.stop(pc.yellow('Browser downloaded, missing Linux deps'))
results.push({
label: 'Run ' + pc.cyan('sudo npx playwright install-deps chromium') + ' to finish setup',
ok: false,
})
} else {
s.stop(pc.yellow('Browser tools — skipped (non-fatal)'))
results.push({
label: 'Browser tools unavailable — run ' + pc.cyan('npx playwright install chromium'),
ok: false,
})
}
}
// --- Summary note --------------------------------------------------------

View file

@ -1,8 +1,9 @@
#!/usr/bin/env node
import { fileURLToPath } from 'url'
import { dirname, resolve, join } from 'path'
import { readFileSync } from 'fs'
import { agentDir } from './app-paths.js'
import { existsSync, readFileSync } from 'fs'
import { agentDir, appRoot } from './app-paths.js'
import { renderLogo } from './logo.js'
// pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's
// theme assets (dist/modes/interactive/theme/) without a src/ directory.
@ -17,7 +18,25 @@ process.env.PI_PACKAGE_DIR = pkgDir
process.env.PI_SKIP_VERSION_CHECK = '1' // GSD ships its own update check — suppress pi's
process.title = 'gsd'
// First-launch branding is handled by the onboarding wizard (src/onboarding.ts)
// Print branded banner on first launch (before ~/.gsd/ exists)
if (!existsSync(appRoot)) {
const cyan = '\x1b[36m'
const green = '\x1b[32m'
const dim = '\x1b[2m'
const reset = '\x1b[0m'
const colorCyan = (s: string) => `${cyan}${s}${reset}`
let version = ''
try {
const pkgJson = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'))
version = pkgJson.version ?? ''
} catch { /* ignore */ }
process.stderr.write(
renderLogo(colorCyan) +
'\n' +
` Get Shit Done ${dim}v${version}${reset}\n` +
` ${green}Welcome.${reset} Setting up your environment...\n\n`
)
}
// GSD_CODING_AGENT_DIR — tells pi's getAgentDir() to return ~/.gsd/agent/ instead of ~/.gsd/agent/
process.env.GSD_CODING_AGENT_DIR = agentDir

View file

@ -3,11 +3,11 @@
*
* Single source of truth imported by:
* - scripts/postinstall.js (via dist/logo.js)
* - src/onboarding.ts (via ./logo.js)
* - src/loader.ts (via ./logo.js)
*/
/** Raw logo lines — no ANSI codes, no leading newline. */
export const GSD_LOGO: string[] = [
export const GSD_LOGO: readonly string[] = [
' ██████╗ ███████╗██████╗ ',
' ██╔════╝ ██╔════╝██╔══██╗',
' ██║ ███╗███████╗██║ ██║',
@ -19,7 +19,7 @@ export const GSD_LOGO: string[] = [
/**
* Render the logo block with a color function applied to each line.
*
* @param color e.g. picocolors.cyan or `(s) => `\x1b[36m${s}\x1b[0m``
* @param color e.g. `(s) => `\x1b[36m${s}\x1b[0m`` or picocolors.cyan
* @returns Ready-to-write string with leading/trailing newlines.
*/
export function renderLogo(color: (s: string) => string): string {

View file

@ -1023,14 +1023,35 @@ async function dispatchNextUnit(
midTitle = state.activeMilestone?.title;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Safety net: if mergeSliceToMain failed to clean up (or the error
// came from switchToMain), ensure the working tree isn't left in a
// conflicted/dirty merge state. Without this, state derivation reads
// conflict-marker-filled files, produces a corrupt phase, and
// dispatch loops forever (see: merge-bug-fix).
try {
const { runGit } = await import("./git-service.ts");
const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true });
if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) {
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
ctx.ui.notify(
`Cleaned up conflicted merge state after failed squash-merge.`,
"warning",
);
}
} catch { /* best-effort cleanup */ }
ctx.ui.notify(
`Slice merge failed: ${message}`,
`Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
"error",
);
// Re-derive state so dispatch can figure out what to do
state = await deriveState(basePath);
mid = state.activeMilestone?.id;
midTitle = state.activeMilestone?.title;
if (currentUnit) {
const modelId = ctx.model?.id ?? "unknown";
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
await stopAuto(ctx, pi);
return;
}
}
}

View file

@ -446,8 +446,22 @@ export class GitServiceImpl {
commitType, milestoneId, sliceId, sliceTitle, mainBranch, branch,
);
// Squash merge
this.git(["merge", "--squash", branch]);
// Squash merge — abort cleanly on conflict so the working tree is never
// left in a half-merged state (see: merge-bug-fix).
try {
this.git(["merge", "--squash", branch]);
} 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.
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. ` +
`Working tree has been reset to a clean state. ` +
`Resolve manually: git checkout ${mainBranch} && git merge --squash ${branch}\n` +
`Original error: ${msg}`,
);
}
// Commit with rich message via stdin pipe
this.git(["commit", "-F", "-"], { input: message });