@anionex/dsh-turn-rewind 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/README.zh.md +13 -4
- package/docs/FORMAT.md +5 -1
- package/lib/client.js +7 -0
- package/lib/client.js.map +1 -1
- package/lib/directory.js +162 -0
- package/lib/engine.js +94 -56
- package/lib/git.js +46 -0
- package/lib/rewind-host.js +16 -7
- package/lib/snapshot.js +13 -5
- package/lib/types/directory.d.ts +10 -0
- package/lib/types/engine.d.ts +6 -0
- package/lib/types/git.d.ts +15 -4
- package/lib/types/snapshot.d.ts +2 -2
- package/lib/types/types.d.ts +15 -2
- package/lib/types/workspace.d.ts +37 -0
- package/lib/validate.js +23 -1
- package/lib/workspace.js +45 -0
- package/package.json +5 -2
- package/src/client/index.tsx +7 -0
- package/src/directory.ts +194 -0
- package/src/engine.ts +104 -68
- package/src/git.ts +49 -4
- package/src/rewind-host.ts +17 -8
- package/src/snapshot.ts +14 -6
- package/src/types.ts +18 -2
- package/src/validate.ts +21 -1
- package/src/workspace.ts +66 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type DirectorySnapshotSource } from './directory.js';
|
|
2
|
+
import { type RepositorySnapshotSource } from './git.js';
|
|
3
|
+
import type { ResolvedChangeLedgerConfig, WorkspaceState } from './types.js';
|
|
4
|
+
/** Eligible snapshot source for either supported workspace mode. */
|
|
5
|
+
export type WorkspaceSnapshotSource = RepositorySnapshotSource | DirectorySnapshotSource;
|
|
6
|
+
/**
|
|
7
|
+
* Discover the owning Git worktree, or treat `cwd` itself as an ordinary
|
|
8
|
+
* directory when it is not inside any repository.
|
|
9
|
+
* @param cwd - working directory the Session runs in.
|
|
10
|
+
* @param config - capture limits applied to ordinary-directory enumeration.
|
|
11
|
+
* @param signal - optional caller cancellation.
|
|
12
|
+
* @returns the snapshot source for the owning workspace.
|
|
13
|
+
*/
|
|
14
|
+
export declare function discoverWorkspace(cwd: string, config: Pick<ResolvedChangeLedgerConfig, 'maxFiles' | 'maxFileBytes'>, signal?: AbortSignal): Promise<WorkspaceSnapshotSource>;
|
|
15
|
+
/** Workspace mode and root resolved without inventorying any files. */
|
|
16
|
+
export interface WorkspaceIdentity {
|
|
17
|
+
readonly type: WorkspaceState['type'];
|
|
18
|
+
readonly root: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the workspace mode and root without inventorying files, for callers
|
|
22
|
+
* that only need to address the workspace (locks, skip markers, storage purge).
|
|
23
|
+
* @param cwd - working directory the Session runs in.
|
|
24
|
+
* @param signal - optional caller cancellation.
|
|
25
|
+
* @returns the workspace mode and canonical root.
|
|
26
|
+
*/
|
|
27
|
+
export declare function discoverWorkspaceIdentity(cwd: string, signal?: AbortSignal): Promise<WorkspaceIdentity>;
|
|
28
|
+
/** Resolve the workspace root without inventorying its files. */
|
|
29
|
+
export declare function discoverWorkspaceRoot(cwd: string, signal?: AbortSignal): Promise<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Whether two fences still describe the same workspace.
|
|
32
|
+
*
|
|
33
|
+
* Git workspaces keep the full repository fence (HEAD, branch, operation);
|
|
34
|
+
* ordinary directories only own their canonical root, so a moved or recreated
|
|
35
|
+
* root is the only thing that can invalidate them.
|
|
36
|
+
*/
|
|
37
|
+
export declare function sameWorkspaceFence(left: WorkspaceState, right: WorkspaceState): boolean;
|
package/lib/validate.js
CHANGED
|
@@ -99,7 +99,9 @@ export function parseManifest(value) {
|
|
|
99
99
|
return { version, ...common };
|
|
100
100
|
if (restorePointKind !== 'turn')
|
|
101
101
|
corrupt('Git-native v2 restore points are automatic turn checkpoints');
|
|
102
|
-
|
|
102
|
+
if (repository.type !== 'git')
|
|
103
|
+
corrupt('Git-native v2 restore points must fence a Git workspace');
|
|
104
|
+
return { version, ...common, repository, git: parseGitCheckpoint(record.git, id) };
|
|
103
105
|
}
|
|
104
106
|
/** Parse an untrusted durable restore-operation journal. */
|
|
105
107
|
export function parseOperation(value) {
|
|
@@ -155,9 +157,28 @@ export function validateBlobHash(value) {
|
|
|
155
157
|
}
|
|
156
158
|
return value;
|
|
157
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Parse the durable workspace fence of one manifest.
|
|
162
|
+
*
|
|
163
|
+
* A missing `type` is the only legacy interpretation: manifests written before
|
|
164
|
+
* ordinary-directory support are always Git workspaces and are upgraded in
|
|
165
|
+
* memory. Directory fences must not carry Git-only fields.
|
|
166
|
+
* @param value - persisted `repository` field.
|
|
167
|
+
* @returns the validated workspace state.
|
|
168
|
+
*/
|
|
158
169
|
function parseRepository(value) {
|
|
159
170
|
const record = objectRecord(value, 'repository state');
|
|
160
171
|
const root = absoluteString(record, 'root');
|
|
172
|
+
if (record.type === 'directory') {
|
|
173
|
+
for (const key of ['commonDir', 'head', 'branch', 'operation', 'stagedPaths']) {
|
|
174
|
+
if (record[key] !== undefined)
|
|
175
|
+
corrupt(`directory workspace state must not contain ${key}`);
|
|
176
|
+
}
|
|
177
|
+
return { type: 'directory', root };
|
|
178
|
+
}
|
|
179
|
+
if (record.type !== undefined && record.type !== 'git') {
|
|
180
|
+
corrupt('workspace state type must be "git" or "directory"');
|
|
181
|
+
}
|
|
161
182
|
const commonDir = absoluteString(record, 'commonDir');
|
|
162
183
|
const head = optionalHash(record, 'head');
|
|
163
184
|
const branch = optionalString(record, 'branch');
|
|
@@ -167,6 +188,7 @@ function parseRepository(value) {
|
|
|
167
188
|
corrupt('repository stagedPaths must be an array');
|
|
168
189
|
const stagedPaths = stagedValue.map((path) => validateRelativePath(requireString(path, 'staged path')));
|
|
169
190
|
return {
|
|
191
|
+
type: 'git',
|
|
170
192
|
root,
|
|
171
193
|
commonDir,
|
|
172
194
|
...(head === undefined ? {} : { head }),
|
package/lib/workspace.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { discoverDirectory, discoverDirectoryRoot } from './directory.js';
|
|
2
|
+
import { discoverRepositoryOptional, discoverRepositoryRootOptional, sameRepositoryFence, } from './git.js';
|
|
3
|
+
/**
|
|
4
|
+
* Discover the owning Git worktree, or treat `cwd` itself as an ordinary
|
|
5
|
+
* directory when it is not inside any repository.
|
|
6
|
+
* @param cwd - working directory the Session runs in.
|
|
7
|
+
* @param config - capture limits applied to ordinary-directory enumeration.
|
|
8
|
+
* @param signal - optional caller cancellation.
|
|
9
|
+
* @returns the snapshot source for the owning workspace.
|
|
10
|
+
*/
|
|
11
|
+
export async function discoverWorkspace(cwd, config, signal) {
|
|
12
|
+
const repository = await discoverRepositoryOptional(cwd, signal);
|
|
13
|
+
return repository ?? discoverDirectory(cwd, config, signal);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the workspace mode and root without inventorying files, for callers
|
|
17
|
+
* that only need to address the workspace (locks, skip markers, storage purge).
|
|
18
|
+
* @param cwd - working directory the Session runs in.
|
|
19
|
+
* @param signal - optional caller cancellation.
|
|
20
|
+
* @returns the workspace mode and canonical root.
|
|
21
|
+
*/
|
|
22
|
+
export async function discoverWorkspaceIdentity(cwd, signal) {
|
|
23
|
+
const root = await discoverRepositoryRootOptional(cwd, signal);
|
|
24
|
+
return root === undefined
|
|
25
|
+
? { type: 'directory', root: await discoverDirectoryRoot(cwd) }
|
|
26
|
+
: { type: 'git', root };
|
|
27
|
+
}
|
|
28
|
+
/** Resolve the workspace root without inventorying its files. */
|
|
29
|
+
export async function discoverWorkspaceRoot(cwd, signal) {
|
|
30
|
+
return (await discoverWorkspaceIdentity(cwd, signal)).root;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether two fences still describe the same workspace.
|
|
34
|
+
*
|
|
35
|
+
* Git workspaces keep the full repository fence (HEAD, branch, operation);
|
|
36
|
+
* ordinary directories only own their canonical root, so a moved or recreated
|
|
37
|
+
* root is the only thing that can invalidate them.
|
|
38
|
+
*/
|
|
39
|
+
export function sameWorkspaceFence(left, right) {
|
|
40
|
+
if (left.type !== right.type || left.root !== right.root)
|
|
41
|
+
return false;
|
|
42
|
+
if (left.type === 'directory' || right.type === 'directory')
|
|
43
|
+
return true;
|
|
44
|
+
return sameRepositoryFence(left, right);
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anionex/dsh-turn-rewind",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Turn-level conversation and workspace rewind for DeepSeek Harness, powered by a persistent Change Ledger",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -109,5 +109,8 @@
|
|
|
109
109
|
"engines": {
|
|
110
110
|
"node": "^22.19.0 || >=24.0.0"
|
|
111
111
|
},
|
|
112
|
-
"packageManager": "pnpm@11.7.0"
|
|
112
|
+
"packageManager": "pnpm@11.7.0",
|
|
113
|
+
"dependencies": {
|
|
114
|
+
"ignore": "^7.0.9"
|
|
115
|
+
}
|
|
113
116
|
}
|
package/src/client/index.tsx
CHANGED
|
@@ -1214,9 +1214,12 @@ function friendlyError(error: unknown): string {
|
|
|
1214
1214
|
case 'REWIND_ENDPOINT_UNAVAILABLE': return '回退服务没有响应。请确认 @anionex/dsh-turn-rewind 已挂载到当前 DSH,并重启 DSH 后重试。'
|
|
1215
1215
|
case 'REWIND_INVALID_RESPONSE': return '回退服务返回了无法解析的内容。请重启 DSH 后重试。'
|
|
1216
1216
|
case 'PLAN_STALE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
|
|
1217
|
+
case 'PLAN_STALE_WORKSPACE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
|
|
1217
1218
|
case 'PLAN_STALE_REPOSITORY': return 'Git 状态在检查后又发生了变化,恢复已失效。请重新检查后再试。'
|
|
1218
1219
|
case 'WORKSPACE_IN_USE': return '这个项目目录还有别的对话正在运行。请等那些对话结束或停止后,再重新检查。'
|
|
1219
1220
|
case 'WORKSPACE_LOCKED': return '另一个恢复操作正在处理这个项目目录。请等待它完成后重新检查。'
|
|
1221
|
+
case 'WORKSPACE_CHANGED': return '这个项目目录已经不在原来的位置,旧的回退点无法再使用。'
|
|
1222
|
+
case 'WORKSPACE_MODE_CHANGED': return '这个项目目录的工作区类型变了(例如从普通目录变成了 Git 仓库)。旧的回退点不再适用,请重新发送一条消息生成新的回退点。'
|
|
1220
1223
|
case 'HEAD_CHANGED': return '项目的提交或分支已发生变化。为避免覆盖新改动,请重新检查后再恢复。'
|
|
1221
1224
|
case 'REPOSITORY_CHANGED': return '这个项目目录已不属于原来的 Git 工作区,无法恢复。'
|
|
1222
1225
|
case 'GIT_OPERATION_CHANGED': return 'Git 正在执行其他操作。请先完成或取消该操作,再重新检查。'
|
|
@@ -1248,6 +1251,10 @@ export function explainCheckpointFailure(message: string): string {
|
|
|
1248
1251
|
case 'SNAPSHOT_TOO_LARGE':
|
|
1249
1252
|
case 'TOO_MANY_FILES':
|
|
1250
1253
|
return '本轮有文件超过检查点的大小或数量上限,没有保存文件检查点。仍可只回溯消息。'
|
|
1254
|
+
case 'UNSUPPORTED_FILE_TYPE':
|
|
1255
|
+
return '项目目录里有无法保存的特殊文件(如 socket、设备文件、FIFO),没有保存文件检查点。仍可只回溯消息。'
|
|
1256
|
+
case 'IGNORE_FILE_INVALID':
|
|
1257
|
+
return '项目目录里的 .dsh-rewindignore 内容无效,没有保存文件检查点。修正该文件后可重新发送消息。'
|
|
1251
1258
|
case 'INVALID_PATH':
|
|
1252
1259
|
return '项目目录里有无法安全保存的路径(例如嵌套的独立 Git 仓库),没有保存文件检查点。仍可只回溯消息。'
|
|
1253
1260
|
default:
|
package/src/directory.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { BigIntStats } from 'node:fs'
|
|
2
|
+
import { lstat, readFile, readdir, realpath } from 'node:fs/promises'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import ignore, { type Ignore } from 'ignore'
|
|
5
|
+
import { ChangeLedgerError } from './errors.js'
|
|
6
|
+
import { canonicalDirectory, isNodeError, isWithin } from './path-utils.js'
|
|
7
|
+
import type { DirectoryWorkspaceState, ResolvedChangeLedgerConfig } from './types.js'
|
|
8
|
+
|
|
9
|
+
const IGNORE_FILE = '.dsh-rewindignore'
|
|
10
|
+
const DEFAULT_IGNORE_PATTERNS = ['.git', 'node_modules'] as const
|
|
11
|
+
|
|
12
|
+
/** Ordinary-directory discovery result plus its eligible path inventory. */
|
|
13
|
+
export interface DirectorySnapshotSource {
|
|
14
|
+
readonly state: DirectoryWorkspaceState
|
|
15
|
+
readonly paths: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolve the canonical ordinary directory used as a non-Git workspace root. */
|
|
19
|
+
export async function discoverDirectoryRoot(cwd: string): Promise<string> {
|
|
20
|
+
return canonicalDirectory(cwd)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Enumerate regular files and symlinks in an ordinary directory using rewind ignore rules. */
|
|
24
|
+
export async function discoverDirectory(
|
|
25
|
+
cwd: string,
|
|
26
|
+
config: Pick<ResolvedChangeLedgerConfig, 'maxFiles' | 'maxFileBytes'>,
|
|
27
|
+
signal?: AbortSignal,
|
|
28
|
+
): Promise<DirectorySnapshotSource> {
|
|
29
|
+
const root = await discoverDirectoryRoot(cwd)
|
|
30
|
+
const matcher = await loadIgnoreMatcher(root, config.maxFileBytes, signal)
|
|
31
|
+
const paths: string[] = []
|
|
32
|
+
await walkDirectory(root, root, '', matcher, paths, config.maxFiles, signal)
|
|
33
|
+
paths.sort(comparePaths)
|
|
34
|
+
return { state: { type: 'directory', root }, paths }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read `.dsh-rewindignore` when present and combine it with the built-in
|
|
39
|
+
* exclusions. The built-in patterns are added last so user negation rules can
|
|
40
|
+
* never re-include the plugin's own metadata or dependency trees.
|
|
41
|
+
*/
|
|
42
|
+
async function loadIgnoreMatcher(root: string, maxFileBytes: number, signal?: AbortSignal): Promise<Ignore> {
|
|
43
|
+
const matcher = ignore()
|
|
44
|
+
const ignorePath = join(root, IGNORE_FILE)
|
|
45
|
+
throwIfAborted(signal)
|
|
46
|
+
let before: BigIntStats
|
|
47
|
+
try {
|
|
48
|
+
before = await lstat(ignorePath, { bigint: true })
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (isNodeError(error, 'ENOENT')) {
|
|
51
|
+
matcher.add(DEFAULT_IGNORE_PATTERNS)
|
|
52
|
+
return matcher
|
|
53
|
+
}
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
if (!before.isFile()) {
|
|
57
|
+
throw new ChangeLedgerError('IGNORE_FILE_INVALID', `${IGNORE_FILE} must be a regular file when present`)
|
|
58
|
+
}
|
|
59
|
+
if (before.size > BigInt(maxFileBytes)) {
|
|
60
|
+
throw new ChangeLedgerError(
|
|
61
|
+
'FILE_TOO_LARGE',
|
|
62
|
+
`${JSON.stringify(IGNORE_FILE)} is ${before.size.toString()} bytes; configured per-file maximum is ${maxFileBytes}`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
const content = await readFile(ignorePath, 'utf8')
|
|
66
|
+
let after: BigIntStats
|
|
67
|
+
try {
|
|
68
|
+
after = await lstat(ignorePath, { bigint: true })
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (isNodeError(error, 'ENOENT')) {
|
|
71
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `${IGNORE_FILE} disappeared while its rules were being read`)
|
|
72
|
+
}
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
75
|
+
if (!sameStat(before, after)) {
|
|
76
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `${IGNORE_FILE} changed while its rules were being read`)
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
matcher.add(content)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new ChangeLedgerError('IGNORE_FILE_INVALID', `${IGNORE_FILE} contains invalid ignore rules`, { cause: error })
|
|
82
|
+
}
|
|
83
|
+
matcher.add(DEFAULT_IGNORE_PATTERNS)
|
|
84
|
+
return matcher
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Walk one ordinary directory without following symlinks, rejecting unstable trees. */
|
|
88
|
+
async function walkDirectory(
|
|
89
|
+
root: string,
|
|
90
|
+
directory: string,
|
|
91
|
+
directoryPath: string,
|
|
92
|
+
matcher: Ignore,
|
|
93
|
+
paths: string[],
|
|
94
|
+
maxFiles: number,
|
|
95
|
+
signal?: AbortSignal,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
throwIfAborted(signal)
|
|
98
|
+
const before = await lstat(directory, { bigint: true })
|
|
99
|
+
await assertSafeDirectory(root, directory, before)
|
|
100
|
+
const entries = await readdir(directory, { withFileTypes: true })
|
|
101
|
+
const after = await lstat(directory, { bigint: true })
|
|
102
|
+
if (!sameStat(before, after)) {
|
|
103
|
+
throw new ChangeLedgerError(
|
|
104
|
+
'WORKSPACE_CHANGED_DURING_CAPTURE',
|
|
105
|
+
`directory changed while being enumerated: ${JSON.stringify(directoryPath || '.')}`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
entries.sort((left, right) => comparePaths(left.name, right.name))
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
throwIfAborted(signal)
|
|
112
|
+
await assertSafeDirectory(root, directory, after)
|
|
113
|
+
const path = directoryPath === '' ? entry.name : `${directoryPath}/${entry.name}`
|
|
114
|
+
const target = join(directory, entry.name)
|
|
115
|
+
let info: BigIntStats
|
|
116
|
+
try {
|
|
117
|
+
info = await lstat(target, { bigint: true })
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (isNodeError(error, 'ENOENT')) continue
|
|
120
|
+
throw error
|
|
121
|
+
}
|
|
122
|
+
if (info.isDirectory() && !info.isSymbolicLink()) {
|
|
123
|
+
if (matcher.ignores(`${path}/`)) continue
|
|
124
|
+
await walkDirectory(root, target, path, matcher, paths, maxFiles, signal)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (path !== IGNORE_FILE && matcher.ignores(path)) continue
|
|
128
|
+
if (!info.isFile() && !info.isSymbolicLink()) {
|
|
129
|
+
throw new ChangeLedgerError(
|
|
130
|
+
'UNSUPPORTED_FILE_TYPE',
|
|
131
|
+
`ordinary-directory path is not a regular file or symlink: ${JSON.stringify(path)}`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
paths.push(path)
|
|
135
|
+
if (paths.length > maxFiles) {
|
|
136
|
+
throw new ChangeLedgerError(
|
|
137
|
+
'TOO_MANY_FILES',
|
|
138
|
+
`workspace has more than ${maxFiles} eligible paths; latest path is ${JSON.stringify(path)}`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Re-prove that one traversed directory is still the same real directory inside
|
|
146
|
+
* the workspace root, so a swapped symlink or moved ancestor cannot redirect
|
|
147
|
+
* the inventory outside it.
|
|
148
|
+
*/
|
|
149
|
+
async function assertSafeDirectory(root: string, directory: string, expected: BigIntStats): Promise<void> {
|
|
150
|
+
if (!expected.isDirectory() || expected.isSymbolicLink()) {
|
|
151
|
+
throw new ChangeLedgerError('SYMLINK_PARENT', `directory traversal reached a symbolic link: ${JSON.stringify(directory)}`)
|
|
152
|
+
}
|
|
153
|
+
let canonical: string
|
|
154
|
+
try {
|
|
155
|
+
canonical = await realpath(directory)
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (isNodeError(error, 'ENOENT') || isNodeError(error, 'ELOOP')) {
|
|
158
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `directory changed during traversal: ${JSON.stringify(directory)}`)
|
|
159
|
+
}
|
|
160
|
+
throw error
|
|
161
|
+
}
|
|
162
|
+
if (canonical !== resolve(directory) || !isWithin(root, canonical)) {
|
|
163
|
+
throw new ChangeLedgerError('UNSAFE_TARGET', `directory traversal escaped the workspace: ${JSON.stringify(directory)}`)
|
|
164
|
+
}
|
|
165
|
+
let current: BigIntStats
|
|
166
|
+
try {
|
|
167
|
+
current = await lstat(directory, { bigint: true })
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (isNodeError(error, 'ENOENT')) {
|
|
170
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `directory disappeared during traversal: ${JSON.stringify(directory)}`)
|
|
171
|
+
}
|
|
172
|
+
throw error
|
|
173
|
+
}
|
|
174
|
+
if (!sameStat(expected, current)) {
|
|
175
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `directory changed during traversal: ${JSON.stringify(directory)}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sameStat(left: BigIntStats, right: BigIntStats): boolean {
|
|
180
|
+
return left.dev === right.dev
|
|
181
|
+
&& left.ino === right.ino
|
|
182
|
+
&& left.mode === right.mode
|
|
183
|
+
&& left.size === right.size
|
|
184
|
+
&& left.mtimeNs === right.mtimeNs
|
|
185
|
+
&& left.ctimeNs === right.ctimeNs
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
189
|
+
if (signal?.aborted === true) throw signal.reason
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function comparePaths(left: string, right: string): number {
|
|
193
|
+
return Buffer.from(left).compare(Buffer.from(right))
|
|
194
|
+
}
|