feat(#2163): opt-in git branch/status segment in the statusline

New statusline.show_git config (default false). When enabled, a git
segment renders after the directory: current branch plus compact
work-state markers (+staged ~unstaged ?untracked ↑ahead ↓behind, or ✓
when clean and in sync), e.g. " │ main+2~1?3".

One git status --porcelain=v2 --branch spawn per render via execFileSync
with a fixed argument array (no shell), a 1.5s timeout, and the
workspace dir passed with -C. Fails silently — segment absent outside a
repo, without git, or on timeout. Default output is unchanged when the
flag is absent.

Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg
This commit is contained in:
Cody Anderson
2026-07-10 23:09:02 -06:00
parent ad7111e50b
commit d6672ff926
5 changed files with 344 additions and 7 deletions

View File

@@ -453,6 +453,7 @@ If `.planning/` is in `.gitignore`, `commit_docs` is automatically `false` regar
| `statusline.show_last_command` | boolean | `false` | Append `last: /<cmd>` suffix to the statusline showing the most recently invoked slash command. Opt-in; reads the active session transcript to extract the latest `<command-name>` 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.

View File

@@ -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",

View File

@@ -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;

View File

@@ -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');

View File

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