diff --git a/.changeset/silly-moles-romp.md b/.changeset/silly-moles-romp.md new file mode 100644 index 000000000..b8f463969 --- /dev/null +++ b/.changeset/silly-moles-romp.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2183 +--- +**Opt-in git branch and working-state segment in the statusline** — the shell prompt's branch/dirty-state signal is hidden for the whole session under the Claude Code TUI, so wrong-branch commits and ship-time push rejections surface only after the fact. New `statusline.show_git` config (default `false`) renders the branch name plus staged/unstaged/untracked/ahead/behind markers (or ✓ when clean and in sync) after the directory segment. When disabled, no git subprocess is spawned and output is unchanged. (#2163) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4b15aab2b..440404c19 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -453,6 +453,7 @@ If `.planning/` is in `.gitignore`, `commit_docs` is automatically `false` regar | `statusline.show_last_command` | boolean | `false` | Append `last: /` suffix to the statusline showing the most recently invoked slash command. Opt-in; reads the active session transcript to extract the latest `` tag (closes #2538) | | `statusline.context_position` | string | `"end"` | Position of the context-window meter. `"end"` (default) renders at line tail; `"front"` renders immediately after the model name so the meter stays visible in narrow terminals. Closes #2937 | | `statusline.show_context_tokens` | boolean | `false` | Append the absolute token count (e.g. `(156k)`) after the context meter's percentage. Sums input, cache-creation, cache-read, and output tokens from the hook payload — a broader basis than the meter's percentage (which excludes output tokens), so the two figures can diverge slightly. Opt-in; the meter is unchanged when the flag is absent | +| `statusline.show_git` | boolean | `false` | Append a git segment after the directory: current branch plus compact work-state markers (`+staged` `~unstaged` `?untracked` `↑ahead` `↓behind`, or `✓` when clean and in sync). One `git status --porcelain=v2` call per render; the segment is absent outside a git repo or when git is unavailable | The prompt injection guard hook (`gsd-prompt-guard.js`) is always active and cannot be disabled — it's a security feature, not a workflow toggle. diff --git a/gsd-core/bin/shared/config-schema.manifest.json b/gsd-core/bin/shared/config-schema.manifest.json index 721e23b1d..67c0f6fde 100644 --- a/gsd-core/bin/shared/config-schema.manifest.json +++ b/gsd-core/bin/shared/config-schema.manifest.json @@ -72,6 +72,7 @@ "statusline.show_last_command", "statusline.context_position", "statusline.show_context_tokens", + "statusline.show_git", "workflow.max_discuss_passes", "features.thinking_partner", "context", diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index a0b611b03..ffe7679fb 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -6,6 +6,9 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +// Namespace (not destructured) so tests can inject spawn failures by +// monkeypatching childProcess.execFileSync. +const childProcess = require('child_process'); const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs'); const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'); @@ -316,6 +319,83 @@ function contextTokenSuffix(currentUsage) { return total > 0 ? ` (${formatTokens(total)})` : ''; } +// --- Git segment (opt-in) ------------------------------------------------------ +// +// Opt-in via `statusline.show_git: true` in .planning/config.json. Renders the +// current branch plus compact work-state markers after the directory segment: +// " │ main+2~1?3↑1" (staged / unstaged / untracked / ahead / behind) +// " │ main✓" (clean, in sync) +// One `git status --porcelain=v2 --branch` spawn per render — no shell, args +// are a fixed array, and the workspace dir is passed via -C. Fails silently +// (segment absent) outside a repo, without git, or on timeout. + +const GIT_STATUS_TIMEOUT_MS = 1500; + +/** + * Run `git status --porcelain=v2 --branch` in dir. + * Returns raw stdout, or null when git is missing, dir isn't a repo, or the + * call times out. Never throws. + */ +function readGitStatus(dir) { + try { + // 8 MiB maxBuffer (default 1 MiB) headroom for repos with very many changed + // or untracked files; overflow still degrades safely to segment-absent via + // the catch below. + return childProcess.execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'], + { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); + } catch (e) { + return null; + } +} + +/** + * Pure function: parse `git status --porcelain=v2 --branch` output. + * + * Returns { branch, ahead, behind, staged, unstaged, untracked } or null when + * the text carries no branch header (not a repo / unparseable). Detached HEAD + * reports branch "(detached)" — porcelain v2's literal spelling, shown as-is. + * Unmerged (conflict) entries count as unstaged: they're pending work either way. + */ +function parseGitStatus(text) { + if (typeof text !== 'string') return null; + const info = { branch: null, ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0 }; + for (const line of text.split('\n')) { + if (line.startsWith('# branch.head ')) { + info.branch = line.slice('# branch.head '.length).trim() || null; + } else if (line.startsWith('# branch.ab ')) { + const m = line.match(/\+(\d+) -(\d+)/); + if (m) { info.ahead = parseInt(m[1], 10); info.behind = parseInt(m[2], 10); } + } else if (line.startsWith('1 ') || line.startsWith('2 ')) { + // Changed / renamed entries: XY pair at cols 2-3, '.' = unmodified side + const xy = line.slice(2, 4); + if (xy[0] !== '.') info.staged++; + if (xy[1] !== '.') info.unstaged++; + } else if (line.startsWith('u ')) { + info.unstaged++; + } else if (line.startsWith('? ')) { + info.untracked++; + } + } + return info.branch ? info : null; +} + +/** + * Pure function: format parsed git info into the statusline segment, divider + * included (mirrors lastCmdSuffix). Branch is dimmed to match the directory + * segment; markers keep their own colors. Returns '' when info is absent. + */ +function buildGitSegment(info) { + if (!info || !info.branch) return ''; + const markers = []; + if (info.staged) markers.push(`\x1b[32m+${info.staged}\x1b[0m`); + if (info.unstaged) markers.push(`\x1b[33m~${info.unstaged}\x1b[0m`); + if (info.untracked) markers.push(`\x1b[31m?${info.untracked}\x1b[0m`); + if (info.ahead) markers.push(`\x1b[32m↑${info.ahead}\x1b[0m`); + if (info.behind) markers.push(`\x1b[31m↓${info.behind}\x1b[0m`); + const state = markers.length ? markers.join('') : '\x1b[32m✓\x1b[0m'; + return ` │ \x1b[2m${info.branch}\x1b[0m${state}`; +} + // --- stdin ------------------------------------------------------------------ function runStatusline() { @@ -462,6 +542,7 @@ function runStatusline() { // Failure here must never break the statusline — wrap the entire lookup. let lastCmdSuffix = ''; let position = 'end'; + let gitSuffix = ''; try { if (getConfigValue(cfg, 'statusline.show_last_command') === true) { const transcriptPath = data.transcript_path; @@ -472,8 +553,11 @@ function runStatusline() { } const cfgPos = getConfigValue(cfg, 'statusline.context_position'); if (cfgPos != null) position = cfgPos; + if (getConfigValue(cfg, 'statusline.show_git') === true) { + gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir))); + } } catch (e) { - // Never break the statusline on config/transcript errors + // Never break the statusline on config/transcript/git errors } // Output @@ -484,7 +568,7 @@ function runStatusline() { ? `\x1b[2m${gsdStateStr}\x1b[0m` : null; - process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, position })); + process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, gitSuffix, position })); } catch (e) { // Silent fail - don't break statusline on parse errors } @@ -503,6 +587,7 @@ function runStatusline() { * @param {string|null} [opts.middle=null] - middle segment (todo task or GSD state), null = absent * @param {string} opts.dirname - project directory basename (dim styling applied here) * @param {string} [opts.lastCmdSuffix=''] - last-command suffix, e.g. ' │ last: /foo' + * @param {string} [opts.gitSuffix=''] - git branch/status segment, e.g. ' │ main✓' (after dirname) * @param {'end'|'front'} [opts.position='end'] * - 'end' (default): ctx appended after dirname — preserved byte-for-byte * - 'front': ctx immediately after model name so the meter stays visible in narrow terminals @@ -518,6 +603,7 @@ function composeStatusline({ middle = null, dirname, lastCmdSuffix = '', + gitSuffix = '', position = 'end', } = {}) { const modelSeg = `\x1b[2m${model}\x1b[0m`; @@ -526,12 +612,12 @@ function composeStatusline({ const pos = position === 'front' ? 'front' : 'end'; if (pos === 'front') { - if (middle) return `${gsdUpdate}${modelSeg}${ctx} │ ${middle} │ ${dirSeg}${lastCmdSuffix}`; - return `${gsdUpdate}${modelSeg}${ctx} │ ${dirSeg}${lastCmdSuffix}`; + if (middle) return `${gsdUpdate}${modelSeg}${ctx} │ ${middle} │ ${dirSeg}${gitSuffix}${lastCmdSuffix}`; + return `${gsdUpdate}${modelSeg}${ctx} │ ${dirSeg}${gitSuffix}${lastCmdSuffix}`; } // 'end' — preserved byte-for-byte relative to original inline templates - if (middle) return `${gsdUpdate}${modelSeg} │ ${middle} │ ${dirSeg}${ctx}${lastCmdSuffix}`; - return `${gsdUpdate}${modelSeg} │ ${dirSeg}${ctx}${lastCmdSuffix}`; + if (middle) return `${gsdUpdate}${modelSeg} │ ${middle} │ ${dirSeg}${gitSuffix}${ctx}${lastCmdSuffix}`; + return `${gsdUpdate}${modelSeg} │ ${dirSeg}${gitSuffix}${ctx}${lastCmdSuffix}`; } function isInstalledAheadOfLatest(installed, latest) { @@ -573,6 +659,7 @@ module.exports = { evaluateUpdateCache, formatTokens, contextTokenSuffix, + readGitStatus, parseGitStatus, buildGitSegment, }; /** @@ -586,6 +673,7 @@ function renderStatusline(data) { let lastCmdSuffix = ''; let position = 'end'; + let gitSuffix = ''; try { const cfg = readGsdConfig(dir); if (getConfigValue(cfg, 'statusline.show_last_command') === true) { @@ -596,11 +684,14 @@ function renderStatusline(data) { } const cfgPos = getConfigValue(cfg, 'statusline.context_position'); if (cfgPos != null) position = cfgPos; + if (getConfigValue(cfg, 'statusline.show_git') === true) { + gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir))); + } } catch (e) { /* swallow */ } const gsdStateStr = formatGsdState(readGsdState(dir) || {}); const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null; - return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, position }); + return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position }); } module.exports.renderStatusline = renderStatusline; diff --git a/src/config.cts b/src/config.cts index 45afc6892..8779dcb3a 100644 --- a/src/config.cts +++ b/src/config.cts @@ -770,6 +770,13 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | } } + // statusline.show_git — boolean only + if (kp === 'statusline.show_git') { + if (typeof parsedValue !== 'boolean') { + error(`Invalid statusline.show_git '${val}'. Must be a boolean (true or false).`); + } + } + // Fallow scope + profile enum validation (#3424) const VALID_FALLOW_SCOPES = ['phase', 'repo']; if (kp === 'code_quality.fallow.scope') assertEnumValue(parsedValue, val, VALID_FALLOW_SCOPES, 'code_quality.fallow.scope'); diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 9f99d3402..5f94d1224 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "600e9ed0cb6ed7bb", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "8bc541aabc2e143c", @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "eefea61f9b0e464c", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "9e64af477e774de6", + "hooks/gsd-statusline.js": "25996df685a0dac9", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index 2931d7735..32f716961 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "c8800819f7443a15", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "3e633cd082663b99", + "hooks/gsd-statusline.js": "2fab68f4fd190331", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index 84d3b28af..d6bd623cd 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -111,7 +111,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -398,7 +398,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "00d2449afefd2e5f", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "920558a55eb86587", + "hooks/gsd-statusline.js": "4ad7c2f59577c5bc", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 15d5be2dd..34c6df3b6 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -40,7 +40,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -327,7 +327,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "00d2449afefd2e5f", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "920558a55eb86587", + "hooks/gsd-statusline.js": "4ad7c2f59577c5bc", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index 2ce000795..99f4728ec 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -44,7 +44,7 @@ "gsd-core/bin/gsd-tools.cjs": "a84914f7cab74332", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 0611c54a4..b53a71fff 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "7f7a7615b303369a", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "6ab2905537f885ba", + "hooks/gsd-statusline.js": "29cdce15038d1ab1", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 6f99e05ae..1a76e4b16 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -147,7 +147,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index d488feccb..ba60f8a6a 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -42,7 +42,7 @@ "gsd-core/bin/gsd-tools.cjs": "600e9ed0cb6ed7bb", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "10226e9512dd44bf", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index b6af16b0a..e19597ac9 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "db16ded31dd6eecf", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 1a52e39cf..556bf625a 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "b3f927ee4b4c4711", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "1f58b020a91f032b", "hooks/gsd-read-injection-scanner.js": "f358eca3fa1eab24", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "34d2ae303558b917", + "hooks/gsd-statusline.js": "884347ccd6549f29", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 77d27e242..f6456b8d3 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index bc7778630..dafccaebe 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -18,7 +18,7 @@ ".kimi/hooks/gsd-read-guard.js": "9e423cd03e2d1b16", ".kimi/hooks/gsd-read-injection-scanner.js": "c519598b9257aafa", ".kimi/hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - ".kimi/hooks/gsd-statusline.js": "ae0c0e7a934c5deb", + ".kimi/hooks/gsd-statusline.js": "be35341758d50fa5", ".kimi/hooks/gsd-update-banner.js": "55143a25f978f301", ".kimi/hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", ".kimi/hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", @@ -105,7 +105,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index d811cce36..e9161b5f0 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "f72060dfe035f706", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "3dd40389fa786dd6", + "hooks/gsd-statusline.js": "6fcb59ad86d2d0ea", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/pi.json b/tests/fixtures/golden-install-parity/pi.json index 43c3f37eb..d7201acd2 100644 --- a/tests/fixtures/golden-install-parity/pi.json +++ b/tests/fixtures/golden-install-parity/pi.json @@ -8,7 +8,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -295,7 +295,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "f454242c010804cf", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "41b0080cf0f3e7e9", + "hooks/gsd-statusline.js": "daa1a98fde95ccaf", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 7b882d4c8..fdf23aea0 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "6454021dec4d9563", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "2c8d417d12b51040", "hooks/gsd-read-injection-scanner.js": "396574bd25e99ff9", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "5686cf31c721b2b1", + "hooks/gsd-statusline.js": "2541196849ec5ffc", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 84f717a92..abd6f9226 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "c28c9076058c5e73", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 6d506daa1..0c9d69c9c 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "568e7c6bdd415c1a", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index d9193766f..ee03a8512 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", + "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/gsd-statusline-git.property.test.cjs b/tests/gsd-statusline-git.property.test.cjs new file mode 100644 index 000000000..07c731dbc --- /dev/null +++ b/tests/gsd-statusline-git.property.test.cjs @@ -0,0 +1,196 @@ +'use strict'; + +/** + * Property-based tests for the statusline git-segment parser (#2163) + * + * Module: hooks/gsd-statusline.js + * Exported: parseGitStatus(text), buildGitSegment(info) + * + * Properties tested: + * (a) parseGitStatus: never throws on any input; returns null or the full + * typed shape with non-negative counts (total robustness) + * (b) round-trip: synthetic porcelain-v2 output for a generated repo state + * parses back to exactly that state + * (c) prefix-robustness: unknown-prefix lines (future porcelain extensions) + * inserted anywhere leave the parsed result unchanged + * (d) no-branch-header: any input lacking '# branch.head' parses to null + * (e) buildGitSegment: never throws; '' iff branch absent; ✓ iff clean+synced + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { parseGitStatus, buildGitSegment } = require('../hooks/gsd-statusline.js'); + +const INFO_KEYS = ['branch', 'ahead', 'behind', 'staged', 'unstaged', 'untracked']; +const COUNT_KEYS = ['ahead', 'behind', 'staged', 'unstaged', 'untracked']; + +// Branch names from a path-safe alphabet (no whitespace/control chars, which +// git itself forbids in ref names). +const arbBranch = fc.stringMatching(/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,40}$/); + +// A generated repo state with bounded counts. +const arbState = fc.record({ + branch: arbBranch, + ahead: fc.nat({ max: 99 }), + behind: fc.nat({ max: 99 }), + staged: fc.nat({ max: 20 }), + unstagedChanged: fc.nat({ max: 20 }), + unmerged: fc.nat({ max: 5 }), + untracked: fc.nat({ max: 20 }), +}); + +/** + * Render synthetic porcelain v2 --branch output for a generated state. + * Staged entries get XY 'M.', unstaged 'M' entries '.M', unmerged 'u UU'. + */ +function renderPorcelain(s) { + const lines = [ + '# branch.oid 0123456789abcdef0123456789abcdef01234567', + `# branch.head ${s.branch}`, + `# branch.upstream origin/${s.branch}`, + `# branch.ab +${s.ahead} -${s.behind}`, + ]; + for (let i = 0; i < s.staged; i++) { + lines.push(`1 M. N... 100644 100644 100644 0123456 0123456 staged-${i}.txt`); + } + for (let i = 0; i < s.unstagedChanged; i++) { + lines.push(`1 .M N... 100644 100644 100644 0123456 0123456 unstaged-${i}.txt`); + } + for (let i = 0; i < s.unmerged; i++) { + lines.push(`u UU N... 100644 100644 100644 100644 0123456 0123456 0123456 conflict-${i}.txt`); + } + for (let i = 0; i < s.untracked; i++) { + lines.push(`? new-${i}.txt`); + } + return lines.join('\n') + '\n'; +} + +describe('gsd-statusline git segment: parseGitStatus properties', () => { + // (a) Total: never throws, result is null or the full typed shape + test('property: never throws and returns null or a well-formed info object', () => { + fc.assert( + fc.property( + fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant(42), + fc.constant({}), + fc.string({ unit: 'binary', maxLength: 300 }), + fc.string({ unit: 'grapheme-composite', maxLength: 300 }), + // line soup: random lines joined with \n + fc.array(fc.string({ maxLength: 60 }), { maxLength: 30 }).map((a) => a.join('\n')) + ), + (input) => { + let result; + assert.doesNotThrow(() => { result = parseGitStatus(input); }); + if (result !== null) { + assert.deepEqual(Object.keys(result).sort(), [...INFO_KEYS].sort()); + for (const k of COUNT_KEYS) { + assert.equal(typeof result[k], 'number'); + assert.ok(result[k] >= 0, `${k} must be >= 0`); + } + assert.equal(typeof result.branch, 'string'); + assert.ok(result.branch.length > 0); + } + } + ) + ); + }); + + // (b) Round-trip: synthetic porcelain for a state parses back exactly + test('property: round-trip through synthetic porcelain v2 output', () => { + fc.assert( + fc.property(arbState, (s) => { + const parsed = parseGitStatus(renderPorcelain(s)); + assert.ok(parsed, 'well-formed porcelain must parse'); + assert.equal(parsed.branch, s.branch); + assert.equal(parsed.ahead, s.ahead); + assert.equal(parsed.behind, s.behind); + assert.equal(parsed.staged, s.staged); + // unmerged (conflict) entries count as unstaged + assert.equal(parsed.unstaged, s.unstagedChanged + s.unmerged); + assert.equal(parsed.untracked, s.untracked); + }) + ); + }); + + // (c) Prefix-robustness: unknown-prefix lines never change the result + test('property: unknown-prefix lines inserted anywhere are ignored', () => { + // Lines whose first two chars are none of the recognized prefixes + // ('# ', '1 ', '2 ', 'u ', '? '). + const arbUnknownLine = fc + .string({ maxLength: 50 }) + .map((s) => `z ${s}`); + fc.assert( + fc.property( + arbState, + fc.array(arbUnknownLine, { minLength: 1, maxLength: 10 }), + fc.nat({ max: 1000 }), + (s, extras, seedPos) => { + const baseline = parseGitStatus(renderPorcelain(s)); + const lines = renderPorcelain(s).split('\n'); + // deterministic insertion positions derived from seedPos + extras.forEach((extra, i) => { + const pos = (seedPos + i * 7) % (lines.length + 1); + lines.splice(pos, 0, extra); + }); + const withExtras = parseGitStatus(lines.join('\n')); + assert.deepEqual(withExtras, baseline); + } + ) + ); + }); + + // (d) No-branch-header: input without '# branch.head' parses to null + test('property: input lacking a branch.head header parses to null', () => { + fc.assert( + fc.property( + fc.array(fc.string({ maxLength: 60 }), { maxLength: 30 }), + (rawLines) => { + const text = rawLines + .filter((l) => !l.startsWith('# branch.head ')) + .join('\n'); + assert.equal(parseGitStatus(text), null); + } + ) + ); + }); +}); + +describe('gsd-statusline git segment: buildGitSegment properties', () => { + // (e) Never throws; '' iff no branch; clean+synced renders the ✓ marker + test('property: never throws and is empty exactly when branch is absent', () => { + fc.assert( + fc.property( + fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant({}), + arbState.map((s) => ({ + branch: s.branch, + ahead: s.ahead, + behind: s.behind, + staged: s.staged, + unstaged: s.unstagedChanged + s.unmerged, + untracked: s.untracked, + })) + ), + (info) => { + let seg; + assert.doesNotThrow(() => { seg = buildGitSegment(info); }); + assert.equal(typeof seg, 'string'); + if (!info || !info.branch) { + assert.equal(seg, ''); + } else { + assert.ok(seg.includes(info.branch)); + const dirty = + info.staged || info.unstaged || info.untracked || info.ahead || info.behind; + assert.equal(seg.includes('✓'), !dirty); + } + } + ) + ); + }); +}); diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index f2fb70b07..f0ddf07d1 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -1363,3 +1363,280 @@ test('config-set statusline.show_context_tokens yes → rejected', () => { }); }); } + + +// ──────────────────────────────────────────────────────────────────────── +// Git segment (statusline.show_git) +// ──────────────────────────────────────────────────────────────────────── +{ + const { test, describe } = require('node:test'); + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const os = require('node:os'); + const path = require('node:path'); + const { execFileSync } = require('node:child_process'); + const { cleanup } = require('./helpers.cjs'); + const statusline = require('../hooks/gsd-statusline.js'); + const { parseGitStatus, buildGitSegment, readGitStatus, composeStatusline } = statusline; + const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); + + describe('config schema: statusline.show_git', () => { + test('registers statusline.show_git', () => { + assert.ok( + VALID_CONFIG_KEYS.has('statusline.show_git'), + 'statusline.show_git must be in VALID_CONFIG_KEYS', + ); + }); + }); + + describe('parseGitStatus', () => { + test('returns null for non-string / missing branch header', () => { + assert.equal(parseGitStatus(null), null); + assert.equal(parseGitStatus(undefined), null); + assert.equal(parseGitStatus(''), null); + assert.equal(parseGitStatus('? some-file\n'), null); + }); + + test('parses a clean, in-sync branch', () => { + const text = [ + '# branch.oid abc123', + '# branch.head main', + '# branch.upstream origin/main', + '# branch.ab +0 -0', + '', + ].join('\n'); + assert.deepEqual(parseGitStatus(text), { + branch: 'main', ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0, + }); + }); + + test('counts staged, unstaged, untracked, ahead, behind', () => { + const text = [ + '# branch.oid abc123', + '# branch.head feat/x', + '# branch.upstream origin/feat/x', + '# branch.ab +2 -1', + '1 M. N... 100644 100644 100644 aaa bbb staged-only.txt', + '1 .M N... 100644 100644 100644 aaa bbb unstaged-only.txt', + '1 MM N... 100644 100644 100644 aaa bbb both.txt', + '2 R. N... 100644 100644 100644 aaa bbb R100 new.txt\told.txt', + '? untracked-1.txt', + '? untracked-2.txt', + '', + ].join('\n'); + assert.deepEqual(parseGitStatus(text), { + branch: 'feat/x', ahead: 2, behind: 1, staged: 3, unstaged: 2, untracked: 2, + }); + }); + + test('counts unmerged (conflict) entries as unstaged', () => { + const text = [ + '# branch.head main', + 'u UU N... 100644 100644 100644 100644 aaa bbb ccc conflict.txt', + '', + ].join('\n'); + const info = parseGitStatus(text); + assert.equal(info.unstaged, 1); + assert.equal(info.staged, 0); + }); + + test('detached HEAD passes through as "(detached)"', () => { + const text = '# branch.head (detached)\n'; + assert.equal(parseGitStatus(text).branch, '(detached)'); + }); + + test('no upstream (no branch.ab line) leaves ahead/behind at 0', () => { + const text = '# branch.head local-only\n? new.txt\n'; + const info = parseGitStatus(text); + assert.deepEqual([info.ahead, info.behind, info.untracked], [0, 0, 1]); + }); + }); + + describe('buildGitSegment', () => { + const strip = (s) => + // eslint-disable-next-line no-control-regex -- stripping ANSI SGR sequences to assert on visible text + s.replace(/\x1b\[[0-9;]*m/g, ''); + + test('returns empty string for null info', () => { + assert.equal(buildGitSegment(null), ''); + assert.equal(buildGitSegment({}), ''); + }); + + test('clean repo renders branch with a check mark', () => { + const seg = buildGitSegment({ branch: 'main', ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0 }); + assert.equal(strip(seg), ' │ main✓'); + }); + + test('dirty repo renders each nonzero marker in order', () => { + const seg = buildGitSegment({ branch: 'feat/x', ahead: 2, behind: 1, staged: 3, unstaged: 2, untracked: 4 }); + assert.equal(strip(seg), ' │ feat/x+3~2?4↑2↓1'); + }); + + test('omits zero markers', () => { + const seg = buildGitSegment({ branch: 'main', ahead: 1, behind: 0, staged: 0, unstaged: 0, untracked: 0 }); + assert.equal(strip(seg), ' │ main↑1'); + }); + }); + + describe('readGitStatus + parseGitStatus against a real repo', () => { + function makeGitRepo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-seg-')); + const run = (args) => execFileSync('git', ['-C', dir, ...args], { + encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); + run(['init', '-q', '-b', 'main']); + run(['config', 'user.email', 'test@test.invalid']); + run(['config', 'user.name', 'Test']); + return { dir, run }; + } + + test('non-repo directory yields null', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-seg-plain-')); + try { + assert.equal(parseGitStatus(readGitStatus(dir)), null); + } finally { + cleanup(dir); + } + }); + + // Deterministic IO-failure injection (repo convention, cf. the fs + // monkeypatch in ensure-runtime-build.test.cjs): readGitStatus shares the + // one cached child_process module object, so replacing execFileSync here + // injects the failure without a real hang or oversized repo. + test('maxBuffer overflow degrades to null (segment absent)', () => { + const childProcess = require('node:child_process'); + const original = childProcess.execFileSync; + childProcess.execFileSync = () => { + const err = new RangeError('stdout maxBuffer length exceeded'); + err.code = 'ERR_CHILD_PROCESS_STDOUT_MAXBUFFER'; + throw err; + }; + try { + assert.equal(readGitStatus('/tmp'), null); + } finally { + childProcess.execFileSync = original; + } + }); + + test('spawn timeout degrades to null (segment absent)', () => { + const childProcess = require('node:child_process'); + const original = childProcess.execFileSync; + childProcess.execFileSync = () => { + const err = new Error('spawnSync git ETIMEDOUT'); + err.code = 'ETIMEDOUT'; + err.errno = -110; + throw err; + }; + try { + assert.equal(readGitStatus('/tmp'), null); + } finally { + childProcess.execFileSync = original; + } + }); + + test('fresh repo with an untracked file is counted', () => { + const { dir } = makeGitRepo(); + try { + fs.writeFileSync(path.join(dir, 'new.txt'), 'hello'); + const info = parseGitStatus(readGitStatus(dir)); + assert.equal(info.branch, 'main'); + assert.equal(info.untracked, 1); + assert.equal(info.staged, 0); + } finally { + cleanup(dir); + } + }); + + test('staged and committed states are reflected', () => { + const { dir, run } = makeGitRepo(); + try { + fs.writeFileSync(path.join(dir, 'a.txt'), '1'); + run(['add', 'a.txt']); + let info = parseGitStatus(readGitStatus(dir)); + assert.equal(info.staged, 1); + run(['commit', '-q', '-m', 'init']); + info = parseGitStatus(readGitStatus(dir)); + assert.deepEqual( + [info.staged, info.unstaged, info.untracked], [0, 0, 0]); + } finally { + cleanup(dir); + } + }); + }); + + describe('composeStatusline gitSuffix placement', () => { + test('git segment renders after the directory in end layout', () => { + const out = composeStatusline({ + model: 'Claude', dirname: 'proj', + gitSuffix: ' │ main✓', ctx: ' CTX', lastCmdSuffix: ' │ last: /foo', + }); + assert.ok( + out.includes('proj\x1b[0m │ main✓ CTX │ last: /foo'), + `expected dir → git → ctx → last-cmd order; got: ${out}`, + ); + }); + test('git segment renders after the directory in front layout', () => { + const out = composeStatusline({ + model: 'Claude', dirname: 'proj', + gitSuffix: ' │ main✓', position: 'front', + }); + assert.ok(out.endsWith('proj\x1b[0m │ main✓'), `got: ${out}`); + }); + test('default (no gitSuffix) output is unchanged', () => { + const a = composeStatusline({ model: 'Claude', dirname: 'proj' }); + const b = composeStatusline({ model: 'Claude', dirname: 'proj', gitSuffix: '' }); + assert.equal(a, b); + }); + }); + + describe('show_git e2e through the hook', () => { + const hookPath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js'); + + function runHook(dir) { + const payload = JSON.stringify({ + model: { display_name: 'Claude' }, + workspace: { current_dir: dir }, + session_id: `test-git-${Date.now()}-${Math.random().toString(36).slice(2)}`, + }); + let stdout = ''; + try { + stdout = execFileSync(process.execPath, [hookPath], { + input: payload, encoding: 'utf8', timeout: 4000, + }); + } catch (e) { + stdout = e.stdout || ''; + } + // eslint-disable-next-line no-control-regex -- stripping ANSI SGR sequences from captured CLI output + return stdout.replace(/\x1b\[[0-9;]*m/g, ''); + } + + test('flag=true renders the branch segment', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-seg-e2e-')); + try { + execFileSync('git', ['-C', dir, 'init', '-q', '-b', 'main']); + fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(dir, '.planning', 'config.json'), + JSON.stringify({ statusline: { show_git: true } }), + ); + const out = runHook(dir); + assert.ok(out.includes('│ main'), `expected branch segment; got: ${out}`); + } finally { + cleanup(dir); + } + }); + + test('default (flag absent) has no git segment', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-seg-e2e-')); + try { + execFileSync('git', ['-C', dir, 'init', '-q', '-b', 'main']); + fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); + const out = runHook(dir); + assert.ok(!out.includes('│ main'), `expected no git segment; got: ${out}`); + } finally { + cleanup(dir); + } + }); + }); +}