Skip to content

Commit e6a92c5

Browse files
aron-cfagent
andauthored
Fixes for latest issues (#124)
* dofs: follow symbolic links in mkdir mkdir walked the target path with a helper that read each dirent directly and treated every node other than a directory as ENOTDIR, so a symbolic link to a directory blocked directory creation rather than resolving through it. Every other write path, writeFile included, follows links and shares one forty-hop budget. The parent walk now expands intermediate links the way writeFile's does, tracking the resolved path so the new directory lands under the directory the link points at and the read-only mount guard sees the location actually written. Recursive creation places its missing ancestors under that resolved parent. A resolved parent that is a file still reports ENOTDIR, a dangling parent link reports ENOENT in both modes rather than being materialised, and a chain beyond forty hops reports ELOOP. Closes #119. * dofs, computer: add exclusion globs to find The walker tested the inclusion glob before yielding an entry but descended into every directory regardless, so a search in a workspace holding node_modules, .git, or generated build output paid for those trees even when the caller wanted nothing from them. FindOptions gains exclude, a list of globs of the same shape as the inclusion pattern and matched against the same directory-relative path. An exclusion is decided before inclusion, so it always wins, and before any child query, so an excluded directory takes its whole subtree with it rather than being filtered out afterwards. Traversal stays deterministic and limit and offset apply to what survives. The option reaches the public find tool, whose schema now advertises it. Closes #121. * dofs, computer: expose rename on the public filesystem The store has implemented transactional file, directory, and symbolic link moves for some time, covering destination replacement, non-empty directories, read-only mounts, tombstones, revision stamping, and subtree tracking. None of that reached Workspace.fs, so a caller had to copy the source and then delete it, and the Worker shell used that fallback for mv. A failure between the two steps left the entry at both paths or a directory half copied. WorkspaceFilesystem now forwards rename, and WorkspaceFilesystemStub mirrors it with the usual filesystem observation span, so the Workers RPC surface matches the in-process one. The shell adapter calls it and keeps copy-then-delete only for a destination rename refuses to replace, which is what the shell expects when it merges a tree. No new method crosses the Cap'n Web boundary: the existing synchronisation protocol already carries the resulting live entries and tombstones. Closes #120. * docs: describe the shipped symbolic-link surface The filesystem specification said symbolic links were an internal primitive, that Workspace.fs exposed neither symlink nor readlink, that there was no lstat, and that an existing file's mode could not be changed. The shipped API contradicts all four: WorkspaceFilesystem exposes symlink, readlink, lstat, and chmod, the stub mirrors them across the Workers RPC boundary, and the Dynamic Worker filesystem adapters rely on them for the node:fs behaviour a shell expects. Removing the methods would be a breaking change and would leave those adapters without a way to serve ln -s, readlink, or test -L, so the document follows the code. Each of the four methods gains a section with its return value and its errors, the comparison with node:fs/promises maps them rather than striking them out, and the note on symbolic links now states the two rules that cover the surface: intermediate segments are always followed, and a trailing link is followed by everything except lstat and readlink. The rename and find entries added alongside are documented in the same pass. Closes #118. --------- Co-authored-by: agent <agent@users.noreply.github.com>
1 parent 5f310b6 commit e6a92c5

21 files changed

Lines changed: 764 additions & 78 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/computer": patch
3+
---
4+
5+
Document the symbolic-link filesystem surface. `docs/04_filesystem_interface.md` claimed that symbolic links were internal and that `Workspace.fs` had no `symlink`, `readlink`, `lstat`, or `chmod`, none of which matched the shipped API. Those four methods now have sections of their own covering return values and the `ENOENT`, `EINVAL`, and `ELOOP` cases, the comparison with `node:fs/promises` maps them, and the specification explains that `stat` follows a trailing link while `lstat` reports the link itself.

.changeset/expose-fs-rename.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@cloudflare/dofs": minor
3+
"@cloudflare/computer": minor
4+
---
5+
6+
`Workspace.fs` gains `rename(oldPath, newPath)`, exposing the store's existing transactional move through the public surface and through `WorkspaceFilesystemStub`. An existing destination is replaced when the two ends agree on kind — a file or symbolic link for a file or symbolic link, an empty directory for a directory — and the operation reports `ENOENT`, `ENOTEMPTY`, `EISDIR`, `ENOTDIR`, `EINVAL`, and `EROFS` as documented in `docs/04_filesystem_interface.md`. The Worker shell's `mv` now calls it, so an interrupted move no longer leaves the entry at both paths or a directory half copied.

.changeset/find-exclude-globs.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@cloudflare/dofs": minor
3+
"@cloudflare/computer": minor
4+
---
5+
6+
`find` accepts `exclude`, a list of glob patterns matched against the same directory-relative path as the inclusion glob. Exclusion is decided first, so it always wins, and an excluded directory is pruned during traversal: neither it nor anything below it is read. `limit` and `offset` apply to the matches that survive. The option travels through `WorkspaceFilesystem`, `WorkspaceFilesystemStub`, and the public find tool.
7+
8+
```ts
9+
const sources = await workspace.fs.find("/workspace", "**/*.ts", {
10+
exclude: ["node_modules", "node_modules/**", ".git", ".git/**"],
11+
});
12+
```
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/dofs": patch
3+
---
4+
5+
`mkdir` now follows symbolic links in intermediate path segments, so a link to a directory resolves transparently instead of failing with `ENOTDIR`. Creating `/alias/new-directory` where `/alias` points at `/real` creates `/real/new-directory`, and recursive creation places its missing ancestors under the resolved parent. A resolved parent that is a file still reports `ENOTDIR`, a dangling parent link reports `ENOENT`, and a chain longer than the shared forty-hop budget reports `ELOOP`.

docs/04_filesystem_interface.md

Lines changed: 155 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,10 @@ stat(path: string): Promise<{
190190
`name` is the last segment of the canonicalized path. For the workspace
191191
root this is the empty string: `(await fs.stat("/")).name === ""`.
192192

193-
`stat` follows symlinks transparently; there is no `lstat`. See the
194-
note on internal symlink support in the appendix.
193+
`stat` follows a trailing symbolic link, so it reports the file or
194+
directory the link points at. Use [`lstat`](#lstat) to inspect the link
195+
itself. A dangling link makes `stat` report `ENOENT` while `lstat`
196+
succeeds.
195197

196198
> When a parent path segment is itself a file, `stat` reports `ENOENT`
197199
> (because resolution returns `null` for that case) rather than
@@ -203,6 +205,117 @@ const s = await fs.stat("/workspace/build/out.wasm");
203205
console.log(`${s.size} bytes, modified ${new Date(s.mtime).toISOString()}`);
204206
```
205207

208+
### `lstat`
209+
210+
```ts
211+
lstat(path: string): Promise<{
212+
name: string;
213+
mode: number;
214+
mtime: number; // ms since epoch
215+
size: number;
216+
isFile: boolean;
217+
isDirectory: boolean;
218+
isSymbolicLink: boolean;
219+
}>
220+
```
221+
222+
Same shape as `stat`, but a trailing symbolic link is reported as the
223+
link rather than followed. `size` is then the byte length of the stored
224+
target string, and `isSymbolicLink` is true. Intermediate segments are
225+
still followed, so a link in the middle of the path resolves as usual.
226+
Throws `ENOENT` when the path does not exist and `ELOOP` when an
227+
intermediate chain exceeds 40 hops.
228+
229+
```ts
230+
await fs.symlink("/workspace/real.txt", "/workspace/alias.txt");
231+
(await fs.stat("/workspace/alias.txt")).isSymbolicLink; // false
232+
(await fs.lstat("/workspace/alias.txt")).isSymbolicLink; // true
233+
```
234+
235+
### `symlink`
236+
237+
```ts
238+
symlink(target: string, path: string): Promise<void>
239+
```
240+
241+
Creates a symbolic link at `path` pointing at `target`, with the
242+
argument order of `node:fs/promises`. The target is stored verbatim: it
243+
may be absolute or relative, and it is allowed to dangle. Reads and
244+
writes that walk through the link follow it, with the same 40-hop cap as
245+
every other resolution.
246+
247+
Throws `EEXIST` when `path` already exists (the link is never replaced
248+
silently), `ENOENT` when the parent directory is missing, `ENOTDIR` when
249+
a parent segment is a file, and `EROFS` under a read-only mount.
250+
251+
```ts
252+
await fs.symlink("../shared/config.json", "/workspace/app/config.json");
253+
```
254+
255+
### `readlink`
256+
257+
```ts
258+
readlink(path: string): Promise<string>
259+
```
260+
261+
Returns the stored target of a symbolic link, exactly as it was
262+
written — relative targets are not resolved. Throws `EINVAL` when `path`
263+
is not a symbolic link and `ENOENT` when it does not exist.
264+
265+
```ts
266+
await fs.readlink("/workspace/app/config.json"); // "../shared/config.json"
267+
```
268+
269+
### `chmod`
270+
271+
```ts
272+
chmod(path: string, mode: number): Promise<void>
273+
```
274+
275+
Changes the permission bits of an existing path without rewriting its
276+
bytes. The mode is masked to twelve bits. Like POSIX `chmod`, a trailing
277+
symbolic link is followed, so the change lands on the target rather than
278+
the link. Throws `ENOENT` for a missing path and `EROFS` under a
279+
read-only mount.
280+
281+
```ts
282+
await fs.chmod("/workspace/bin/run.sh", 0o755);
283+
```
284+
285+
### `rename`
286+
287+
```ts
288+
rename(oldPath: string, newPath: string): Promise<void>
289+
```
290+
291+
Moves a file, directory, or symbolic link in a single transaction, so an
292+
interrupted call can never leave the entry at both paths or a directory
293+
partly copied. The moved entry keeps its inode, its bytes, and its mode;
294+
a directory move carries its whole subtree.
295+
296+
Overwrite behavior follows POSIX `rename(2)`: an existing destination is
297+
replaced when the two ends agree on kind. A file or symbolic link
298+
replaces a file or symbolic link, and a directory replaces an *empty*
299+
directory. Nothing else is replaced.
300+
301+
| Code | When |
302+
| --- | --- |
303+
| `ENOENT` | `oldPath` does not exist, or `newPath`'s parent directory is missing. |
304+
| `ENOTEMPTY` | `newPath` is a directory with children. |
305+
| `EISDIR` | `newPath` is a directory and `oldPath` is not. |
306+
| `ENOTDIR` | `oldPath` is a directory and `newPath` is not. |
307+
| `EINVAL` | Either end is the root, or a directory would be moved inside itself. |
308+
| `EROFS` | Either end falls under a read-only mount. |
309+
310+
```ts
311+
// Publish a build atomically.
312+
await fs.writeFile("/workspace/site/index.html.tmp", html);
313+
await fs.rename("/workspace/site/index.html.tmp", "/workspace/site/index.html");
314+
315+
// Move a whole tree.
316+
await fs.rename("/workspace/draft", "/workspace/published");
317+
```
318+
206319
### `find`
207320

208321
```ts
@@ -212,6 +325,7 @@ find(
212325
options?: {
213326
limit?: number;
214327
offset?: number;
328+
exclude?: string[];
215329
},
216330
): Promise<Array<{ path; type: "file" | "dir" }>>
217331
```
@@ -225,12 +339,24 @@ its absolute path — so `**/*.ts` under `/workspace/src` matches
225339
The glob supports `*`, `**`, `**/`, and `?`. Character classes and
226340
brace expansions are matched literally.
227341

342+
`exclude` takes globs of the same shape, matched against the same
343+
relative path. An exclusion is decided before the inclusion glob, so it
344+
always wins. When an excluded entry is a directory the walk prunes it:
345+
neither the directory nor anything beneath it is read, which is what
346+
makes skipping `node_modules` or `.git` cheap rather than merely quiet.
347+
`limit` and `offset` then paginate whatever survives.
348+
228349
```ts
229350
// Every TypeScript file in the project.
230351
const ts = await fs.find("/workspace/src", "**/*.ts");
231352

232353
// Everything under a directory (no pattern).
233354
const all = await fs.find("/workspace/notes");
355+
356+
// Skip generated trees without descending into them.
357+
const sources = await fs.find("/workspace", "**/*.ts", {
358+
exclude: ["node_modules", "node_modules/**", ".git", ".git/**"],
359+
});
234360
```
235361

236362
### `ls`
@@ -321,12 +447,12 @@ so handlers from Node code port over directly.
321447
| Code | When |
322448
| --- | --- |
323449
| `ENOENT` | Path does not exist and `force` is not true. Also raised by `stat` when a parent segment turns out to be a file. |
324-
| `ENOTEMPTY` | Path is a non-empty directory and `recursive` is not true. |
325-
| `ENOTDIR` | A parent path segment is a file (raised explicitly by `mkdir` and `writeFile`; `find` raises it when its `directory` argument is a file). |
326-
| `EISDIR` | Expected a file, got a directory (e.g. `readFile` on a dir, `writeFile` on `/`). |
327-
| `EEXIST` | `mkdir` without `recursive: true` on an existing path. |
328-
| `EINVAL` | Invalid path or unsupported options. |
329-
| `ELOOP` | Symlink traversal exceeded 40 hops. Thrown by the internal resolver when the `node:vfs` adapter wires up a cycle. |
450+
| `ENOTEMPTY` | Path is a non-empty directory and `recursive` is not true. Also raised by `rename` when the destination directory has children. |
451+
| `ENOTDIR` | A parent path segment is a file (raised explicitly by `mkdir` and `writeFile`; `find` raises it when its `directory` argument is a file; `rename` raises it when a directory would replace a non-directory). |
452+
| `EISDIR` | Expected a file, got a directory (e.g. `readFile` on a dir, `writeFile` on `/`, `rename` of a file onto a directory). |
453+
| `EEXIST` | `mkdir` without `recursive: true` on an existing path, or `symlink` onto an existing path. |
454+
| `EINVAL` | Invalid path or unsupported options: `readlink` on something that is not a symbolic link, `rename` of the root or of a directory into itself. |
455+
| `ELOOP` | Symbolic-link traversal exceeded 40 hops. Every path-walking method shares that budget, so a cycle surfaces from `stat`, `readFile`, `writeFile`, `mkdir`, and the rest alike. |
330456
| `EPERM` | Operation is forbidden, e.g. deleting the workspace root. |
331457
| `EIO` | Backing storage failed unexpectedly. |
332458
| `EACCES` | *Reserved for future mount layer (see [06. Mount Interface](./06_mount_interface.md)).* No code path in `workspace-fs` currently throws it. |
@@ -379,28 +505,35 @@ maps to `Workspace.fs`:
379505
| `rm` | `rm` | `{ recursive: true }` for non-empty dirs. |
380506
| `unlink` | `rm` | Same. |
381507
| `readdir` | `readdir` | Always returns dirent-shaped entries. |
382-
| `stat` / `lstat` | `stat` | No `lstat`; `stat` follows symlinks. See note below. |
508+
| `stat` / `lstat` | `stat` / `lstat` | `stat` follows a trailing symbolic link; `lstat` reports the link. |
383509
| `truncate` || Read, slice, write. |
384-
| `chmod` | | Pass `mode` to `writeFile` / `mkdir` at create time. There is no way to chmod an existing file without rewriting its bytes. |
510+
| `chmod` | `chmod` | Mode masked to twelve bits; follows a trailing symbolic link. `mode` can also be passed to `writeFile` / `mkdir` at create time. |
385511
| `chown` || No ownership model. |
386512
| `utimes` || `mtime` is managed by the VFS. |
387513
| `cp` / `copyFile` || Read + write. |
388-
| `rename` | | Read + write + delete. |
514+
| `rename` | `rename` | One transaction; replaces a destination of the same kind. |
389515
| `realpath` || Paths are already canonical. |
390-
| `symlink` / `readlink` | | Not on the public surface; see note below. |
516+
| `symlink` / `readlink` | `symlink` / `readlink` | Same argument order as Node. Targets are stored verbatim and may dangle. |
391517
| `watch` || Low-level primitive in `fs/watch.ts` (`createWatcher`, `createWatchAsyncIterable`, `WatchHandle`, `WatchOptions`); not exposed on the `WorkspaceFilesystem` class. |
392518
| `open` / `FileHandle` || Use streams instead. |
393-
| `glob` | `find` | Limited glob support (`*`, `**`, `**/`, and `?`). |
519+
| `glob` | `find` | Limited glob support (`*`, `**`, `**/`, and `?`), plus `exclude` for pruning subtrees. |
394520
|| `grep` | Not in `node:fs`; literal by default, with optional regular expressions. |
395521
|| `find` | Recursive directory walk with an optional glob, relative-rooted. |
396522
|| `ls` | Flat list of file paths under a directory (segment-aware). |
397523

398-
### Note: symlinks
399-
400-
Symlinks exist as an **internal primitive** used by the `node:vfs`
401-
adapter — the schema supports a `'symlink'` node type with a
402-
`link_target`, and the resolver in `fs/resolve.ts` follows them with a
403-
40-hop cap (throws `ELOOP` on overflow). They are **not** part of the
404-
public `WorkspaceFilesystem` surface: there are no `fs.symlink` or
405-
`fs.readlink` methods on `Workspace.fs`, and callers should treat all
406-
visible paths as if they pointed straight at real files.
524+
### Note: symbolic links
525+
526+
Symbolic links are part of the public surface. The schema carries a
527+
`'symlink'` node type with a `link_target`, the resolver in
528+
`fs/resolve.ts` follows them with a 40-hop cap (throws `ELOOP` on
529+
overflow), and `Workspace.fs` exposes `symlink`, `readlink`, and
530+
`lstat` on top of that. `WorkspaceFilesystemStub` mirrors all three
531+
across the Workers RPC boundary, which is how the Dynamic Worker
532+
filesystem adapters provide the `node:fs` behavior a shell expects from
533+
`ln -s`, `readlink`, and `test -L`.
534+
535+
Two rules cover the whole surface. Intermediate segments are always
536+
followed, so a link to a directory behaves like the directory for every
537+
method, `mkdir` included. A trailing link is followed by everything
538+
except `lstat` and `readlink`, which are the two methods whose purpose
539+
is to describe the link itself.

docs/09_tool_interface.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,18 @@ Entries are in name order. A non-final page includes `nextOffset`; pass it as th
166166

167167
```ts
168168
{
169-
path?: string; // default /workspace
169+
path?: string; // default /workspace
170170
pattern: string;
171-
limit?: number; // default 200, maximum 1000
171+
exclude?: string[];
172+
limit?: number; // default 200, maximum 1000
172173
offset?: number;
173174
}
174175
```
175176

176177
The pattern is relative to `path`. `*` stays within one path segment, `**` crosses directories, and `?` matches one non-separator character. Results contain `path` and `type`; a non-final page includes `nextOffset`. Pagination reaches `workspace.fs.find`, which walks directory children in fixed-size pages and stops after collecting the requested page instead of materializing every match.
177178

179+
`exclude` takes globs of the same shape, matched against the same relative path, and beats the inclusion pattern. An excluded directory is pruned rather than filtered, so `exclude: ["node_modules", "node_modules/**"]` keeps the walk out of a package tree instead of walking it and discarding the results.
180+
178181
## `grep`
179182

180183
```ts

docs/12_worker_backend.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,9 @@ policy. The backend does not own the external runtime's lifecycle.
223223
- **No hard links, no `utimes`.** The adapter throws `ENOSYS` on
224224
`link` (the store has no hard-link model) and no-ops on
225225
`utimes` (no atime column). `chmod`, `symlink`, `readlink`,
226-
and `lstat` all work end-to-end against the DO's store.
226+
`lstat`, and `rename` all work end-to-end against the DO's
227+
store, so `mv` moves an entry in one operation instead of
228+
copying and then deleting it.
227229
- **No cross-request reattach.** `ShellWorker.getExec` always
228230
returns ENOENT; `killExec` is a no-op. Each exec is scoped to
229231
its own call. The previous in-isolate event log shape didn't

packages/computer/src/backends/worker-shell/adapter.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,34 @@ describe("WorkspaceFsAdapter — composites", () => {
275275
expect(await workspace.fs.readFile("/dst", "utf8")).toBe("hello");
276276
await expect(workspace.fs.stat("/src")).rejects.toMatchObject({ code: "ENOENT" });
277277
});
278+
279+
it("mv moves through the store's rename rather than copy and delete", async () => {
280+
await workspace.fs.writeFile("/src", "hello");
281+
const rename = vi.spyOn(stub, "rename");
282+
const writeFile = vi.spyOn(stub, "writeFile");
283+
await adapter.mv("/src", "/dst");
284+
expect(rename).toHaveBeenCalledWith("/src", "/dst");
285+
expect(writeFile).not.toHaveBeenCalled();
286+
});
287+
288+
it("mv moves a directory tree in one operation", async () => {
289+
await workspace.fs.mkdir("/src/inner", { recursive: true });
290+
await workspace.fs.writeFile("/src/inner/b", "b");
291+
await adapter.mv("/src", "/dst");
292+
expect(await workspace.fs.readFile("/dst/inner/b", "utf8")).toBe("b");
293+
await expect(workspace.fs.stat("/src")).rejects.toMatchObject({ code: "ENOENT" });
294+
});
295+
296+
it("mv falls back to copy and delete when rename cannot replace the destination", async () => {
297+
await workspace.fs.mkdir("/src", { recursive: true });
298+
await workspace.fs.writeFile("/src/a", "a");
299+
await workspace.fs.mkdir("/dst", { recursive: true });
300+
await workspace.fs.writeFile("/dst/keep", "keep");
301+
await adapter.mv("/src", "/dst");
302+
expect(await workspace.fs.readFile("/dst/a", "utf8")).toBe("a");
303+
expect(await workspace.fs.readFile("/dst/keep", "utf8")).toBe("keep");
304+
await expect(workspace.fs.stat("/src")).rejects.toMatchObject({ code: "ENOENT" });
305+
});
278306
});
279307

280308
describe("WorkspaceFsAdapter — pure utilities", () => {

packages/computer/src/backends/worker-shell/adapter.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
//
44
// The adapter is a thin façade. Operations that map one-for-one
55
// (writeFile, readdir, mkdir, rm, chmod, symlink, readlink, stat,
6-
// lstat) forward directly. Operations the stub doesn't expose —
7-
// appendFile, cp, mv, exists — synthesize from the available
6+
// lstat, rename) forward directly. Operations the stub doesn't
7+
// expose — appendFile, cp — synthesize from the available
88
// primitives. Hard links and utimes aren't supported: link throws
99
// ENOSYS so a script that depends on them fails loudly; utimes
1010
// is a documented no-op because the store has no atime column.
@@ -50,6 +50,7 @@ export interface WorkspaceFs {
5050
rm(path: string, options?: RmOptions): Promise<void>;
5151
chmod(path: string, mode: number): Promise<void>;
5252
symlink(target: string, path: string): Promise<void>;
53+
rename(oldPath: string, newPath: string): Promise<void>;
5354
}
5455

5556
// Matches the subset of just-bash's IFileSystem the adapter
@@ -213,10 +214,19 @@ export class WorkspaceFsAdapter {
213214
}
214215

215216
async mv(src: string, dest: string): Promise<void> {
216-
// The store doesn't have a native rename today, so model mv as
217-
// copy+delete. POSIX mv is atomic when src and dest live on
218-
// the same filesystem; this approach isn't, but it matches
219-
// what just-bash's other adapters do.
217+
// The store renames in one transaction, so an interrupted move can
218+
// no longer leave the bytes at both paths or a directory half
219+
// copied. A destination that rename refuses to replace — a
220+
// non-empty directory, or a directory and a non-directory in
221+
// either order — still falls back to copy-then-delete, which is
222+
// what the shell's own `mv` expects when it merges a tree.
223+
try {
224+
await this.#fs.rename(src, dest);
225+
return;
226+
} catch (err) {
227+
const code = (err as { code?: string }).code;
228+
if (code !== "ENOTEMPTY" && code !== "EISDIR" && code !== "ENOTDIR") throw err;
229+
}
220230
await this.cp(src, dest, { recursive: true });
221231
await this.#fs.rm(src, { recursive: true });
222232
}

0 commit comments

Comments
 (0)