[io] copyPath/copyPathSync: add a `deepCopyLinks` parameter (#1267)
The original intention was to create new `Link` objects when copying
links, but the implementation left the implicit `followLinks: true`
argument which meant that in practice links were recursed into and files
and directories deeply copied. This behavior is the only behavior that
will work when there are links present and the copy is across volumes
which do not permit links, but it is not the optimal behavior for most
use cases. Add a `deepCopyLinks` argument which defaults to `true` to
keep the current behavior by default, but allow the smaller copy with
shallow links through a boolean argument.
Update the doc and add tests for the behavior around links both with and
without the argument.
diff --git a/pkgs/io/CHANGELOG.md b/pkgs/io/CHANGELOG.md
index e0631fa..8c0057f 100644
--- a/pkgs/io/CHANGELOG.md
+++ b/pkgs/io/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 1.1.0-wip
+
+* Add a `deepCopyLinks` argument to `copyPath` and `copyPathSync`.
+
## 1.0.5
* Require Dart 3.4.
diff --git a/pkgs/io/lib/src/copy_path.dart b/pkgs/io/lib/src/copy_path.dart
index 3a999b6..8a1c3ca 100644
--- a/pkgs/io/lib/src/copy_path.dart
+++ b/pkgs/io/lib/src/copy_path.dart
@@ -19,18 +19,23 @@
/// Copies all of the files in the [from] directory to [to].
///
/// This is similar to `cp -R <from> <to>`:
-/// * Symlinks are supported.
/// * Existing files are over-written, if any.
/// * If [to] is within [from], throws [ArgumentError] (an infinite operation).
/// * If [from] and [to] are canonically the same, no operation occurs.
+/// * If [deepCopyLinks] is `true` (the default) then links are followed and
+/// the content of linked directories and files are copied entirely. If
+/// `false` then new [Link] file system entities are created linking to the
+/// same target the links under [from].
///
/// Returns a future that completes when complete.
-Future<void> copyPath(String from, String to) async {
+Future<void> copyPath(String from, String to,
+ {bool deepCopyLinks = true}) async {
if (_doNothing(from, to)) {
return;
}
await Directory(to).create(recursive: true);
- await for (final file in Directory(from).list(recursive: true)) {
+ await for (final file
+ in Directory(from).list(recursive: true, followLinks: deepCopyLinks)) {
final copyTo = p.join(to, p.relative(file.path, from: from));
if (file is Directory) {
await Directory(copyTo).create(recursive: true);
@@ -45,18 +50,22 @@
/// Copies all of the files in the [from] directory to [to].
///
/// This is similar to `cp -R <from> <to>`:
-/// * Symlinks are supported.
/// * Existing files are over-written, if any.
/// * If [to] is within [from], throws [ArgumentError] (an infinite operation).
/// * If [from] and [to] are canonically the same, no operation occurs.
+/// * If [deepCopyLinks] is `true` (the default) then links are followed and
+/// the content of linked directories and files are copied entirely. If
+/// `false` then new [Link] file system entities are created linking to the
+/// same target the links under [from].
///
/// This action is performed synchronously (blocking I/O).
-void copyPathSync(String from, String to) {
+void copyPathSync(String from, String to, {bool deepCopyLinks = true}) {
if (_doNothing(from, to)) {
return;
}
Directory(to).createSync(recursive: true);
- for (final file in Directory(from).listSync(recursive: true)) {
+ for (final file in Directory(from)
+ .listSync(recursive: true, followLinks: deepCopyLinks)) {
final copyTo = p.join(to, p.relative(file.path, from: from));
if (file is Directory) {
Directory(copyTo).createSync(recursive: true);
diff --git a/pkgs/io/pubspec.yaml b/pkgs/io/pubspec.yaml
index 7e00d99..46b76b3 100644
--- a/pkgs/io/pubspec.yaml
+++ b/pkgs/io/pubspec.yaml
@@ -2,7 +2,7 @@
description: >-
Utilities for the Dart VM Runtime including support for ANSI colors, file
copying, and standard exit code values.
-version: 1.0.5
+version: 1.1.0-wip
repository: https://github.com/dart-lang/tools/tree/main/pkgs/io
environment:
diff --git a/pkgs/io/test/copy_path_test.dart b/pkgs/io/test/copy_path_test.dart
index 0c72a0b..df10395 100644
--- a/pkgs/io/test/copy_path_test.dart
+++ b/pkgs/io/test/copy_path_test.dart
@@ -5,6 +5,8 @@
@TestOn('vm')
library;
+import 'dart:io';
+
import 'package:io/io.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
@@ -33,6 +35,82 @@
throwsArgumentError,
);
});
+
+ group('links', () {
+ const linkTarget = 'link_target';
+ const linkSource = 'link_source';
+ const linkContent = 'link_content.txt';
+ late String targetPath;
+ setUp(() async {
+ await _create();
+ await d
+ .dir(linkTarget, [d.file(linkContent, 'original content')]).create();
+ targetPath = p.join(d.sandbox, linkTarget);
+ await Link(p.join(d.sandbox, _parentDir, linkSource)).create(targetPath);
+ });
+
+ test('are shallow copied with deepCopyLinks: false in copyPath', () async {
+ await copyPath(
+ deepCopyLinks: false,
+ p.join(d.sandbox, _parentDir),
+ p.join(d.sandbox, _copyDir));
+
+ final expectedLink = Link(p.join(d.sandbox, _copyDir, linkSource));
+ expect(await expectedLink.exists(), isTrue);
+ expect(await expectedLink.target(), targetPath);
+ });
+
+ test('are shallow copied with deepCopyLinks: false in copyPathSync',
+ () async {
+ copyPathSync(
+ deepCopyLinks: false,
+ p.join(d.sandbox, _parentDir),
+ p.join(d.sandbox, _copyDir));
+
+ final expectedLink = Link(p.join(d.sandbox, _copyDir, linkSource));
+ expect(await expectedLink.exists(), isTrue);
+ expect(await expectedLink.target(), targetPath);
+ });
+
+ test('are deep copied by default in copyPath', () async {
+ await copyPath(
+ p.join(d.sandbox, _parentDir), p.join(d.sandbox, _copyDir));
+
+ final expectedDir = Directory(p.join(d.sandbox, _copyDir, linkSource));
+ final expectedFile =
+ File(p.join(d.sandbox, _copyDir, linkSource, linkContent));
+ expect(await expectedDir.exists(), isTrue);
+ expect(await expectedFile.exists(), isTrue);
+
+ expect(await expectedFile.readAsString(), 'original content',
+ reason: 'The file behind the link was copied with invalid content');
+
+ await expectedFile.writeAsString('new content');
+ final originalFile =
+ File(p.join(d.sandbox, _parentDir, linkSource, linkContent));
+ expect(await originalFile.readAsString(), 'original content',
+ reason: 'The file behind the link should not change');
+ });
+
+ test('are deep copied by default in copyPathSync', () async {
+ copyPathSync(p.join(d.sandbox, _parentDir), p.join(d.sandbox, _copyDir));
+
+ final expectedDir = Directory(p.join(d.sandbox, _copyDir, linkSource));
+ final expectedFile =
+ File(p.join(d.sandbox, _copyDir, linkSource, linkContent));
+ expect(await expectedDir.exists(), isTrue);
+ expect(await expectedFile.exists(), isTrue);
+
+ expect(await expectedFile.readAsString(), 'original content',
+ reason: 'The file behind the link was copied with invalid content');
+
+ await expectedFile.writeAsString('new content');
+ final originalFile =
+ File(p.join(d.sandbox, _parentDir, linkSource, linkContent));
+ expect(await originalFile.readAsString(), 'original content',
+ reason: 'The file behind the link should not change');
+ });
+ });
}
const _parentDir = 'parent';