fix(recovery): never discard local work, and deliver the commits it reports #148

Merged
james.maes merged 6 commits from fix/147-recovery-local-work into main 2026-11-10 22:55:33 +00:00
6 Commits
Author SHA1 Message Date
james.maesandClaude Fable 5.2 0324e58575 fix(recovery): a tree with nothing to rename is an ordinary clone
A repository without an index was taken for one path recovery wrote
whenever gclo had cloned it. An ordinary clone whose index was deleted and
that has no path to rename then went through the hand-written apply on a
pull, was marked as managed for good, and had later edits overwritten.

The pull now takes that route only when the current commit holds a path
that cannot be created here. Anything else takes the ordinary pull, which
leaves local changes alone and marks nothing.

Refs #147

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 16:53:27 -06:00
james.maesandClaude Fable 5.2 52adc1a450 fix(recovery): address the re-check of the review fixes
- A branch ahead of what was written counts as 'commits made here' only
  when origin has nothing beyond what was written. A pull of the released
  build that failed after it moved the branch leaves the branch at
  origin's commit and the tree behind it; that tree is now written, and a
  new invalid path is reported again instead of 'nothing to do'.
- A tree is taken for one gclo wrote without an index only in a
  repository gclo cloned (it carries gclo's checkout marker). A clone made
  by git without a checkout is not.
- Where nothing records that gclo wrote the tree (never checked out,
  empty, or recognised only by its shape), a file already at a destination
  is replaced only when it is, byte for byte, what the current commit has
  there. A file a person put there or changed stops the apply.
- The pull recognises a tree written without an index, so a repository of
  the released build whose recovery file was lost reports its paths again
  and can be resolved from the app and with --sanitize-paths.
- A pull does not check out over a branch that has no commits yet when
  files are staged in the folder ('checkout --orphan' in a checked-out
  clone).
- What counts as origin's commit is what a clone, fetch, or pull recorded;
  any other entry on the remote-tracking branch does not.
- The checkout marker is read from the repository's own configuration
  only. Messages: a stale index.lock, a branch that tracks a local branch.

Refs #147

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 16:53:27 -06:00
james.maesandClaude Fable 5.2 a114d10a32 fix(recovery): address the third independent review
- An empty repository counts as never checked out only while its index is
  empty and no file sits where the repository has one; a branch started
  with 'checkout --orphan' in a checked-out clone is refused like any
  ordinary clone.
- What counts as origin's commit: reflog entries a push wrote do not; a
  branch that tracks another local branch has no record of origin.
- A pull that has nothing to write writes nothing: commits made on top
  with origin unchanged leave every other file alone; a branch moved back
  by hand is brought to origin's tip again.
- A repository written by the released build (commits, no index) whose
  recovery file was lost can be resolved again, as the message advises.
- A clone that was never checked out follows origin by the same rule as
  everything else: a commit made in it by hand is not dropped.
- Found before anything changes: another git process at work, a folder
  with someone's files where a file is needed, a link on the way to a file
  that is to be removed. A file of gclo's own is recognised whatever its
  case on a file system that ignores case.
- A file that left the tree, where a folder of the user's now sits, leaves
  the folder; a refused folder removal has touched nothing in it.
- The managed marker is read from the repository's own configuration only;
  a refused apply leaves the configuration as it was. Threat model updated.
- The CLI keeps the list of invalid paths on a repository whose
  sanitization failed.

Refs #147

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 16:53:26 -06:00
james.maesandClaude Fable 5.2 af43cc95fc fix(recovery): never rewrite a checked-out clone; keep managed trees simple
Two independent reviews of this branch found that rewriting a clone someone
checked out cannot be made safe by checks: untracked files, files git was
told to ignore changes in, an apply interrupted and edited since, and git's
own view of a renamed tree each defeated one attempt, and the checks added
for managed repositories stopped repositories managed by the released build
from updating.

So the rule is the simple one:

- A clone that was checked out normally is never rewritten. Resolve and
  --sanitize-paths refuse it with a message that says how to get a fresh
  clone with the renames; nothing in it changes. This replaces the behaviour
  this branch set out to fix (the tree of the commit already checked out
  was rewritten with force, and later pulls moved the branch off local
  commits).
- A repository gclo manages, or one that was never checked out, keeps the
  semantics it has in the release: gclo writes its tree, and the
  documentation says to treat it as a read-only mirror.

With these fixes for managed repositories, each with a regression test:

- The branch never moves off a commit made in the repository. It follows a
  rewritten origin only when the current commit is one origin's branch
  itself once pointed at, shown by the remote-tracking branch's reflog; a
  local commit that was synced once is not mistaken for that. Local commits
  on top of origin are left alone instead of being moved back.
- The branch moves after validation, so an uncovered invalid path leaves it
  where it was.
- What would fail half-way (an unusable destination, a link on the way, a
  file where a folder is needed) is found before anything changes.
- An interrupted apply is completed by the next pull, through a recovery
  file saved without a materialized tip before the first destructive step.
  The pending-checkout marker is not used for a repository that has a tree.
- A config flag, set once a recovery has been read or written, keeps a
  repository known as managed when its recovery file is deleted as the
  message for a corrupt one advises. An unreadable file in an ordinary
  clone sets nothing.
- The index is written, attribute files are materialized first, the newer
  choice wins when recoveries merge, and an empty clone whose origin's
  first commit has invalid paths can be resolved.

Refs #147

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 16:53:26 -06:00
james.maesandClaude Fable 5.2 02817e7acc fix(recovery): redesign the apply so a checked-out clone keeps its work
The independent review of this branch found that converting an ordinary
clone into a recovery-managed one opened new ways to lose local work: an
untracked file where a file was about to be written was overwritten, and an
apply that failed part-way reused the pending-checkout marker, whose handler
treats the repository as never checked out and force-checks-out over it.

The apply now follows one set of rules for every repository:

- Which commit it writes is decided in one place. The branch only moves to
  origin's tip when that drops no commit; a managed repository still at the
  commit gclo last wrote follows a rewritten origin; local commits on top of
  origin are left alone; diverged histories are refused. The move happens
  after validation, so an uncovered invalid path leaves the branch where it
  was.
- Before anything changes, an ordinary clone and a managed repository whose
  last apply finished are checked for local work: uncommitted changes to
  tracked files, and files gclo did not write and git does not track
  sitting where a file is about to be written (an ignored file is replaced,
  as git does). A managed repository gets this on every later pull too.
- The pending marker is no longer set on a repository that was checked out.
  Before the first destructive step the recovery file is saved without a
  materialized tip, listing everything that was and will be on disk; an
  interrupted apply is then completed by the next pull through the same
  code, and what left the tree is still cleaned up.
- A config flag records that a repository is recovery-managed, so deleting
  a corrupt recovery file (as its message advises) no longer makes the
  renamed tree look like uncommitted changes.
- An empty clone whose origin's first commit has invalid paths can be
  resolved; resolving a clone that has nothing new on origin is refused
  instead of converting it for nothing.
- A folder without files where a file belongs is replaced; one with files
  is refused before anything changes.

Refs #147

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 16:53:25 -06:00
james.maesandClaude Fable 5.2 6112b38ef7 fix(recovery): never discard local work, and deliver the commits it reports
"Resolve" and --sanitize-paths were built for a clone that was fetched but
never checked out. They are also offered when a pull into an ordinary,
checked-out clone finds Windows-invalid paths in the incoming commits, and
there the apply wrote the tree of the commit already checked out, with
force: uncommitted edits were overwritten, the incoming commits were not
delivered although the row said Done, and the next pull moved the branch to
origin's tip without checking that local commits were contained in it.

- Applying a recovery to an ordinary clone now goes to origin's tip, after
  proving that loses nothing: uncommitted changes to tracked files, or
  commits origin does not have, stop it with the advice the ordinary pull
  gives, and nothing is changed. Files that left the tree are removed.
- A recovery-managed repository is only ever fast-forwarded.
- The pending-checkout marker is set for the whole apply, so an apply that
  fails part-way is completed or re-reported by the next pull instead of
  being called up to date.
- The index is written from the commit, so git reports only the renamed
  and skipped paths as changed instead of every file as deleted.
- Attribute files are materialized first and apply to the whole tree.
- When recoveries are merged, the newer choice for a path wins whatever
  kind it is: a rename replaces a stored skip, a skip a stored rename.

Closes #147

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 16:53:25 -06:00