From 3bc73d9dd6c2fdbaed02cdd6a1fb1a5a7dbbfe24 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 26 Aug 2026 15:27:35 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(vscode):=20?= =?UTF-8?q?=E6=8A=BD=E5=87=BA=20worktreePath=20=E6=A8=A1=E5=9D=97=EF=BC=88?= =?UTF-8?q?slug=20/=20=E7=9B=AE=E5=BD=95=E6=A8=A1=E6=9D=BF=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph --- vscode/src/git/worktreePath.test.ts | 32 +++++++++++++++++ vscode/src/git/worktreePath.ts | 42 +++++++++++++++++++++++ vscode/src/panel/handlers/sessions.ts | 49 +++------------------------ 3 files changed, 78 insertions(+), 45 deletions(-) create mode 100644 vscode/src/git/worktreePath.test.ts create mode 100644 vscode/src/git/worktreePath.ts diff --git a/vscode/src/git/worktreePath.test.ts b/vscode/src/git/worktreePath.test.ts new file mode 100644 index 0000000..b5ef67f --- /dev/null +++ b/vscode/src/git/worktreePath.test.ts @@ -0,0 +1,32 @@ +import * as os from 'node:os' +import * as path from 'node:path' +import { describe, expect, it } from 'vitest' +import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir, slugFromBranch } from './worktreePath' + +describe('deriveSlug', () => { + it('取 plans/ 后一段', () => { + expect(deriveSlug('docs/superpowers/plans/foo-bar/plan.md')).toBe('foo-bar') + }) + it('没有 plans/specs 锚点时退回父目录名', () => { + expect(deriveSlug('docs/x/baz/plan.md')).toBe('baz') + }) +}) + +describe('slugFromBranch', () => { + it('去掉 feature/ 前缀', () => { + expect(slugFromBranch('feature/foo-bar')).toBe('foo-bar') + }) + it('无前缀原样返回', () => { + expect(slugFromBranch('hotfix-1')).toBe('hotfix-1') + }) +}) + +describe('resolveWorktreeDir', () => { + it('展开三个占位符与 ~', () => { + const out = resolveWorktreeDir(DEFAULT_WORKTREE_TEMPLATE, '/w/proj', 'slug') + expect(out).toBe(path.join(os.homedir(), 'Sources', 'worktree', 'proj', 'slug')) + }) + it('$project_root 展开为工作区绝对路径', () => { + expect(resolveWorktreeDir('$project_root/.wt/$feature_name', '/w/proj', 's')).toBe('/w/proj/.wt/s') + }) +}) diff --git a/vscode/src/git/worktreePath.ts b/vscode/src/git/worktreePath.ts new file mode 100644 index 0000000..ff56011 --- /dev/null +++ b/vscode/src/git/worktreePath.ts @@ -0,0 +1,42 @@ +import { createHash } from 'node:crypto' +import * as os from 'node:os' +import * as path from 'node:path' + +export const DEFAULT_WORKTREE_TEMPLATE = '~/Sources/worktree/$project_name/$feature_name' + +/** + * spec/plan 以目录形式存放(docs/superpowers/{specs,plans}//…),slug 取 + * `plans`/`specs` 后一段。兜底父目录名,再兜底路径 hash,保证目录名不为空。 + */ +export function deriveSlug(planFile: string): string { + const segments = planFile.split('/').filter(Boolean) + const anchor = segments.findIndex(s => s === 'plans' || s === 'specs') + if (anchor >= 0 && segments[anchor + 1]) + return segments[anchor + 1] + const parent = path.basename(path.dirname(planFile)) + if (parent && parent !== '.' && parent !== '/') + return parent + return createHash('sha256').update(planFile).digest('hex').slice(0, 8) +} + +/** 实施分支固定形如 `feature/`;接管方只有分支名,从这里反推 worktree 目录名。 */ +export function slugFromBranch(branch: string): string { + return branch.startsWith('feature/') ? branch.slice('feature/'.length) : branch +} + +/** + * 把 worktreeDirectory 模板展开成绝对路径。`~` 在这里展开:git/execFile 不经 shell, + * 不会自己做 tilde 展开。 + */ +export function resolveWorktreeDir(template: string, workspaceRoot: string, slug: string): string { + let p = template + .split('$project_name') + .join(path.basename(workspaceRoot)) + .split('$project_root') + .join(workspaceRoot) + .split('$feature_name') + .join(slug) + if (p.startsWith('~')) + p = path.join(os.homedir(), p.slice(1)) + return path.resolve(p) +} diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index 13b25ac..edfd62b 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -1,7 +1,6 @@ import type { Terminal } from 'vscode' import type { KanbanWebviewPanel } from '../KanbanPanel' import { execFile } from 'node:child_process' -import { createHash } from 'node:crypto' import * as fs from 'node:fs' import { promises as fsp } from 'node:fs' import * as os from 'node:os' @@ -16,6 +15,7 @@ import { resolveProfilePath } from '../../cc/profiles' import { getBrainstormContinuePrompt, getImplementPlanPrompt } from '../../cc/prompts' import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' import { detectRepo } from '../../git/remote' +import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath' import { ensureWorktree, resolveWorktreePath } from '../../git/worktree' import { updateIssueAssignees } from '../../gitea/api' import { logger } from '../../logging/logger' @@ -634,50 +634,9 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { return true } -/** - * Derive a human-readable slug from a spec/plan file path. - * - * spec/plan live as directories on disk - * (docs/superpowers/{specs,plans}//{spec,plan}.md), so the slug is the - * path segment right after the `specs`/`plans` segment — that's the name the - * user sees in the tree. Falls back to the parent dir name, then to a stable - * 8-char sha256 of the path so the worktree dir name is never empty. Paths - * come from gitea body comments and use `/` separators. - */ -function deriveSlug(planFile: string): string { - const segments = planFile.split('/').filter(Boolean) - const anchor = segments.findIndex(s => s === 'plans' || s === 'specs') - if (anchor >= 0 && segments[anchor + 1]) { - return segments[anchor + 1] - } - const parent = path.basename(path.dirname(planFile)) - if (parent && parent !== '.' && parent !== '/') { - return parent - } - return createHash('sha256').update(planFile).digest('hex').slice(0, 8) -} -/** - * Expand the `superpowers.worktreeDirectory` template into an ABSOLUTE path. - * Placeholders: $project_root (workspace abs path), $project_name (its - * basename), $feature_name (the slug). A leading `~` is expanded here because - * git/execFile run without a shell and won't do tilde expansion themselves. - * The result is resolved to absolute since the worktree now lives outside the - * workspace and is stored/compared as an absolute path downstream. - */ -function resolveWorktreeDir(template: string, workspaceRoot: string, slug: string): string { - let p = template - .split('$project_name') - .join(path.basename(workspaceRoot)) - .split('$project_root') - .join(workspaceRoot) - .split('$feature_name') - .join(slug) - if (p.startsWith('~')) { - p = path.join(os.homedir(), p.slice(1)) - } - return path.resolve(p) -} + + /** * Kick off the end-to-end "implement this plan" flow: @@ -766,7 +725,7 @@ export async function handleImplement( // 目录模板取自插件自己的设置面板(globalState),不是 VS Code 原生配置; // 留空时回退到默认 ~/Sources/worktree/<项目>/。 const worktreeTemplate = getSettings(panel.context).worktreeDirectory - || '~/Sources/worktree/$project_name/$feature_name' + || DEFAULT_WORKTREE_TEMPLATE let worktreePath = resolveWorktreeDir(worktreeTemplate, workspaceRoot, slug) // 工单级 profilePath > 默认 fallback。工单的 profile 锁定可在工单详情面板里覆盖。