🐛 fix(vscode): 接管时把会话文件里的发送方路径重写为本机路径
Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
1 parent
07c40897e2
commit
71a91fe7ec
7 files changed
+151
-10
No files matched your search
@@ -106,4 +106,45 @@ describe('pack → unpack → install', () => {
|
||||
await installHandoffBundle(staging, manifest, { projectsRoot, codexRoot: tempDir('receiver-codex-'), workspaceProjectsDir })
|
||||
expect(existsSync(path.join(workspaceProjectsDir, `${IMPL}.jsonl`))).toBe(true)
|
||||
})
|
||||
|
||||
it('带 worktreePath + pathRewrite:装机文件里的发送方路径全部改写成接收方路径', async () => {
|
||||
const WORKTREE_FROM = '/Users/chw/wt/x'
|
||||
const WORKTREE_TO = '/home/me/wt/x'
|
||||
const roots = senderRoots()
|
||||
// sender 会话文件里嵌入发送方的 worktree 路径(plain / file:// 两种形式)
|
||||
const wt = path.join(roots.projectsRoot, '-Users-chw-wt-x')
|
||||
writeFileSync(path.join(wt, `${IMPL}.jsonl`), `{"cwd":"${WORKTREE_FROM}"}\n{"cwd":"file://${WORKTREE_FROM}"}\n`)
|
||||
writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), `{"cwd":"${WORKTREE_FROM}"}\n`)
|
||||
const day = path.join(roots.codexRoot, '2026', '08', '26')
|
||||
writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), `{"cwd":"${WORKTREE_FROM}"}\n{"cwd":"file://${WORKTREE_FROM}"}\n`)
|
||||
|
||||
const staging = tempDir('staging-')
|
||||
const manifest = await stageHandoffBundle({
|
||||
issueNumber: 7,
|
||||
from: 'chw',
|
||||
worktreePath: WORKTREE_FROM,
|
||||
sessions: { implementSessionId: IMPL, reviewSessionId: REVIEW },
|
||||
profiles: {},
|
||||
}, staging, roots)
|
||||
expect(manifest.worktreePath).toBe(WORKTREE_FROM)
|
||||
|
||||
const projectsRoot = tempDir('receiver-projects-')
|
||||
const codexRoot = tempDir('receiver-codex-')
|
||||
const worktreeProjectsDir = path.join(projectsRoot, '-home-me-wt-x')
|
||||
await installHandoffBundle(staging, manifest, {
|
||||
projectsRoot,
|
||||
codexRoot,
|
||||
workspaceProjectsDir: path.join(projectsRoot, '-home-me-proj'),
|
||||
worktreeProjectsDir,
|
||||
pathRewrite: { from: WORKTREE_FROM, to: WORKTREE_TO },
|
||||
})
|
||||
|
||||
const installedJsonl = readFileSync(path.join(worktreeProjectsDir, `${IMPL}.jsonl`), 'utf8')
|
||||
const installedSubagent = readFileSync(path.join(worktreeProjectsDir, IMPL, 'subagents', 'agent-1.jsonl'), 'utf8')
|
||||
const installedRollout = readFileSync(path.join(codexRoot, manifest.codex[0].relPath), 'utf8')
|
||||
for (const content of [installedJsonl, installedSubagent, installedRollout]) {
|
||||
expect(content).toContain(WORKTREE_TO)
|
||||
expect(content).not.toContain(WORKTREE_FROM)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
findClaudeSessionFiles,
|
||||
installClaudeSession,
|
||||
installCodexSession,
|
||||
rewritePathInFile,
|
||||
rewritePathInTree,
|
||||
} from './sessionBundle'
|
||||
|
||||
export interface HandoffSource {
|
||||
issueNumber: number
|
||||
from: string
|
||||
branch?: string
|
||||
worktreePath?: string
|
||||
sessions: HandoffSessions
|
||||
profiles: HandoffProfiles
|
||||
}
|
||||
@@ -64,6 +67,7 @@ export async function stageHandoffBundle(
|
||||
from: src.from,
|
||||
createdAt: now.toISOString(),
|
||||
...(src.branch ? { branch: src.branch } : {}),
|
||||
...(src.worktreePath ? { worktreePath: src.worktreePath } : {}),
|
||||
sessions: src.sessions,
|
||||
profiles: src.profiles,
|
||||
claude,
|
||||
@@ -76,6 +80,8 @@ export async function stageHandoffBundle(
|
||||
export interface HandoffInstallTargets extends HandoffRoots {
|
||||
workspaceProjectsDir: string
|
||||
worktreeProjectsDir?: string
|
||||
/** 发送方与接收方的 worktree 路径不同:安装前先把会话文件里的旧路径重写成新路径。 */
|
||||
pathRewrite?: { from: string, to: string }
|
||||
}
|
||||
|
||||
export async function installHandoffBundle(
|
||||
@@ -84,6 +90,18 @@ export async function installHandoffBundle(
|
||||
targets: HandoffInstallTargets,
|
||||
): Promise<void> {
|
||||
const claudeDir = path.join(extractedDir, 'claude')
|
||||
const rewrite = targets.pathRewrite
|
||||
// 发送方 worktree 在接收方上不存在:装文件前先把 cwd 类路径全部改写,
|
||||
// 否则 `codex resume` / MCP 起进程时照着旧路径找目录会直接 ENOENT。
|
||||
if (rewrite && rewrite.from !== rewrite.to) {
|
||||
for (const { id } of manifest.claude) {
|
||||
await rewritePathInFile(path.join(claudeDir, `${id}.jsonl`), rewrite.from, rewrite.to)
|
||||
await rewritePathInTree(path.join(claudeDir, id), rewrite.from, rewrite.to)
|
||||
}
|
||||
for (const { relPath } of manifest.codex)
|
||||
await rewritePathInFile(path.join(extractedDir, 'codex', relPath), rewrite.from, rewrite.to)
|
||||
}
|
||||
|
||||
for (const { id, kind } of manifest.claude) {
|
||||
const dstProjectsDir = kind === 'brainstorm'
|
||||
? targets.workspaceProjectsDir
|
||||
|
||||
@@ -43,4 +43,11 @@ describe('parseHandoffManifest', () => {
|
||||
it('不是 JSON → 抛错', () => {
|
||||
expect(() => parseHandoffManifest('nope', 42)).toThrow()
|
||||
})
|
||||
it('带 worktreePath → 原样带出', () => {
|
||||
const withPath = { ...good, worktreePath: '/Users/chw/wt/x' }
|
||||
expect(parseHandoffManifest(JSON.stringify(withPath), 42)).toEqual(withPath)
|
||||
})
|
||||
it('不带 worktreePath → 结果里没有这个 key', () => {
|
||||
expect(parseHandoffManifest(JSON.stringify(good), 42)).not.toHaveProperty('worktreePath')
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ export interface HandoffManifest {
|
||||
from: string
|
||||
createdAt: string
|
||||
branch?: string
|
||||
worktreePath?: string
|
||||
sessions: HandoffSessions
|
||||
profiles: HandoffProfiles
|
||||
claude: Array<{ id: string, kind: ClaudeSessionKind }>
|
||||
@@ -96,6 +97,7 @@ export function parseHandoffManifest(json: string, expectedIssue: number): Hando
|
||||
from,
|
||||
createdAt,
|
||||
...(optString(raw.branch) ? { branch: optString(raw.branch) } : {}),
|
||||
...(optString(raw.worktreePath) ? { worktreePath: optString(raw.worktreePath) } : {}),
|
||||
sessions,
|
||||
profiles,
|
||||
claude,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
installClaudeSession,
|
||||
installCodexSession,
|
||||
listClaudeSessionCopies,
|
||||
rewritePathInFile,
|
||||
rewritePathInTree,
|
||||
} from './sessionBundle'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
@@ -123,3 +125,41 @@ describe('codex', () => {
|
||||
expect(readFileSync(path.join(codexRoot, rel), 'utf8')).toBe('new\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewritePathInFile / rewritePathInTree', () => {
|
||||
it('文件里出现 3 次 → 返回 3 且内容被替换', async () => {
|
||||
const dir = tempDir('rewrite-')
|
||||
const file = path.join(dir, 'a.jsonl')
|
||||
writeFileSync(file, '{"cwd":"/Users/chw/wt/x"}\n{"cwd":"file:///Users/chw/wt/x"}\n/Users/chw/wt/x\n')
|
||||
const count = await rewritePathInFile(file, '/Users/chw/wt/x', '/home/me/wt/x')
|
||||
expect(count).toBe(3)
|
||||
const content = readFileSync(file, 'utf8')
|
||||
expect(content).not.toContain('/Users/chw/wt/x')
|
||||
expect(content).toBe('{"cwd":"/home/me/wt/x"}\n{"cwd":"file:///home/me/wt/x"}\n/home/me/wt/x\n')
|
||||
})
|
||||
|
||||
it('文件里没出现 → 返回 0 且内容不变', async () => {
|
||||
const dir = tempDir('rewrite-')
|
||||
const file = path.join(dir, 'a.jsonl')
|
||||
writeFileSync(file, '{"cwd":"/other/path"}\n')
|
||||
const count = await rewritePathInFile(file, '/Users/chw/wt/x', '/home/me/wt/x')
|
||||
expect(count).toBe(0)
|
||||
expect(readFileSync(file, 'utf8')).toBe('{"cwd":"/other/path"}\n')
|
||||
})
|
||||
|
||||
it('递归重写目录下所有文件,返回总替换次数', async () => {
|
||||
const dir = tempDir('rewrite-tree-')
|
||||
writeFileSync(path.join(dir, 'a.jsonl'), '/Users/chw/wt/x\n')
|
||||
mkdirSync(path.join(dir, 'sub', 'subagents'), { recursive: true })
|
||||
writeFileSync(path.join(dir, 'sub', 'subagents', 'agent-1.jsonl'), '/Users/chw/wt/x /Users/chw/wt/x\n')
|
||||
writeFileSync(path.join(dir, 'sub', 'unrelated.txt'), 'no match here\n')
|
||||
const total = await rewritePathInTree(dir, '/Users/chw/wt/x', '/home/me/wt/x')
|
||||
expect(total).toBe(3)
|
||||
expect(readFileSync(path.join(dir, 'a.jsonl'), 'utf8')).toBe('/home/me/wt/x\n')
|
||||
expect(readFileSync(path.join(dir, 'sub', 'subagents', 'agent-1.jsonl'), 'utf8')).toBe('/home/me/wt/x /home/me/wt/x\n')
|
||||
})
|
||||
|
||||
it('目录不存在 → 返回 0', async () => {
|
||||
expect(await rewritePathInTree('/nonexistent/dir', '/a', '/b')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -120,3 +120,35 @@ export async function installCodexSession(relPath: string, srcFile: string, code
|
||||
await fsp.mkdir(path.dirname(dst), { recursive: true })
|
||||
await fsp.copyFile(srcFile, dst)
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移会话里发送方的 worktree 路径重写成接收方的路径。
|
||||
* split/join 而非正则替换——路径里的 `.` `(` 等字符不用转义,出现次数就是替换次数。
|
||||
*/
|
||||
export async function rewritePathInFile(file: string, from: string, to: string): Promise<number> {
|
||||
const content = await fsp.readFile(file, 'utf8')
|
||||
const parts = content.split(from)
|
||||
const count = parts.length - 1
|
||||
if (count > 0)
|
||||
await fsp.writeFile(file, parts.join(to))
|
||||
return count
|
||||
}
|
||||
|
||||
export async function rewritePathInTree(dir: string, from: string, to: string): Promise<number> {
|
||||
let entries: Array<{ name: string, isDirectory: () => boolean, isFile: () => boolean }>
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||
}
|
||||
catch {
|
||||
return 0
|
||||
}
|
||||
let total = 0
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory())
|
||||
total += await rewritePathInTree(full, from, to)
|
||||
else if (entry.isFile())
|
||||
total += await rewritePathInFile(full, from, to)
|
||||
}
|
||||
return total
|
||||
}
|
||||
Reference in new issue
Block a user