diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2edea4a..60dd745 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,7 +10,7 @@
-
+
diff --git a/PowerKit.Tests/DirectoryExtensionsTests.cs b/PowerKit.Tests/DirectoryExtensionsTests.cs
index ae0f145..b8d0db1 100644
--- a/PowerKit.Tests/DirectoryExtensionsTests.cs
+++ b/PowerKit.Tests/DirectoryExtensionsTests.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using System.Runtime.Versioning;
using FluentAssertions;
using PowerKit;
using PowerKit.Extensions;
@@ -9,6 +10,124 @@ namespace PowerKit.Tests;
public class DirectoryExtensionsTests
{
+ [Fact]
+ public void Copy_Test()
+ {
+ // Arrange
+ using var sourceDirectory = TempDirectory.Create();
+ using var destinationDirectory = TempDirectory.Create();
+
+ File.WriteAllText(Path.Combine(sourceDirectory.Path, "file.txt"), "hello");
+
+ // Act
+ Directory.Copy(sourceDirectory.Path, destinationDirectory.Path);
+
+ // Assert
+ File.ReadAllText(Path.Combine(destinationDirectory.Path, "file.txt")).Should().Be("hello");
+ }
+
+ [Fact]
+ public void Copy_Nested_Test()
+ {
+ // Arrange
+ using var sourceDirectory = TempDirectory.Create();
+ using var destinationDirectory = TempDirectory.Create();
+
+ Directory.CreateDirectory(Path.Combine(sourceDirectory.Path, "sub"));
+ File.WriteAllText(Path.Combine(sourceDirectory.Path, "sub", "file.txt"), "nested");
+
+ // Act
+ Directory.Copy(sourceDirectory.Path, destinationDirectory.Path);
+
+ // Assert
+ File.ReadAllText(Path.Combine(destinationDirectory.Path, "sub", "file.txt"))
+ .Should()
+ .Be("nested");
+ }
+
+ [Fact]
+ public void Copy_Overwrite_Test()
+ {
+ // Arrange
+ using var sourceDirectory = TempDirectory.Create();
+ using var destinationDirectory = TempDirectory.Create();
+
+ File.WriteAllText(Path.Combine(sourceDirectory.Path, "file.txt"), "new");
+ File.WriteAllText(Path.Combine(destinationDirectory.Path, "file.txt"), "old");
+
+ // Act
+ Directory.Copy(sourceDirectory.Path, destinationDirectory.Path, true);
+
+ // Assert
+ File.ReadAllText(Path.Combine(destinationDirectory.Path, "file.txt")).Should().Be("new");
+ }
+
+ [Fact]
+ public void Copy_NoOverwrite_Test()
+ {
+ // Arrange
+ using var sourceDirectory = TempDirectory.Create();
+ using var destinationDirectory = TempDirectory.Create();
+
+ File.WriteAllText(Path.Combine(sourceDirectory.Path, "file.txt"), "source");
+ File.WriteAllText(Path.Combine(destinationDirectory.Path, "file.txt"), "existing");
+
+ // Act
+ var act = () => Directory.Copy(sourceDirectory.Path, destinationDirectory.Path, false);
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Copy_Truncates_Test()
+ {
+ // Arrange
+ using var sourceDirectory = TempDirectory.Create();
+ using var destinationDirectory = TempDirectory.Create();
+
+ File.WriteAllText(Path.Combine(sourceDirectory.Path, "file.txt"), "hi");
+ File.WriteAllText(Path.Combine(destinationDirectory.Path, "file.txt"), "longer content");
+
+ // Act
+ Directory.Copy(sourceDirectory.Path, destinationDirectory.Path, true);
+
+ // Assert
+ File.ReadAllText(Path.Combine(destinationDirectory.Path, "file.txt")).Should().Be("hi");
+ }
+
+ [SkippableFact]
+ [UnsupportedOSPlatform("windows")]
+ public void Copy_UnixFileMode_Test()
+ {
+ Skip.If(OperatingSystem.IsWindows());
+
+ // Arrange
+ using var sourceDirectory = TempDirectory.Create();
+ using var destinationDirectory = TempDirectory.Create();
+
+ var sourceFilePath = Path.Combine(sourceDirectory.Path, "file.sh");
+ File.WriteAllText(sourceFilePath, "#!/bin/sh");
+ File.SetUnixFileMode(
+ sourceFilePath,
+ UnixFileMode.UserRead
+ | UnixFileMode.UserWrite
+ | UnixFileMode.UserExecute
+ | UnixFileMode.GroupRead
+ | UnixFileMode.GroupExecute
+ | UnixFileMode.OtherRead
+ | UnixFileMode.OtherExecute
+ );
+
+ // Act
+ Directory.Copy(sourceDirectory.Path, destinationDirectory.Path);
+
+ // Assert
+ File.GetUnixFileMode(Path.Combine(destinationDirectory.Path, "file.sh"))
+ .Should()
+ .Be(File.GetUnixFileMode(sourceFilePath));
+ }
+
[Fact]
public void CheckWriteAccess_Test()
{
diff --git a/PowerKit/Extensions/DirectoryExtensions.cs b/PowerKit/Extensions/DirectoryExtensions.cs
index c9d793f..9e40b9f 100644
--- a/PowerKit/Extensions/DirectoryExtensions.cs
+++ b/PowerKit/Extensions/DirectoryExtensions.cs
@@ -1,7 +1,9 @@
#nullable enable
using System;
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
+using System.Linq;
namespace PowerKit.Extensions;
@@ -43,6 +45,94 @@ public static bool TryDelete(string path, bool recursive = false)
}
}
+ ///
+ /// Recursively copies all files from to .
+ /// Destination files are opened with exclusive locks before any data is written.
+ /// Concurrent readers may be blocked or fail with a sharing violation while a file is being updated,
+ /// and this method does not guarantee atomic old-or-new visibility to readers.
+ ///
+ public static void Copy(string sourcePath, string destinationPath, bool overwrite = true)
+ {
+ var sourceStreams = new List();
+ var destinationStreams = new List();
+
+ try
+ {
+ // Create all destination directories
+ Directory.CreateDirectory(destinationPath);
+ foreach (
+ var sourceDirectoryPath in Directory.GetDirectories(
+ sourcePath,
+ "*",
+ SearchOption.AllDirectories
+ )
+ )
+ {
+ Directory.CreateDirectory(
+ Path.Combine(
+ destinationPath,
+ Path.GetRelativePath(sourcePath, sourceDirectoryPath)
+ )
+ );
+ }
+
+ // Create file stream pairs
+ foreach (
+ var sourceFilePath in Directory.GetFiles(
+ sourcePath,
+ "*",
+ SearchOption.AllDirectories
+ )
+ )
+ {
+ sourceStreams.Add(File.OpenRead(sourceFilePath));
+
+ var destinationFilePath = Path.Combine(
+ destinationPath,
+ Path.GetRelativePath(sourcePath, sourceFilePath)
+ );
+
+ destinationStreams.Add(
+ overwrite
+ ? File.OpenWrite(destinationFilePath)
+ : File.Open(
+ destinationFilePath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None
+ )
+ );
+ }
+
+ // Copy the file contents
+ foreach (
+ var (sourceStream, destinationStream) in sourceStreams.Zip(
+ destinationStreams,
+ (s, d) => (s, d)
+ )
+ )
+ {
+ sourceStream.CopyTo(destinationStream);
+
+ // Truncate the destination file if the source file is shorter
+ destinationStream.SetLength(sourceStream.Length);
+
+ // Preserve Unix file permissions on non-Windows platforms
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(
+ destinationStream.Name,
+ File.GetUnixFileMode(sourceStream.Name)
+ );
+ }
+ }
+ }
+ finally
+ {
+ Disposable.Merge([.. sourceStreams, .. destinationStreams]).Dispose();
+ }
+ }
+
///
/// Checks if it's possible to write to the specified directory.
///