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:50:30 +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 };