1212"""
1313
1414import logging
15+ import os
16+ import stat as stat_module
1517from collections import defaultdict
1618from pathlib import Path
17- from typing import Callable , Optional , TypedDict
19+ from typing import Callable , Final , Literal , Optional , TypedDict
1820
1921from .miner import is_gitignored , load_gitignore_matcher
2022from .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
4549def _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+
99159def _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+
181290def _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