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