Skip to content

Commit e0a4ae0

Browse files
Implement recursive directory deletion for Windows (#224)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 07e730e commit e0a4ae0

File tree

5 files changed

+232
-5
lines changed

5 files changed

+232
-5
lines changed

pkgs/io_file/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ See
1919
| create tmp file | | | | | | | |
2020
| delete directory | ||||| | |
2121
| delete file | | | | | | | |
22-
| delete tree | | | | | | | |
22+
| delete tree | | | | | | | |
2323
| enum dir contents | | | | | | | |
2424
| exists | | | | | | | |
2525
| get metadata (stat) | | | | | | | |

pkgs/io_file/lib/src/file_system.dart

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,8 @@ base class FileSystem {
8989
/// Deletes the directory at the given path.
9090
///
9191
/// If `path` is a directory but the directory is not empty, then
92-
/// `FileSystemException` is thrown.
93-
///
94-
/// TODO(bquinlan): Explain how to delete non-empty directories.
92+
/// `FileSystemException` is thrown. Use [removeDirectoryTree] to delete
93+
/// non-empty directories.
9594
///
9695
/// If `path` is not directory:
9796
///
@@ -102,6 +101,14 @@ base class FileSystem {
102101
throw UnsupportedError('removeDirectory');
103102
}
104103

104+
/// Deletes the directory at the given path and its contents.
105+
///
106+
/// If the directory (or its subdirectories) contains any symbolic links then
107+
/// those links are deleted but their targets are not.
108+
void removeDirectoryTree(String path) {
109+
throw UnsupportedError('removeDirectoryTree');
110+
}
111+
105112
/// Renames, and possibly moves a file system object from one path to another.
106113
///
107114
/// If `newPath` is a relative path, it is resolved against the current

pkgs/io_file/lib/src/vm_windows_file_system.dart

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,74 @@ base class WindowsFileSystem extends FileSystem {
159159
}
160160
});
161161

162+
@override
163+
void removeDirectoryTree(String path) => using((arena) {
164+
_primeGetLastError();
165+
166+
final findData = arena<win32.WIN32_FIND_DATA>();
167+
final searchPath = p.join(path, '*');
168+
169+
final findHandle = win32.FindFirstFile(
170+
searchPath.toNativeUtf16(allocator: arena),
171+
findData,
172+
);
173+
174+
if (findHandle == win32.INVALID_HANDLE_VALUE) {
175+
final errorCode = win32.GetLastError();
176+
throw _getError(errorCode, 'FindFirstFile failed', path);
177+
}
178+
179+
do {
180+
final childPath = findData.ref.cFileName;
181+
if (childPath == '.' || childPath == '..') {
182+
continue;
183+
}
184+
185+
final fullPath = p.join(path, childPath);
186+
final attributes = findData.ref.dwFileAttributes;
187+
188+
if ((attributes & win32.FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
189+
// Do not recurse into directory links.
190+
if ((attributes & win32.FILE_ATTRIBUTE_DIRECTORY) != 0) {
191+
if (win32.RemoveDirectory(fullPath.toNativeUtf16(allocator: arena)) ==
192+
win32.FALSE) {
193+
final errorCode = win32.GetLastError();
194+
throw _getError(
195+
errorCode,
196+
'RemoveDirectory failed for link',
197+
fullPath,
198+
);
199+
}
200+
} else {
201+
if (win32.DeleteFile(fullPath.toNativeUtf16(allocator: arena)) ==
202+
win32.FALSE) {
203+
final errorCode = win32.GetLastError();
204+
throw _getError(errorCode, 'DeleteFile failed for link', fullPath);
205+
}
206+
}
207+
} else if ((attributes & win32.FILE_ATTRIBUTE_DIRECTORY) != 0) {
208+
removeDirectoryTree(fullPath);
209+
} else {
210+
if (win32.DeleteFile(fullPath.toNativeUtf16(allocator: arena)) ==
211+
win32.FALSE) {
212+
final errorCode = win32.GetLastError();
213+
throw _getError(errorCode, 'DeleteFile failed', fullPath);
214+
}
215+
}
216+
} while (win32.FindNextFile(findHandle, findData) != win32.FALSE);
217+
218+
final errorCode = win32.GetLastError();
219+
if (errorCode != win32.ERROR_NO_MORE_FILES) {
220+
throw _getError(errorCode, 'FindNextFile failed', path);
221+
}
222+
223+
if (win32.RemoveDirectory(path.toNativeUtf16(allocator: arena)) ==
224+
win32.FALSE) {
225+
final errorCode = win32.GetLastError();
226+
throw _getError(errorCode, 'remove directory failed', path);
227+
}
228+
});
229+
162230
@override
163231
void rename(String oldPath, String newPath) => using((arena) {
164232
_primeGetLastError();

pkgs/io_file/test/remove_directory_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ void main() {
1818
group('removeDirectory', () {
1919
late String tmp;
2020

21-
setUp(() => tmp = createTemp('createDirectory'));
21+
setUp(() => tmp = createTemp('removeDirectory'));
2222

2323
tearDown(() => deleteTemp(tmp));
2424

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
@TestOn('windows')
6+
library;
7+
8+
import 'dart:io';
9+
10+
import 'package:io_file/io_file.dart';
11+
import 'package:stdlibc/stdlibc.dart' as stdlibc;
12+
import 'package:test/test.dart';
13+
import 'package:win32/win32.dart' as win32;
14+
15+
import 'test_utils.dart';
16+
17+
void main() {
18+
group('removeDirectoryTree', () {
19+
late String tmp;
20+
21+
setUp(() => tmp = createTemp('removeDirectoryTree'));
22+
23+
tearDown(() => deleteTemp(tmp));
24+
25+
//TODO(brianquinlan): test with a very long path.
26+
27+
test('empty', () {
28+
final path = '$tmp/dir';
29+
Directory(path).createSync();
30+
31+
fileSystem.removeDirectoryTree(path);
32+
33+
expect(FileSystemEntity.typeSync(path), FileSystemEntityType.notFound);
34+
});
35+
36+
test('contains single file', () {
37+
final path = '$tmp/dir';
38+
Directory(path).createSync();
39+
File('$path/file').writeAsStringSync('Hello World!');
40+
41+
fileSystem.removeDirectoryTree(path);
42+
43+
expect(FileSystemEntity.typeSync(path), FileSystemEntityType.notFound);
44+
});
45+
46+
test('contains single link', () {
47+
final path = '$tmp/dir';
48+
Directory(path).createSync();
49+
Link('$path/link').createSync(tmp);
50+
51+
fileSystem.removeDirectoryTree(path);
52+
53+
expect(FileSystemEntity.typeSync(path), FileSystemEntityType.notFound);
54+
expect(FileSystemEntity.typeSync(tmp), FileSystemEntityType.directory);
55+
});
56+
57+
test('contains single empty directory', () {
58+
final path = '$tmp/dir';
59+
Directory(path).createSync();
60+
Directory('$path/subdir').createSync();
61+
62+
fileSystem.removeDirectoryTree(path);
63+
64+
expect(FileSystemEntity.typeSync(path), FileSystemEntityType.notFound);
65+
});
66+
67+
test('complex tree', () {
68+
void createTree(String path, int depth) {
69+
Directory(path).createSync();
70+
71+
File('$path/file1').writeAsStringSync('Hello World');
72+
Link('$path/filelink1').createSync('$path/file1');
73+
Link('$path/dirlink1').createSync(path);
74+
75+
if (depth > 0) {
76+
createTree('$path/dir1', depth - 1);
77+
Link('$path/dirlink2').createSync('$path/dir1');
78+
}
79+
}
80+
81+
final path = '$tmp/dir';
82+
createTree(path, 5);
83+
84+
fileSystem.removeDirectoryTree(path);
85+
86+
expect(FileSystemEntity.typeSync(path), FileSystemEntityType.notFound);
87+
});
88+
89+
test('non-existent directory', () {
90+
final path = '$tmp/foo/dir';
91+
92+
expect(
93+
() => fileSystem.removeDirectoryTree(path),
94+
throwsA(
95+
isA<PathNotFoundException>().having(
96+
(e) => e.osError?.errorCode,
97+
'errorCode',
98+
Platform.isWindows ? win32.ERROR_PATH_NOT_FOUND : stdlibc.ENOENT,
99+
),
100+
),
101+
);
102+
});
103+
104+
test('file', () {
105+
final path = '$tmp/file';
106+
File(path).writeAsStringSync('Hello World!');
107+
108+
expect(
109+
() => fileSystem.removeDirectoryTree(path),
110+
throwsA(
111+
isA<FileSystemException>().having(
112+
(e) => e.osError?.errorCode,
113+
'errorCode',
114+
Platform.isWindows ? win32.ERROR_DIRECTORY : stdlibc.ENOTDIR,
115+
),
116+
),
117+
);
118+
});
119+
120+
test('file link', () {
121+
File('$tmp/file').writeAsStringSync('Hello World!');
122+
Link('$tmp/link').createSync('$tmp/file');
123+
124+
expect(
125+
() => fileSystem.removeDirectoryTree('$tmp/link'),
126+
throwsA(
127+
isA<FileSystemException>().having(
128+
(e) => e.osError?.errorCode,
129+
'errorCode',
130+
Platform.isWindows ? win32.ERROR_DIRECTORY : stdlibc.ENOTDIR,
131+
),
132+
),
133+
);
134+
});
135+
136+
test('directory link', () {
137+
File('$tmp/dir').createSync();
138+
Link('$tmp/link').createSync('$tmp/dir');
139+
140+
expect(
141+
() => fileSystem.removeDirectoryTree('$tmp/link'),
142+
throwsA(
143+
isA<FileSystemException>().having(
144+
(e) => e.osError?.errorCode,
145+
'errorCode',
146+
Platform.isWindows ? win32.ERROR_DIRECTORY : stdlibc.ENOTDIR,
147+
),
148+
),
149+
);
150+
});
151+
});
152+
}

0 commit comments

Comments
 (0)