opencode-db 0.8.2__tar.gz → 0.9.0__tar.gz
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.
- {opencode_db-0.8.2/opencode_db.egg-info → opencode_db-0.9.0}/PKG-INFO +3 -2
- {opencode_db-0.8.2 → opencode_db-0.9.0}/README.md +147 -3
- {opencode_db-0.8.2 → opencode_db-0.9.0}/docs/README_PYPI.md +2 -1
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/cli.py +6 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_delete.py +174 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_prune.py +10 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/i18n.py +24 -0
- opencode_db-0.9.0/opencode_db/version.py +25 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0/opencode_db.egg-info}/PKG-INFO +3 -2
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db.egg-info/SOURCES.txt +3 -1
- {opencode_db-0.8.2 → opencode_db-0.9.0}/pyproject.toml +1 -1
- {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_commands.py +121 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_store_v2.py +25 -0
- opencode_db-0.9.0/tests/test_version.py +22 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/LICENSE +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/__init__.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/__main__.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/__init__.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_costs.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_directories.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_export.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_help.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_info.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_list.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_projects.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_schema.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_search.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_stats.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_todos.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_tree.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_vacuum.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_view.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/config.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/db.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/formatters.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/markdown_to_ansi.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/runtime.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/schema.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/schema_manifest.json +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/store/__init__.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/store/base.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/store/v1.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/store/v2.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/utils.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db.egg-info/dependency_links.txt +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db.egg-info/entry_points.txt +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db.egg-info/requires.txt +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db.egg-info/top_level.txt +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/setup.cfg +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_db.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_formatters.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_i18n.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_runtime.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_schema.py +0 -0
- {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-db
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
|
|
5
5
|
Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
|
|
6
6
|
Project-URL: Source, https://github.com/VasilevNStas/opencode-db
|
|
@@ -31,6 +31,7 @@ uv tool install opencode-db
|
|
|
31
31
|
## Quick start
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
+
opencode-db --version # installed version
|
|
34
35
|
opencode-db list # recent sessions
|
|
35
36
|
opencode-db directories # launch directories, including missing ones
|
|
36
37
|
opencode-db view # interactive session viewer (colored, scrollable)
|
|
@@ -47,7 +48,7 @@ opencode-db help # full reference
|
|
|
47
48
|
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
48
49
|
- **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
|
|
49
50
|
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
50
|
-
- **`delete`** / **`prune`** — удаление и массовая очистка с
|
|
51
|
+
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами; `prune --missing-directories` — защищённая очистка сессий из отсутствующих путей
|
|
51
52
|
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
52
53
|
- **`schema`** — определяет v1/v2-схему OpenCode, миграции и доступные операции
|
|
53
54
|
- **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
> **Published on PyPI:** [opencode-db](https://pypi.org/project/opencode-db/) — install the ready-made package with `uv tool install opencode-db` or `pip install opencode-db`.
|
|
14
14
|
|
|
15
15
|
```
|
|
16
|
+
opencode-db --version # installed version
|
|
16
17
|
opencode-db help # full reference with examples
|
|
17
18
|
opencode-db list # list recent sessions
|
|
18
19
|
opencode-db stats # database summary
|
|
@@ -312,6 +313,7 @@ opencode-db delete --after 2026-01-01 # sessions after a spec
|
|
|
312
313
|
opencode-db delete --project proj_xxx # sessions in a specific project
|
|
313
314
|
opencode-db delete --older-than 90d --keep-last 20 # combined filters
|
|
314
315
|
opencode-db delete --interactive # choose from a list interactively
|
|
316
|
+
opencode-db prune --missing-directories # preview sessions from missing paths
|
|
315
317
|
```
|
|
316
318
|
|
|
317
319
|
| Flag | Description |
|
|
@@ -325,6 +327,10 @@ opencode-db delete --interactive # choose from a list in
|
|
|
325
327
|
| `--dry-run` | Only show what would be deleted |
|
|
326
328
|
| `--force`, `-f` | Skip confirmation prompt |
|
|
327
329
|
| `--interactive` | Pick sessions from a list |
|
|
330
|
+
| `--missing-directories` | Sessions whose directory no longer exists (preview by default) |
|
|
331
|
+
| `--execute` | Execute missing-directory cleanup |
|
|
332
|
+
| `--backup` | Create a DB backup before deletion |
|
|
333
|
+
| `--no-backup` | Explicitly skip the DB backup |
|
|
328
334
|
|
|
329
335
|
---
|
|
330
336
|
|
|
@@ -364,6 +370,144 @@ opencode-db prune --keep-last 20 # same as opencode-db delete --keep-l
|
|
|
364
370
|
|
|
365
371
|
Time spec formats: `30d` (days), `6m` (months), `1y` (years).
|
|
366
372
|
|
|
373
|
+
### Missing-directory cleanup
|
|
374
|
+
|
|
375
|
+
OpenCode stores the launch directory of every session in `session.directory`.
|
|
376
|
+
The TUI only shows sessions for the current project, so sessions created in a
|
|
377
|
+
deleted, renamed or unmounted directory can be hard to find. This mode finds
|
|
378
|
+
those sessions and deletes them only after several independent confirmations.
|
|
379
|
+
|
|
380
|
+
#### 1. Inspect candidates first (read-only)
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
opencode-db directories --missing
|
|
384
|
+
opencode-db directories --missing --json
|
|
385
|
+
opencode-db prune --missing-directories
|
|
386
|
+
opencode-db prune --missing-directories --project global
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`directories --missing` is the safest first step: it does not touch the
|
|
390
|
+
database and shows the same paths, counts and costs.
|
|
391
|
+
|
|
392
|
+
Example preview:
|
|
393
|
+
|
|
394
|
+
```text
|
|
395
|
+
============================================================
|
|
396
|
+
DELETE SESSIONS FROM MISSING DIRECTORIES
|
|
397
|
+
============================================================
|
|
398
|
+
Database: ~/.local/share/opencode/opencode.db
|
|
399
|
+
Sessions: 51, directories: 10, cost: $3.85
|
|
400
|
+
2026-05-06 08:19:14 — 2026-09-12 16:45:41
|
|
401
|
+
/Users/me/old-project — 15 sessions
|
|
402
|
+
/Users/me/Documents/old-notes — 7 sessions
|
|
403
|
+
...
|
|
404
|
+
This is a preview. Add --execute to delete.
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
How “missing” is detected:
|
|
408
|
+
|
|
409
|
+
- the tool reads `session.directory` (or `session_v2.directory`);
|
|
410
|
+
- it calls `os.path.isdir()` on each path at command runtime;
|
|
411
|
+
- `False` means **not found or inaccessible** — it may be deleted, renamed,
|
|
412
|
+
on an unmounted disk, on an offline network volume, or blocked by permissions;
|
|
413
|
+
- symlinks are followed; results are only true at the moment the command runs;
|
|
414
|
+
- `--no-check` on `directories` skips this check and reports `exists: null`.
|
|
415
|
+
|
|
416
|
+
Review this list carefully. A temporarily unavailable external disk can look
|
|
417
|
+
exactly like a deleted directory.
|
|
418
|
+
|
|
419
|
+
#### 2. Execute with the safety gates
|
|
420
|
+
|
|
421
|
+
```bash
|
|
422
|
+
# recommended: create a consistent SQLite backup first
|
|
423
|
+
opencode-db prune --missing-directories --execute --backup
|
|
424
|
+
|
|
425
|
+
# only if you already have an external backup
|
|
426
|
+
opencode-db prune --missing-directories --execute --no-backup
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
`delete --missing-directories ...` works the same way as `prune`.
|
|
430
|
+
|
|
431
|
+
Before anything is deleted, all of these gates must pass:
|
|
432
|
+
|
|
433
|
+
1. `--missing-directories` selects only sessions whose path is not found.
|
|
434
|
+
2. `--execute` is required; without it the command always stops after preview.
|
|
435
|
+
3. `--backup` or `--no-backup` must be chosen explicitly.
|
|
436
|
+
4. The command prints every path, session count, cost and date range.
|
|
437
|
+
5. You must type the exact phrase `DELETE <N> SESSIONS`.
|
|
438
|
+
6. You must answer a final `y/N` confirmation.
|
|
439
|
+
7. The candidate list is recomputed; if it changed, deletion is aborted.
|
|
440
|
+
8. All deletes run in one transaction; on error the transaction is rolled back.
|
|
441
|
+
|
|
442
|
+
`--force` does **not** bypass any of these gates. It is ignored in this mode.
|
|
443
|
+
|
|
444
|
+
#### What exactly is deleted
|
|
445
|
+
|
|
446
|
+
For each selected session:
|
|
447
|
+
|
|
448
|
+
- the `session` row;
|
|
449
|
+
- `message`, `part`, `todo`;
|
|
450
|
+
- `session_message`, `session_input`, `session_context_epoch`, `session_share`;
|
|
451
|
+
- `event` and `event_sequence` rows for that session.
|
|
452
|
+
|
|
453
|
+
Projects and the synthetic `global` project are **never** deleted. The tool
|
|
454
|
+
does not touch directories on disk — it only removes database rows.
|
|
455
|
+
|
|
456
|
+
After a successful cleanup you can reclaim disk space:
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
opencode-db vacuum
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
`VACUUM` can take time and temporarily needs free disk space roughly equal to
|
|
463
|
+
the database size.
|
|
464
|
+
|
|
465
|
+
#### Backup and recovery
|
|
466
|
+
|
|
467
|
+
With `--backup`, the tool creates a consistent SQLite copy next to the database:
|
|
468
|
+
|
|
469
|
+
```text
|
|
470
|
+
opencode.db.bak.20261020-111530
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
The exact path is printed before deletion. If you delete something by mistake:
|
|
474
|
+
|
|
475
|
+
1. stop OpenCode;
|
|
476
|
+
2. restore the backup over `opencode.db`;
|
|
477
|
+
3. remove stale `opencode.db-wal` / `opencode.db-shm` files if present;
|
|
478
|
+
4. start OpenCode again.
|
|
479
|
+
|
|
480
|
+
Without a backup, deleted sessions and their events cannot be recovered.
|
|
481
|
+
|
|
482
|
+
#### Recommended workflow
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
# 1. Close OpenCode to avoid concurrent writes.
|
|
486
|
+
opencode-db directories --missing
|
|
487
|
+
|
|
488
|
+
# 2. Preview the exact deletion.
|
|
489
|
+
opencode-db prune --missing-directories
|
|
490
|
+
|
|
491
|
+
# 3. Optional: only one project.
|
|
492
|
+
opencode-db prune --missing-directories --project global
|
|
493
|
+
|
|
494
|
+
# 4. Execute with a backup.
|
|
495
|
+
opencode-db prune --missing-directories --execute --backup
|
|
496
|
+
|
|
497
|
+
# 5. Reclaim disk space.
|
|
498
|
+
opencode-db vacuum
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
#### Limitations
|
|
502
|
+
|
|
503
|
+
- This mode is available for the v1 schema; v2 is refused until v2 writes are
|
|
504
|
+
implemented.
|
|
505
|
+
- It cannot tell a deleted directory from an offline volume or permission error.
|
|
506
|
+
- It does not remove `project` rows or directories from disk.
|
|
507
|
+
- It cannot be combined with `--older-than`, `--before`, `--after`,
|
|
508
|
+
`--keep-last`, `--interactive` or a session ID.
|
|
509
|
+
- There is no automatic or background cleanup.
|
|
510
|
+
|
|
367
511
|
---
|
|
368
512
|
|
|
369
513
|
### `search` — search across messages
|
|
@@ -576,7 +720,7 @@ opencode-db/
|
|
|
576
720
|
│ ├── config.py ← constants and paths
|
|
577
721
|
│ ├── db.py ← SQLite queries
|
|
578
722
|
│ ├── formatters.py ← tables, JSON, Markdown, snapshots
|
|
579
|
-
│ ├── i18n.py ← localization (EN/RU,
|
|
723
|
+
│ ├── i18n.py ← localization (EN/RU, 348 keys)
|
|
580
724
|
│ ├── markdown_to_ansi.py ← Markdown → ANSI renderer for `view`
|
|
581
725
|
│ ├── runtime.py ← Python 3.12+ version guard
|
|
582
726
|
│ ├── utils.py ← date/token formatting, validation
|
|
@@ -725,7 +869,7 @@ opencode-db --lang en help # English help text
|
|
|
725
869
|
opencode-db --lang en stats # English statistics
|
|
726
870
|
```
|
|
727
871
|
|
|
728
|
-
The `--lang` flag applies to any command. All
|
|
872
|
+
The `--lang` flag applies to any command. All 348 UI strings are translated. Data content (session titles, project names, etc.) remains in its original language.
|
|
729
873
|
|
|
730
874
|
The language can also be set via environment variable `OPENCODE_DB_LANG=en`.
|
|
731
875
|
|
|
@@ -810,7 +954,7 @@ source .venv/bin/activate
|
|
|
810
954
|
uv pip install -e ".[rich]"
|
|
811
955
|
uv pip install pytest ruff==0.17.1
|
|
812
956
|
|
|
813
|
-
make check # check-python + ruff +
|
|
957
|
+
make check # check-python + ruff + 224 tests
|
|
814
958
|
```
|
|
815
959
|
|
|
816
960
|
`make check` fails fast with a clear message if the active interpreter is older than 3.12.
|
|
@@ -15,6 +15,7 @@ uv tool install opencode-db
|
|
|
15
15
|
## Quick start
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
+
opencode-db --version # installed version
|
|
18
19
|
opencode-db list # recent sessions
|
|
19
20
|
opencode-db directories # launch directories, including missing ones
|
|
20
21
|
opencode-db view # interactive session viewer (colored, scrollable)
|
|
@@ -31,7 +32,7 @@ opencode-db help # full reference
|
|
|
31
32
|
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
32
33
|
- **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
|
|
33
34
|
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
34
|
-
- **`delete`** / **`prune`** — удаление и массовая очистка с
|
|
35
|
+
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами; `prune --missing-directories` — защищённая очистка сессий из отсутствующих путей
|
|
35
36
|
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
36
37
|
- **`schema`** — определяет v1/v2-схему OpenCode, миграции и доступные операции
|
|
37
38
|
- **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
|
|
@@ -21,6 +21,7 @@ require_python()
|
|
|
21
21
|
from . import commands # noqa: E402
|
|
22
22
|
from .db import get_db # noqa: E402
|
|
23
23
|
from .i18n import _, set_lang # noqa: E402
|
|
24
|
+
from .version import get_version # noqa: E402
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -41,6 +42,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
41
42
|
default=None,
|
|
42
43
|
help="Path to opencode.db (default: ~/.local/share/opencode/opencode.db)",
|
|
43
44
|
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--version",
|
|
47
|
+
action="version",
|
|
48
|
+
version=f"%(prog)s {get_version()}",
|
|
49
|
+
)
|
|
44
50
|
|
|
45
51
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
46
52
|
seen: set[str] = set()
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
"""Команда delete: удаление сессии или массовая очистка."""
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
from datetime import datetime
|
|
4
7
|
|
|
8
|
+
from ..config import resolve_db_path
|
|
5
9
|
from ..db import (
|
|
6
10
|
SessionError,
|
|
7
11
|
get_message_count,
|
|
@@ -23,6 +27,8 @@ _DELETE_EXAMPLES = [
|
|
|
23
27
|
("--keep-last 30", "help.delete.e4"),
|
|
24
28
|
("--before 2026-05-20", "help.delete.e5"),
|
|
25
29
|
("--interactive", "help.delete.e6"),
|
|
30
|
+
("--missing-directories", "help.delete.e7"),
|
|
31
|
+
("--missing-directories --execute", "help.delete.e8"),
|
|
26
32
|
]
|
|
27
33
|
|
|
28
34
|
|
|
@@ -42,6 +48,14 @@ def register(subparsers) -> None:
|
|
|
42
48
|
p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
|
|
43
49
|
p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
|
44
50
|
p.add_argument("--interactive", action="store_true", help="Choose sessions from a list")
|
|
51
|
+
p.add_argument(
|
|
52
|
+
"--missing-directories",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="Sessions whose directory no longer exists (preview by default)",
|
|
55
|
+
)
|
|
56
|
+
p.add_argument("--execute", action="store_true", help="Execute missing-directory cleanup")
|
|
57
|
+
p.add_argument("--backup", action="store_true", help="Create a DB backup before deletion")
|
|
58
|
+
p.add_argument("--no-backup", action="store_true", help="Skip the DB backup")
|
|
45
59
|
|
|
46
60
|
|
|
47
61
|
def _delete_single(db, session_id, dry_run, force) -> int:
|
|
@@ -317,6 +331,161 @@ def _parse_choices(text: str, max_n: int) -> list[int]:
|
|
|
317
331
|
return sorted(result)
|
|
318
332
|
|
|
319
333
|
|
|
334
|
+
_SESSION_SCOPED_TABLES = (
|
|
335
|
+
"part",
|
|
336
|
+
"message",
|
|
337
|
+
"todo",
|
|
338
|
+
"session_message",
|
|
339
|
+
"session_input",
|
|
340
|
+
"session_context_epoch",
|
|
341
|
+
"session_share",
|
|
342
|
+
)
|
|
343
|
+
_EVENT_SCOPED_TABLES = ("event", "event_sequence")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _missing_directory_targets(db, project_id=None):
|
|
347
|
+
where = "WHERE directory IS NOT NULL AND directory != ''"
|
|
348
|
+
params = []
|
|
349
|
+
if project_id:
|
|
350
|
+
where += " AND project_id = ?"
|
|
351
|
+
params.append(project_id)
|
|
352
|
+
rows = db.execute(
|
|
353
|
+
f"SELECT id, title, directory, time_created, cost FROM session {where}",
|
|
354
|
+
params,
|
|
355
|
+
).fetchall()
|
|
356
|
+
return [dict(row) for row in rows if not os.path.isdir(row["directory"])]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _delete_in_chunks(db, table, column, ids):
|
|
360
|
+
deleted = 0
|
|
361
|
+
for start in range(0, len(ids), 500):
|
|
362
|
+
chunk = ids[start : start + 500]
|
|
363
|
+
placeholders = ",".join("?" for _ in chunk)
|
|
364
|
+
cur = db.execute(f"DELETE FROM {table} WHERE {column} IN ({placeholders})", chunk)
|
|
365
|
+
deleted += max(cur.rowcount, 0)
|
|
366
|
+
return deleted
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _delete_session_rows(db, ids, tables):
|
|
370
|
+
db.execute("PRAGMA foreign_keys=ON")
|
|
371
|
+
deleted = 0
|
|
372
|
+
for table in _EVENT_SCOPED_TABLES:
|
|
373
|
+
if table in tables:
|
|
374
|
+
deleted += _delete_in_chunks(db, table, "aggregate_id", ids)
|
|
375
|
+
for table in _SESSION_SCOPED_TABLES:
|
|
376
|
+
if table in tables:
|
|
377
|
+
deleted += _delete_in_chunks(db, table, "session_id", ids)
|
|
378
|
+
deleted += _delete_in_chunks(db, "session", "id", ids)
|
|
379
|
+
return deleted
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _backup_database(db, db_path):
|
|
383
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
384
|
+
target = f"{db_path}.bak.{stamp}"
|
|
385
|
+
suffix = 1
|
|
386
|
+
while os.path.exists(target):
|
|
387
|
+
target = f"{db_path}.bak.{stamp}-{suffix}"
|
|
388
|
+
suffix += 1
|
|
389
|
+
dest = sqlite3.connect(target)
|
|
390
|
+
try:
|
|
391
|
+
db.backup(dest)
|
|
392
|
+
finally:
|
|
393
|
+
dest.close()
|
|
394
|
+
return target
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _delete_missing_dirs(db, args) -> int:
|
|
398
|
+
incompatible = any(
|
|
399
|
+
_a(args, name)
|
|
400
|
+
for name in ("older_than", "before", "after", "keep_last", "interactive", "session_id")
|
|
401
|
+
)
|
|
402
|
+
if incompatible:
|
|
403
|
+
print(_("delete.missing_incompatible"))
|
|
404
|
+
return 1
|
|
405
|
+
|
|
406
|
+
backup = _a(args, "backup", False)
|
|
407
|
+
no_backup = _a(args, "no_backup", False)
|
|
408
|
+
if backup and no_backup:
|
|
409
|
+
print(_("delete.missing_both_backup"))
|
|
410
|
+
return 1
|
|
411
|
+
|
|
412
|
+
project_id = _a(args, "project")
|
|
413
|
+
targets = _missing_directory_targets(db, project_id)
|
|
414
|
+
if not targets:
|
|
415
|
+
print(_("delete.missing_none"))
|
|
416
|
+
return 0
|
|
417
|
+
|
|
418
|
+
db_path = resolve_db_path()[0]
|
|
419
|
+
groups = {}
|
|
420
|
+
for target in targets:
|
|
421
|
+
groups.setdefault(target["directory"], []).append(target)
|
|
422
|
+
total_cost = sum(target["cost"] or 0 for target in targets)
|
|
423
|
+
times = [target["time_created"] for target in targets if target["time_created"]]
|
|
424
|
+
first_ts = min(times) if times else None
|
|
425
|
+
last_ts = max(times) if times else None
|
|
426
|
+
|
|
427
|
+
print(f"\n {'=' * 60}")
|
|
428
|
+
print(f" {_('delete.missing_header')}")
|
|
429
|
+
print(f" {'=' * 60}")
|
|
430
|
+
print(_("delete.missing_db", path=db_path))
|
|
431
|
+
print(_("delete.missing_summary", sessions=len(targets), dirs=len(groups), cost=format_cost(total_cost)))
|
|
432
|
+
first_label = format_ts(first_ts) if first_ts else "—"
|
|
433
|
+
last_label = format_ts(last_ts) if last_ts else "—"
|
|
434
|
+
print(f" {first_label} — {last_label}")
|
|
435
|
+
for path in sorted(groups):
|
|
436
|
+
print(_("delete.missing_dir_line", path=path, n=len(groups[path])))
|
|
437
|
+
|
|
438
|
+
if not _a(args, "execute", False) or _a(args, "dry_run", False):
|
|
439
|
+
print(f"\n{_('delete.missing_preview')}")
|
|
440
|
+
return 0
|
|
441
|
+
|
|
442
|
+
if not backup and not no_backup:
|
|
443
|
+
print(_("delete.missing_backup_choice"))
|
|
444
|
+
return 1
|
|
445
|
+
if no_backup:
|
|
446
|
+
print(_("delete.missing_no_backup_warning"))
|
|
447
|
+
|
|
448
|
+
phrase = f"DELETE {len(targets)} SESSIONS"
|
|
449
|
+
try:
|
|
450
|
+
answer = input(_("delete.missing_phrase_prompt", phrase=phrase))
|
|
451
|
+
except EOFError:
|
|
452
|
+
print(_("canceled"))
|
|
453
|
+
return 0
|
|
454
|
+
if answer.strip() != phrase:
|
|
455
|
+
print(_("delete.missing_phrase_mismatch"))
|
|
456
|
+
return 0
|
|
457
|
+
|
|
458
|
+
if not confirm(_("delete.missing_final_confirm", n=len(targets))):
|
|
459
|
+
print(_("canceled"))
|
|
460
|
+
return 0
|
|
461
|
+
|
|
462
|
+
if backup:
|
|
463
|
+
try:
|
|
464
|
+
backup_path = _backup_database(db, db_path)
|
|
465
|
+
except Exception as exc:
|
|
466
|
+
print(_("delete.missing_backup_failed", error=exc))
|
|
467
|
+
return 1
|
|
468
|
+
print(_("delete.missing_backup_done", path=backup_path))
|
|
469
|
+
|
|
470
|
+
fresh = _missing_directory_targets(db, project_id)
|
|
471
|
+
fresh_ids = {target["id"] for target in fresh}
|
|
472
|
+
target_ids = {target["id"] for target in targets}
|
|
473
|
+
if fresh_ids != target_ids:
|
|
474
|
+
print(_("delete.missing_changed"))
|
|
475
|
+
return 1
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
deleted = _delete_session_rows(db, sorted(target_ids), set(open_store(db).schema.tables))
|
|
479
|
+
db.commit()
|
|
480
|
+
except Exception as exc:
|
|
481
|
+
db.rollback()
|
|
482
|
+
print(_("delete.missing_error", error=exc))
|
|
483
|
+
return 1
|
|
484
|
+
|
|
485
|
+
print(_("delete.missing_done", n=len(target_ids), rows=deleted))
|
|
486
|
+
return 0
|
|
487
|
+
|
|
488
|
+
|
|
320
489
|
def _a(args, name: str, default=None):
|
|
321
490
|
"""getattr с дефолтом — для совместимости Namespace из разных парсеров."""
|
|
322
491
|
return getattr(args, name, default)
|
|
@@ -327,6 +496,11 @@ def run(args, db) -> int:
|
|
|
327
496
|
if store.schema.read_only:
|
|
328
497
|
print(_("schema.unsupported_write"))
|
|
329
498
|
return 1
|
|
499
|
+
if _a(args, "execute", False) and not _a(args, "missing_directories", False):
|
|
500
|
+
print(_("delete.missing_execute_requires"))
|
|
501
|
+
return 1
|
|
502
|
+
if _a(args, "missing_directories", False):
|
|
503
|
+
return _delete_missing_dirs(db, args)
|
|
330
504
|
if _a(args, "interactive", False):
|
|
331
505
|
return _delete_interactive(db, args)
|
|
332
506
|
|
|
@@ -11,6 +11,8 @@ _PRUNE_EXAMPLES = [
|
|
|
11
11
|
("--keep-last 20", "help.prune.e1"),
|
|
12
12
|
("--dry-run", "help.prune.e2"),
|
|
13
13
|
("--force", "help.prune.e3"),
|
|
14
|
+
("--missing-directories", "help.prune.e4"),
|
|
15
|
+
("--missing-directories --execute", "help.prune.e5"),
|
|
14
16
|
]
|
|
15
17
|
|
|
16
18
|
|
|
@@ -26,6 +28,14 @@ def register(subparsers) -> None:
|
|
|
26
28
|
p.add_argument("--project", type=str, help="Limit to project")
|
|
27
29
|
p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
|
|
28
30
|
p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
|
31
|
+
p.add_argument(
|
|
32
|
+
"--missing-directories",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="Sessions whose directory no longer exists (preview by default)",
|
|
35
|
+
)
|
|
36
|
+
p.add_argument("--execute", action="store_true", help="Execute missing-directory cleanup")
|
|
37
|
+
p.add_argument("--backup", action="store_true", help="Create a DB backup before deletion")
|
|
38
|
+
p.add_argument("--no-backup", action="store_true", help="Skip the DB backup")
|
|
29
39
|
|
|
30
40
|
|
|
31
41
|
def run(args, db) -> int:
|
|
@@ -215,6 +215,26 @@ L: dict[str, dict[str, str]] = {
|
|
|
215
215
|
"delete.mass_confirm": {"ru": "Удалить {n} сессий?", "en": "Delete {n} sessions?"},
|
|
216
216
|
"delete.done_mass": {"ru": " ✅ Удалено {n} сессий.", "en": " ✅ Deleted {n} sessions."},
|
|
217
217
|
"delete.interactive_header": {"ru": "Последние сессии:", "en": "Recent sessions:"},
|
|
218
|
+
"delete.missing_header": {"ru": "ОЧИСТКА СЕССИЙ ИЗ ОТСУТСТВУЮЩИХ ДИРЕКТОРИЙ", "en": "DELETE SESSIONS FROM MISSING DIRECTORIES"},
|
|
219
|
+
"delete.missing_none": {"ru": " ✅ Сессий из отсутствующих директорий не найдено.", "en": " ✅ No sessions from missing directories found."},
|
|
220
|
+
"delete.missing_summary": {"ru": " Сессий: {sessions}, директорий: {dirs}, стоимость: {cost}", "en": " Sessions: {sessions}, directories: {dirs}, cost: {cost}"},
|
|
221
|
+
"delete.missing_dir_line": {"ru": " {path} — {n} сессий", "en": " {path} — {n} sessions"},
|
|
222
|
+
"delete.missing_preview": {"ru": "Это preview. Для удаления добавь --execute.", "en": "This is a preview. Add --execute to delete."},
|
|
223
|
+
"delete.missing_incompatible": {"ru": "--missing-directories нельзя сочетать с --older-than/--before/--after/--keep-last/--interactive/session_id.", "en": "--missing-directories cannot be combined with --older-than/--before/--after/--keep-last/--interactive/session_id."},
|
|
224
|
+
"delete.missing_execute_requires": {"ru": "--execute используется только вместе с --missing-directories.", "en": "--execute can only be used with --missing-directories."},
|
|
225
|
+
"delete.missing_no_backup_warning": {"ru": " ⚠️ Backup отключён (--no-backup). Удаление необратимо.", "en": " ⚠️ Backup disabled (--no-backup). Deletion is irreversible."},
|
|
226
|
+
"delete.missing_backup_choice": {"ru": "Для --execute укажи --backup или --no-backup (осознанный выбор).", "en": "For --execute, choose --backup or --no-backup explicitly."},
|
|
227
|
+
"delete.missing_both_backup": {"ru": "Нельзя одновременно --backup и --no-backup.", "en": "Cannot use --backup and --no-backup together."},
|
|
228
|
+
"delete.missing_phrase_prompt": {"ru": "Введи точно «{phrase}» для продолжения: ", "en": "Type exactly \"{phrase}\" to continue: "},
|
|
229
|
+
"delete.missing_phrase_mismatch": {"ru": "Фраза не совпала. Удаление отменено.", "en": "Phrase did not match. Deletion canceled."},
|
|
230
|
+
"delete.missing_final_confirm": {"ru": "Последнее подтверждение: удалить {n} сессий? Это необратимо.", "en": "Final confirmation: delete {n} sessions? This is irreversible."},
|
|
231
|
+
"delete.missing_changed": {"ru": "Список кандидатов изменился. Удаление отменено; повтори preview.", "en": "Candidate list changed. Deletion canceled; rerun the preview."},
|
|
232
|
+
"delete.missing_backup_done": {"ru": " Backup: {path}", "en": " Backup: {path}"},
|
|
233
|
+
"delete.missing_backup_failed": {"ru": "Не удалось создать backup: {error}. Удаление отменено.", "en": "Failed to create backup: {error}. Deletion canceled."},
|
|
234
|
+
"delete.missing_done": {"ru": " ✅ Удалено сессий: {n}; строк: {rows}.", "en": " ✅ Sessions deleted: {n}; rows: {rows}."},
|
|
235
|
+
"delete.missing_error": {"ru": "Ошибка удаления: {error}. Транзакция отменена.", "en": "Deletion error: {error}. Transaction rolled back."},
|
|
236
|
+
"delete.missing_db": {"ru": " База: {path}", "en": " Database: {path}"},
|
|
237
|
+
"delete.missing_dry_run": {"ru": "Это preview; база не изменена.", "en": "This is a preview; the database was not changed."},
|
|
218
238
|
"delete.interactive_prompt": {
|
|
219
239
|
"ru": " Введи номера (1,3-5) или Enter для отмены: ",
|
|
220
240
|
"en": " Enter numbers (1,3-5) or Enter to cancel: ",
|
|
@@ -550,6 +570,10 @@ L: dict[str, dict[str, str]] = {
|
|
|
550
570
|
"ru": "Удалить сессии до указанной даты",
|
|
551
571
|
"en": "Delete sessions before a date",
|
|
552
572
|
},
|
|
573
|
+
"help.delete.e7": {"ru": "Очистить сессии из отсутствующих директорий (preview)", "en": "Preview sessions from missing directories"},
|
|
574
|
+
"help.delete.e8": {"ru": "Выполнить очистку после подтверждений", "en": "Execute cleanup after confirmations"},
|
|
575
|
+
"help.prune.e4": {"ru": "Preview очистки отсутствующих директорий", "en": "Preview missing-directory cleanup"},
|
|
576
|
+
"help.prune.e5": {"ru": "Выполнить очистку отсутствующих директорий", "en": "Execute missing-directory cleanup"},
|
|
553
577
|
"help.delete.e6": {
|
|
554
578
|
"ru": "Интерактивный выбор из списка",
|
|
555
579
|
"en": "Choose sessions from a list",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Version lookup for the installed package and source checkouts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from importlib.metadata import PackageNotFoundError
|
|
8
|
+
from importlib.metadata import version as package_version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@lru_cache(maxsize=1)
|
|
13
|
+
def get_version() -> str:
|
|
14
|
+
"""Return the installed package version, with a source-tree fallback."""
|
|
15
|
+
try:
|
|
16
|
+
return package_version("opencode-db")
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
|
21
|
+
try:
|
|
22
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
23
|
+
return str(data["project"]["version"])
|
|
24
|
+
except (OSError, KeyError, TypeError, tomllib.TOMLDecodeError):
|
|
25
|
+
return "unknown"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-db
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
|
|
5
5
|
Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
|
|
6
6
|
Project-URL: Source, https://github.com/VasilevNStas/opencode-db
|
|
@@ -31,6 +31,7 @@ uv tool install opencode-db
|
|
|
31
31
|
## Quick start
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
+
opencode-db --version # installed version
|
|
34
35
|
opencode-db list # recent sessions
|
|
35
36
|
opencode-db directories # launch directories, including missing ones
|
|
36
37
|
opencode-db view # interactive session viewer (colored, scrollable)
|
|
@@ -47,7 +48,7 @@ opencode-db help # full reference
|
|
|
47
48
|
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
48
49
|
- **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
|
|
49
50
|
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
50
|
-
- **`delete`** / **`prune`** — удаление и массовая очистка с
|
|
51
|
+
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами; `prune --missing-directories` — защищённая очистка сессий из отсутствующих путей
|
|
51
52
|
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
52
53
|
- **`schema`** — определяет v1/v2-схему OpenCode, миграции и доступные операции
|
|
53
54
|
- **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
|
|
@@ -14,6 +14,7 @@ opencode_db/runtime.py
|
|
|
14
14
|
opencode_db/schema.py
|
|
15
15
|
opencode_db/schema_manifest.json
|
|
16
16
|
opencode_db/utils.py
|
|
17
|
+
opencode_db/version.py
|
|
17
18
|
opencode_db.egg-info/PKG-INFO
|
|
18
19
|
opencode_db.egg-info/SOURCES.txt
|
|
19
20
|
opencode_db.egg-info/dependency_links.txt
|
|
@@ -48,4 +49,5 @@ tests/test_i18n.py
|
|
|
48
49
|
tests/test_runtime.py
|
|
49
50
|
tests/test_schema.py
|
|
50
51
|
tests/test_store_v2.py
|
|
51
|
-
tests/test_utils.py
|
|
52
|
+
tests/test_utils.py
|
|
53
|
+
tests/test_version.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opencode-db"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
description = "OpenCode database CLI manager — browse, export, analyze, clean up sessions"
|
|
9
9
|
readme = "docs/README_PYPI.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -784,3 +784,124 @@ class TestDirectoriesCommand:
|
|
|
784
784
|
)
|
|
785
785
|
assert run(args, db) == 0
|
|
786
786
|
assert "None" not in capsys.readouterr().out
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
class TestMissingDirectoryCleanup:
|
|
790
|
+
def _args(self, **overrides):
|
|
791
|
+
values = dict(
|
|
792
|
+
session_id=None,
|
|
793
|
+
older_than=None,
|
|
794
|
+
before=None,
|
|
795
|
+
after=None,
|
|
796
|
+
keep_last=None,
|
|
797
|
+
project=None,
|
|
798
|
+
dry_run=False,
|
|
799
|
+
force=False,
|
|
800
|
+
interactive=False,
|
|
801
|
+
missing_directories=True,
|
|
802
|
+
execute=False,
|
|
803
|
+
backup=False,
|
|
804
|
+
no_backup=False,
|
|
805
|
+
)
|
|
806
|
+
values.update(overrides)
|
|
807
|
+
return _ns(**values)
|
|
808
|
+
|
|
809
|
+
def test_preview_does_not_delete(self, db, capsys) -> None:
|
|
810
|
+
from opencode_db.commands.cmd_delete import run
|
|
811
|
+
|
|
812
|
+
assert run(self._args(), db) == 0
|
|
813
|
+
assert db.execute("SELECT COUNT(*) FROM session").fetchone()[0] == 4
|
|
814
|
+
output = capsys.readouterr().out
|
|
815
|
+
assert "preview" in output.lower()
|
|
816
|
+
assert "/home/user/proj1" in output
|
|
817
|
+
|
|
818
|
+
def test_execute_requires_backup_choice(self, db) -> None:
|
|
819
|
+
from opencode_db.commands.cmd_delete import run
|
|
820
|
+
|
|
821
|
+
assert run(self._args(execute=True, force=True), db) == 1
|
|
822
|
+
assert db.execute("SELECT COUNT(*) FROM session").fetchone()[0] == 4
|
|
823
|
+
|
|
824
|
+
def test_execute_requires_missing_directories(self, db) -> None:
|
|
825
|
+
from opencode_db.commands.cmd_delete import run
|
|
826
|
+
|
|
827
|
+
args = self._args(missing_directories=False, execute=True)
|
|
828
|
+
assert run(args, db) == 1
|
|
829
|
+
|
|
830
|
+
def test_phrase_mismatch_cancels(self, db) -> None:
|
|
831
|
+
from opencode_db.commands.cmd_delete import run
|
|
832
|
+
|
|
833
|
+
with patch("builtins.input", return_value="no"):
|
|
834
|
+
assert run(self._args(execute=True, no_backup=True), db) == 0
|
|
835
|
+
assert db.execute("SELECT COUNT(*) FROM session").fetchone()[0] == 4
|
|
836
|
+
|
|
837
|
+
def test_final_confirmation_cancels(self, db) -> None:
|
|
838
|
+
from opencode_db.commands.cmd_delete import run
|
|
839
|
+
|
|
840
|
+
with (
|
|
841
|
+
patch("builtins.input", return_value="DELETE 2 SESSIONS"),
|
|
842
|
+
patch("opencode_db.commands.cmd_delete.confirm", return_value=False),
|
|
843
|
+
):
|
|
844
|
+
assert run(self._args(execute=True, no_backup=True), db) == 0
|
|
845
|
+
assert db.execute("SELECT COUNT(*) FROM session").fetchone()[0] == 4
|
|
846
|
+
|
|
847
|
+
def test_successful_delete_preserves_existing_directory(self, db_file, tmp_path) -> None:
|
|
848
|
+
import sqlite3
|
|
849
|
+
|
|
850
|
+
from opencode_db.commands.cmd_delete import run
|
|
851
|
+
|
|
852
|
+
present = tmp_path / "present"
|
|
853
|
+
present.mkdir()
|
|
854
|
+
conn = sqlite3.connect(db_file)
|
|
855
|
+
conn.row_factory = sqlite3.Row
|
|
856
|
+
conn.execute("UPDATE session SET directory = ? WHERE id = 'ses_001'", (str(present),))
|
|
857
|
+
conn.commit()
|
|
858
|
+
|
|
859
|
+
with (
|
|
860
|
+
patch("builtins.input", return_value="DELETE 1 SESSIONS"),
|
|
861
|
+
patch("opencode_db.commands.cmd_delete.confirm", return_value=True),
|
|
862
|
+
):
|
|
863
|
+
assert run(
|
|
864
|
+
self._args(execute=True, no_backup=True),
|
|
865
|
+
conn,
|
|
866
|
+
) == 0
|
|
867
|
+
|
|
868
|
+
assert conn.execute("SELECT COUNT(*) FROM session").fetchone()[0] == 3
|
|
869
|
+
assert conn.execute("SELECT id FROM session WHERE id = 'ses_001'").fetchone() is not None
|
|
870
|
+
assert conn.execute("SELECT id FROM session WHERE id = 'ses_002'").fetchone() is None
|
|
871
|
+
conn.close()
|
|
872
|
+
|
|
873
|
+
def test_backup_and_event_cleanup(self, db_file, tmp_path) -> None:
|
|
874
|
+
import sqlite3
|
|
875
|
+
|
|
876
|
+
from opencode_db.commands.cmd_delete import run
|
|
877
|
+
|
|
878
|
+
present = tmp_path / "present"
|
|
879
|
+
present.mkdir()
|
|
880
|
+
conn = sqlite3.connect(db_file)
|
|
881
|
+
conn.row_factory = sqlite3.Row
|
|
882
|
+
conn.execute("UPDATE session SET directory = ? WHERE id = 'ses_001'", (str(present),))
|
|
883
|
+
conn.executescript(
|
|
884
|
+
"""
|
|
885
|
+
CREATE TABLE event_sequence (aggregate_id TEXT, seq INTEGER, owner_id TEXT);
|
|
886
|
+
CREATE TABLE event (id TEXT, aggregate_id TEXT, seq INTEGER, type TEXT, data TEXT);
|
|
887
|
+
"""
|
|
888
|
+
)
|
|
889
|
+
conn.execute(
|
|
890
|
+
"INSERT INTO event_sequence VALUES ('ses_002', 1, NULL)"
|
|
891
|
+
)
|
|
892
|
+
conn.execute(
|
|
893
|
+
"INSERT INTO event VALUES ('evt_1', 'ses_002', 1, 'test', '{}')"
|
|
894
|
+
)
|
|
895
|
+
conn.commit()
|
|
896
|
+
|
|
897
|
+
with (
|
|
898
|
+
patch("builtins.input", return_value="DELETE 1 SESSIONS"),
|
|
899
|
+
patch("opencode_db.commands.cmd_delete.confirm", return_value=True),
|
|
900
|
+
patch("opencode_db.commands.cmd_delete.resolve_db_path", return_value=(db_file, False)),
|
|
901
|
+
):
|
|
902
|
+
assert run(self._args(execute=True, backup=True), conn) == 0
|
|
903
|
+
|
|
904
|
+
assert list(tmp_path.glob("test.db.bak.*"))
|
|
905
|
+
assert conn.execute("SELECT COUNT(*) FROM event").fetchone()[0] == 0
|
|
906
|
+
assert conn.execute("SELECT COUNT(*) FROM event_sequence").fetchone()[0] == 0
|
|
907
|
+
conn.close()
|
|
@@ -379,3 +379,28 @@ def test_v2_projects_global_and_paths(v2db, capsys) -> None:
|
|
|
379
379
|
assert by_id["global"]["path"] is None
|
|
380
380
|
assert by_id["proj_v2"]["path"] == "/tmp/v2"
|
|
381
381
|
assert by_id["proj_v2"]["name"] == "V2 Project"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_v2_missing_directory_cleanup_is_refused(v2db, capsys) -> None:
|
|
385
|
+
from opencode_db.commands import cmd_delete
|
|
386
|
+
|
|
387
|
+
code = cmd_delete.run(
|
|
388
|
+
_ns(
|
|
389
|
+
session_id=None,
|
|
390
|
+
older_than=None,
|
|
391
|
+
before=None,
|
|
392
|
+
after=None,
|
|
393
|
+
keep_last=None,
|
|
394
|
+
project=None,
|
|
395
|
+
dry_run=False,
|
|
396
|
+
force=False,
|
|
397
|
+
interactive=False,
|
|
398
|
+
missing_directories=True,
|
|
399
|
+
execute=True,
|
|
400
|
+
backup=False,
|
|
401
|
+
no_backup=True,
|
|
402
|
+
),
|
|
403
|
+
v2db,
|
|
404
|
+
)
|
|
405
|
+
assert code == 1
|
|
406
|
+
assert "v2" in capsys.readouterr().out.lower()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from opencode_db.version import get_version
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_get_version_returns_a_version() -> None:
|
|
9
|
+
value = get_version()
|
|
10
|
+
assert value == "unknown" or re.fullmatch(r"\d+\.\d+\.\d+(?:[.\-+][0-9A-Za-z.\-+]+)?", value)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_version_flag_prints_version(capsys) -> None:
|
|
14
|
+
from opencode_db.cli import build_parser
|
|
15
|
+
|
|
16
|
+
parser = build_parser()
|
|
17
|
+
with pytest.raises(SystemExit) as exc:
|
|
18
|
+
parser.parse_args(["--version"])
|
|
19
|
+
|
|
20
|
+
assert exc.value.code == 0
|
|
21
|
+
output = capsys.readouterr().out.strip()
|
|
22
|
+
assert output == f"opencode-db {get_version()}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|