Skip to content

Commit 7d1088c

Browse files
authored
Merge pull request #2322 from mvalentsev/fix/2320-sync-unresolved-source
fix(sync): require an established absence before removing a drawer (#2320)
2 parents 3e56979 + 2b3ac32 commit 7d1088c

6 files changed

Lines changed: 1249 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88

99
## [Unreleased]
1010

11+
### Bug Fixes
12+
13+
- **`sync --apply` no longer deletes drawers whose source file it could not reach.** `_classify_drawer` separated keep from remove with one `Path.exists()`, and `missing` feeds `removable_ids`, so every state that answered no became a deletion. Of twelve source-file states driven through `sync_palace`, eight were deleted and four of those had really gone: two were a file on a volume that is not mounted, at an empty mount point or at one holding a committed `.gitkeep`, and two were a path that could not be walked at all. Three further states ended the whole run instead, dry run included, and two of those moved with the interpreter as `pathlib` stopped raising. End to end, a project mined from a mounted volume lost every drawer to one `sync --apply` while the volume was away, and the search came back empty once it returned. Removal now asks for corroboration rather than a probe: a file not at its path is removable only while the palace still sees a source of its own, a regular file, in that same directory. A deletion leaves the file's neighbours where they were; an unmounted volume takes all of them at once, and no errno separates the two. Both halves of the verdict are read again at the moment it is formed, because a volume can leave inside one pass and come back inside one. Everything uncorroborated lands in a new bucket, `unresolved`, never added to `removable_ids` and reported the way removals are: counted beside the other buckets, and its source files named in `unresolved_by_source` and printed, with the remainder stated when that list is cut at five. The price: a directory with no surviving known source corroborates nothing, so a file deleted alone from a one-file directory, or a directory's files deleted together, is kept and reported rather than pruned. (#2320)
14+
1115
---
1216

1317
## [3.8.0] — 2026-08-20

mempalace/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,7 @@ def cmd_sync(args):
11381138
print(f" Kept: {report['kept']}")
11391139
print(f" Gitignored: {report['gitignored']} {removed_suffix}")
11401140
print(f" Missing: {report['missing']} {removed_suffix}")
1141+
print(f" Unresolved: {report['unresolved']} (kept)")
11411142
print(f" No source: {report['no_source']} (kept)")
11421143
print(f" Out of scope: {report['out_of_scope']} (kept)")
11431144

@@ -1149,6 +1150,17 @@ def cmd_sync(args):
11491150
for src, n in top:
11501151
print(f" {src} ({n})")
11511152

1153+
if report["unresolved"]:
1154+
print("\n Unresolved drawers are kept: nothing here could show their source file is gone.")
1155+
unresolved_sources = report.get("unresolved_by_source") or {}
1156+
if unresolved_sources:
1157+
top = sorted(unresolved_sources.items(), key=lambda kv: -kv[1])[:5]
1158+
for src, n in top:
1159+
print(f" {src} ({n})")
1160+
rest = len(unresolved_sources) - len(top)
1161+
if rest:
1162+
print(f" and {rest} more source file(s)")
1163+
11521164
if args.dry_run:
11531165
if report["gitignored"] + report["missing"] > 0:
11541166
print("\n Re-run with --apply to commit these deletions.")

mempalace/service.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ def run_sync(payload: dict[str, Any]) -> dict[str, Any]:
364364
print(f" Kept: {report['kept']}")
365365
print(f" Gitignored: {report['gitignored']} {removed_suffix}")
366366
print(f" Missing: {report['missing']} {removed_suffix}")
367+
print(f" Unresolved: {report['unresolved']} (kept)")
367368
print(f" No source: {report['no_source']} (kept)")
368369
print(f" Out of scope: {report['out_of_scope']} (kept)")
369370

@@ -375,6 +376,17 @@ def run_sync(payload: dict[str, Any]) -> dict[str, Any]:
375376
for src, n in top:
376377
print(f" {src} ({n})")
377378

379+
if report["unresolved"]:
380+
print("\n Unresolved drawers are kept: nothing here could show their source file is gone.")
381+
unresolved_sources = report.get("unresolved_by_source") or {}
382+
if unresolved_sources:
383+
top = sorted(unresolved_sources.items(), key=lambda kv: -kv[1])[:5]
384+
for src, n in top:
385+
print(f" {src} ({n})")
386+
rest = len(unresolved_sources) - len(top)
387+
if rest:
388+
print(f" and {rest} more source file(s)")
389+
378390
if dry_run:
379391
if report["gitignored"] + report["missing"] > 0:
380392
print("\n Re-run with --apply to commit these deletions.")

mempalace/sync.py

Lines changed: 216 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
"""
1313

1414
import logging
15+
import os
16+
import stat as stat_module
1517
from collections import defaultdict
1618
from pathlib import Path
17-
from typing import Callable, Optional, TypedDict
19+
from typing import Callable, Final, Literal, Optional, TypedDict
1820

1921
from .miner import is_gitignored, load_gitignore_matcher
2022
from .palace import (
@@ -34,12 +36,14 @@ class SyncReport(TypedDict):
3436
kept: int
3537
gitignored: int
3638
missing: int
39+
unresolved: int
3740
no_source: int
3841
out_of_scope: int
3942
removed_drawers: int
4043
removed_closets: int
4144
dry_run: bool
4245
by_source: dict[str, int]
46+
unresolved_by_source: dict[str, int]
4347

4448

4549
def _resolve_project_root(source_file: Path, project_roots: list) -> Optional[Path]:
@@ -96,12 +100,74 @@ def _is_registry_row(meta: dict, drawer_id: str) -> bool:
96100
return False
97101

98102

103+
# ``Final`` keeps these literal types rather than widening them to ``str``,
104+
# which the annotated return of ``_source_state`` needs.
105+
_STATE_PRESENT: Final = "present"
106+
_STATE_NOT_THERE: Final = "not_there"
107+
_STATE_UNKNOWN: Final = "unknown"
108+
109+
110+
def _source_state(src: Path) -> Literal["present", "not_there", "unknown"]:
111+
"""Report what one ``stat`` of ``src`` established, and nothing beyond it.
112+
113+
``not_there`` means the path answered ``ENOENT``. On its own that is not
114+
a reason to delete anything, because no errno separates "nothing is
115+
here" from "this cannot be reached right now". ``_uncopyable_reason``
116+
in ``backups.py`` states the same rule for an operation that only leaves
117+
a file out of a backup copy, and records that on Windows an unmapped
118+
drive letter and an unreachable share both arrive as ``ENOENT`` as well.
119+
Turning ``not_there`` into a removal is ``sync_palace``'s job, and it
120+
needs a second reading to do it.
121+
122+
``unknown`` is every other failure: a path that could not be walked at
123+
all, which says nothing whatever about the leaf.
124+
"""
125+
try:
126+
os.stat(src)
127+
except FileNotFoundError:
128+
return _STATE_NOT_THERE
129+
except (OSError, ValueError):
130+
# ENOTDIR, ELOOP, EACCES, a share that stopped answering. ValueError
131+
# is a path the platform cannot encode; the caller's ``resolve``
132+
# raises on those first, so it should not arrive, and catching it
133+
# costs nothing next to letting one drawer end the run.
134+
return _STATE_UNKNOWN
135+
return _STATE_PRESENT
136+
137+
138+
def _is_a_present_file(src: Path) -> bool:
139+
"""Whether ``src`` is a regular file that answers right now.
140+
141+
A witness has to be a file *in* the directory it speaks for, and
142+
``os.path.dirname`` alone does not establish that. A ``source_file``
143+
whose last component is empty, ``.`` or ``..`` keys to that directory
144+
while naming the directory itself, or its parent, and a directory
145+
outlives the unmount that takes its contents away. ``tool_add_drawer``
146+
stores whatever string its caller passed, so those spellings reach a
147+
real palace without anything being corrupt.
148+
149+
Three answers collapse to ``False`` here on purpose: not a file, not
150+
there, and could not be read. Only the first of them corroborates a
151+
removal, so anything else has to keep the drawer.
152+
"""
153+
try:
154+
return stat_module.S_ISREG(os.stat(src).st_mode)
155+
except (OSError, ValueError):
156+
return False
157+
158+
99159
def _classify_drawer(
100160
meta: dict, matcher_cache: dict, project_roots: list, drawer_id: str = ""
101161
) -> str:
102162
"""Classify a drawer by its source_file metadata.
103163
104-
Returns one of: kept, gitignored, missing, no_source, out_of_scope.
164+
Returns one of: kept, gitignored, absent, unresolved, no_source,
165+
out_of_scope.
166+
167+
``absent`` is provisional and never reaches a ``SyncReport``. It says
168+
the file is not at its path, which is not yet a reason to remove the
169+
drawer; ``sync_palace`` decides that, and settles every ``absent`` into
170+
``missing`` or ``unresolved`` once the whole pass is done.
105171
"""
106172
# Defensive: main loop filters registry rows; this guards direct callers.
107173
if _is_registry_row(meta, drawer_id):
@@ -114,14 +180,25 @@ def _classify_drawer(
114180
src = Path(source_file)
115181
if not src.is_absolute():
116182
return "no_source"
117-
src = src.resolve(strict=False)
183+
try:
184+
src = src.resolve(strict=False)
185+
except (OSError, RuntimeError, ValueError):
186+
# A symlink loop: up to 3.12 pathlib turns ELOOP into RuntimeError
187+
# here, and 3.13 stopped raising and leaves it to the stat below. A
188+
# path the platform cannot encode raises ValueError here instead,
189+
# before any probe runs at all. Either way this drawer is one the
190+
# run must survive, not end on.
191+
return "unresolved"
118192

119193
root = _resolve_project_root(src, project_roots)
120194
if root is None:
121195
return "out_of_scope"
122196

123-
if not src.exists():
124-
return "missing"
197+
state = _source_state(src)
198+
if state == _STATE_UNKNOWN:
199+
return "unresolved"
200+
if state == _STATE_NOT_THERE:
201+
return "absent"
125202

126203
matchers = _ancestor_matchers(src, root, matcher_cache)
127204
if matchers and is_gitignored(src, matchers, is_dir=False):
@@ -172,12 +249,44 @@ def _auto_detect_project_roots(col, wing: Optional[str]) -> list:
172249
if not src.is_absolute():
173250
continue
174251
for parent in src.parents:
175-
if (parent / ".git").exists() or (parent / ".gitignore").is_file():
252+
if not _has_project_marker(parent):
253+
continue
254+
try:
176255
roots.add(parent.resolve(strict=False))
177-
break
256+
except (OSError, RuntimeError, ValueError):
257+
# The marker is here but this path will not resolve. Stop
258+
# rather than let the climb register a higher ancestor as
259+
# the root, which would widen what the run treats as in
260+
# scope instead of narrowing it. No POSIX input reaches
261+
# this: a marker that stats means its own path resolved.
262+
# It is kept because the cost of being wrong about that on
263+
# another platform is the whole run, not one drawer.
264+
pass
265+
break
178266
return sorted(roots, key=lambda p: (-len(str(p)), str(p)))
179267

180268

269+
def _has_project_marker(directory: Path) -> bool:
270+
"""Whether ``directory`` looks like a project root, without ever raising.
271+
272+
Each marker is probed on its own. An ancestor this process cannot walk
273+
holds no marker it could have read anyway, and an unreadable ``.git``
274+
must not hide a ``.gitignore`` beside it. A root that goes undetected
275+
leaves its drawers ``out_of_scope``, which is the side that keeps them,
276+
while a probe that escaped would end the run before a single drawer was
277+
classified.
278+
"""
279+
try:
280+
if (directory / ".git").exists():
281+
return True
282+
except (OSError, RuntimeError, ValueError):
283+
pass
284+
try:
285+
return (directory / ".gitignore").is_file()
286+
except (OSError, RuntimeError, ValueError):
287+
return False
288+
289+
181290
def _normalize_project_dirs(project_dirs) -> list:
182291
"""Resolve and sort project dirs so deepest-prefix wins on first match."""
183292
resolved = [Path(p).resolve(strict=False) for p in project_dirs]
@@ -213,6 +322,34 @@ def sync_palace(
213322
Returns a SyncReport with bucket counts. Dry-run by default; pass
214323
dry_run=False to actually delete drawers and matching closets.
215324
325+
Only ``gitignored`` and ``missing`` are removed. A source file this
326+
could not establish as deleted lands in ``unresolved`` and is counted,
327+
printed and kept: an unmounted volume must not be read as a deletion.
328+
329+
A file that is not at its path reaches ``missing`` only when the palace
330+
can still see a source file of its own in that same directory. A
331+
deletion leaves the file's neighbours where they were; a volume that is
332+
not mounted takes every one of them away at once, and there is no call
333+
that tells those two apart from the file alone. Both halves of that are
334+
read again when the verdict is formed rather than trusted from earlier
335+
in the pass, since a volume can leave inside one pass and can come back
336+
inside one.
337+
338+
Three limits of that reading are worth stating. A directory the palace
339+
knows no *surviving* file in cannot corroborate anything, so a file
340+
deleted on its own from a one-file directory is kept and reported, and
341+
so is a whole directory's worth of files deleted together. A mount
342+
point that also holds a mined file of its own does corroborate, since
343+
that file survives the unmount: separating those needs the identity of
344+
the filesystem each source was mined from, which is not recorded. And a
345+
volume that leaves and returns between two adjacent ``stat`` calls is
346+
not covered, because nothing spans two syscalls.
347+
348+
``wing`` scopes the corroboration as well as the scan, since only that
349+
wing's drawers are read. A wing-scoped run therefore keeps what a run
350+
over the whole palace would prune, and every candidate the wider run
351+
has is a candidate it has too.
352+
216353
Holds ``mine_palace_lock`` for the whole call so the classify pass and
217354
the apply branch see the same drawer snapshot. Raises
218355
``MineAlreadyRunning`` if another mine is in progress on this palace.
@@ -238,10 +375,12 @@ def sync_palace(
238375
"kept": 0,
239376
"gitignored": 0,
240377
"missing": 0,
378+
"unresolved": 0,
241379
"no_source": 0,
242380
"out_of_scope": 0,
243381
}
244382
by_source: dict = defaultdict(int)
383+
unresolved_by_source: dict = defaultdict(int)
245384
removable_ids: list = []
246385
removable_sources: set = set()
247386

@@ -258,12 +397,22 @@ def sync_palace(
258397
# blocks concurrent writers and the loop is synchronous.
259398
classification_cache: dict = {}
260399

400+
# Candidate witnesses per directory, and the drawers whose source
401+
# file is not at its path. The second list waits for the first to be
402+
# complete: one drawer cannot say whether its directory lost one
403+
# file or all of them. Every candidate is kept rather than the first
404+
# one met, so the verdict does not turn on which drawer the pass
405+
# happened to reach first.
406+
live_dirs: dict = {}
407+
not_there: list = []
408+
261409
for drawer_id, meta in _iter_drawer_metadata(col, wing):
262410
counts["scanned"] += 1
263411
meta = meta or {}
264412
source_file = meta.get("source_file")
265413

266-
if _is_registry_row(meta, drawer_id):
414+
registry_row = _is_registry_row(meta, drawer_id)
415+
if registry_row:
267416
bucket = "kept"
268417
elif source_file and source_file in classification_cache:
269418
bucket = classification_cache[source_file]
@@ -272,19 +421,77 @@ def sync_palace(
272421
if source_file:
273422
classification_cache[source_file] = bucket
274423

424+
if bucket == "absent":
425+
not_there.append((drawer_id, source_file))
426+
continue
427+
428+
# A registry row is kept without the file being looked at, so it
429+
# is not evidence that anything is on disk. The inner mapping is
430+
# a set that keeps its insertion order: a file arrives once per
431+
# chunk it was split into, and re-reading the same path a
432+
# hundred times would be the whole cost of this pass.
433+
if source_file and not registry_row and bucket in ("kept", "gitignored"):
434+
live_dirs.setdefault(os.path.dirname(source_file), {})[source_file] = None
435+
275436
counts[bucket] += 1
276-
if bucket in ("gitignored", "missing"):
437+
if bucket == "unresolved":
438+
# Reachable only through a source_file that is a non-empty
439+
# absolute path, like the removal below.
440+
unresolved_by_source[source_file] += 1
441+
if bucket == "gitignored":
277442
removable_ids.append(drawer_id)
278443
if source_file:
279444
removable_sources.add(source_file)
280445
by_source[source_file] += 1
281446

447+
# The directory keys are the metadata strings as written, while the
448+
# classification above works on the resolved path, so two spellings
449+
# of one directory need not meet: ``os.path.dirname`` leaves a
450+
# ``/./`` segment alone, and a path through a symlink keeps the
451+
# link's spelling. Not unifying them errs towards keeping. It cannot
452+
# err the other way as long as a candidate is a file in the
453+
# directory it is filed under, which is what ``_is_a_present_file``
454+
# is there to require: a path whose last component is empty, ``.``
455+
# or ``..`` is filed under the directory it names, or under that
456+
# directory's parent, and neither is a file in it.
457+
#
458+
# Both halves of the verdict are read again here rather than trusted
459+
# from earlier in the pass, and back to back rather than one of them
460+
# early: a pass over a large palace runs for minutes, so a volume can
461+
# leave or return inside one. Re-reading only the neighbour would let
462+
# a volume that came back condemn every drawer read while it was
463+
# away, exactly as re-reading neither would let one that left condemn
464+
# every drawer read after it. ``any`` stops at the candidate that
465+
# answered, so that reading is the one immediately before the
466+
# source's own. Two syscalls are still two syscalls, and a volume
467+
# that flaps between them is not covered by anything here.
468+
settled: dict = {}
469+
for drawer_id, source_file in not_there:
470+
# ``absent`` is only reachable through a source_file that is a
471+
# non-empty absolute path, so nothing here guards against a
472+
# missing one, the same way the removal below does not.
473+
if source_file not in settled:
474+
witnesses = live_dirs.get(os.path.dirname(source_file), ())
475+
settled[source_file] = (
476+
any(_is_a_present_file(Path(w)) for w in witnesses)
477+
and _source_state(Path(source_file)) == _STATE_NOT_THERE
478+
)
479+
established = settled[source_file]
480+
counts["missing" if established else "unresolved"] += 1
481+
if established:
482+
removable_ids.append(drawer_id)
483+
removable_sources.add(source_file)
484+
by_source[source_file] += 1
485+
else:
486+
unresolved_by_source[source_file] += 1
487+
282488
report: SyncReport = {
283489
**counts,
284490
"removed_drawers": 0,
285491
"removed_closets": 0,
286492
"dry_run": dry_run,
287493
"by_source": dict(by_source),
494+
"unresolved_by_source": dict(unresolved_by_source),
288495
}
289496

290497
if dry_run or not removable_ids:

0 commit comments

Comments
 (0)