Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75dec8fa90 | ||
|
|
ce0622b6fe | ||
|
|
afdb98ad55 | ||
|
|
23ebdc0509 | ||
|
|
24b1ae2ecf | ||
|
|
0db43f4cc3 | ||
|
|
07b2bf75a0 |
+45
-36
@@ -1,42 +1,6 @@
|
||||
# the name by which the project can be referenced within Serena/when chatting with the LLM.
|
||||
project_name: "superwork"
|
||||
|
||||
# list of languages for which language servers are started (LSP backend only); choose from:
|
||||
# ada al angular ansible bash
|
||||
# bsl clojure cpp cpp_ccls crystal
|
||||
# csharp csharp_omnisharp cue dart elixir
|
||||
# elm erlang fortran fsharp gdscript
|
||||
# go groovy haskell haxe hlsl
|
||||
# html java json julia kotlin
|
||||
# latex lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor php_phpantom powershell
|
||||
# python python_jedi python_pyrefly python_ty r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# scss solidity svelte swift systemverilog
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# yaml zig
|
||||
# (This list may be outdated; generated with scripts/print_language_list.py;
|
||||
# For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- go
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
@@ -64,6 +28,13 @@ ls_specific_settings: {}
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
|
||||
# Example:
|
||||
# ignored_paths:
|
||||
# - "examples/**"
|
||||
# - ".worktrees/**"
|
||||
# - "**/bin/**"
|
||||
# - "**/obj/**"
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
@@ -159,3 +130,41 @@ activation_command:
|
||||
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
|
||||
# must be a positive number.
|
||||
activation_command_timeout: 180.0
|
||||
|
||||
# list of language servers to start when using the LSP backend; choose from:
|
||||
# ada al angular ansible bash
|
||||
# bsl clojure cpp cpp_ccls crystal
|
||||
# csharp csharp_omnisharp cue dart deno
|
||||
# elixir elm erlang fortran fsharp
|
||||
# gdscript gleam go groovy haskell
|
||||
# haxe hlsl html java json
|
||||
# julia kotlin latex lean4 lua
|
||||
# luau markdown matlab msl nextflow
|
||||
# nix ocaml pascal perl php
|
||||
# php_phpactor php_phpantom powershell python python_basedpyright
|
||||
# python_jedi python_pyrefly python_ty qml r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# scss solidity svelte swift systemverilog
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# wolfram yaml zig
|
||||
# (This list may be outdated; generated with scripts/print_language_list.py;
|
||||
# For the current list, see values of the LanguageServerId enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
|
||||
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some language servers require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple language servers, the first language server that supports a given file will be used for that file.
|
||||
# The first language server is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
language_servers:
|
||||
- go
|
||||
- typescript
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"publisher": "clurdra",
|
||||
"name": "superpowers-vscode-clurdra",
|
||||
"displayName": "Superpowers-clurdra",
|
||||
"version": "0.2.87",
|
||||
"version": "0.2.90",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"description": "Superpowers specs and plans Kanban explorer",
|
||||
"author": "clurdra",
|
||||
|
||||
+97
-29
@@ -1,15 +1,13 @@
|
||||
/**
|
||||
* Thin wrapper around `git worktree add -b` for the 实施 flow.
|
||||
*
|
||||
* We invoke the system `git` binary via `execFile` rather than depend on a
|
||||
* git library — keeps the surface minimal and lets the user benefit from
|
||||
* whatever git they already have configured.
|
||||
* git worktree 的薄封装:实施流用 ensureWorktree 保证「这个 feature 有一个可用
|
||||
* worktree」,不依赖 git 库,走用户机器上的系统 git。
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promises as fsp, readdirSync, readFileSync, readlinkSync } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { deleteLocalBranch } from './branchSync'
|
||||
|
||||
/**
|
||||
* 把开头的 `~` 展开为用户 home 目录。execFile 不走 shell,传给它的命令/路径
|
||||
@@ -30,41 +28,111 @@ export function resolveWorktreePath(stored: string, workspaceRoot: string): stri
|
||||
}
|
||||
|
||||
export interface WorktreeOpts {
|
||||
/** Absolute path to the main workspace root. */
|
||||
workspaceRoot: string
|
||||
/** Absolute path of the worktree to create. */
|
||||
/** 模板 path;返回值即此路径。 */
|
||||
worktreePath: string
|
||||
/** New branch name; e.g. `feature/abcd1234`. */
|
||||
branch: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new git worktree with a freshly-created branch off current HEAD.
|
||||
* git -C <workspaceRoot> worktree add <worktreePath> -b <branch>
|
||||
*
|
||||
* Rejects with a descriptive Error if git exits non-zero. The caller should
|
||||
* surface stderr to the user via a toast.
|
||||
*/
|
||||
export async function createWorktree(opts: WorktreeOpts): Promise<void> {
|
||||
const { workspaceRoot, worktreePath, branch } = opts
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
execFile(
|
||||
'git',
|
||||
['-C', workspaceRoot, 'worktree', 'add', worktreePath, '-b', branch],
|
||||
{ timeout: 30_000 },
|
||||
(err, _stdout, stderr) => {
|
||||
function runGit(workspaceRoot: string, args: string[], failLabel?: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('git', ['-C', workspaceRoot, ...args], { timeout: 30_000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
// execFile sets `err.code` for non-zero exits; for spawn failures
|
||||
// (e.g. git not on PATH) it's a string like 'ENOENT'.
|
||||
// execFile: 非零退出是数字 code;git 不在 PATH 时是 'ENOENT'
|
||||
const code = (err as NodeJS.ErrnoException).code ?? 'unknown'
|
||||
const trimmed = (stderr ?? '').toString().trim() || err.message
|
||||
reject(new Error(`git worktree add 失败 (${code}): ${trimmed}`))
|
||||
const label = failLabel ?? `git ${args.join(' ')}`
|
||||
reject(new Error(`${label} 失败 (${code}): ${trimmed}`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
},
|
||||
)
|
||||
resolve((stdout ?? '').toString())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fsp.stat(p)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function parseWorktreePorcelain(stdout: string): Array<{ path: string, branch: string | null, prunable: boolean }> {
|
||||
const entries: Array<{ path: string, branch: string | null, prunable: boolean }> = []
|
||||
let current: { path: string, branch: string | null, prunable: boolean } | null = null
|
||||
const flush = (): void => {
|
||||
if (current?.path)
|
||||
entries.push(current)
|
||||
current = null
|
||||
}
|
||||
for (const line of stdout.split('\n')) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
flush()
|
||||
current = { path: line.slice('worktree '.length), branch: null, prunable: false }
|
||||
continue
|
||||
}
|
||||
if (!current)
|
||||
continue
|
||||
if (line.startsWith('branch ')) {
|
||||
const ref = line.slice('branch '.length)
|
||||
current.branch = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref
|
||||
}
|
||||
else if (line === 'detached') {
|
||||
current.branch = null
|
||||
}
|
||||
else if (line === 'prunable' || line.startsWith('prunable ')) {
|
||||
current.prunable = true
|
||||
}
|
||||
else if (line === '') {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return entries
|
||||
}
|
||||
|
||||
async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise<string | undefined> {
|
||||
const stdout = await runGit(workspaceRoot, ['worktree', 'list', '--porcelain'])
|
||||
for (const entry of parseWorktreePorcelain(stdout)) {
|
||||
if (entry.prunable || entry.branch !== branch)
|
||||
continue
|
||||
if (await pathExists(entry.path))
|
||||
return entry.path
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖到「进行中」= 从当前 HEAD 全新实施:清 leftover worktree / 本地分支,再 `add -b`。
|
||||
*/
|
||||
export async function ensureWorktree(opts: WorktreeOpts): Promise<string> {
|
||||
const { workspaceRoot, branch } = opts
|
||||
const worktreePath = path.resolve(opts.worktreePath)
|
||||
|
||||
// ① 该 branch 的 live worktree 可能不在模板 path → kill + 整目录删掉
|
||||
const live = await findLiveWorktreeForBranch(workspaceRoot, branch)
|
||||
if (live) {
|
||||
killProcessesUsingWorktree(live)
|
||||
await removeWorktreeDir(workspaceRoot, live)
|
||||
}
|
||||
|
||||
// ② 模板 path 仍被垃圾目录占着 → 同样 kill + remove,否则 ④ add 撞路径
|
||||
if (await pathExists(worktreePath)) {
|
||||
killProcessesUsingWorktree(worktreePath)
|
||||
await removeWorktreeDir(workspaceRoot, worktreePath)
|
||||
}
|
||||
|
||||
// ③ leftover 本地分支必须删掉;失败不能静默,否则 ④ `add -b` 撞同名分支
|
||||
const deleted = await deleteLocalBranch(workspaceRoot, branch)
|
||||
if (!deleted.ok)
|
||||
throw new Error(`删除本地分支失败 ${branch}: ${deleted.stderr || deleted.stdout}`)
|
||||
|
||||
// ④ 始终 -b 从当前 HEAD 新建,不挂旧分支
|
||||
await runGit(workspaceRoot, ['worktree', 'add', worktreePath, '-b', branch], 'git worktree add')
|
||||
return worktreePath
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -556,7 +556,7 @@ export class KanbanWebviewPanel {
|
||||
return
|
||||
}
|
||||
if (msg.type === 'managed-sessions/resume') {
|
||||
void managedSessions.handleManagedSessionsResume(this, msg.sessionId)
|
||||
void managedSessions.handleManagedSessionsResume(this, msg.sessionId, msg.profilePath)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'managed-sessions/delete') {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { buildCcCommand } from '../../cc/ccCommand'
|
||||
import { pollForNewSession, projectsDirFor } from '../../cc/sessionWatcher'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { listImportableNamedSessions } from '../../sessions/importableNamedSessions'
|
||||
import { decideManagedResumeProfile } from '../../sessions/managedResumeProfile'
|
||||
import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore'
|
||||
import { inferProfilePathForSession } from '../../sessions/sessionProfileInference'
|
||||
import { resolveProfilePath } from '../../cc/profiles'
|
||||
@@ -225,7 +226,7 @@ export async function handleManagedSessionsRename(panel: KanbanWebviewPanel, ses
|
||||
* 恢复一个受管理会话:在新终端 tab 里跑 `claude ... --resume <id>`,
|
||||
* cwd = workspaceRoot。in-flight 锁防止重复点击重复 createTerminal。
|
||||
*/
|
||||
export async function handleManagedSessionsResume(panel: KanbanWebviewPanel, sessionId: string): Promise<void> {
|
||||
export async function handleManagedSessionsResume(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string): Promise<void> {
|
||||
const lockKey = `managed:${sessionId}`
|
||||
if (panel.resumeInFlight.has(lockKey)) {
|
||||
logger.add({
|
||||
@@ -244,29 +245,35 @@ export async function handleManagedSessionsResume(panel: KanbanWebviewPanel, ses
|
||||
return
|
||||
}
|
||||
|
||||
const data = await readManagedSessions(workspaceRoot)
|
||||
const target = data.sessions.find(s => s.id === sessionId)
|
||||
|
||||
const override = profilePath?.trim() || undefined
|
||||
let inferred: string | null = null
|
||||
if (!override && (!target?.profilePath || target.profilePath.trim() === '') && target) {
|
||||
inferred = await inferProfilePathForSession(sessionId, projectsDirFor(workspaceRoot))
|
||||
}
|
||||
|
||||
const decided = decideManagedResumeProfile({
|
||||
storedProfile: target?.profilePath,
|
||||
overrideProfile: profilePath,
|
||||
inferredProfile: inferred,
|
||||
})
|
||||
if (decided.persist && target) {
|
||||
target.profilePath = decided.persist
|
||||
await writeManagedSessions(workspaceRoot, data)
|
||||
}
|
||||
|
||||
// 已有该会话的存活终端,直接聚焦、不再开新的(防重复)。
|
||||
const existing = panel.managedTerminals.get(sessionId)
|
||||
if (existing) {
|
||||
if (decided.persist)
|
||||
await pushManagedSessions(panel)
|
||||
existing.show(false)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await readManagedSessions(workspaceRoot)
|
||||
const target = data.sessions.find(s => s.id === sessionId)
|
||||
|
||||
// store 缺 profilePath 时从 transcript 推断并回写,避免 resume 落到 default profile。
|
||||
let storedProfile = target?.profilePath
|
||||
if ((!storedProfile || storedProfile.trim() === '') && target) {
|
||||
const inferred = await inferProfilePathForSession(sessionId, projectsDirFor(workspaceRoot))
|
||||
if (inferred) {
|
||||
storedProfile = inferred
|
||||
target.profilePath = inferred
|
||||
await writeManagedSessions(workspaceRoot, data)
|
||||
}
|
||||
}
|
||||
const effectiveProfilePath = resolveProfilePath(
|
||||
storedProfile && storedProfile.trim() !== '' ? storedProfile : undefined,
|
||||
)
|
||||
const effectiveProfilePath = resolveProfilePath(decided.effective)
|
||||
if (effectiveProfilePath.includes('\'')) {
|
||||
void window.showErrorMessage(
|
||||
`resume 失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
|
||||
@@ -16,13 +16,14 @@ import { resolveProfilePath } from '../../cc/profiles'
|
||||
import { getBrainstormContinuePrompt, getImplementPlanPrompt } from '../../cc/prompts'
|
||||
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
|
||||
import { detectRepo } from '../../git/remote'
|
||||
import { createWorktree, resolveWorktreePath } from '../../git/worktree'
|
||||
import { ensureWorktree, resolveWorktreePath } from '../../git/worktree'
|
||||
import { updateIssueAssignees } from '../../gitea/api'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { getSettings } from '../../settings/store'
|
||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||
import { decideAutoReviewPath } from '../autoReviewDecision'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { cleanupFeatureBranch } from './branchCleanup'
|
||||
|
||||
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
|
||||
// kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造
|
||||
@@ -766,45 +767,7 @@ export async function handleImplement(
|
||||
// 留空时回退到默认 ~/Sources/worktree/<项目>/<slug>。
|
||||
const worktreeTemplate = getSettings(panel.context).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
|
||||
// unwind partial state.
|
||||
let worktreeExists = false
|
||||
try {
|
||||
await fsp.stat(worktreePath)
|
||||
worktreeExists = true
|
||||
}
|
||||
catch {
|
||||
// ENOENT — good.
|
||||
}
|
||||
let branchExists = false
|
||||
try {
|
||||
branchExists = await new Promise<boolean>((resolve) => {
|
||||
execFile(
|
||||
'git',
|
||||
['-C', workspaceRoot, 'branch', '--list', branch],
|
||||
{ timeout: 10_000 },
|
||||
(err, stdout) => {
|
||||
if (err) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
resolve((stdout ?? '').trim().length > 0)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
catch {
|
||||
branchExists = false
|
||||
}
|
||||
if (worktreeExists || branchExists) {
|
||||
void window.showErrorMessage(
|
||||
`feature ${slug} 的 worktree 或分支已存在,请先清理`,
|
||||
)
|
||||
return
|
||||
}
|
||||
let worktreePath = resolveWorktreeDir(worktreeTemplate, workspaceRoot, slug)
|
||||
|
||||
// 工单级 profilePath > 默认 fallback。工单的 profile 锁定可在工单详情面板里覆盖。
|
||||
const effectiveProfilePath = panel.resolveImplementProfilePath(profilePath)
|
||||
@@ -834,20 +797,30 @@ export async function handleImplement(
|
||||
return
|
||||
}
|
||||
|
||||
// Create the worktree before anything else, so a worktree-add failure
|
||||
// doesn't leave half-written state behind.
|
||||
// 先保证 worktree 可用:后面写 state / 开终端都依赖实际 path。
|
||||
try {
|
||||
// 先清远程残留;本地 leftover 由 ensureWorktree 丢掉。必须在 add -b 之前,
|
||||
// 否则 cleanup 会把刚建的新分支删掉。live worktree 上本地 -D 失败只 warn。
|
||||
await cleanupFeatureBranch({
|
||||
workspaceRoot,
|
||||
branch,
|
||||
issueNumber,
|
||||
devBranch: settings.devBranch,
|
||||
autoBuildBranch: settings.autoBuildBranch,
|
||||
remote,
|
||||
token,
|
||||
})
|
||||
// worktree 现在建在工作区外(~/Sources/worktree/<项目>/...),父目录可能不存在;
|
||||
// git worktree add 不会创建多层父目录,先补齐。
|
||||
await fsp.mkdir(path.dirname(worktreePath), { recursive: true })
|
||||
await createWorktree({ workspaceRoot, worktreePath, branch })
|
||||
worktreePath = await ensureWorktree({ workspaceRoot, worktreePath, branch })
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({
|
||||
level: 'error',
|
||||
source: 'implement',
|
||||
message: 'git worktree add 失败',
|
||||
message: 'ensureWorktree 失败',
|
||||
details: message,
|
||||
})
|
||||
void window.showErrorMessage(message)
|
||||
|
||||
@@ -191,7 +191,7 @@ export type WebviewToExtension
|
||||
| { type: 'managed-sessions/get' }
|
||||
| { type: 'managed-sessions/create', profilePath: string, name?: string, prompt?: string }
|
||||
| { type: 'managed-sessions/rename', sessionId: string, name: string }
|
||||
| { type: 'managed-sessions/resume', sessionId: string }
|
||||
| { type: 'managed-sessions/resume', sessionId: string, profilePath?: string }
|
||||
| { type: 'managed-sessions/delete', sessionId: string }
|
||||
| { type: 'managed-sessions/close-tab', sessionId: string }
|
||||
| { type: 'managed-sessions/list-importable' }
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decideManagedResumeProfile } from './managedResumeProfile'
|
||||
|
||||
describe('decideManagedResumeProfile', () => {
|
||||
it('override 非空时用 override,与 stored 不同则写回', () => {
|
||||
expect(decideManagedResumeProfile({
|
||||
storedProfile: '/profiles/grok-4.5.json',
|
||||
overrideProfile: '/profiles/opus.json',
|
||||
inferredProfile: '/profiles/from-jsonl.json',
|
||||
})).toEqual({
|
||||
effective: '/profiles/opus.json',
|
||||
persist: '/profiles/opus.json',
|
||||
})
|
||||
})
|
||||
|
||||
it('override 与 stored trim 后相同则不写回,且忽略 inferred', () => {
|
||||
expect(decideManagedResumeProfile({
|
||||
storedProfile: ' /profiles/grok-4.5.json ',
|
||||
overrideProfile: '/profiles/grok-4.5.json',
|
||||
inferredProfile: '/profiles/from-jsonl.json',
|
||||
})).toEqual({
|
||||
effective: '/profiles/grok-4.5.json',
|
||||
persist: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('空白 override 视为未提供,回落到 stored', () => {
|
||||
expect(decideManagedResumeProfile({
|
||||
storedProfile: '/profiles/grok-4.5.json',
|
||||
overrideProfile: ' ',
|
||||
inferredProfile: '/profiles/from-jsonl.json',
|
||||
})).toEqual({
|
||||
effective: '/profiles/grok-4.5.json',
|
||||
persist: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('无 override 时用 stored,不写回也不用 inferred', () => {
|
||||
expect(decideManagedResumeProfile({
|
||||
storedProfile: '/profiles/grok-4.5.json',
|
||||
overrideProfile: undefined,
|
||||
inferredProfile: '/profiles/from-jsonl.json',
|
||||
})).toEqual({
|
||||
effective: '/profiles/grok-4.5.json',
|
||||
persist: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('override 与 stored 都空时用 inferred 并写回', () => {
|
||||
expect(decideManagedResumeProfile({
|
||||
storedProfile: ' ',
|
||||
overrideProfile: undefined,
|
||||
inferredProfile: '/profiles/from-jsonl.json',
|
||||
})).toEqual({
|
||||
effective: '/profiles/from-jsonl.json',
|
||||
persist: '/profiles/from-jsonl.json',
|
||||
})
|
||||
})
|
||||
|
||||
it('三者都空则 effective 与 persist 都为 undefined', () => {
|
||||
expect(decideManagedResumeProfile({
|
||||
storedProfile: undefined,
|
||||
overrideProfile: '',
|
||||
inferredProfile: null,
|
||||
})).toEqual({
|
||||
effective: undefined,
|
||||
persist: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
export function decideManagedResumeProfile(input: {
|
||||
storedProfile: string | undefined
|
||||
overrideProfile: string | undefined
|
||||
inferredProfile: string | null
|
||||
}): { effective: string | undefined, persist: string | undefined } {
|
||||
const override = input.overrideProfile?.trim() || undefined
|
||||
const stored = input.storedProfile?.trim() || undefined
|
||||
|
||||
if (override) { // ① 行内覆盖:与 store 不同才写回;忽略 jsonl 推断
|
||||
return {
|
||||
effective: override,
|
||||
persist: override !== stored ? override : undefined,
|
||||
}
|
||||
}
|
||||
if (stored) // ② store 已有 → 沿用,不写回
|
||||
return { effective: stored, persist: undefined }
|
||||
if (input.inferredProfile) // ③ 缺 profile 才用 jsonl 回填并写回
|
||||
return { effective: input.inferredProfile, persist: input.inferredProfile }
|
||||
return { effective: undefined, persist: undefined }
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, promises as fsp, rmSync, writeFileSync } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ensureWorktree } from '../../src/git/worktree'
|
||||
|
||||
interface Fixture {
|
||||
root: string
|
||||
repo: string
|
||||
templatePath: string
|
||||
}
|
||||
|
||||
const gitEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_AUTHOR_NAME: 'ensure-test',
|
||||
GIT_AUTHOR_EMAIL: 'ensure@test.local',
|
||||
GIT_COMMITTER_NAME: 'ensure-test',
|
||||
GIT_COMMITTER_EMAIL: 'ensure@test.local',
|
||||
}
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim()
|
||||
}
|
||||
|
||||
async function setupRepo(): Promise<Fixture> {
|
||||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-ensure-'))
|
||||
const repo = path.join(root, 'sw-ensure', 'repo')
|
||||
await fsp.mkdir(repo, { recursive: true })
|
||||
git(repo, ['init', '-b', 'main'])
|
||||
git(repo, ['config', 'user.email', 'ensure@test.local'])
|
||||
git(repo, ['config', 'user.name', 'ensure-test'])
|
||||
writeFileSync(path.join(repo, 'README.md'), 'hello\n')
|
||||
git(repo, ['add', 'README.md'])
|
||||
git(repo, ['commit', '-m', 'init'])
|
||||
const templatePath = path.join(root, 'sw-ensure', 'wt', 'feature-x')
|
||||
await fsp.mkdir(path.dirname(templatePath), { recursive: true })
|
||||
return { root, repo, templatePath }
|
||||
}
|
||||
|
||||
async function cleanup(fx: Fixture | undefined): Promise<void> {
|
||||
if (!fx)
|
||||
return
|
||||
try {
|
||||
const out = git(fx.repo, ['worktree', 'list', '--porcelain'])
|
||||
const extras: string[] = []
|
||||
for (const line of out.split('\n')) {
|
||||
if (!line.startsWith('worktree '))
|
||||
continue
|
||||
const p = line.slice('worktree '.length)
|
||||
if (path.resolve(p) !== path.resolve(fx.repo))
|
||||
extras.push(p)
|
||||
}
|
||||
for (const p of extras) {
|
||||
try {
|
||||
git(fx.repo, ['worktree', 'remove', '--force', p])
|
||||
}
|
||||
catch {
|
||||
rmSync(p, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// repo 可能已残,下面整棵 tmp 仍要删
|
||||
}
|
||||
rmSync(fx.root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
describe('ensureWorktree', () => {
|
||||
let fx: Fixture | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanup(fx)
|
||||
fx = undefined
|
||||
})
|
||||
|
||||
it('本地已有 feature 分支且无 worktree 时丢掉旧 commit 再新建', async () => {
|
||||
fx = await setupRepo()
|
||||
git(fx.repo, ['checkout', '-b', 'feature/x'])
|
||||
writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\n')
|
||||
git(fx.repo, ['add', 'README.md'])
|
||||
git(fx.repo, ['commit', '-m', 'extra 1'])
|
||||
writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\nextra-2\n')
|
||||
git(fx.repo, ['add', 'README.md'])
|
||||
git(fx.repo, ['commit', '-m', 'extra 2'])
|
||||
writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\nextra-2\nextra-3\n')
|
||||
git(fx.repo, ['add', 'README.md'])
|
||||
git(fx.repo, ['commit', '-m', 'extra 3'])
|
||||
git(fx.repo, ['checkout', 'main'])
|
||||
const ahead = git(fx.repo, ['log', '--oneline', 'main..feature/x'])
|
||||
expect(ahead.split('\n').filter(Boolean).length).toBe(3)
|
||||
|
||||
const actual = await ensureWorktree({
|
||||
workspaceRoot: fx.repo,
|
||||
worktreePath: fx.templatePath,
|
||||
branch: 'feature/x',
|
||||
})
|
||||
|
||||
expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath))
|
||||
expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
|
||||
expect(git(fx.repo, ['log', '--oneline', 'main..feature/x'])).toBe('')
|
||||
})
|
||||
|
||||
it('分支和 worktree 都不存在时创建新分支 worktree', async () => {
|
||||
fx = await setupRepo()
|
||||
|
||||
const actual = await ensureWorktree({
|
||||
workspaceRoot: fx.repo,
|
||||
worktreePath: fx.templatePath,
|
||||
branch: 'feature/fresh',
|
||||
})
|
||||
|
||||
expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath))
|
||||
expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/fresh')
|
||||
expect(git(fx.repo, ['show-ref', '--verify', '--quiet', 'refs/heads/feature/fresh'])).toBe('')
|
||||
})
|
||||
|
||||
it('该 branch 已有 live worktree 时删掉旧 path 在模板 path 新建', async () => {
|
||||
fx = await setupRepo()
|
||||
git(fx.repo, ['branch', 'feature/x'])
|
||||
const existing = path.join(fx.root, 'sw-ensure', 'wt', 'already-here')
|
||||
await fsp.mkdir(path.dirname(existing), { recursive: true })
|
||||
git(fx.repo, ['worktree', 'add', existing, 'feature/x'])
|
||||
|
||||
const actual = await ensureWorktree({
|
||||
workspaceRoot: fx.repo,
|
||||
worktreePath: fx.templatePath,
|
||||
branch: 'feature/x',
|
||||
})
|
||||
|
||||
expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath))
|
||||
expect(existsSync(existing)).toBe(false)
|
||||
expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
|
||||
const listed = git(fx.repo, ['worktree', 'list', '--porcelain'])
|
||||
const wtLines = listed.split('\n').filter(l => l.startsWith('worktree '))
|
||||
expect(wtLines).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('模板 path 是垃圾目录且分支不存在时删掉后 add -b 成功', async () => {
|
||||
fx = await setupRepo()
|
||||
await fsp.mkdir(fx.templatePath, { recursive: true })
|
||||
writeFileSync(path.join(fx.templatePath, 'junk.txt'), 'garbage')
|
||||
|
||||
const actual = await ensureWorktree({
|
||||
workspaceRoot: fx.repo,
|
||||
worktreePath: fx.templatePath,
|
||||
branch: 'feature/fresh',
|
||||
})
|
||||
|
||||
expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath))
|
||||
expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/fresh')
|
||||
expect(existsSync(path.join(fx.templatePath, 'junk.txt'))).toBe(false)
|
||||
expect(existsSync(path.join(fx.templatePath, 'README.md'))).toBe(true)
|
||||
})
|
||||
|
||||
it('第二次调用仍成功且在同一模板 path', async () => {
|
||||
fx = await setupRepo()
|
||||
|
||||
const first = await ensureWorktree({
|
||||
workspaceRoot: fx.repo,
|
||||
worktreePath: fx.templatePath,
|
||||
branch: 'feature/x',
|
||||
})
|
||||
writeFileSync(path.join(first, 'leftover.txt'), 'should-be-gone')
|
||||
const second = await ensureWorktree({
|
||||
workspaceRoot: fx.repo,
|
||||
worktreePath: fx.templatePath,
|
||||
branch: 'feature/x',
|
||||
})
|
||||
|
||||
expect(path.resolve(second)).toBe(path.resolve(fx.templatePath))
|
||||
expect(git(second, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
|
||||
expect(existsSync(path.join(second, 'leftover.txt'))).toBe(false)
|
||||
const wtLines = git(fx.repo, ['worktree', 'list', '--porcelain'])
|
||||
.split('\n')
|
||||
.filter(l => l.startsWith('worktree '))
|
||||
expect(wtLines).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -60,7 +60,7 @@ interface BottomTabsProps {
|
||||
profiles: ClaudeProfile[]
|
||||
onManagedSessionCreate: (profilePath: string, name?: string, prompt?: string) => void
|
||||
onManagedSessionRename: (id: string, name: string) => void
|
||||
onManagedSessionResume: (id: string) => void
|
||||
onManagedSessionResume: (id: string, profilePath?: string) => void
|
||||
onManagedSessionDelete: (id: string) => void
|
||||
onManagedSessionCloseTab: (id: string) => void
|
||||
importableSessions: Array<{ id: string, name: string, mtimeMs: number }>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* - 上方表单:选 profile(下拉,复用 useIssues 的 ClaudeProfile 列表)+ 首个提示词
|
||||
* (可选,多行)+「创建」按钮 → 主进程开一个 cc 终端 tab 并捕获会话。
|
||||
* - 下方列表:每行显示名字(双击行内改名,Enter 提交、Esc 取消)、profile 文件名、
|
||||
* 创建时间;点击行(非编辑区)→ 主进程新终端 resume;行尾删除按钮(仅移除记录)。
|
||||
* - 下方列表:每行显示名字(双击行内改名,Enter 提交、Esc 取消)、行内可改 profile、
|
||||
* 创建时间;点行用该 profile resume 并写回;行尾删除按钮(仅移除记录)。
|
||||
*/
|
||||
|
||||
import type { ClaudeProfile } from '../hooks/useIssues'
|
||||
@@ -17,7 +17,7 @@ interface ManagedSessionsPanelProps {
|
||||
profiles: ClaudeProfile[]
|
||||
onCreate: (profilePath: string, name?: string, prompt?: string) => void
|
||||
onRename: (id: string, name: string) => void
|
||||
onResume: (id: string) => void
|
||||
onResume: (id: string, profilePath?: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onCloseTab: (id: string) => void
|
||||
importableSessions: Array<{ id: string, name: string, mtimeMs: number }>
|
||||
@@ -33,6 +33,16 @@ function basename(p?: string): string {
|
||||
return idx >= 0 ? p.slice(idx + 1) : p
|
||||
}
|
||||
|
||||
function matchStoredProfile(stored: string | undefined, profiles: ClaudeProfile[]): string {
|
||||
if (!stored)
|
||||
return ''
|
||||
if (profiles.some(p => p.path === stored))
|
||||
return stored
|
||||
const storedBase = basename(stored)
|
||||
const fuzzy = profiles.find(p => basename(p.path) === storedBase || p.name === storedBase)
|
||||
return fuzzy?.path ?? stored
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
if (!ts)
|
||||
return ''
|
||||
@@ -246,6 +256,7 @@ export function ManagedSessionsPanel({
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
profiles={profiles}
|
||||
onRename={onRename}
|
||||
onResume={onResume}
|
||||
onDelete={onDelete}
|
||||
@@ -261,16 +272,19 @@ export function ManagedSessionsPanel({
|
||||
|
||||
interface SessionRowProps {
|
||||
session: ManagedSession
|
||||
profiles: ClaudeProfile[]
|
||||
onRename: (id: string, name: string) => void
|
||||
onResume: (id: string) => void
|
||||
onResume: (id: string, profilePath?: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onCloseTab: (id: string) => void
|
||||
}
|
||||
|
||||
function SessionRow({ session, onRename, onResume, onDelete, onCloseTab }: SessionRowProps) {
|
||||
function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTab }: SessionRowProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(session.name)
|
||||
const [userPicked, setUserPicked] = useState<string | undefined>(undefined)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const pickedProfile = userPicked ?? matchStoredProfile(session.profilePath, profiles)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editing)
|
||||
@@ -282,6 +296,10 @@ function SessionRow({ session, onRename, onResume, onDelete, onCloseTab }: Sessi
|
||||
el.select()
|
||||
}, [editing])
|
||||
|
||||
useEffect(() => {
|
||||
setUserPicked(undefined)
|
||||
}, [session.profilePath])
|
||||
|
||||
const beginEdit = useCallback((): void => {
|
||||
setDraft(session.name)
|
||||
setEditing(true)
|
||||
@@ -299,8 +317,12 @@ function SessionRow({ session, onRename, onResume, onDelete, onCloseTab }: Sessi
|
||||
setDraft(session.name)
|
||||
}, [session.name])
|
||||
|
||||
const profileName = basename(session.profilePath)
|
||||
const resume = useCallback((): void => {
|
||||
onResume(session.id, pickedProfile || session.profilePath)
|
||||
}, [onResume, session.id, session.profilePath, pickedProfile])
|
||||
|
||||
const created = formatTime(session.createdAt)
|
||||
const storedMissing = !profiles.some(p => p.path === pickedProfile)
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -308,7 +330,7 @@ function SessionRow({ session, onRename, onResume, onDelete, onCloseTab }: Sessi
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResume(session.id)}
|
||||
onClick={resume}
|
||||
title="点击恢复会话(新终端 resume)"
|
||||
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-[var(--vscode-foreground)]"
|
||||
aria-label="恢复会话"
|
||||
@@ -320,7 +342,7 @@ function SessionRow({ session, onRename, onResume, onDelete, onCloseTab }: Sessi
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (!editing)
|
||||
onResume(session.id)
|
||||
resume()
|
||||
}}
|
||||
>
|
||||
{editing
|
||||
@@ -357,8 +379,32 @@ function SessionRow({ session, onRename, onResume, onDelete, onCloseTab }: Sessi
|
||||
>
|
||||
{session.name || <span className="opacity-40">(未命名)</span>}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-[var(--vscode-descriptionForeground)]">
|
||||
{[profileName, created].filter(Boolean).join(' · ')}
|
||||
<span className="flex min-w-0 items-center text-[10px] text-[var(--vscode-descriptionForeground)]">
|
||||
{profiles.length === 0
|
||||
? (
|
||||
<span className="truncate">{basename(session.profilePath)}</span>
|
||||
)
|
||||
: (
|
||||
<select
|
||||
value={pickedProfile}
|
||||
title="换 profile 后点行打开;已打开的终端下次才生效"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
setUserPicked(e.target.value)
|
||||
}}
|
||||
className="min-w-0 bg-transparent py-0 pr-1 text-[10px] text-[var(--vscode-descriptionForeground)] outline-none focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
|
||||
>
|
||||
{storedMissing && (
|
||||
<option value={pickedProfile}>{basename(pickedProfile) || '(未设置)'}</option>
|
||||
)}
|
||||
{profiles.map(p => (
|
||||
<option key={p.path} value={p.path}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{created ? <span className="shrink-0">{(profiles.length > 0 || Boolean(basename(session.profilePath))) ? ' · ' : ''}{created}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface UseManagedSessionsResult {
|
||||
managedSessions: ManagedSessionsData
|
||||
createManagedSession: (profilePath: string, name?: string, prompt?: string) => void
|
||||
renameManagedSession: (id: string, name: string) => void
|
||||
resumeManagedSession: (id: string) => void
|
||||
resumeManagedSession: (id: string, profilePath?: string) => void
|
||||
deleteManagedSession: (id: string) => void
|
||||
closeManagedSessionTab: (id: string) => void
|
||||
listImportableNamedSessions: () => void
|
||||
@@ -57,8 +57,8 @@ export function useManagedSessions(): UseManagedSessionsResult {
|
||||
postMessage({ type: 'managed-sessions/rename', sessionId: id, name })
|
||||
}, [])
|
||||
|
||||
const resumeManagedSession = useCallback((id: string): void => {
|
||||
postMessage({ type: 'managed-sessions/resume', sessionId: id })
|
||||
const resumeManagedSession = useCallback((id: string, profilePath?: string): void => {
|
||||
postMessage({ type: 'managed-sessions/resume', sessionId: id, profilePath })
|
||||
}, [])
|
||||
|
||||
const deleteManagedSession = useCallback((id: string): void => {
|
||||
|
||||
@@ -206,7 +206,7 @@ export type WebviewToExtension
|
||||
| { type: 'managed-sessions/get' }
|
||||
| { type: 'managed-sessions/create', profilePath: string, name?: string, prompt?: string }
|
||||
| { type: 'managed-sessions/rename', sessionId: string, name: string }
|
||||
| { type: 'managed-sessions/resume', sessionId: string }
|
||||
| { type: 'managed-sessions/resume', sessionId: string, profilePath?: string }
|
||||
| { type: 'managed-sessions/delete', sessionId: string }
|
||||
| { type: 'managed-sessions/close-tab', sessionId: string }
|
||||
| { type: 'managed-sessions/list-importable' }
|
||||
|
||||
Reference in New Issue
Block a user