Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c4de6e6
PathIo: support LocalPathPattern in GetFiles
hamdiakin Oct 5, 2026
59be3d1
(#202) PathIo: return an array of AbsolutePaths
ForNeVeR Oct 21, 2026
aa71bf9
(#202) Docs: add a changelog entry
ForNeVeR Oct 21, 2026
e999628
Update dependency FsCheck.Xunit.v3 to 3.6.0
renovate[bot] Oct 21, 2026
e68cfcf
LocalPath: normalize current directory to .
ForNeVeR Oct 23, 2026
0878acd
Kernel32: drop the unused Win32 declarations
ElectLive Oct 18, 2026
7395f8e
(#241) Kernel32: bump the copyright year
ForNeVeR Oct 24, 2026
a2c35fd
(#241) Kernel32: only leave one test about the data structure size
ForNeVeR Oct 24, 2026
cacdfc1
(#241) Docs: drop the changelog entry about internal change
ForNeVeR Oct 24, 2026
b8d74b7
(#241) DiskUtils: migrate to use CsWin32 instead of our own interop
ForNeVeR Oct 24, 2026
86a0e72
Return the pooled reparse buffer on every path in TryGetReparseDataBu…
FalconTechDev Oct 19, 2026
8da7719
(#240) AbsolutePath: only check for Windows junction if we already se…
ForNeVeR Oct 24, 2026
0f6bb7e
(#240) AbsolutePath.ReadKind: even less IO, more correct behavior w.r…
ForNeVeR Oct 24, 2026
2565c20
(#240) DiskUtils: drop usage of array pool for junction detection
ForNeVeR Oct 24, 2026
dc2a40b
(#240) Docs: mention the contributor
ForNeVeR Oct 24, 2026
216141f
Tests: handle unavailable Windows symlink privilege
hamdiakin Oct 19, 2026
cbdc771
(#245) Tests: use ERROR_PRIVILEGE_NOT_HELD value from CsWin32
ForNeVeR Oct 24, 2026
0788fd0
(#245) Docs: sort the links
ForNeVeR Oct 24, 2026
4e11e8b
Legal: bump the copyright years where necessary
ForNeVeR Oct 25, 2026
6dfdc09
Release: prepare v2.0.0
ForNeVeR Oct 25, 2026
f49bfbc
CI: migrate to Trusted Publishing Policy
ForNeVeR Oct 25, 2026
d621430
CI: more permissions for the release workflow
ForNeVeR Oct 25, 2026
6162c76
CI: fix the NuGet API key passing
ForNeVeR Oct 25, 2026
cd13963
Update dotnet monorepo
renovate[bot] Oct 25, 2026
faa19ae
Update dependency dotnet-sdk to v10.0.403
renovate[bot] Oct 26, 2026
d3f4e16
Update dependency Microsoft.Windows.CsWin32 to 0.3.369
renovate[bot] Nov 6, 2026
9eaae32
(#256) CI: check the API compatibility
ForNeVeR Nov 3, 2026
a3cf2ff
(#256) Code review fixes
ForNeVeR Nov 3, 2026
37cfbb8
feat(SystemIo): generate System.IO forwarding APIs with a source gene…
ghostflyby Oct 21, 2026
a886753
refactor(PathIoGenerator): cleanup, cache parsed members in the doc i…
ghostflyby Oct 21, 2026
ce01a9b
fix: satisfy CI checks
ghostflyby Oct 21, 2026
3aad4c9
fix: mark the generated header literals as REUSE-ignored
ghostflyby Oct 21, 2026
c76cbae
refactor(PathIoGenerator): review follow-ups
ghostflyby Oct 21, 2026
ac545ae
fix(PathIoGenerator): complete special-type keywords, restore short h…
ghostflyby Oct 21, 2026
b154907
fix(PathIoGenerator): preserve the released PathIo API surface
ghostflyby Nov 9, 2026
5f284b6
fix(SystemIo): return a nullable DirectoryInfo from GetParent
ghostflyby Nov 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .config/dotnet-tools.json.license
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
SPDX-FileCopyrightText: 2024 Friedrich von Never <friedrich@fornever.me>
SPDX-FileCopyrightText: 2024-2026 Friedrich von Never <friedrich@fornever.me>

SPDX-License-Identifier: MIT
15 changes: 13 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
nuget:
permissions:
contents: write
id-token: write
runs-on: ubuntu-26.04
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
Expand All @@ -36,6 +37,10 @@ jobs:
with:
key: ${{ runner.os }}.nuget.${{ hashFiles('**/*.*proj', '**/*.props') }}
path: ${{ env.NUGET_PACKAGES }}
- if: startsWith(github.ref, 'refs/tags/v')
name: Verify that all the public API is marked as shipped
shell: pwsh
run: scripts/Test-PublicApiShipped.ps1
- id: version
name: Get version
shell: pwsh
Expand Down Expand Up @@ -65,9 +70,15 @@ jobs:
./TruePath.SystemIo/bin/Release/TruePath.SystemIo.${{ steps.version.outputs.version }}.nupkg
./TruePath.SystemIo/bin/Release/TruePath.SystemIo.${{ steps.version.outputs.version }}.snupkg
name: TruePath v${{ steps.version.outputs.version }}
- if: startsWith(github.ref, 'refs/tags/v')
id: nuget-login
name: Log in to NuGet
uses: NuGet/login@v1
with:
user: ${{ secrets.NUGET_USER }}
- if: startsWith(github.ref, 'refs/tags/v')
name: Push TruePath to NuGet
run: dotnet nuget push ./TruePath/bin/Release/TruePath.${{ steps.version.outputs.version }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_TOKEN_TRUE_PATH }}
run: dotnet nuget push ./TruePath/bin/Release/TruePath.${{ steps.version.outputs.version }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }}
- if: startsWith(github.ref, 'refs/tags/v')
name: Push TruePath.SystemIo to NuGet
run: dotnet nuget push ./TruePath.SystemIo/bin/Release/TruePath.SystemIo.${{ steps.version.outputs.version }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_TOKEN_TRUE_PATH_SYSTEM_IO }}
run: dotnet nuget push ./TruePath.SystemIo/bin/Release/TruePath.SystemIo.${{ steps.version.outputs.version }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }}
62 changes: 41 additions & 21 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!--
SPDX-FileCopyrightText: 2024-2025 Friedrich von Never <friedrich@fornever.me>
SPDX-FileCopyrightText: 2024-2026 TruePath contributors <https://github.com/ForNeVeR/TruePath>

SPDX-License-Identifier: MIT
-->
Expand All @@ -10,32 +10,51 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), with one exception: the behavior of the members and types marked as `[System.Diagnostics.CodeAnalysis.ExperimentalAttribute]` may change without a major version bump (see [Versioning](README.md#versioning)).

## [Unreleased]
### Added
- `LocalPath.PathRoot`, returning the root of an absolute path, or the drive root of a drive-relative path (e.g. `C:foo`, which only exists on Windows), or `null` when the root can't be determined.
- `AbsolutePath.PathRoot` property.
- [#224](https://github.com/ForNeVeR/TruePath/issues/224): `PathKind` enumeration and `LocalPath.Kind` property, telling an absolute path from a relative one, and on Windows also from a path rooted without a drive letter (`\Windows`) and from a path relative to the current directory of a drive (`C:Windows`). These are **experimental** (diagnostic `TRUEPATH001`): more kinds may be added later, e.g. for UNC paths.
## [2.0.0] - 2026-10-25
This is a major release with certain breaking changes. No public APIs have been broken, but the amount of minor behavior changes required us to bump the major version.

The main change (outlined below) is the introduction of the _relative path kinds_ (most important for Windows). TruePath's path resolve APIs now follow the new rules regarding the relative path resolve (especially regarding more exotic relative path kinds on Windows); check the relevant documentation.

Another notable change is that, while `LocalPath` still considers an empty path to designate the current directory, current directory now always gets normalized as `"."` and not an empty string.

There were a bunch of unrelated important (sometimes breaking) changes to other APIs, read the details below.

### Changed
- **Breaking:** on Windows, `LocalPath.IsAbsolute` now only returns `true` for fully qualified paths, such as `C:\Windows`. Paths rooted without a drive letter (`\Windows`), and paths relative to the current directory of a drive (`C:Windows`, `C:`) are not absolute anymore, and `AbsolutePath` now rejects them. Previously, this was following the `Path.IsPathRooted` API.
- **Breaking:** `LocalPath.IsPrefixOf` and `StartsWith` now consider paths of different `PathKind` unrelated: an absolute path is never a prefix of a relative one, nor the other way round, and on Windows `\Windows`, `C:Windows` and `Windows` are all unrelated to each other. Previously, the result depended on a string comparison.
- **Breaking:** on Windows, the `/` operator of `LocalPath` and `AbsolutePath` no longer delegates to `Path.Combine`, and instead follows the algorithm of C++ `std::filesystem::path::operator/` ([fs.path.append](https://eel.is/c++draft/fs.path.append)) with case-insensitive drive letters. In particular, `C:\base / \x` is now `C:\x` (was `\x`), `C:\base / C:x` is now `C:\base\x` (was `C:x`), and `C: / x` is now `C:x` (was `C:\x`). On Unix, the behavior is unchanged.
- **Breaking:** `PathExtensions.WithExtension` now throws an `ArgumentException` unless the path ends with a file name and the result of the change is a file name in its place: an extension change may no longer alter the number or the kind of a path's segments. It used to do so silently — `new LocalPath(@"C:\foo\.gitignore").WithExtension(null)` returned `C:\foo`, `new LocalPath(".gitignore").WithExtension(null)` returned the empty path, and `new LocalPath(@"C:\").WithExtension("bar")` returned `C:\.bar`.
- On Windows, `AbsolutePath / LocalPath` now resolves a path relative to the current directory of another drive against the current directory of that drive, so the result is always absolute: e.g. `C:\base / D:x` is `D:\x` if the current directory of drive `D:` is its root. Previously, the result was `D:x`, which is not absolute.
- On Windows, `LocalPath.ResolveToCurrentDirectory` now resolves `\x` against the drive of the current directory, and `D:x` against the current directory of drive `D:`.
- **Breaking:** `LocalPath.StartsWith` and `AbsolutePath.StartsWith` now compare whole path segments instead of raw strings, which makes them exact inverses of `IsPrefixOf` as originally intended in [#43](https://github.com/ForNeVeR/TruePath/issues/43). For example, `new LocalPath("/foo1").StartsWith(new LocalPath("/foo"))` is now `false`, where it used to be `true`.
- `LocalPath.IsPrefixOf` now treats an empty path — the normalized form of `""`, `"."` and `"a/.."`, and the parent of any single-segment relative path — as the current directory, so it is a prefix of every path of kind `PathKind.Relative` that does not begin with a `..` reference.
- **Breaking:** ([#224](https://github.com/ForNeVeR/TruePath/issues/224)) on Windows, `LocalPath.IsAbsolute` now only returns `true` for fully qualified paths, such as `C:\Windows`. Paths rooted without a drive letter (`\Windows`), and paths relative to the current directory of a drive (`C:Windows`, `C:`) are not absolute anymore, and `AbsolutePath` now rejects them. Previously, this was following the `Path.IsPathRooted` API.
- **Breaking:** ([#224](https://github.com/ForNeVeR/TruePath/issues/224)) on Windows, the `/` operator of `LocalPath` and `AbsolutePath` no longer delegates to `Path.Combine`, and instead follows the algorithm of C++ `std::filesystem::path::operator/` ([fs.path.append](https://eel.is/c++draft/fs.path.append)) with case-insensitive drive letters. In particular, `C:\base / \x` is now `C:\x` (was `\x`), `C:\base / C:x` is now `C:\base\x` (was `C:x`), and `C: / x` is now `C:x` (was `C:\x`). On Unix, the behavior is unchanged.
- **Breaking:** path normalization now represents the current directory as `.` instead of an empty string: `PathStrings.Normalize` returns `"."` for `""`, `"."` and `"a/.."`, and so do `LocalPath.Value` and `ToString()` (and `LocalPath.Parent` of a single-segment relative path is `.`). As a consequence, `LocalPath.FileName` of such a path is `.`, the same way it is `..` for `..`, and `GetExtensionWithDot` returns `.` for it.
- **Breaking:** `LocalPath.IsPrefixOf` and `StartsWith` now consider paths of different `PathKind` unrelated: an absolute path is never a prefix of a relative one, nor the other way round, and on Windows `\Windows`, `C:Windows` and `Windows` are all unrelated to each other.
- **Breaking:** `PathExtensions.WithExtension` now throws an `ArgumentException` unless the path ends with a file name and the result of the change is a file name in its place: an extension change may no longer alter the number or the kind of any path's segments. It used to do so, e.g.:
- `new LocalPath(@"C:\foo\.gitignore").WithExtension(null)` returned `C:\foo`,
- `new LocalPath(".gitignore").WithExtension(null)` returned the empty path,
- and `new LocalPath(@"C:\").WithExtension("bar")` returned `C:\.bar`.

None of the examples above take place anymore.
- [#224](https://github.com/ForNeVeR/TruePath/issues/224): on Windows, `AbsolutePath / LocalPath` now resolves a path relative to the current directory of another drive against the current directory of that drive, so the result is always absolute: e.g. `C:\base / D:x` is `D:\x` if the current directory of drive `D:` is its root. Previously, the result was `D:x`, which is not absolute.
- [#224](https://github.com/ForNeVeR/TruePath/issues/224): on Windows, `LocalPath.ResolveToCurrentDirectory` now resolves `\x` against the drive of the current directory, and `D:x` against the current directory of drive `D:`.
- [#240](https://github.com/ForNeVeR/TruePath/issues/240): more performant `AbsolutePath.ReadKind`: it now queries the entry attributes only once, and on Windows it no longer allocates or performs additional IO in cases not concerning reparse points. Thanks to @FalconTechDev for help!
- `AbsolutePath.ReadKind` now only returns `null` if the entry doesn't exist, and throws an exception if its attributes cannot be read for another reason: e.g. an `UnauthorizedAccessException` if the access is denied, or an `IOException` if the path contains a name rejected by the file system (on .NET Framework, also `ArgumentException` or `PathTooLongException`). Previously, it returned `null` in these cases.
- The platform-default path comparers (`LocalPath.PlatformDefaultComparer`, `AbsolutePath.PlatformDefaultComparer`) are now case-insensitive on iOS and tvOS as well, matching the .NET runtime.

### Added
- [#215](https://github.com/ForNeVeR/TruePath/issues/215): `AbsolutePath.PathRoot` property. Thanks to @fskalkan!
- `LocalPath.PathRoot` property, similar to the previous entry, returning the root of an absolute path, or the drive root of a drive-relative path (e.g. `C:foo`, which only exists on Windows, will return `C:`), or `null` when the root can't be determined.
- [#202: `path.GetFiles(LocalPathPattern)`](https://github.com/ForNeVeR/TruePath/issues/202). Thanks to @hamdiakin!
- [#224](https://github.com/ForNeVeR/TruePath/issues/224): `PathKind` enumeration and `LocalPath.Kind` property, telling an absolute path from a relative one, and on Windows also from a path rooted without a drive letter (`\Windows`) and from a path relative to the current directory of a drive (`C:Windows`). These are **experimental** (diagnostic `TRUEPATH001`): more kinds may be added later, e.g. for UNC paths.

### Fixed
- [#239](https://github.com/ForNeVeR/TruePath/issues/239): on Unix, `AbsolutePath.Canonicalize()` no longer closes the file descriptor 0 when the path doesn't exist. This used to close the standard input of the process, or any file or socket that later took this descriptor number. Also, it no longer leaks the memory allocated for the resolved path.
- [#226](https://github.com/ForNeVeR/TruePath/issues/226): `LocalPath.IsPrefixOf` and `StartsWith` now take leading `..` references of relative paths into account instead of comparing them as strings. For example, `new LocalPath("..").IsPrefixOf(new LocalPath("foo"))` is now `true`, and `new LocalPath("..").IsPrefixOf(new LocalPath("../.."))` is now `false`.
- [#225](https://github.com/ForNeVeR/TruePath/issues/225): Make path prefix checks use the same platform-default case sensitivity as path equality.
- `LocalPath.IsPrefixOf` and `StartsWith` now compare path strings ordinally. Previously they used the current culture, which ignores collation-ignorable characters, so a path could be reported as a prefix of an unrelated one.
- Path normalization no longer drops the root separator when a rooted path ends with `..`: `C:\base\..` is now normalized to `C:\` (was `C:`, a path relative to the current directory of drive `C:`), and `/base/..` to `/` (was an empty path, i.e. the current directory). This affects `PathStrings.Normalize`, `LocalPath` and `AbsolutePath`.
- [#217](https://github.com/ForNeVeR/TruePath/issues/217): the `IsPrefixOf` and `StartsWith` methods for both `LocalPath` and `AbsolutePath` are now exact opposites (use same code), and always operate on whole path segments (file and directory names) instead of raw strings. For example, `new AbsolutePath("/foo1").StartsWith(new AbsolutePath("/foo"))` is now `false`, where it used to be `true`. Thanks to @berkcantmr for help on this fix!
- [#221](https://github.com/ForNeVeR/TruePath/issues/221): `IsPrefixOf` now handles the root folders correctly as prefixes of all the absolute paths under the same root. Thanks to @berkcantmr and @t957095!
- `IsPrefixOf` and `StartsWith` now compare path strings using ordinal string comparison.
- [#225](https://github.com/ForNeVeR/TruePath/issues/225): `IsPrefixOf` and `StartsWith` now compare path strings using the same platform-default case sensitivity as path equality. Thanks to @amirani8137!
- [#224](https://github.com/ForNeVeR/TruePath/issues/224): path normalization no longer drops the root separator when a rooted path ends with `..`: `C:\base\..` is now normalized to `C:\` (was `C:`, a path relative to the current directory of drive `C:`), and `/base/..` to `/` (was an empty path, i.e. the current directory).
- [#219](https://github.com/ForNeVeR/TruePath/issues/219): path normalization no longer applies Windows drive-specific logic to all paths. Thanks to @hamdiakin!
- [#95](https://github.com/ForNeVeR/TruePath/issues/95): Path normalization now drops a `..` directly after the root, since the root has no parent: `/..` is normalized to `/`, and `C:\..\x` to `C:\x`, the same as `Path.GetFullPath` does. Thanks to @sinthu-14 for help!
- `LocalPath.RelativeTo` no longer throws an exception when either path is empty (i.e. designates the current directory).
- On .NET Standard 2.0 (e.g., .NET Framework), `LocalPath.RelativeTo` and `AbsolutePath.RelativeTo` now use a port of the .NET runtime's `Path.GetRelativePath`, and return the same results as on .NET 8+ (except for the stream syntax, see [#235](https://github.com/ForNeVeR/TruePath/issues/235)). Previously, the result was wrong for a destination equal to or above the base path, and for names containing `%XX` sequences.
- [#226](https://github.com/ForNeVeR/TruePath/issues/226): `LocalPath.IsPrefixOf` and `StartsWith` now take leading `..` references of relative paths into account instead of comparing them as strings. For example, `new LocalPath("..").IsPrefixOf(new LocalPath("foo"))` is now `true`, and `new LocalPath("..").IsPrefixOf(new LocalPath("../.."))` is now `false`. Thanks to @rghvgrv!
- In .NET Standard 2.0's version of TruePath (e.g., the one targeting .NET Framework), `LocalPath.RelativeTo` and `AbsolutePath.RelativeTo` now use a port of the .NET runtime's `Path.GetRelativePath`, and return the same results as on .NET 8+ (except for the stream syntax, see [#235](https://github.com/ForNeVeR/TruePath/issues/235)). Previously, the result was wrong for a destination equal to or above the base path, and for names containing `%XX` sequences.
- `AbsolutePath.ReadKind` no longer throws an exception if the entry gets deleted during the call.
- `LocalPath.RelativeTo` no longer throws an exception when either path designates the current directory.

## [1.12.0] - 2026-03-14
### Changed
Expand Down Expand Up @@ -222,4 +241,5 @@ This is the first published version of the package. It doesn't contain any featu
[1.10.0]: https://github.com/ForNeVeR/TruePath/compare/v1.9.0...v1.10.0
[1.11.0]: https://github.com/ForNeVeR/TruePath/compare/v1.10.0...v1.11.0
[1.12.0]: https://github.com/ForNeVeR/TruePath/compare/v1.11.0...v1.12.0
[Unreleased]: https://github.com/ForNeVeR/TruePath/compare/v1.12.0...HEAD
[2.0.0]: https://github.com/ForNeVeR/TruePath/compare/v1.12.0...v2.0.0
[Unreleased]: https://github.com/ForNeVeR/TruePath/compare/v2.0.0...HEAD
31 changes: 29 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!--
SPDX-FileCopyrightText: 2024 Friedrich von Never <friedrich@fornever.me>
SPDX-FileCopyrightText: 2024-2026 TruePath contributors <https://github.com/ForNeVeR/TruePath>

SPDX-License-Identifier: MIT
-->
Expand All @@ -26,7 +26,32 @@ Use the following shell command:
```console
$ dotnet test
```
Tests involving symlink creation require elevated privileges on Windows, as this is enforced at the OS level.

On Windows, tests that create symbolic links require either [Developer Mode][windows-developer-mode] or an elevated
process. Developer Mode lets an unelevated process create symbolic links, as documented for the
[`CreateSymbolicLinkW`][create-symbolic-link] API. When neither is available, these tests are skipped locally; CI
runners fail instead, ensuring that the tests are not silently skipped in automation.

Public API
----------
In TruePath, we track the changes made to the public API, for the package stability purposes.

The public API of every packable project is recorded per target framework in the `PublicAPI/<target framework>/` directories:
- `PublicAPI.Shipped.txt` lists the API of the latest release,
- `PublicAPI.Unshipped.txt` lists the changes made since then.

If you change the public API, the build-time diagnostic will report any difference as `RS0000`-style errors. These are warnings in local builds (for the development purpose) and errors on CI.

To update the files, either use the code fix for these diagnostics in your IDE, or run the following shell command for every changed project, e.g.:
```console
$ dotnet format analyzers <path to project> --diagnostics RS0016 --severity info --include-generated
```

This command only updates one target framework per run, so you might need to run it several times.

Removed members are not handled by this command, so they need to be removed separately:
- if the member is listed in `PublicAPI.Unshipped.txt`, delete that line;
- if the member is listed in `PublicAPI.Shipped.txt`, add a copy of that line prefixed with `*REMOVED*` to `PublicAPI.Unshipped.txt`.

Test Documentation
------------------
Expand Down Expand Up @@ -73,6 +98,8 @@ Then run the following shell command:
$ dotnet fsi scripts/github-actions.fsx
```

[create-symbolic-link]: https://learn.microsoft.com/windows/win32/api/winbase/nf-winbase-createsymboliclinkw
[dotnet-sdk]: https://dotnet.microsoft.com/en-us/download
[powershell]: https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell
[reuse]: https://reuse.software/
[windows-developer-mode]: https://learn.microsoft.com/windows/apps/get-started/enable-your-device-for-development
Loading
Loading