clutterxp 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clutterxp/__init__.py +4 -0
- clutterxp/cli/__init__.py +7 -0
- clutterxp/cli/app.py +251 -0
- clutterxp/cli/cmd_config.py +250 -0
- clutterxp/cli/cmd_doctor.py +449 -0
- clutterxp/cli/cmd_duplicates.py +253 -0
- clutterxp/cli/cmd_gui.py +22 -0
- clutterxp/cli/cmd_history.py +233 -0
- clutterxp/cli/cmd_init.py +76 -0
- clutterxp/cli/cmd_organize.py +226 -0
- clutterxp/cli/cmd_rules.py +540 -0
- clutterxp/cli/cmd_scan.py +109 -0
- clutterxp/cli/cmd_service.py +130 -0
- clutterxp/cli/cmd_undo.py +180 -0
- clutterxp/cli/cmd_watch.py +147 -0
- clutterxp/cli/errors.py +166 -0
- clutterxp/cli/render.py +390 -0
- clutterxp/config/__init__.py +40 -0
- clutterxp/config/jsonschema.py +12 -0
- clutterxp/config/loader.py +212 -0
- clutterxp/config/placeholders.py +79 -0
- clutterxp/config/runtime.py +30 -0
- clutterxp/config/schema.py +166 -0
- clutterxp/config/validate.py +226 -0
- clutterxp/config/writer.py +248 -0
- clutterxp/config.example.yaml +76 -0
- clutterxp/duplicates/__init__.py +22 -0
- clutterxp/duplicates/detector.py +438 -0
- clutterxp/duplicates/hashcache.py +149 -0
- clutterxp/duplicates/namenorm.py +45 -0
- clutterxp/duplicates/quarantine.py +196 -0
- clutterxp/duplicates/report.py +110 -0
- clutterxp/gui/__init__.py +1 -0
- clutterxp/gui/api.py +494 -0
- clutterxp/gui/app.py +205 -0
- clutterxp/gui/models_qt.py +544 -0
- clutterxp/gui/views/__init__.py +21 -0
- clutterxp/gui/views/dashboard.py +253 -0
- clutterxp/gui/views/duplicates.py +404 -0
- clutterxp/gui/views/folders.py +354 -0
- clutterxp/gui/views/history.py +281 -0
- clutterxp/gui/views/preview.py +277 -0
- clutterxp/gui/views/rules.py +527 -0
- clutterxp/gui/views/settings.py +172 -0
- clutterxp/gui/workers.py +147 -0
- clutterxp/history/__init__.py +23 -0
- clutterxp/history/db.py +174 -0
- clutterxp/history/pathcodec.py +42 -0
- clutterxp/history/recovery.py +318 -0
- clutterxp/history/schema.sql +43 -0
- clutterxp/history/service.py +493 -0
- clutterxp/history/undo.py +373 -0
- clutterxp/interfaces.py +164 -0
- clutterxp/locking.py +113 -0
- clutterxp/logging_setup.py +73 -0
- clutterxp/models.py +266 -0
- clutterxp/mover/__init__.py +26 -0
- clutterxp/mover/collision.py +46 -0
- clutterxp/mover/fsops.py +191 -0
- clutterxp/mover/safe_move.py +741 -0
- clutterxp/organizer.py +388 -0
- clutterxp/packaging/clutterxp.service +15 -0
- clutterxp/paths.py +112 -0
- clutterxp/planner.py +339 -0
- clutterxp/py.typed +0 -0
- clutterxp/rules/__init__.py +16 -0
- clutterxp/rules/engine.py +374 -0
- clutterxp/rules/matcher.py +222 -0
- clutterxp/rules/template.py +171 -0
- clutterxp/rules/tokenizer.py +118 -0
- clutterxp/safety.py +111 -0
- clutterxp/scanner.py +196 -0
- clutterxp/service_install.py +208 -0
- clutterxp/units.py +59 -0
- clutterxp/watch/__init__.py +37 -0
- clutterxp/watch/debounce.py +84 -0
- clutterxp/watch/events.py +259 -0
- clutterxp/watch/ignore.py +77 -0
- clutterxp/watch/service.py +662 -0
- clutterxp/watch/settle.py +170 -0
- clutterxp-1.1.0.dist-info/METADATA +149 -0
- clutterxp-1.1.0.dist-info/RECORD +84 -0
- clutterxp-1.1.0.dist-info/WHEEL +4 -0
- clutterxp-1.1.0.dist-info/entry_points.txt +6 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Command handler for `clutterxp doctor` diagnostics and recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from clutterxp import paths
|
|
15
|
+
from clutterxp.cli.errors import handle_errors
|
|
16
|
+
from clutterxp.cli.render import print_json, render_doctor_results, safe_str
|
|
17
|
+
from clutterxp.config import loader
|
|
18
|
+
from clutterxp.history.db import integrity_check
|
|
19
|
+
from clutterxp.history.recovery import reconcile
|
|
20
|
+
from clutterxp.history.service import HistoryService
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from clutterxp.cli.app import GlobalOptions
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def doctor_command(
|
|
27
|
+
ctx: typer.Context,
|
|
28
|
+
fix: Annotated[
|
|
29
|
+
bool,
|
|
30
|
+
typer.Option(
|
|
31
|
+
"--fix",
|
|
32
|
+
help="Automatically reconcile pending ops, clean partial files, and fix permissions",
|
|
33
|
+
),
|
|
34
|
+
] = False,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Run diagnostics on configuration, database integrity, locks, and filesystem health."""
|
|
37
|
+
from clutterxp.cli.app import get_globals
|
|
38
|
+
|
|
39
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
40
|
+
checks: list[dict[str, Any]] = []
|
|
41
|
+
fixes: list[dict[str, Any]] = []
|
|
42
|
+
|
|
43
|
+
with handle_errors(json_mode=opts.json_mode, command_name="doctor"):
|
|
44
|
+
# 1. Root check
|
|
45
|
+
if hasattr(os, "geteuid") and os.geteuid() == 0 and not opts.allow_root:
|
|
46
|
+
checks.append(
|
|
47
|
+
{
|
|
48
|
+
"name": "running_as_root",
|
|
49
|
+
"status": "error",
|
|
50
|
+
"message": (
|
|
51
|
+
"Running as root is forbidden for safety. Pass --allow-root to override."
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
checks.append(
|
|
57
|
+
{
|
|
58
|
+
"name": "running_as_root",
|
|
59
|
+
"status": "ok",
|
|
60
|
+
"message": "Running as standard unprivileged user.",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# 2. Config validity
|
|
65
|
+
cfg_path = opts.config if opts.config is not None else paths.config_path()
|
|
66
|
+
cfg_valid = False
|
|
67
|
+
loaded_cfg = None
|
|
68
|
+
if not cfg_path.exists():
|
|
69
|
+
checks.append(
|
|
70
|
+
{
|
|
71
|
+
"name": "config_validity",
|
|
72
|
+
"status": "error",
|
|
73
|
+
"message": f"Config file not found at {safe_str(cfg_path)}",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
try:
|
|
78
|
+
loaded_cfg = loader.load(cfg_path)
|
|
79
|
+
if loaded_cfg.is_valid:
|
|
80
|
+
cfg_valid = True
|
|
81
|
+
checks.append(
|
|
82
|
+
{
|
|
83
|
+
"name": "config_validity",
|
|
84
|
+
"status": "ok",
|
|
85
|
+
"message": f"Config valid ({len(loaded_cfg.rules)} active rules)",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
checks.append(
|
|
90
|
+
{
|
|
91
|
+
"name": "config_validity",
|
|
92
|
+
"status": "error",
|
|
93
|
+
"message": f"Config errors: {', '.join(loaded_cfg.errors)}",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
checks.append(
|
|
98
|
+
{
|
|
99
|
+
"name": "config_validity",
|
|
100
|
+
"status": "error",
|
|
101
|
+
"message": f"Failed to load config: {e}",
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# 3. Database integrity
|
|
106
|
+
db_path = paths.history_db_path()
|
|
107
|
+
if not db_path.exists():
|
|
108
|
+
checks.append(
|
|
109
|
+
{
|
|
110
|
+
"name": "database_integrity",
|
|
111
|
+
"status": "ok",
|
|
112
|
+
"message": "History database does not exist yet (clean state).",
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
try:
|
|
117
|
+
conn = sqlite3.connect(db_path)
|
|
118
|
+
is_ok = integrity_check(conn)
|
|
119
|
+
if is_ok:
|
|
120
|
+
with contextlib.suppress(sqlite3.Error):
|
|
121
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
122
|
+
checks.append(
|
|
123
|
+
{
|
|
124
|
+
"name": "database_integrity",
|
|
125
|
+
"status": "ok",
|
|
126
|
+
"message": "Database passed PRAGMA integrity_check.",
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
checks.append(
|
|
131
|
+
{
|
|
132
|
+
"name": "database_integrity",
|
|
133
|
+
"status": "error",
|
|
134
|
+
"message": "Database failed PRAGMA integrity_check.",
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
conn.close()
|
|
138
|
+
except Exception as e:
|
|
139
|
+
checks.append(
|
|
140
|
+
{
|
|
141
|
+
"name": "database_integrity",
|
|
142
|
+
"status": "error",
|
|
143
|
+
"message": f"Database check exception: {e}",
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# 4. Pending operations & reconciliation
|
|
148
|
+
if db_path.exists():
|
|
149
|
+
try:
|
|
150
|
+
history = HistoryService(db_path)
|
|
151
|
+
pending = history.pending_ops()
|
|
152
|
+
if pending:
|
|
153
|
+
if fix:
|
|
154
|
+
actions = reconcile(history)
|
|
155
|
+
fixes.append(
|
|
156
|
+
{
|
|
157
|
+
"check": "pending_operations",
|
|
158
|
+
"action": "reconciled",
|
|
159
|
+
"message": f"Reconciled {len(actions)} pending operations.",
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
checks.append(
|
|
163
|
+
{
|
|
164
|
+
"name": "pending_operations",
|
|
165
|
+
"status": "ok",
|
|
166
|
+
"message": f"Reconciled {len(pending)} pending operations.",
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
checks.append(
|
|
171
|
+
{
|
|
172
|
+
"name": "pending_operations",
|
|
173
|
+
"status": "error",
|
|
174
|
+
"message": (
|
|
175
|
+
f"Found {len(pending)} un-reconciled pending operations. "
|
|
176
|
+
"Run with --fix to recover."
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
checks.append(
|
|
182
|
+
{
|
|
183
|
+
"name": "pending_operations",
|
|
184
|
+
"status": "ok",
|
|
185
|
+
"message": "No pending operations in history journal.",
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
checks.append(
|
|
190
|
+
{
|
|
191
|
+
"name": "pending_operations",
|
|
192
|
+
"status": "error",
|
|
193
|
+
"message": f"Failed to check pending operations: {e}",
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
checks.append(
|
|
198
|
+
{
|
|
199
|
+
"name": "pending_operations",
|
|
200
|
+
"status": "ok",
|
|
201
|
+
"message": "No history database present.",
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# 5. Stale partial files (*.clutterxp-partial older than 1h)
|
|
206
|
+
dirs_to_check: set[Path] = set()
|
|
207
|
+
if loaded_cfg:
|
|
208
|
+
for w in loaded_cfg.watch_entries:
|
|
209
|
+
with contextlib.suppress(Exception):
|
|
210
|
+
dirs_to_check.add(paths.expand(w.path))
|
|
211
|
+
for r in loaded_cfg.all_rules:
|
|
212
|
+
with contextlib.suppress(Exception):
|
|
213
|
+
expanded_dest = paths.expand(r.destination)
|
|
214
|
+
dirs_to_check.add(expanded_dest)
|
|
215
|
+
|
|
216
|
+
stale_files: list[Path] = []
|
|
217
|
+
now_ts = time.time()
|
|
218
|
+
for d in dirs_to_check:
|
|
219
|
+
if d.exists() and d.is_dir():
|
|
220
|
+
for p in d.glob("**/*.clutterxp-partial"):
|
|
221
|
+
try:
|
|
222
|
+
if now_ts - p.stat().st_mtime > 3600:
|
|
223
|
+
stale_files.append(p)
|
|
224
|
+
except OSError:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
if stale_files:
|
|
228
|
+
if fix:
|
|
229
|
+
removed_count = 0
|
|
230
|
+
for sf in stale_files:
|
|
231
|
+
try:
|
|
232
|
+
sf.unlink()
|
|
233
|
+
removed_count += 1
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
fixes.append(
|
|
237
|
+
{
|
|
238
|
+
"check": "stale_partial_files",
|
|
239
|
+
"action": "cleaned",
|
|
240
|
+
"message": (
|
|
241
|
+
f"Removed {removed_count} stale partial files older than 1 hour."
|
|
242
|
+
),
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
checks.append(
|
|
246
|
+
{
|
|
247
|
+
"name": "stale_partial_files",
|
|
248
|
+
"status": "ok",
|
|
249
|
+
"message": f"Cleaned {removed_count} stale partial files.",
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
checks.append(
|
|
254
|
+
{
|
|
255
|
+
"name": "stale_partial_files",
|
|
256
|
+
"status": "error",
|
|
257
|
+
"message": (
|
|
258
|
+
f"Found {len(stale_files)} stale partial files older than 1 hour. "
|
|
259
|
+
"Run with --fix to clean."
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
checks.append(
|
|
265
|
+
{
|
|
266
|
+
"name": "stale_partial_files",
|
|
267
|
+
"status": "ok",
|
|
268
|
+
"message": "No stale partial files found.",
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# 6. Destination reachability and writability
|
|
273
|
+
if loaded_cfg and cfg_valid:
|
|
274
|
+
unwritable: list[str] = []
|
|
275
|
+
for r in loaded_cfg.rules:
|
|
276
|
+
try:
|
|
277
|
+
exp_dest = paths.expand(r.destination)
|
|
278
|
+
curr: Path | None = exp_dest
|
|
279
|
+
while curr and not curr.exists() and curr != curr.parent:
|
|
280
|
+
curr = curr.parent
|
|
281
|
+
if curr and curr.exists():
|
|
282
|
+
if not os.access(curr, os.W_OK):
|
|
283
|
+
unwritable.append(f"{r.name} -> {curr} not writable")
|
|
284
|
+
else:
|
|
285
|
+
unwritable.append(f"{r.name} -> {exp_dest} unreachable")
|
|
286
|
+
except Exception as e:
|
|
287
|
+
unwritable.append(f"{r.name} -> {e}")
|
|
288
|
+
|
|
289
|
+
if unwritable:
|
|
290
|
+
checks.append(
|
|
291
|
+
{
|
|
292
|
+
"name": "destination_permissions",
|
|
293
|
+
"status": "error",
|
|
294
|
+
"message": f"Destination access issues: {'; '.join(unwritable)}",
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
checks.append(
|
|
299
|
+
{
|
|
300
|
+
"name": "destination_permissions",
|
|
301
|
+
"status": "ok",
|
|
302
|
+
"message": "All rule destinations are reachable and writable.",
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# 7. Filesystem boundaries
|
|
307
|
+
if loaded_cfg and cfg_valid and loaded_cfg.watch_entries and loaded_cfg.rules:
|
|
308
|
+
cross_dev = False
|
|
309
|
+
for w in loaded_cfg.watch_entries:
|
|
310
|
+
with contextlib.suppress(Exception):
|
|
311
|
+
w_path = paths.expand(w.path)
|
|
312
|
+
if w_path.exists():
|
|
313
|
+
w_dev = w_path.stat().st_dev
|
|
314
|
+
for r in loaded_cfg.rules:
|
|
315
|
+
r_dest = paths.expand(r.destination)
|
|
316
|
+
curr = r_dest
|
|
317
|
+
while curr and not curr.exists() and curr != curr.parent:
|
|
318
|
+
curr = curr.parent
|
|
319
|
+
if curr and curr.exists() and curr.stat().st_dev != w_dev:
|
|
320
|
+
cross_dev = True
|
|
321
|
+
break
|
|
322
|
+
checks.append(
|
|
323
|
+
{
|
|
324
|
+
"name": "filesystem_boundaries",
|
|
325
|
+
"status": "ok",
|
|
326
|
+
"message": (
|
|
327
|
+
"Cross-filesystem boundaries detected (will use safe copy-verify)."
|
|
328
|
+
if cross_dev
|
|
329
|
+
else "All paths share the same filesystem (fast rename available)."
|
|
330
|
+
),
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# 8. Inotify limits
|
|
335
|
+
inotify_path = Path("/proc/sys/fs/inotify/max_user_watches")
|
|
336
|
+
if inotify_path.exists():
|
|
337
|
+
try:
|
|
338
|
+
val = int(inotify_path.read_text().strip())
|
|
339
|
+
if val < 8192:
|
|
340
|
+
checks.append(
|
|
341
|
+
{
|
|
342
|
+
"name": "inotify_limits",
|
|
343
|
+
"status": "warning",
|
|
344
|
+
"message": f"max_user_watches is low ({val}). Recommended >= 524288.",
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
else:
|
|
348
|
+
checks.append(
|
|
349
|
+
{
|
|
350
|
+
"name": "inotify_limits",
|
|
351
|
+
"status": "ok",
|
|
352
|
+
"message": f"max_user_watches is adequate ({val}).",
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
except Exception:
|
|
356
|
+
checks.append(
|
|
357
|
+
{
|
|
358
|
+
"name": "inotify_limits",
|
|
359
|
+
"status": "ok",
|
|
360
|
+
"message": "Could not parse inotify max_user_watches.",
|
|
361
|
+
}
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# 9. Lock files check
|
|
365
|
+
fs_lock = paths.fs_lock_path()
|
|
366
|
+
watch_lock = paths.watch_lock_path()
|
|
367
|
+
checks.append(
|
|
368
|
+
{
|
|
369
|
+
"name": "lock_files",
|
|
370
|
+
"status": "ok",
|
|
371
|
+
"message": f"Lock paths: {safe_str(fs_lock)}, {safe_str(watch_lock)}",
|
|
372
|
+
}
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# 10. File permissions on state directory and log files
|
|
376
|
+
perm_issues = False
|
|
377
|
+
state_d = paths.state_dir()
|
|
378
|
+
if state_d.exists():
|
|
379
|
+
mode = state_d.stat().st_mode & 0o777
|
|
380
|
+
if mode != 0o700:
|
|
381
|
+
if fix:
|
|
382
|
+
state_d.chmod(0o700)
|
|
383
|
+
fixes.append(
|
|
384
|
+
{
|
|
385
|
+
"check": "state_permissions",
|
|
386
|
+
"action": "chmod 0700",
|
|
387
|
+
"message": f"Corrected permissions on {safe_str(state_d)}",
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
perm_issues = True
|
|
392
|
+
|
|
393
|
+
log_p = paths.log_path()
|
|
394
|
+
if log_p.exists():
|
|
395
|
+
mode = log_p.stat().st_mode & 0o777
|
|
396
|
+
if mode != 0o600:
|
|
397
|
+
if fix:
|
|
398
|
+
log_p.chmod(0o600)
|
|
399
|
+
fixes.append(
|
|
400
|
+
{
|
|
401
|
+
"check": "log_permissions",
|
|
402
|
+
"action": "chmod 0600",
|
|
403
|
+
"message": f"Corrected permissions on {safe_str(log_p)}",
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
else:
|
|
407
|
+
perm_issues = True
|
|
408
|
+
|
|
409
|
+
if perm_issues:
|
|
410
|
+
checks.append(
|
|
411
|
+
{
|
|
412
|
+
"name": "state_permissions",
|
|
413
|
+
"status": "error",
|
|
414
|
+
"message": (
|
|
415
|
+
"State files or directory have insecure permissions (expected 0700/0600). "
|
|
416
|
+
"Run with --fix."
|
|
417
|
+
),
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
else:
|
|
421
|
+
checks.append(
|
|
422
|
+
{
|
|
423
|
+
"name": "state_permissions",
|
|
424
|
+
"status": "ok",
|
|
425
|
+
"message": "State directory and files have secure permissions (0700/0600).",
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# Overall diagnosis
|
|
430
|
+
has_error = any(c["status"] == "error" for c in checks)
|
|
431
|
+
all_ok = not has_error
|
|
432
|
+
|
|
433
|
+
if opts.json_mode:
|
|
434
|
+
print_json(
|
|
435
|
+
{
|
|
436
|
+
"schema_version": 1,
|
|
437
|
+
"ok": all_ok,
|
|
438
|
+
"checks": checks,
|
|
439
|
+
"fixes": fixes,
|
|
440
|
+
}
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
render_doctor_results(checks, fixes, no_color=opts.no_color)
|
|
444
|
+
|
|
445
|
+
if has_error:
|
|
446
|
+
raise typer.Exit(code=3)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
__all__ = ["doctor_command"]
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Command handlers for `clutterxp duplicates` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from clutterxp import paths
|
|
17
|
+
from clutterxp.cli.errors import handle_errors
|
|
18
|
+
from clutterxp.cli.render import get_console, print_json, safe_str
|
|
19
|
+
from clutterxp.duplicates.detector import DuplicateDetectorImpl
|
|
20
|
+
from clutterxp.duplicates.hashcache import HashCache
|
|
21
|
+
from clutterxp.duplicates.report import groups_to_csv_rows, groups_to_json
|
|
22
|
+
from clutterxp.units import parse_size
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from clutterxp.cli.app import GlobalOptions
|
|
26
|
+
|
|
27
|
+
duplicates_app = typer.Typer(
|
|
28
|
+
help="Detect duplicate files across directories",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@duplicates_app.command("scan")
|
|
34
|
+
def duplicates_scan_command(
|
|
35
|
+
ctx: typer.Context,
|
|
36
|
+
paths_arg: Annotated[
|
|
37
|
+
list[Path],
|
|
38
|
+
typer.Argument(
|
|
39
|
+
metavar="PATH...",
|
|
40
|
+
help="One or more directory paths to scan for duplicates",
|
|
41
|
+
),
|
|
42
|
+
],
|
|
43
|
+
recursive: Annotated[
|
|
44
|
+
bool,
|
|
45
|
+
typer.Option(
|
|
46
|
+
"--recursive/--no-recursive",
|
|
47
|
+
help="Scan directories recursively",
|
|
48
|
+
),
|
|
49
|
+
] = True,
|
|
50
|
+
min_size: Annotated[
|
|
51
|
+
str,
|
|
52
|
+
typer.Option(
|
|
53
|
+
"--min-size",
|
|
54
|
+
help="Minimum file size to consider (e.g. 1B, 10KB, 1MB)",
|
|
55
|
+
),
|
|
56
|
+
] = "1B",
|
|
57
|
+
exclude: Annotated[
|
|
58
|
+
list[str] | None,
|
|
59
|
+
typer.Option(
|
|
60
|
+
"--exclude",
|
|
61
|
+
help="Glob pattern to exclude from scan (repeatable)",
|
|
62
|
+
),
|
|
63
|
+
] = None,
|
|
64
|
+
jobs: Annotated[
|
|
65
|
+
int,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--jobs",
|
|
68
|
+
"-j",
|
|
69
|
+
help="Number of parallel hashing threads",
|
|
70
|
+
),
|
|
71
|
+
] = min(4, os.cpu_count() or 1),
|
|
72
|
+
no_potential: Annotated[
|
|
73
|
+
bool,
|
|
74
|
+
typer.Option(
|
|
75
|
+
"--no-potential",
|
|
76
|
+
help="Skip potential duplicates stage based on name normalization",
|
|
77
|
+
),
|
|
78
|
+
] = False,
|
|
79
|
+
output_format: Annotated[
|
|
80
|
+
str,
|
|
81
|
+
typer.Option(
|
|
82
|
+
"--format",
|
|
83
|
+
help="Output format: table | json | csv",
|
|
84
|
+
),
|
|
85
|
+
] = "table",
|
|
86
|
+
output_file: Annotated[
|
|
87
|
+
Path | None,
|
|
88
|
+
typer.Option(
|
|
89
|
+
"--output",
|
|
90
|
+
"-o",
|
|
91
|
+
help="Write results to file instead of stdout",
|
|
92
|
+
),
|
|
93
|
+
] = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Scan directories for exact and potential duplicate files."""
|
|
96
|
+
from clutterxp.cli.app import get_globals
|
|
97
|
+
|
|
98
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
99
|
+
cache_path = paths.cache_db_path()
|
|
100
|
+
|
|
101
|
+
with handle_errors(json_mode=opts.json_mode, command_name="duplicates_scan"):
|
|
102
|
+
# Validate folders exist
|
|
103
|
+
for p in paths_arg:
|
|
104
|
+
if not p.exists():
|
|
105
|
+
sys.stderr.write(f"error: Directory not found: {p}\n")
|
|
106
|
+
raise typer.Exit(code=2)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
min_size_bytes = parse_size(min_size)
|
|
110
|
+
except Exception:
|
|
111
|
+
sys.stderr.write(f"error: Invalid min-size '{min_size}'\n")
|
|
112
|
+
raise typer.Exit(code=2) from None
|
|
113
|
+
|
|
114
|
+
cache = HashCache(cache_path)
|
|
115
|
+
cancel_event = threading.Event()
|
|
116
|
+
detector = DuplicateDetectorImpl(cache=cache, cancel=cancel_event)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
groups = detector.scan(
|
|
120
|
+
paths_arg,
|
|
121
|
+
recursive=recursive,
|
|
122
|
+
min_size=min_size_bytes,
|
|
123
|
+
exclude=exclude if exclude else (),
|
|
124
|
+
jobs=jobs,
|
|
125
|
+
potential=not no_potential,
|
|
126
|
+
)
|
|
127
|
+
except (KeyboardInterrupt, InterruptedError):
|
|
128
|
+
if not opts.quiet and not opts.json_mode:
|
|
129
|
+
get_console(no_color=opts.no_color).print("\nScan interrupted. Cache preserved.")
|
|
130
|
+
raise typer.Exit(code=0) from None
|
|
131
|
+
|
|
132
|
+
is_json = opts.json_mode or output_format.lower() == "json"
|
|
133
|
+
is_csv = output_format.lower() == "csv"
|
|
134
|
+
|
|
135
|
+
if is_json:
|
|
136
|
+
result_json = groups_to_json(
|
|
137
|
+
groups,
|
|
138
|
+
paths_arg,
|
|
139
|
+
detector.last_scanned_files,
|
|
140
|
+
detector.last_scanned_bytes,
|
|
141
|
+
)
|
|
142
|
+
if output_file is not None:
|
|
143
|
+
import json
|
|
144
|
+
|
|
145
|
+
output_file.write_text(json.dumps(result_json, indent=2) + "\n", encoding="utf-8")
|
|
146
|
+
else:
|
|
147
|
+
print_json(result_json)
|
|
148
|
+
elif is_csv:
|
|
149
|
+
rows = groups_to_csv_rows(groups)
|
|
150
|
+
out_buf = io.StringIO()
|
|
151
|
+
csv_writer = csv.writer(out_buf)
|
|
152
|
+
csv_writer.writerows(rows)
|
|
153
|
+
csv_text = out_buf.getvalue()
|
|
154
|
+
|
|
155
|
+
if output_file is not None:
|
|
156
|
+
output_file.write_text(csv_text, encoding="utf-8")
|
|
157
|
+
else:
|
|
158
|
+
sys.stdout.write(csv_text)
|
|
159
|
+
sys.stdout.flush()
|
|
160
|
+
else:
|
|
161
|
+
# Table format
|
|
162
|
+
console = get_console(no_color=opts.no_color)
|
|
163
|
+
exact_groups = [g for g in groups if g.kind == "exact"]
|
|
164
|
+
hardlink_groups = [g for g in groups if g.kind == "hardlink"]
|
|
165
|
+
potential_groups = [g for g in groups if g.kind == "potential"]
|
|
166
|
+
|
|
167
|
+
total_wasted = sum(g.wasted_bytes for g in exact_groups)
|
|
168
|
+
|
|
169
|
+
console.print(
|
|
170
|
+
f"[bold]Duplicates Scan Summary[/bold]: {len(exact_groups)} exact groups "
|
|
171
|
+
f"({total_wasted} bytes wasted), {len(potential_groups)} potential groups, "
|
|
172
|
+
f"{len(hardlink_groups)} hardlinks\n"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if exact_groups:
|
|
176
|
+
table = Table(title="Exact Duplicate Groups", header_style="bold green")
|
|
177
|
+
table.add_column("SHA-256", style="dim")
|
|
178
|
+
table.add_column("Size", justify="right")
|
|
179
|
+
table.add_column("Wasted", justify="right")
|
|
180
|
+
table.add_column("Files (oldest first)")
|
|
181
|
+
|
|
182
|
+
for g in exact_groups:
|
|
183
|
+
sha = (g.sha256 or "")[:12] + "..."
|
|
184
|
+
size_str = str(g.files[0].size) if g.files else "0"
|
|
185
|
+
files_str = "\n".join(safe_str(f.path) for f in g.files)
|
|
186
|
+
table.add_row(sha, size_str, str(g.wasted_bytes), files_str)
|
|
187
|
+
console.print(table)
|
|
188
|
+
console.print()
|
|
189
|
+
|
|
190
|
+
if potential_groups:
|
|
191
|
+
table = Table(
|
|
192
|
+
title="Potential Duplicates (review manually)",
|
|
193
|
+
header_style="bold yellow",
|
|
194
|
+
)
|
|
195
|
+
table.add_column("Note")
|
|
196
|
+
table.add_column("Files")
|
|
197
|
+
|
|
198
|
+
for g in potential_groups:
|
|
199
|
+
files_str = "\n".join(safe_str(f.path) for f in g.files)
|
|
200
|
+
table.add_row(g.note, files_str)
|
|
201
|
+
console.print(table)
|
|
202
|
+
console.print()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@duplicates_app.command("cache")
|
|
206
|
+
def duplicates_cache_command(
|
|
207
|
+
ctx: typer.Context,
|
|
208
|
+
clear: Annotated[
|
|
209
|
+
bool,
|
|
210
|
+
typer.Option(
|
|
211
|
+
"--clear",
|
|
212
|
+
help="Clear all cached file hashes",
|
|
213
|
+
),
|
|
214
|
+
] = False,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Manage the duplicate detection hash cache."""
|
|
217
|
+
from clutterxp.cli.app import get_globals
|
|
218
|
+
|
|
219
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
220
|
+
cache_path = paths.cache_db_path()
|
|
221
|
+
|
|
222
|
+
with handle_errors(json_mode=opts.json_mode, command_name="duplicates_cache"):
|
|
223
|
+
cache = HashCache(cache_path)
|
|
224
|
+
if clear:
|
|
225
|
+
cache.clear()
|
|
226
|
+
if opts.json_mode:
|
|
227
|
+
print_json(
|
|
228
|
+
{
|
|
229
|
+
"schema_version": 1,
|
|
230
|
+
"command": "duplicates_cache",
|
|
231
|
+
"status": "cleared",
|
|
232
|
+
"path": safe_str(cache_path),
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
console = get_console(no_color=opts.no_color)
|
|
237
|
+
console.print(f"Duplicate hash cache cleared ({safe_str(cache_path)}).")
|
|
238
|
+
else:
|
|
239
|
+
if opts.json_mode:
|
|
240
|
+
print_json(
|
|
241
|
+
{
|
|
242
|
+
"schema_version": 1,
|
|
243
|
+
"command": "duplicates_cache",
|
|
244
|
+
"path": safe_str(cache_path),
|
|
245
|
+
"exists": cache_path.exists(),
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
console = get_console(no_color=opts.no_color)
|
|
250
|
+
console.print(f"Hash cache path: {safe_str(cache_path)}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
__all__ = ["duplicates_app"]
|