mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: recover legacy Windows session workdirs
This commit is contained in:
parent
cf7c4487f0
commit
3c3030fddf
@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import * as path from 'node:path'
|
||||
import { isOutsideWorkDir } from '../path-safety.js'
|
||||
|
||||
// ---------- helpers extracted from feishu/index.ts for testability ----------
|
||||
|
||||
@ -209,14 +209,6 @@ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
|
||||
}
|
||||
}
|
||||
|
||||
function isOutsideWorkDir(filePath: string, workDir: string): boolean {
|
||||
const abs = path.isAbsolute(filePath)
|
||||
? path.normalize(filePath)
|
||||
: path.resolve(workDir, filePath)
|
||||
const normWork = path.normalize(workDir).replace(/\/+$/, '')
|
||||
return abs !== normWork && !abs.startsWith(normWork + path.sep)
|
||||
}
|
||||
|
||||
function truncateTarget(s: string, maxLen = 160): string {
|
||||
if (s.length <= maxLen) return s
|
||||
return s.slice(0, maxLen - 1) + '…'
|
||||
|
||||
@ -37,6 +37,7 @@ import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
||||
import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
|
||||
import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js'
|
||||
import type { PendingUpload } from '../common/attachment/attachment-types.js'
|
||||
import { isOutsideWorkDir } from './path-safety.js'
|
||||
|
||||
// ---------- init ----------
|
||||
|
||||
@ -502,16 +503,6 @@ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
|
||||
}
|
||||
}
|
||||
|
||||
/** True if `filePath` resolves to a location outside of `workDir`.
|
||||
* Relative paths are resolved against workDir first. */
|
||||
function isOutsideWorkDir(filePath: string, workDir: string): boolean {
|
||||
const abs = path.isAbsolute(filePath)
|
||||
? path.normalize(filePath)
|
||||
: path.resolve(workDir, filePath)
|
||||
const normWork = path.normalize(workDir).replace(/\/+$/, '')
|
||||
return abs !== normWork && !abs.startsWith(normWork + path.sep)
|
||||
}
|
||||
|
||||
/** Truncate a single-line target preview (e.g. shell command) to maxLen. */
|
||||
function truncateTarget(s: string, maxLen = 160): string {
|
||||
if (s.length <= maxLen) return s
|
||||
|
||||
23
adapters/feishu/path-safety.ts
Normal file
23
adapters/feishu/path-safety.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import * as path from 'node:path'
|
||||
|
||||
/** True if `filePath` resolves to a location outside of `workDir`.
|
||||
* Relative paths are resolved against workDir first. */
|
||||
export function isOutsideWorkDir(filePath: string, workDir: string): boolean {
|
||||
const pathApi = usesWindowsPath(filePath) || usesWindowsPath(workDir) ? path.win32 : path.posix
|
||||
const abs = pathApi.isAbsolute(filePath)
|
||||
? pathApi.normalize(filePath)
|
||||
: pathApi.resolve(workDir, filePath)
|
||||
const normWork = stripTrailingSeparators(pathApi.normalize(workDir), pathApi)
|
||||
const relative = pathApi.relative(normWork, abs)
|
||||
return relative !== '' && (relative.startsWith('..') || pathApi.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function usesWindowsPath(value: string): boolean {
|
||||
return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\')
|
||||
}
|
||||
|
||||
function stripTrailingSeparators(value: string, pathApi: typeof path.posix | typeof path.win32): string {
|
||||
const root = pathApi.parse(value).root
|
||||
if (value === root) return value
|
||||
return value.replace(/[\\/]+$/, '')
|
||||
}
|
||||
@ -2,19 +2,21 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import css from './globals.css?raw'
|
||||
|
||||
const normalizedCss = css.replace(/\r\n/g, '\n')
|
||||
|
||||
function getThemeBlock(selector: ':root,\n[data-theme="light"]' | '[data-theme="white"]' | '[data-theme="dark"]') {
|
||||
const start = css.indexOf(`${selector} {`)
|
||||
const start = normalizedCss.indexOf(`${selector} {`)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const bodyStart = css.indexOf('{', start)
|
||||
const bodyStart = normalizedCss.indexOf('{', start)
|
||||
let depth = 0
|
||||
for (let index = bodyStart; index < css.length; index += 1) {
|
||||
const char = css[index]
|
||||
for (let index = bodyStart; index < normalizedCss.length; index += 1) {
|
||||
const char = normalizedCss[index]
|
||||
if (char === '{') depth += 1
|
||||
if (char === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return css.slice(bodyStart + 1, index)
|
||||
return normalizedCss.slice(bodyStart + 1, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,11 +25,11 @@ function getThemeBlock(selector: ':root,\n[data-theme="light"]' | '[data-theme="
|
||||
}
|
||||
|
||||
function getCssBetween(startMarker: string, endMarker: string) {
|
||||
const start = css.indexOf(startMarker)
|
||||
const start = normalizedCss.indexOf(startMarker)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = css.indexOf(endMarker, start)
|
||||
const end = normalizedCss.indexOf(endMarker, start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return css.slice(start, end)
|
||||
return normalizedCss.slice(start, end)
|
||||
}
|
||||
|
||||
describe('desktop theme tokens', () => {
|
||||
|
||||
@ -35,6 +35,7 @@ describe('installPrePushHook', () => {
|
||||
expect(result.hookPath).toBe(hookPath)
|
||||
expect(result.liveConfigured).toBe(false)
|
||||
expect(readFileSync(hookPath, 'utf8')).toContain('echo quality')
|
||||
if (process.platform === 'win32') return
|
||||
expect(statSync(hookPath).mode & 0o111).toBeGreaterThan(0)
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
|
||||
@ -67,6 +67,10 @@ describe('Adapters API', () => {
|
||||
|
||||
const configPath = path.join(tmpDir, 'adapters.json')
|
||||
const stat = await fs.stat(configPath)
|
||||
if (process.platform === 'win32') {
|
||||
expect(stat.isFile()).toBe(true)
|
||||
return
|
||||
}
|
||||
expect(stat.mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
|
||||
@ -763,9 +763,9 @@ describe('WebSocket Chat Integration', () => {
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
server?.stop(true)
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
await rmWithRetry(tmpDir)
|
||||
}
|
||||
if (originalCliPath) {
|
||||
process.env.CLAUDE_CLI_PATH = originalCliPath
|
||||
@ -906,6 +906,7 @@ describe('WebSocket Chat Integration', () => {
|
||||
workDir: worktreePath!,
|
||||
repository: launchInfo!.repository,
|
||||
})
|
||||
await sessionService.deletePlaceholderSessionFiles(sessionId, worktreePath!)
|
||||
|
||||
const messages = await runTurn(sessionId, 'Continue in the existing worktree')
|
||||
const statusVerbs = messages
|
||||
|
||||
@ -1226,6 +1226,20 @@ describe('SessionService', () => {
|
||||
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep stale worktree records when their paths cannot be resolved', async () => {
|
||||
const workDir = await createCleanGitRepo(tmpDir)
|
||||
const staleWorktree = path.join(tmpDir, `stale-worktree-${Date.now()}`)
|
||||
git(workDir, 'worktree', 'add', '-b', 'stale-worktree', staleWorktree, 'feature/rail')
|
||||
await fs.rm(staleWorktree, { recursive: true, force: true })
|
||||
|
||||
const context = await getRepositoryContext(workDir)
|
||||
const expectedPath = path.resolve(staleWorktree).normalize('NFC')
|
||||
expect(context.state).toBe('ok')
|
||||
expect(context.worktrees.some((worktree) => (
|
||||
worktree.path === expectedPath && worktree.branch === 'stale-worktree' && !worktree.current
|
||||
))).toBe(true)
|
||||
})
|
||||
|
||||
it('should let git carry compatible dirty changes during direct branch launch', async () => {
|
||||
const workDir = await createCleanGitRepo(tmpDir)
|
||||
await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n')
|
||||
@ -1515,6 +1529,26 @@ describe('SessionService', () => {
|
||||
expect(launchInfo!.transcriptMessageCount).toBe(2)
|
||||
expect(launchInfo!.customTitle).toBe('Saved chat')
|
||||
})
|
||||
|
||||
it('should recover Windows drive paths from sanitized project dirs for old transcripts without metadata', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff'
|
||||
const userUuid = crypto.randomUUID()
|
||||
const userEntry = makeUserEntry('Resume this Windows session', userUuid)
|
||||
delete userEntry.cwd
|
||||
await writeSessionFile('g--AI-NTos-NT-deepseek-nano-core', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
userEntry,
|
||||
makeAssistantEntry('Welcome back', userUuid),
|
||||
])
|
||||
|
||||
const expectedWorkDir = 'g:\\AI\\NTos\\NT\\deepseek\\nano\\core'
|
||||
expect(await service.getSessionWorkDir(sessionId)).toBe(expectedWorkDir)
|
||||
|
||||
const launchInfo = await service.getSessionLaunchInfo(sessionId)
|
||||
expect(launchInfo).not.toBeNull()
|
||||
expect(launchInfo!.workDir).toBe(expectedWorkDir)
|
||||
expect(launchInfo!.transcriptMessageCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
@ -1533,11 +1567,8 @@ describe('Sessions API', () => {
|
||||
const { handleSessionsApi } = await import('../api/sessions.js')
|
||||
const { handleConversationsApi } = await import('../api/conversations.js')
|
||||
|
||||
const port = 30000 + Math.floor(Math.random() * 10000)
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
|
||||
server = Bun.serve({
|
||||
port,
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
|
||||
async fetch(req) {
|
||||
@ -1555,6 +1586,7 @@ describe('Sessions API', () => {
|
||||
return new Response('Not Found', { status: 404 })
|
||||
},
|
||||
})
|
||||
baseUrl = `http://127.0.0.1:${server.port}`
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@ -461,11 +461,8 @@ describe('Teams API', () => {
|
||||
|
||||
const { handleTeamsApi } = await import('../api/teams.js')
|
||||
|
||||
const port = 40000 + Math.floor(Math.random() * 10000)
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
|
||||
server = Bun.serve({
|
||||
port,
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
|
||||
async fetch(req) {
|
||||
@ -479,6 +476,7 @@ describe('Teams API', () => {
|
||||
return new Response('Not Found', { status: 404 })
|
||||
},
|
||||
})
|
||||
baseUrl = `http://127.0.0.1:${server.port}`
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@ -777,9 +777,10 @@ const RECENT_PROJECTS_CACHE_TTL = 30_000
|
||||
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
|
||||
|
||||
function projectNameForRecentPath(realPath: string, fallback: string): string {
|
||||
const displayRoot = realPath.includes(DESKTOP_WORKTREE_MARKER)
|
||||
? realPath.slice(0, realPath.indexOf(DESKTOP_WORKTREE_MARKER))
|
||||
: realPath
|
||||
const normalizedRealPath = realPath.replace(/\\/g, '/')
|
||||
const displayRoot = normalizedRealPath.includes(DESKTOP_WORKTREE_MARKER)
|
||||
? normalizedRealPath.slice(0, normalizedRealPath.indexOf(DESKTOP_WORKTREE_MARKER))
|
||||
: normalizedRealPath
|
||||
return displayRoot.split('/').filter(Boolean).pop() || fallback
|
||||
}
|
||||
|
||||
|
||||
@ -189,6 +189,19 @@ async function resolveDirectory(workDir: string): Promise<string> {
|
||||
return realPath
|
||||
}
|
||||
|
||||
async function canonicalizeKnownPath(candidate: string): Promise<string> {
|
||||
try {
|
||||
return (await fs.realpath(candidate)).normalize('NFC')
|
||||
} catch {
|
||||
return path.resolve(candidate).normalize('NFC')
|
||||
}
|
||||
}
|
||||
|
||||
function isSameOrInsidePath(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(root, candidate)
|
||||
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function normalizeRemoteBranch(ref: string): { name: string; remoteRef: string } | null {
|
||||
if (!ref || ref.endsWith('/HEAD')) return null
|
||||
const slash = ref.indexOf('/')
|
||||
@ -353,11 +366,17 @@ export async function getRepositoryContext(workDir: string): Promise<RepositoryC
|
||||
])
|
||||
|
||||
const currentBranch = branchResult.stdout.trim() || null
|
||||
const worktreeRecords = worktreeResult.code === 0 ? parseWorktreeList(worktreeResult.stdout) : []
|
||||
const rawWorktreeRecords = worktreeResult.code === 0 ? parseWorktreeList(worktreeResult.stdout) : []
|
||||
const worktreeRecords = await Promise.all(
|
||||
rawWorktreeRecords.map(async (worktree) => ({
|
||||
...worktree,
|
||||
path: await canonicalizeKnownPath(worktree.path),
|
||||
})),
|
||||
)
|
||||
const worktrees = worktreeRecords.map((worktree) => ({
|
||||
path: worktree.path,
|
||||
branch: worktree.branch,
|
||||
current: absWorkDir === worktree.path || absWorkDir.startsWith(`${worktree.path}${path.sep}`),
|
||||
current: isSameOrInsidePath(worktree.path, absWorkDir),
|
||||
}))
|
||||
|
||||
return {
|
||||
|
||||
@ -950,10 +950,16 @@ export class SessionService {
|
||||
* Reverses sanitizePath(): `-Users-nanmi-workspace` → `/Users/nanmi/workspace`.
|
||||
*/
|
||||
desanitizePath(sanitized: string): string {
|
||||
// The sanitized form replaces all '/' (and '\') with '-'.
|
||||
// The sanitized form replaces all non-alphanumeric characters with '-'.
|
||||
// This fallback is necessarily lossy, but old Windows transcripts without
|
||||
// session-meta still need the drive separator restored well enough to resume.
|
||||
const windowsDrivePath = sanitized.match(/^([a-zA-Z])--(.+)$/)
|
||||
if (windowsDrivePath) {
|
||||
return `${windowsDrivePath[1]}:${path.win32.sep}${windowsDrivePath[2].replace(/-/g, path.win32.sep)}`
|
||||
}
|
||||
|
||||
// On POSIX the original path starts with '/', so the sanitized form starts with '-'.
|
||||
// We restore by replacing every '-' with '/' (the platform separator).
|
||||
// On Windows the leading character would be a drive letter, but we handle POSIX here.
|
||||
// UNC-style Windows paths also recover to a leading double separator on Windows.
|
||||
return sanitized.replace(/-/g, path.sep)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user