♻️ refactor(vscode): 抽出 worktreePath 模块(slug / 目录模板解析)

Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
2026-08-26 15:27:35 +08:00
parent 4b66d567b0
commit 3bc73d9dd6
3 changed files with 78 additions and 45 deletions
+32
View File
@@ -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')
})
})
+42
View File
@@ -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>/…),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/<slug>`;接管方只有分支名,从这里反推 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)
}
+4 -45
View File
@@ -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}/<slug>/{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/<项目>/<slug>。
const worktreeTemplate = getSettings(panel.context).worktreeDirectory
|| '~/Sources/worktree/$project_name/$feature_name'
|| DEFAULT_WORKTREE_TEMPLATE
let worktreePath = resolveWorktreeDir(worktreeTemplate, workspaceRoot, slug)
// 工单级 profilePath > 默认 fallback。工单的 profile 锁定可在工单详情面板里覆盖。