Add git worktree awareness to status line

Sessions in a linked worktree previously showed only the worktree's
directory name, so the underlying repository was unidentifiable and the
session looked like an unrelated project.

The project segment now renders as "repo | worktree". Repo identity comes
from git rev-parse --git-common-dir, so it is correct regardless of how the
worktree directory was named. A leading repo prefix is stripped from the
worktree name, and the name collapses to a bare marker when it merely
restates the branch already on screen -- matched across / _ . - separators
and type prefixes like feature/, and only when the branch is displayed.

Gated behind the new items.worktree config flag (default on). Costs one
extra timeout-bounded git call, skipped entirely outside git repos.
This commit is contained in:
2026-07-31 22:20:38 -07:00
parent aedfe944b2
commit 6740e26261
3 changed files with 117 additions and 5 deletions
+31
View File
@@ -20,6 +20,14 @@ A persistent three-line status bar for Claude Code that displays model info, pro
╰─╯ 2m ($0.18) │ ▰▰▱▱▱▱▱▱▱▱▱▱ 18% (36k) │ 88k in · 3k out ╰─╯ 2m ($0.18) │ ▰▰▱▱▱▱▱▱▱▱▱▱ 18% (36k) │ 88k in · 3k out
``` ```
**Git worktree** — session running in a linked worktree at `C:\git\plan2code-user-auth`:
```
╭─╮ Opus 4.6 | High │ plan2code ⑂ │ feature/user-auth │ +12 -3
│★│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
╰─╯ 3h 5m ($4.62) │ ▰▰▰▰▰▱▱▱▱▱▱▱ 42% (84k) │ 5h: 28% · 7d: 61%
```
**Line 1:** Planny icon | Model name + reasoning effort | Project name | Git branch | Uncommitted changes **Line 1:** Planny icon | Model name + reasoning effort | Project name | Git branch | Uncommitted changes
**Line 2:** Planny icon | Gray separator **Line 2:** Planny icon | Gray separator
**Line 3:** Planny icon | Session duration + cost | Context window bar | Usage (rate limits or token counts) **Line 3:** Planny icon | Session duration + cost | Context window bar | Usage (rate limits or token counts)
@@ -65,6 +73,7 @@ Edit `~/.claude/statusline-config.json`:
"model": true, "model": true,
"effort": true, "effort": true,
"project": true, "project": true,
"worktree": true,
"branch": true, "branch": true,
"contextBar": true, "contextBar": true,
"planUsage": true, "planUsage": true,
@@ -83,6 +92,7 @@ Edit `~/.claude/statusline-config.json`:
| `items.model` | `true` | Show model name (Opus, Sonnet, Haiku) | | `items.model` | `true` | Show model name (Opus, Sonnet, Haiku) |
| `items.effort` | `true` | Append the current reasoning effort level (Low/Medium/High/XHigh/Max) to the model segment, e.g. `Sonnet 5 \| High`. Reads `effort.level` from stdin; hidden when the current model doesn't support an effort parameter (field absent from stdin). | | `items.effort` | `true` | Append the current reasoning effort level (Low/Medium/High/XHigh/Max) to the model segment, e.g. `Sonnet 5 \| High`. Reads `effort.level` from stdin; hidden when the current model doesn't support an effort parameter (field absent from stdin). |
| `items.project` | `true` | Show project directory name | | `items.project` | `true` | Show project directory name |
| `items.worktree` | `true` | Detect [git worktrees](https://git-scm.com/docs/git-worktree) and render the project segment as `repo ⑂ worktree` instead of just the worktree's directory name. See [Worktree Display](#worktree-display). Requires `items.project`. |
| `items.branch` | `true` | Show current git branch (falls back to short SHA when detached HEAD) | | `items.branch` | `true` | Show current git branch (falls back to short SHA when detached HEAD) |
| `items.contextBar` | `true` | Show context window usage bar with percentage | | `items.contextBar` | `true` | Show context window usage bar with percentage |
| `items.contextTokens` | `true` | Append raw tokens used in the context window next to the percentage, e.g. `42% (84k)`. Reads `context_window.total_input_tokens` (the same input-token count `used_percentage` is derived from — excludes output tokens). Requires `items.contextBar` to also be enabled. | | `items.contextTokens` | `true` | Append raw tokens used in the context window next to the percentage, e.g. `42% (84k)`. Reads `context_window.total_input_tokens` (the same input-token count `used_percentage` is derived from — excludes output tokens). Requires `items.contextBar` to also be enabled. |
@@ -101,6 +111,27 @@ Malformed config falls back to defaults silently. Type errors on individual keys
| Copilot CLI | Planned | Deferred — CLI lacks status line script support as of v0.0.421 | | Copilot CLI | Planned | Deferred — CLI lacks status line script support as of v0.0.421 |
| Others | Not supported | Codex CLI, Gemini CLI have built-in status, not scriptable | | Others | Not supported | Codex CLI, Gemini CLI have built-in status, not scriptable |
## Worktree Display
With `items.worktree` disabled, a session in a linked worktree shows only that worktree's directory name (`plan2code-user-auth`) — the underlying repository identity is lost, and the session looks like an unrelated project.
When enabled, the project segment becomes `repo ⑂ worktree`:
| Worktree directory | Branch | Rendered | Why |
|--------------------|--------|----------|-----|
| `plan2code` (primary checkout) | `main` | `plan2code` | Not a linked worktree — unchanged |
| `plan2code-spike` | `spike-thing` | `plan2code ⑂ spike` | Repo prefix stripped from the worktree name |
| `plan2code-user-auth` | `feature/user-auth` | `plan2code ⑂` | Name is redundant with the visible branch — collapses to the bare marker |
| `plan2code-user-auth` | `feature/user-auth`, `items.branch: false` | `plan2code ⑂ user-auth` | No branch shown, so the name is kept |
Details:
- **Detection** — a single `git rev-parse --git-dir --git-common-dir`. The two paths differ only inside a linked worktree. The true repo name comes from `--git-common-dir` (its parent directory, or the `<name>.git` basename for a bare main repo), so it is correct regardless of how the worktree directory was named.
- **Prefix stripping** — a leading repo name followed by `-`, `_`, or `.` is removed, so `plan2code-user-auth` reads as `user-auth`.
- **Redundancy collapse** — worktree directories usually mirror their branch. The name is dropped (leaving ``) when it matches the branch after normalizing case and `/ _ . -` separators, compared against both the full branch and its trailing segment so type prefixes like `feature/` don't defeat the match. This only applies when the branch is actually displayed.
- **Color** — the repo keeps its bold silver; the `` marker and worktree name render in amber so a non-primary checkout is obvious at a glance. Monochrome output is `repo ⑂ worktree`.
- **Cost** — one extra `git` invocation, subject to the same 1.5s timeout, and skipped entirely outside git repos.
## Usage Display ## Usage Display
The usage segment reads data directly from Claude Code's stdin JSON — no API calls, no auth needed, no background processes. The usage segment reads data directly from Claude Code's stdin JSON — no API calls, no auth needed, no background processes.
@@ -6,6 +6,7 @@
"model": true, "model": true,
"effort": true, "effort": true,
"project": true, "project": true,
"worktree": true,
"branch": true, "branch": true,
"contextBar": true, "contextBar": true,
"contextTokens": true, "contextTokens": true,
+85 -5
View File
@@ -28,6 +28,7 @@ const DEFAULTS = {
model: true, model: true,
effort: true, effort: true,
project: true, project: true,
worktree: true,
branch: true, branch: true,
contextBar: true, contextBar: true,
contextTokens: true, contextTokens: true,
@@ -90,6 +91,7 @@ const C = {
// Plan2Code brand // Plan2Code brand
teal: rgb(190, 192, 200), // Silver — repo teal: rgb(190, 192, 200), // Silver — repo
sky: rgb(120, 195, 255), // Light sky blue — branch sky: rgb(120, 195, 255), // Light sky blue — branch
amber: rgb(235, 170, 95), // Amber — linked worktree marker
muted: rgb(40, 120, 200), // Mid blue — model muted: rgb(40, 120, 200), // Mid blue — model
label: rgb(130, 135, 150), // Mid gray — field labels (5h, 7d) label: rgb(130, 135, 150), // Mid gray — field labels (5h, 7d)
// Bar & quota thresholds // Bar & quota thresholds
@@ -150,6 +152,54 @@ function getGitBranch(stdinData) {
return sha || ''; return sha || '';
} }
// Linked worktrees have their own .git dir under the main repo's
// .git/worktrees/<name>, while --git-common-dir still points at the main repo's
// .git. Equal paths mean we're in the primary checkout.
function getWorktreeInfo(cwd) {
const raw = gitExec(['rev-parse', '--git-dir', '--git-common-dir'], cwd);
if (!raw) return null;
const [gitDir, commonDir] = raw.split('\n').map((p) => path.resolve(cwd, p.trim()));
if (!gitDir || !commonDir) return null;
const samePath = process.platform === 'win32'
? gitDir.toLowerCase() === commonDir.toLowerCase()
: gitDir === commonDir;
if (samePath) return null;
// commonDir is normally <repo>/.git; bare repos use <name>.git directly
const commonBase = path.basename(commonDir);
const repoName = commonBase === '.git'
? path.basename(path.dirname(commonDir))
: commonBase.replace(/\.git$/i, '');
const toplevel = gitExec(['rev-parse', '--show-toplevel'], cwd);
const worktreeName = path.basename(toplevel || cwd);
if (!repoName || !worktreeName) return null;
return { repoName, worktreeName };
}
// "plan2code-user-auth" in repo "plan2code" reads as just "user-auth"
function stripRepoPrefix(worktreeName, repoName) {
const lowerWt = worktreeName.toLowerCase();
const lowerRepo = repoName.toLowerCase();
if (!lowerWt.startsWith(lowerRepo) || lowerWt === lowerRepo) return worktreeName;
const rest = worktreeName.slice(repoName.length);
return /^[-_.]/.test(rest) ? rest.slice(1) : worktreeName;
}
const normalizeName = (s) => s.toLowerCase().replace(/[/_.\s-]+/g, '-').replace(/^-|-$/g, '');
// Worktree dirs usually mirror their branch (plan2code-user-auth / feature/user-auth).
// Compare against both the full branch and its trailing segment so the common
// type prefixes (feature/, bugfix/, ...) don't defeat the match.
function isRedundantWithBranch(worktreeName, branch) {
if (!branch) return false;
const candidates = new Set([normalizeName(branch), normalizeName(branch.split('/').pop())]);
return candidates.has(normalizeName(worktreeName));
}
// ============================================================================ // ============================================================================
// Formatters // Formatters
// ============================================================================ // ============================================================================
@@ -192,6 +242,34 @@ function getProjectName(stdinData) {
return path.basename(projectDir); return path.basename(projectDir);
} }
// Returns the project segment: "plan2code" normally, "plan2code ⑂ spike" in a
// linked worktree — the name collapsing to a bare ⑂ when the branch says it already.
function formatProject(stdinData, config, branch) {
const project = getProjectName(stdinData);
if (!project) return null;
const plain = (text) => (config.color ? `${C.bold}${C.teal}${text}${C.reset}` : text);
if (!config.items.worktree) return plain(project);
const cwd = stdinData?.workspace?.project_dir || process.cwd();
if (!isGitRepo(cwd)) return plain(project);
const info = getWorktreeInfo(cwd);
if (!info) return plain(project);
const shortName = stripRepoPrefix(info.worktreeName, info.repoName);
// Only dedupe against a branch the user can actually see — otherwise the
// worktree identity would vanish entirely.
const visibleBranch = config.items.branch ? branch : '';
const redundant = isRedundantWithBranch(shortName, visibleBranch)
|| isRedundantWithBranch(info.worktreeName, visibleBranch)
|| normalizeName(shortName) === normalizeName(info.repoName);
const marker = redundant ? '⑂' : `${shortName}`;
if (!config.color) return `${info.repoName} ${marker}`;
return `${C.bold}${C.teal}${info.repoName}${C.reset} ${C.amber}${marker}${C.reset}`;
}
function calculateContextPercent(stdinData, config) { function calculateContextPercent(stdinData, config) {
const cw = stdinData?.context_window; const cw = stdinData?.context_window;
if (!cw) return null; if (!cw) return null;
@@ -339,14 +417,16 @@ function formatLine1(stdinData, config) {
} }
} }
// Branch is resolved first — the project segment dedupes the worktree name against it
const branch = config.items.branch || config.items.worktree ? getGitBranch(stdinData) : '';
if (config.items.project) { if (config.items.project) {
const project = getProjectName(stdinData); const project = formatProject(stdinData, config, branch);
if (project) segments.push(config.color ? `${C.bold}${C.teal}${project}${C.reset}` : project); if (project) segments.push(project);
} }
if (config.items.branch) { if (config.items.branch && branch) {
const branch = getGitBranch(stdinData); segments.push(config.color ? `${C.sky}${branch}${C.reset}` : branch);
if (branch) segments.push(config.color ? `${C.sky}${branch}${C.reset}` : branch);
} }
if (config.items.linesChanged) { if (config.items.linesChanged) {