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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user