feat(coding-agent): implemented git worktree detection and rendering

- Added `git.repo.linkedWorktreeSync` to identify and resolve git worktree metadata without spawning subprocesses.
- Updated `StatusLineComponent` to detect linked worktrees and resolve project/worktree context names.
- Modified path segment rendering to collapse nested git worktree paths and display the worktree name when it diverges from the active branch.
- Introduced `icon.worktree` symbol across themes to visually distinguish git worktree paths.
This commit is contained in:
can1357
2026-06-28 22:47:31 +02:00
parent b46b62b6ef
commit 4db3d68bdb
5 changed files with 77 additions and 10 deletions
@@ -160,6 +160,29 @@ interface ActiveRepoCache {
projectDir: string;
activeRepo: ActiveRepoContext | null;
effectiveGitCwd: string;
/** Project + worktree dir name when `projectDir` is a linked worktree, else null. */
worktree: WorktreeContext | null;
}
interface WorktreeContext {
/** Primary-checkout (project) name shown by the path segment. */
projectName: string;
/** Worktree directory name — suppressed from the path when it equals the branch. */
worktreeName: string;
}
/**
* Project + worktree-dir names when `cwd` is a linked git worktree, else null.
* The project name comes from the shared primary checkout; bare-repo worktrees
* resolve to the shared `foo.git` dir, so a trailing `.git` is stripped.
*/
function resolveWorktreeContext(cwd: string): WorktreeContext | null {
const worktree = git.repo.linkedWorktreeSync(cwd);
if (!worktree) return null;
const base = path.basename(worktree.primaryRoot);
const projectName = base.endsWith(".git") ? base.slice(0, -4) : base;
if (!projectName) return null;
return { projectName, worktreeName: path.basename(worktree.root) };
}
/**
@@ -312,7 +335,10 @@ export class StatusLineComponent implements Component {
const activeRepo = resolveActiveRepoContextSync(projectDir);
const effectiveGitCwd = activeRepo?.repoRoot ?? projectDir;
this.#activeRepoCache = { projectDir, activeRepo, effectiveGitCwd };
// Only collapse the bare-cwd case: a single-direct-child-repo context
// (activeRepo set) renders `<parent> ↳ <child>`, which we leave intact.
const worktree = activeRepo ? null : resolveWorktreeContext(effectiveGitCwd);
this.#activeRepoCache = { projectDir, activeRepo, effectiveGitCwd, worktree };
return this.#activeRepoCache;
}
@@ -988,7 +1014,7 @@ export class StatusLineComponent implements Component {
const projectDir = getProjectDir();
const activeRepoCache = shouldResolveActiveRepo
? this.#resolveActiveRepoCache()
: { projectDir, activeRepo: null, effectiveGitCwd: projectDir };
: { projectDir, activeRepo: null, effectiveGitCwd: projectDir, worktree: null };
const gitBranch = includeGit || includePr ? this.#getCurrentBranch(activeRepoCache.effectiveGitCwd) : null;
const gitStatus = includeGit ? this.#getGitStatus(activeRepoCache.effectiveGitCwd) : null;
const gitPr = includePr ? this.#lookupPr(activeRepoCache.effectiveGitCwd) : null;
@@ -1015,6 +1041,7 @@ export class StatusLineComponent implements Component {
status: gitStatus,
pr: gitPr,
},
worktree: activeRepoCache.worktree,
usage: this.#cachedUsage,
};
}
@@ -20,6 +20,13 @@ function withIcon(icon: string, text: string): string {
return icon ? `${icon} ${text}` : text;
}
/** Left-truncate a path/label to `maxLen`, prefixing an ellipsis when clipped. */
function clampPathLength(pwd: string, maxLen: number): string {
if (pwd.length <= maxLen) return pwd;
const ellipsis = "…";
return `${ellipsis}${pwd.slice(-Math.max(0, maxLen - ellipsis.length))}`;
}
/**
* Leading glyph of a thinking-level display string (e.g. "◉ xhigh" → "◉").
* Compact mode promotes this glyph to the model-segment icon so the level
@@ -220,12 +227,24 @@ const pathSegment: StatusLineSegment = {
id: "path",
render(ctx) {
const opts = ctx.options.path ?? {};
const stripPrefix = opts.stripWorkPrefix !== false;
// Linked git worktree: the on-disk path nests the worktree base, the
// project, and a worktree dir that usually duplicates the branch (already
// shown by the git segment). Collapse to the project name, appending the
// worktree dir only when it diverges from the branch.
if (stripPrefix && ctx.worktree) {
const { projectName, worktreeName } = ctx.worktree;
const label = ctx.git.branch === worktreeName ? projectName : `${projectName}/${worktreeName}`;
const content = withIcon(theme.icon.worktree, clampPathLength(label, opts.maxLength ?? 40));
return { content: theme.fg("statusLinePath", content), visible: true };
}
const projectDir = ctx.activeRepo?.cwd ?? getProjectDir();
const { scratch, relative } = classifyProjectDir(projectDir);
let pwd = projectDir;
if (opts.stripWorkPrefix !== false) {
if (stripPrefix) {
if (scratch) {
if (relative) pwd = relative;
} else {
@@ -237,17 +256,12 @@ const pathSegment: StatusLineSegment = {
pwd = shortenPath(pwd);
}
const maxLen = opts.maxLength ?? 40;
if (pwd.length > maxLen) {
const ellipsis = "…";
const sliceLen = Math.max(0, maxLen - ellipsis.length);
pwd = `${ellipsis}${pwd.slice(-sliceLen)}`;
}
pwd = clampPathLength(pwd, opts.maxLength ?? 40);
if (repoSuffix) {
pwd = `${pwd}${repoSuffix}`;
}
const showScratchIcon = scratch && opts.stripWorkPrefix !== false;
const showScratchIcon = scratch && stripPrefix;
const icon = showScratchIcon ? theme.icon.scratchFolder : theme.icon.folder;
const content = withIcon(icon, pwd);
return { content: theme.fg("statusLinePath", content), visible: true };
@@ -97,6 +97,13 @@ export interface SegmentContext {
status: { staged: number; unstaged: number; untracked: number } | null;
pr: { number: number; url: string } | null;
};
/**
* Set when the path cwd is a *linked* git worktree, naming the shared
* primary checkout (the project). Lets the path segment collapse the
* base-prefixed `<base>/<project>/<worktree>` path to the project name —
* the worktree/branch is already shown by the git segment.
*/
worktree: { projectName: string; worktreeName: string } | null;
usage: {
tier?: string;
fiveHour?: { percent: number; resetMinutes?: number };
@@ -96,6 +96,7 @@ export type SymbolKey =
| "icon.pause"
| "icon.loop"
| "icon.folder"
| "icon.worktree"
| "icon.search"
| "icon.scratchFolder"
| "icon.file"
@@ -299,6 +300,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
"icon.pause": "⏸",
"icon.loop": "↻",
"icon.folder": "📁",
"icon.worktree": "🌳",
"icon.search": "🔍",
"icon.scratchFolder": "🗑",
"icon.file": "📄",
@@ -561,6 +563,8 @@ const NERD_SYMBOLS: SymbolMap = {
"icon.search": "\uf002",
// pick: | alt:
"icon.scratchFolder": "\uf014",
// pick: nf-fa-sitemap | alt: nf-cod-list_tree
"icon.worktree": "\uf0e8",
// pick:  | alt:  
"icon.file": "\uf15b",
// pick:  | alt:  ⎇
@@ -808,6 +812,7 @@ const ASCII_SYMBOLS: SymbolMap = {
"icon.pause": "||",
"icon.loop": "loop",
"icon.folder": "[D]",
"icon.worktree": "[wt]",
"icon.search": "[/]",
"icon.scratchFolder": "[T]",
"icon.file": "[F]",
@@ -1797,6 +1802,7 @@ export class Theme {
pause: this.#symbols["icon.pause"],
loop: this.#symbols["icon.loop"],
folder: this.#symbols["icon.folder"],
worktree: this.#symbols["icon.worktree"],
scratchFolder: this.#symbols["icon.scratchFolder"],
file: this.#symbols["icon.file"],
git: this.#symbols["icon.git"],
+13
View File
@@ -1712,6 +1712,19 @@ export const repo = {
return primaryRootFromRepositorySync(repository);
},
/**
* Linked-worktree metadata for `cwd`, or `null` when `cwd` is the primary
* checkout (or outside a repository). `root` is the worktree's own checkout
* root; `primaryRoot` is the shared main checkout that names the project.
* Resolves purely via on-disk `.git`/`commondir` walking — no subprocess —
* so the status line may call it on every render.
*/
linkedWorktreeSync(cwd: string): { root: string; primaryRoot: string } | null {
const repository = resolveRepositorySync(cwd);
if (!repository || !isLinkedWorktree(repository)) return null;
return { root: repository.repoRoot, primaryRoot: primaryRootFromRepositorySync(repository) };
},
/** Full GitRepository metadata (sync). */
resolveSync(cwd: string): GitRepository | null {
return resolveRepositorySync(cwd);