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..c089bda34 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -316,6 +316,81 @@ 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 { + const { execFileSync } = require('child_process'); + return execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'], + { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] }); + } 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 +537,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 +548,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 +563,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 +582,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 +598,7 @@ function composeStatusline({ middle = null, dirname, lastCmdSuffix = '', + gitSuffix = '', position = 'end', } = {}) { const modelSeg = `\x1b[2m${model}\x1b[0m`; @@ -526,12 +607,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 +654,7 @@ module.exports = { evaluateUpdateCache, formatTokens, contextTokenSuffix, + readGitStatus, parseGitStatus, buildGitSegment, }; /** @@ -586,6 +668,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 +679,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/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index f2fb70b07..c92264efc 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -1363,3 +1363,245 @@ 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); + } + }); + + 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); + } + }); + }); +}