Path recovery (rename and skip choices for Windows-invalid paths) now does every file write and delete through one internal, fully tested helper, WorkingTree:
the target must resolve inside the working directory;
it must not be in a .git folder, in any spelling Windows opens as that folder (letter case, the GIT~1 short name, trailing dots and spaces, a stream suffix);
it must not be reached through a link;
an existing file is removed and created anew, never written through.
Also:
The recovery file .git\gclo-recovery.json is validated when it is loaded. A file that lists a path outside the working tree fails the row with a message that says how to rebuild it; nothing is deleted.
WindowsPathValidator treats .git and its aliases as an invalid segment, with a suggested replacement.
Cleanup of a failed clone skips links when clearing attributes.
The recovery materializes the tree itself instead of using a git checkout, and it was less careful than one. The stored list of previously written files decided what to delete without being checked, a segment named .git was valid, and writes followed links.
Behaviour changes worth a release note
A repository whose tree (or a rename the user chose) would put a file in a .git folder is reported as an invalid path, with a suggested name.
A gclo-recovery.json that lists a path outside the repository fails that repository until the file is deleted and Resolve is used again.
Recovery refuses to write through a folder that is a link (symbolic link or junction).
How it was tested
gclo.Engine.Tests: 680 passed (was 606). New WorkingTreeTests (resolve, create, delete; outside paths; every .git spelling; a junction inside the working tree; a file symlink where the platform allows creating one). New recovery tests: a recovery file listing an outside file, absolute and relative, is refused on pull and on apply and the file survives; a rename into .git, .GIT, git~1 stays invalid and writes nothing; a folder in the tree that is a junction to elsewhere is refused. Validator tests for the new rule and for names that only start like .git.
gclo.Cli.Tests: 142 passed. Line coverage 100% on gclo.Engine, gclo.ViewModels, gclo.
Zero-warning x64 build with locked restore; dotnet format --verify-no-changes clean.
Not run: the UI suite (no shell or view-model change). The file-symlink test returns early when the machine cannot create a symlink, so on such a machine that one case is not exercised; the junction tests always run on Windows.
Checklist
Commits are signed and signed off (git commit -S -s)
Zero build warnings; format and lint are clean
Tests added or updated; the 100% coverage gate still passes
Docs updated for user-visible changes; threat model updated if the attack surface changed (threat model T3, T4 and review log)
No secrets, credentials, or personal data
New dependencies are justified below, or none were added (none)
Substantial AI assistance, if any, is noted here: written with Claude Code (Claude Fable 5.2)
## What
Path recovery (rename and skip choices for Windows-invalid paths) now does every file write and delete through one internal, fully tested helper, `WorkingTree`:
- the target must resolve inside the working directory;
- it must not be in a `.git` folder, in any spelling Windows opens as that folder (letter case, the `GIT~1` short name, trailing dots and spaces, a stream suffix);
- it must not be reached through a link;
- an existing file is removed and created anew, never written through.
Also:
- The recovery file `.git\gclo-recovery.json` is validated when it is loaded. A file that lists a path outside the working tree fails the row with a message that says how to rebuild it; nothing is deleted.
- `WindowsPathValidator` treats `.git` and its aliases as an invalid segment, with a suggested replacement.
- Cleanup of a failed clone skips links when clearing attributes.
## Why
Closes #141.
The recovery materializes the tree itself instead of using a git checkout, and it was less careful than one. The stored list of previously written files decided what to delete without being checked, a segment named `.git` was valid, and writes followed links.
## Behaviour changes worth a release note
- A repository whose tree (or a rename the user chose) would put a file in a `.git` folder is reported as an invalid path, with a suggested name.
- A `gclo-recovery.json` that lists a path outside the repository fails that repository until the file is deleted and Resolve is used again.
- Recovery refuses to write through a folder that is a link (symbolic link or junction).
## How it was tested
- `gclo.Engine.Tests`: 680 passed (was 606). New `WorkingTreeTests` (resolve, create, delete; outside paths; every `.git` spelling; a junction inside the working tree; a file symlink where the platform allows creating one). New recovery tests: a recovery file listing an outside file, absolute and relative, is refused on pull and on apply and the file survives; a rename into `.git`, `.GIT`, `git~1` stays invalid and writes nothing; a folder in the tree that is a junction to elsewhere is refused. Validator tests for the new rule and for names that only start like `.git`.
- `gclo.Cli.Tests`: 142 passed. Line coverage 100% on `gclo.Engine`, `gclo.ViewModels`, `gclo`.
- Zero-warning x64 build with locked restore; `dotnet format --verify-no-changes` clean.
Not run: the UI suite (no shell or view-model change). The file-symlink test returns early when the machine cannot create a symlink, so on such a machine that one case is not exercised; the junction tests always run on Windows.
## Checklist
- [x] Commits are signed and signed off (`git commit -S -s`)
- [x] Zero build warnings; format and lint are clean
- [x] Tests added or updated; the 100% coverage gate still passes
- [x] Docs updated for user-visible changes; threat model updated if the attack surface changed (threat model T3, T4 and review log)
- [x] No secrets, credentials, or personal data
- [x] New dependencies are justified below, or none were added (none)
- [x] Substantial AI assistance, if any, is noted here: written with Claude Code (Claude Fable 5.2)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Path recovery materializes the working tree itself instead of using a git
checkout, and it was less careful than one: the list of previously written
files in .git\gclo-recovery.json decided what to delete without being
checked, a path segment named .git was valid, writes followed links, and
cleanup of a failed clone followed directory links.
- New internal WorkingTree is the only way the recovery writes or deletes
a file. The target must resolve inside the working directory, must not
be in a .git folder (in any spelling Windows opens as that folder: letter
case, the GIT~1 short name, trailing dots and spaces, a stream suffix),
and must not be reached through a link. An existing file is removed and
created anew, never written through.
- The recovery file's stored paths are validated when it is loaded. A file
that lists a path outside the working tree fails the row with a message
that says how to rebuild it; nothing is deleted.
- WindowsPathValidator treats .git and its aliases as an invalid segment,
with a suggested replacement, so a tree entry or a rename can no longer
land in git's own folder.
- Cleanup of a failed clone skips links when clearing attributes.
Threat model: T3 and T4 updated.
Closes#141
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
Follow-up to the independent review of this branch.
- A folder whose name ends in a space was checked under one name and opened
under another, so a link behind it could still carry a delete out of the
working directory. WorkingTree.Resolve now refuses any part that Windows
does not open literally (empty, trailing dot or space, a colon), and the
recovery file's stored paths must also pass the path validator.
- A link is recognized by having a target, not by the reparse-point
attribute: folders under a cloud-sync root carry that attribute without
being links, and recovery refused to write into them.
- Cleanup of a failed clone moved to WorkingTree.DeleteDirectory, which
removes a link inside the folder as a link and never enters it; it is
tested, where the previous change to that cleanup was not.
- The destination's old content is removed whatever it is: a file, a hard
link, a file link, a folder link. Only the name in the working tree goes.
- The git-folder rule is the one git has: .git and GIT~1 (with trailing
dots, spaces or a stream suffix). git~2 and up are ordinary names again.
The rule now runs before the trailing-dot rule, whose suggestion for
".git." was ".git" itself; every suggestion re-validates clean.
- Shared parent folders are checked for links once per materialization.
- A bare repository with a recovery file gets a clear message.
- Tests remove the junctions they create, and a hard-link test replaces
reliance on the symlink test that needs a privilege.
Refs #141
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
Independent review of the first commit (a second reviewer session, read-only, with its own experiments) found one blocker and several smaller defects; all addressed in a900c5d.
Blocker, fixed: a folder whose name ends in a space was checked under one name and opened under another, so a link behind it could still carry a delete out of the working directory. Resolve now refuses any part Windows does not open literally, and the recovery file's stored paths must also pass the path validator.
Fixed: a link was recognized by the reparse-point attribute, which cloud-synced folders (OneDrive) carry without being links; recovery would have refused to write there. A link is now something with a target.
Fixed: the git-folder rule was broader than git's (git~2 and up are ordinary names) and ran after the trailing-dot rule, whose suggestion for .git. was .git itself.
Fixed: cleanup of a failed clone was changed without a test; it is now WorkingTree.DeleteDirectory, tested, and removes a link as a link.
Fixed: a folder link or a read-only link at the destination; shared parent folders checked once per materialization (the first version roughly doubled the time to rewrite 4,000 files); a bare repository gets a clear message.
Tests: junctions are removed explicitly; a hard-link test (no privilege needed) pins the replace-not-overwrite behaviour.
Accepted and documented: the link check and the write are two steps (named as a residual in the threat model). A file another process holds open without delete sharing now fails where an in-place overwrite might have succeeded.
Gates after the second commit: 703 engine tests, 142 CLI tests, 100% line coverage, zero-warning locked build, format clean.
Independent review of the first commit (a second reviewer session, read-only, with its own experiments) found one blocker and several smaller defects; all addressed in a900c5d.
- **Blocker, fixed:** a folder whose name ends in a space was checked under one name and opened under another, so a link behind it could still carry a delete out of the working directory. `Resolve` now refuses any part Windows does not open literally, and the recovery file's stored paths must also pass the path validator.
- **Fixed:** a link was recognized by the reparse-point attribute, which cloud-synced folders (OneDrive) carry without being links; recovery would have refused to write there. A link is now something with a target.
- **Fixed:** the git-folder rule was broader than git's (`git~2` and up are ordinary names) and ran after the trailing-dot rule, whose suggestion for `.git.` was `.git` itself.
- **Fixed:** cleanup of a failed clone was changed without a test; it is now `WorkingTree.DeleteDirectory`, tested, and removes a link as a link.
- **Fixed:** a folder link or a read-only link at the destination; shared parent folders checked once per materialization (the first version roughly doubled the time to rewrite 4,000 files); a bare repository gets a clear message.
- **Tests:** junctions are removed explicitly; a hard-link test (no privilege needed) pins the replace-not-overwrite behaviour.
Accepted and documented: the link check and the write are two steps (named as a residual in the threat model). A file another process holds open without delete sharing now fails where an in-place overwrite might have succeeded.
Gates after the second commit: 703 engine tests, 142 CLI tests, 100% line coverage, zero-warning locked build, format clean.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
Path recovery (rename and skip choices for Windows-invalid paths) now does every file write and delete through one internal, fully tested helper,
WorkingTree:.gitfolder, in any spelling Windows opens as that folder (letter case, theGIT~1short name, trailing dots and spaces, a stream suffix);Also:
.git\gclo-recovery.jsonis validated when it is loaded. A file that lists a path outside the working tree fails the row with a message that says how to rebuild it; nothing is deleted.WindowsPathValidatortreats.gitand its aliases as an invalid segment, with a suggested replacement.Why
Closes #141.
The recovery materializes the tree itself instead of using a git checkout, and it was less careful than one. The stored list of previously written files decided what to delete without being checked, a segment named
.gitwas valid, and writes followed links.Behaviour changes worth a release note
.gitfolder is reported as an invalid path, with a suggested name.gclo-recovery.jsonthat lists a path outside the repository fails that repository until the file is deleted and Resolve is used again.How it was tested
gclo.Engine.Tests: 680 passed (was 606). NewWorkingTreeTests(resolve, create, delete; outside paths; every.gitspelling; a junction inside the working tree; a file symlink where the platform allows creating one). New recovery tests: a recovery file listing an outside file, absolute and relative, is refused on pull and on apply and the file survives; a rename into.git,.GIT,git~1stays invalid and writes nothing; a folder in the tree that is a junction to elsewhere is refused. Validator tests for the new rule and for names that only start like.git.gclo.Cli.Tests: 142 passed. Line coverage 100% ongclo.Engine,gclo.ViewModels,gclo.dotnet format --verify-no-changesclean.Not run: the UI suite (no shell or view-model change). The file-symlink test returns early when the machine cannot create a symlink, so on such a machine that one case is not exercised; the junction tests always run on Windows.
Checklist
git commit -S -s)🤖 Generated with Claude Code
Independent review of the first commit (a second reviewer session, read-only, with its own experiments) found one blocker and several smaller defects; all addressed in
a900c5d.Resolvenow refuses any part Windows does not open literally, and the recovery file's stored paths must also pass the path validator.git~2and up are ordinary names) and ran after the trailing-dot rule, whose suggestion for.git.was.gititself.WorkingTree.DeleteDirectory, tested, and removes a link as a link.Accepted and documented: the link check and the write are two steps (named as a residual in the threat model). A file another process holds open without delete sharing now fails where an in-place overwrite might have succeeded.
Gates after the second commit: 703 engine tests, 142 CLI tests, 100% line coverage, zero-warning locked build, format clean.