Skip to content

Commit 67eb227

Browse files
KiaroSamaclaude
andcommitted
Harden Move/Copy destination handling and make link replacement rollback-safe
Adds a shared final-path classification for standard Move/Copy: type conflicts (folder onto file, file onto folder) are blocked outright instead of merged, destinations that are reparse points are refused (/XJ does not make a linked destination argument safe), and the classification is re-checked immediately before robocopy runs so drift between review and execution stops the job instead of running it. Move + Symlink's existing-link replacement is now a single rollback-safe transaction: the old link is removed only immediately before the new one is created, and a failed creation restores the original link automatically, reporting failure rather than success either way. Also fixes a .gitignore ordering bug that silently re-ignored the tracked .env template files, and updates CI to run the full regression suite under both Windows PowerShell 5.1 and PowerShell 7+ on every push and pull request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 614381b commit 67eb227

9 files changed

Lines changed: 969 additions & 160 deletions

File tree

‎.github/workflows/lint.yml‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,31 @@ jobs:
5656
throw "PSScriptAnalyzer reported $($results.Count) issue(s)."
5757
}
5858
Write-Host "PSScriptAnalyzer found no issues."
59+
60+
- name: Regression tests (Windows PowerShell 5.1)
61+
shell: powershell
62+
run: |
63+
$failed = $false
64+
Get-ChildItem -Path .\tests -Filter *.Tests.ps1 | ForEach-Object {
65+
Write-Host "== $($_.Name) (Windows PowerShell 5.1) ==" -ForegroundColor Cyan
66+
powershell.exe -NoProfile -ExecutionPolicy Bypass -File $_.FullName
67+
if ($LASTEXITCODE -ne 0) {
68+
$failed = $true
69+
Write-Host "$($_.Name) failed under Windows PowerShell 5.1 (exit code $LASTEXITCODE)." -ForegroundColor Red
70+
}
71+
}
72+
if ($failed) { throw "One or more regression test files failed under Windows PowerShell 5.1." }
73+
74+
- name: Regression tests (PowerShell 7+)
75+
shell: pwsh
76+
run: |
77+
$failed = $false
78+
Get-ChildItem -Path .\tests -Filter *.Tests.ps1 | ForEach-Object {
79+
Write-Host "== $($_.Name) (PowerShell 7+) ==" -ForegroundColor Cyan
80+
pwsh -NoProfile -File $_.FullName
81+
if ($LASTEXITCODE -ne 0) {
82+
$failed = $true
83+
Write-Host "$($_.Name) failed under PowerShell 7+ (exit code $LASTEXITCODE)." -ForegroundColor Red
84+
}
85+
}
86+
if ($failed) { throw "One or more regression test files failed under PowerShell 7+." }

‎.gitignore‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,6 @@ Thumbs.db
116116
desktop.ini
117117

118118
# Hook Maker: local/private files (auto-managed)
119-
!/.env.dist
120-
!/.env.example
121-
!/.env.sample
122-
!/.env.template
123119
/.agents/
124120
/.ai/
125121
/.claude/
@@ -128,6 +124,10 @@ desktop.ini
128124
/.cursor/
129125
/.env
130126
/.env.*
127+
!/.env.dist
128+
!/.env.example
129+
!/.env.sample
130+
!/.env.template
131131
/.kiro/
132132
/AGENTS.md
133133
/CLAUDE.md

‎CHANGELOG.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@
66

77
- Fixed Move and Copy for folder sources so the selected folder is transferred as a folder and keeps its own name at the destination, instead of flattening its contents into the selected destination path. A destination that already ends with the source folder's name is reused instead of doubling the name (for example, no more `...\Docs\Docs`). Single-file Move and Copy are unaffected and keep the original file name.
88
- Fixed `Invoke-RobocopyCommand` and `Invoke-CmdDeleteCommand` so they return exactly one integer exit code. Native command output was previously captured into the same pipeline as the exit code, which could make a log entry read as `exitCode=System.Object[]` instead of a number such as `3`.
9-
- Fixed Move + Symlink so an existing symbolic link or junction at the original path is no longer removed during preview/validation. It is now removed only after the final confirmation, immediately before the replacement link is created, so cancelling leaves the existing link untouched.
9+
- Fixed Move + Symlink so an existing symbolic link or junction at the original path is no longer removed during preview/validation. It is now removed only immediately before the replacement link is created, as one rollback-safe transaction: if creating the new link fails, RoboSy automatically restores the original link and reports the job as failed, never as successful. If the restore itself cannot complete, RoboSy reports a critical error with the original link's target and kind plus a manual recovery command. Cancelling with `n`, `0`, `exit`, or `quit` leaves the existing link completely untouched.
1010
- Updated the README to describe the existing Move + Symlink behavior for a same-name existing target folder (merge instead of stop), matching the current code instead of the previously stale description.
11+
- Fixed the `.gitignore` Hook Maker block so its `!/.env.example`-style exceptions come after the broad `/.env.*` rule instead of before it; the exceptions were previously overridden by the later broad rule and had no effect.
1112

1213
### Added
1314

1415
- Move and Copy now show the effective final destination path (and, on collision, what already exists there) in the review screen, command preview, breadcrumbs, and logs before asking for confirmation.
1516
- Move and Copy now block drive roots, share roots, and protected system/profile roots as sources, matching the existing Fast Delete guard.
16-
- Added `tests/RoboSy.Tests.ps1`, a non-interactive regression test suite covering destination resolution, robocopy argument building, native-command return values, and symlink replacement safety.
17+
- Move and Copy now classify the final destination path before running: a type conflict (a folder onto an existing file, or a file onto an existing folder) is blocked outright instead of being merged or overwritten, and a destination that is itself a symbolic link, junction, or other reparse point is refused, since `/XJ` only excludes junctions encountered while recursing and does not make a linked destination argument safe.
18+
- Move and Copy now re-check the destination classification immediately before running, using the same command computed at review time. Any drift since the review (the final path changed type, appeared, disappeared, or became a reparse point) stops the job before `robocopy` runs.
19+
- Added `tests/TestHelpers.ps1`, `tests/RoboSy.LinkSafety.Tests.ps1`, and `tests/RoboSy.Classification.Tests.ps1` alongside `tests/RoboSy.Tests.ps1`, covering the rollback-safe link replacement transaction, final-path classification, type conflicts, reparse-point hardening, and execution-time revalidation, including real interactive cancel/confirm scenarios driven through piped input.
20+
- `.github/workflows/lint.yml` now runs the full regression test suite under both Windows PowerShell 5.1 and PowerShell 7+ on every push and pull request, in addition to the existing parse check and PSScriptAnalyzer run.
1721

1822
## v1.2.0 - 2026-06-24
1923

‎README.md‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ It is designed for users who want a simple Windows terminal workflow without man
3232
- Refuse symbolic links, junctions, and other reparse points as Move/Copy sources to avoid accidentally moving the real target's contents.
3333
- Refuse drive roots, share roots, and protected system/profile roots as Move/Copy sources.
3434
- Transfer a selected folder as a folder, preserving its name at the destination, instead of flattening its contents into the destination root.
35+
- Block a Move/Copy when the resolved destination is a type conflict (a folder onto an existing file, or a file onto an existing folder) instead of merging or overwriting the wrong thing.
36+
- Refuse a Move/Copy destination that is itself a symbolic link, junction, or other reparse point, and re-check the destination immediately before running so a change between review and execution stops the job instead of running it.
37+
- Roll back automatically if replacing an existing Move + Symlink link fails partway through, so a failed replacement never leaves you without the original link.
3538
- Print total elapsed time after each operation.
3639
- Write daily log files next to the script, with fallback to `%LOCALAPPDATA%\RoboSy\logs`.
3740

@@ -52,6 +55,10 @@ RoboSy blocks drive roots, share roots, and protected root paths for destructive
5255

5356
Move and Copy refuse source paths that are symbolic links, junctions, or other reparse points. Choose the real target path directly, or use Move + Symlink when you want to manage a link.
5457

58+
Move and Copy also refuse a destination that is a symbolic link, junction, or other reparse point, and refuse a type conflict (folder onto an existing file, or file onto an existing folder) instead of merging or overwriting the wrong thing. The destination is checked again immediately before the job runs, so a change between the review screen and confirmation stops the job instead of running it.
59+
60+
If replacing an existing Move + Symlink link fails partway through, RoboSy restores the original link automatically and reports the job as failed, never as successful.
61+
5562
## Installation
5663

5764
Clone the repository:
@@ -115,7 +122,7 @@ Use this when you want RoboSy to transfer data and remove the original copy afte
115122

116123
For a folder source, RoboSy transfers the folder itself: entering destination `F:\B` for source `E:\A\Docs` moves it to `F:\B\Docs`, not into `F:\B` directly. A destination that already ends with the source folder's name (for example `F:\B\Docs`) is used as-is instead of doubling the name. A single file keeps its original file name at the destination.
117124

118-
If the resolved final path already exists and holds other items, RoboSy shows what it found and asks for a separate confirmation before merging into it or overwriting a same-named file.
125+
If the resolved final path already exists and holds other items, RoboSy shows what it found and asks for a separate confirmation before merging into it or overwriting a same-named file. A type conflict at the final path (a folder onto an existing file, or a file onto an existing folder) is blocked outright; `robocopy` is never invoked for it.
119126

120127
If `robocopy` leaves an empty source folder behind after a directory move, RoboSy attempts to remove that empty source folder.
121128

@@ -155,6 +162,7 @@ Behavior:
155162
- If the original path is missing and the target path exists, RoboSy only creates the link.
156163
- If both paths already exist, RoboSy stops without overwriting either path, unless the target is an existing folder whose name already matches the source folder name; in that case RoboSy moves the source's contents into that existing folder before creating the link.
157164
- If the original path is already a symbolic link or junction, RoboSy leaves it untouched until you confirm the replacement, then removes only the link entry (never its target) immediately before creating the new link.
165+
- If creating the replacement link fails after the old link was removed, RoboSy immediately restores the original link and reports the job as failed. If the restore itself cannot complete, RoboSy reports a critical error with the original link's target and a manual recovery command; the old target is never deleted or modified either way.
158166
- If directory symlinks are unavailable, RoboSy tries a junction fallback.
159167

160168
## Marker File
@@ -199,7 +207,10 @@ Runtime logs are ignored by Git and should not be published.
199207
| File | Purpose |
200208
| --- | --- |
201209
| `RoboSy.ps1` | Main interactive PowerShell script. |
202-
| `tests/RoboSy.Tests.ps1` | Non-interactive regression tests for path resolution and native-command wrappers. |
210+
| `tests/TestHelpers.ps1` | Shared test assertions, sandbox helpers, and the interactive end-to-end test harness. |
211+
| `tests/RoboSy.Tests.ps1` | Regression tests for destination resolution and native-command wrappers. |
212+
| `tests/RoboSy.LinkSafety.Tests.ps1` | Regression tests for the rollback-safe Move + Symlink replacement transaction. |
213+
| `tests/RoboSy.Classification.Tests.ps1` | Regression tests for final-path classification, type conflicts, reparse-point hardening, and execution-time revalidation. |
203214
| `RoboSy.cmd` | Normal launcher. |
204215
| `RoboSy Admin.cmd` | Administrator launcher. |
205216
| `Install-RoboSyPath.ps1` | Adds RoboSy to the user `PATH` and installs the command shim. |
@@ -237,7 +248,7 @@ These local paths are intentionally ignored by Git:
237248

238249
## Development
239250

240-
RoboSy is linted with [PSScriptAnalyzer](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/overview) using the rules in `PSScriptAnalyzerSettings.psd1`. The same checks run in GitHub Actions (`.github/workflows/lint.yml`).
251+
RoboSy is linted with [PSScriptAnalyzer](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/overview) using the rules in `PSScriptAnalyzerSettings.psd1`, and its regression tests run under both Windows PowerShell 5.1 and PowerShell 7+. The same checks run in GitHub Actions (`.github/workflows/lint.yml`) on every push and pull request that touches a `.ps1` file.
241252

242253
Run the checks locally before pushing:
243254

@@ -251,11 +262,11 @@ A clean run reports no issues.
251262
Run the regression tests locally with either PowerShell version:
252263

253264
```powershell
254-
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\tests\RoboSy.Tests.ps1
255-
pwsh -NoProfile -File .\tests\RoboSy.Tests.ps1
265+
Get-ChildItem .\tests -Filter *.Tests.ps1 | ForEach-Object { powershell.exe -NoProfile -ExecutionPolicy Bypass -File $_.FullName }
266+
Get-ChildItem .\tests -Filter *.Tests.ps1 | ForEach-Object { pwsh -NoProfile -File $_.FullName }
256267
```
257268

258-
Tests run entirely inside disposable temporary directories and clean up after themselves.
269+
Each test file prints its own passed/failed/skipped counts and exits non-zero on any failure. Tests run entirely inside disposable temporary directories and clean up after themselves; the interactive end-to-end scenarios in `RoboSy.LinkSafety.Tests.ps1` and `RoboSy.Classification.Tests.ps1` drive a disposable copy of `RoboSy.ps1` through piped input, so its own log directory never touches the real repository.
259270

260271
## Troubleshooting
261272

0 commit comments

Comments
 (0)