diff --git a/vscode/package.json b/vscode/package.json index 7979917..869e930 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -93,6 +93,16 @@ "group": "navigation" } ] + }, + "configuration": { + "title": "Superpowers", + "properties": { + "superpowers.worktreeDirectory": { + "type": "string", + "default": "~/Sources/worktree/$project_name/$feature_name", + "markdownDescription": "运行 Plan 时创建 worktree 的目录模板。支持占位符 `$project_root`(工作区根目录的绝对路径)、`$project_name`(工作区目录名)、`$feature_name`(spec/plan 的 slug)。开头的 `~` 会展开为当前用户主目录。默认会把 worktree 建到 `~/Sources/worktree/<项目名>/`。" + } + } } }, "scripts": { diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index bcd4c6b..2caca12 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -558,6 +558,48 @@ export async function triggerAutoReviewTab(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: * 1. Detect repo + token, compute a stable feature_name from the plan path. @@ -639,10 +681,13 @@ export async function handleImplement( return } - const feature = createHash('sha256').update(planFile).digest('hex').slice(0, 8) - const branch = `feature/${feature}` - const relativeWorktreePath = `.claude/worktrees/${feature}` - const worktreePath = path.join(workspaceRoot, relativeWorktreePath) + // worktree 目录名用 spec/plan 的 slug(可读、与 spec 目录同名),不再用 planFile 的 hash。 + const slug = deriveSlug(planFile) + const branch = `feature/${slug}` + // 目录走 superpowers.worktreeDirectory 模板(设置里可改),默认 ~/Sources/worktree/<项目>/。 + const worktreeTemplate = workspace.getConfiguration('superpowers').get('worktreeDirectory') + || '~/Sources/worktree/$project_name/$feature_name' + const worktreePath = resolveWorktreeDir(worktreeTemplate, workspaceRoot, slug) // Pre-flight: refuse if either the directory or the branch already // exists, since `git worktree add -b` would fail and we'd have to @@ -677,7 +722,7 @@ export async function handleImplement( } if (worktreeExists || branchExists) { void window.showErrorMessage( - `feature ${feature} 的 worktree 或分支已存在,请先清理`, + `feature ${slug} 的 worktree 或分支已存在,请先清理`, ) return } @@ -713,6 +758,9 @@ export async function handleImplement( // Create the worktree before anything else, so a worktree-add failure // doesn't leave half-written state behind. try { + // worktree 现在建在工作区外(~/Sources/worktree/<项目>/...),父目录可能不存在; + // git worktree add 不会创建多层父目录,先补齐。 + await fsp.mkdir(path.dirname(worktreePath), { recursive: true }) await createWorktree({ workspaceRoot, worktreePath, branch }) } catch (err) { @@ -759,7 +807,7 @@ export async function handleImplement( await panel.mergeIssueState(issueNumber, { column: 'in-progress', branch, - worktreePath: relativeWorktreePath, + worktreePath, implementStatus: 'running', }) panel.postMessage({ @@ -768,7 +816,7 @@ export async function handleImplement( patch: { column: 'in-progress', branch, - worktreePath: relativeWorktreePath, + worktreePath, implementStatus: 'running', worktreeExists: true, },