filekeeper-cli 0.1.2__tar.gz → 0.1.4__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.
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/PKG-INFO +7 -1
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/README.md +6 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/pyproject.toml +1 -1
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/pyproject.toml.orig +1 -1
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/__init__.py +1 -1
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/cli/app.py +20 -1
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/config.py +22 -6
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/database.py +23 -8
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/service.py +49 -20
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/workspace.py +166 -23
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/LICENSE +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/cli/__init__.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/cli/exit_codes.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/cli/output.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/__init__.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/diffing.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/errors.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/__init__.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/blobs.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/journal.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/repository.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/undo.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/models.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/__init__.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/directory.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/edit.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/read.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/search.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/write.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/paths.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/__init__.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/atomic.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/hashing.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/sensitive.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/textdata.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/vcs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: filekeeper-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement.
|
|
5
5
|
Keywords: filesystem,atomic-write,cli,agents,mcp
|
|
6
6
|
Author: sangingeum
|
|
@@ -150,6 +150,12 @@ home directory instead. The trade-off: history survives deleting the checkout, b
|
|
|
150
150
|
per-project attribution and pruning become harder, and undo history no longer
|
|
151
151
|
travels with the tree.
|
|
152
152
|
|
|
153
|
+
If the workspace root cannot host `.filekeeper/` — a read-only checkout, or a root
|
|
154
|
+
at `/` — Filekeeper falls back to the global store and says so in the response.
|
|
155
|
+
`keeper init` writes the setting only as a commented example, so that fallback
|
|
156
|
+
stays available; uncomment the key to pin the choice, and a project-local choice
|
|
157
|
+
then fails loudly instead of being redirected.
|
|
158
|
+
|
|
153
159
|
If the workspace is a git repository, Filekeeper adds `.filekeeper/` to
|
|
154
160
|
`.gitignore` when it creates the state directory. Outside a repository it writes
|
|
155
161
|
nothing.
|
|
@@ -129,6 +129,12 @@ home directory instead. The trade-off: history survives deleting the checkout, b
|
|
|
129
129
|
per-project attribution and pruning become harder, and undo history no longer
|
|
130
130
|
travels with the tree.
|
|
131
131
|
|
|
132
|
+
If the workspace root cannot host `.filekeeper/` — a read-only checkout, or a root
|
|
133
|
+
at `/` — Filekeeper falls back to the global store and says so in the response.
|
|
134
|
+
`keeper init` writes the setting only as a commented example, so that fallback
|
|
135
|
+
stays available; uncomment the key to pin the choice, and a project-local choice
|
|
136
|
+
then fails loudly instead of being redirected.
|
|
137
|
+
|
|
132
138
|
If the workspace is a git repository, Filekeeper adds `.filekeeper/` to
|
|
133
139
|
`.gitignore` when it creates the state directory. Outside a repository it writes
|
|
134
140
|
nothing.
|
|
@@ -408,11 +408,30 @@ class _Context:
|
|
|
408
408
|
)
|
|
409
409
|
return self._service
|
|
410
410
|
|
|
411
|
+
def state_warnings(self) -> tuple[str, ...]:
|
|
412
|
+
"""Warnings that explain where durable state went for this workspace."""
|
|
413
|
+
|
|
414
|
+
if self._service is None:
|
|
415
|
+
return ()
|
|
416
|
+
warning = self._service.handle.state_warning
|
|
417
|
+
return () if warning is None else (warning,)
|
|
418
|
+
|
|
411
419
|
def emit(self, payload: dict[str, object], text: str) -> None:
|
|
420
|
+
warnings = self.state_warnings()
|
|
421
|
+
if warnings:
|
|
422
|
+
existing = payload.get("warnings")
|
|
423
|
+
merged = list(existing) if isinstance(existing, list) else []
|
|
424
|
+
for warning in warnings:
|
|
425
|
+
if warning not in merged:
|
|
426
|
+
merged.append(warning)
|
|
427
|
+
payload = {**payload, "warnings": merged}
|
|
412
428
|
if self.as_json:
|
|
413
429
|
output.print_json(payload)
|
|
414
|
-
|
|
430
|
+
return
|
|
431
|
+
if text:
|
|
415
432
|
sys.stdout.write(text if text.endswith("\n") else text + "\n")
|
|
433
|
+
for warning in warnings:
|
|
434
|
+
sys.stderr.write(f"warning: {warning}\n")
|
|
416
435
|
|
|
417
436
|
|
|
418
437
|
def _content_from_args(args: argparse.Namespace) -> str:
|
|
@@ -11,7 +11,9 @@ Two kinds of state exist, and they live in different places on purpose:
|
|
|
11
11
|
|
|
12
12
|
``config.toml`` in the home directory holds tunable limits. It is read on every
|
|
13
13
|
invocation; writing it is left to the user (``keeper init`` creates it with the
|
|
14
|
-
defaults spelled out).
|
|
14
|
+
defaults spelled out). ``state_location`` is written only as a commented example:
|
|
15
|
+
a generated file must not turn a default into an explicit operator decision,
|
|
16
|
+
because an explicit decision refuses the global fallback.
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
from __future__ import annotations
|
|
@@ -40,6 +42,11 @@ class Config:
|
|
|
40
42
|
max_history_blob_bytes: int = 4 * 1024 * 1024
|
|
41
43
|
fsync: bool = True
|
|
42
44
|
state_location: str = "project"
|
|
45
|
+
# True only when the configuration file named ``state_location`` explicitly.
|
|
46
|
+
# A workspace whose root cannot host its state directory may fall back to the
|
|
47
|
+
# global store when the setting was left at the default, but an explicit
|
|
48
|
+
# operator choice is honoured and fails loudly instead of being redirected.
|
|
49
|
+
state_location_explicit: bool = False
|
|
43
50
|
|
|
44
51
|
def __post_init__(self) -> None:
|
|
45
52
|
if self.state_location not in {"project", "global"}:
|
|
@@ -93,13 +100,14 @@ def load_config(home: Path) -> Config:
|
|
|
93
100
|
if not isinstance(section, dict):
|
|
94
101
|
raise StorageError("configuration file must contain a table", details={"path": str(path)})
|
|
95
102
|
|
|
96
|
-
known = {field_info.name for field_info in fields(Config)}
|
|
103
|
+
known = {field_info.name for field_info in fields(Config)} - {"state_location_explicit"}
|
|
97
104
|
values: dict[str, object] = {}
|
|
98
105
|
for key, value in section.items():
|
|
99
106
|
if key in known:
|
|
100
107
|
values[key] = value
|
|
101
108
|
try:
|
|
102
|
-
|
|
109
|
+
explicit = "state_location" in section
|
|
110
|
+
return Config(**values, state_location_explicit=explicit) # type: ignore[arg-type]
|
|
103
111
|
except TypeError as exc:
|
|
104
112
|
raise StorageError(
|
|
105
113
|
f"invalid configuration value: {exc}", details={"path": str(path)}
|
|
@@ -127,7 +135,15 @@ max_history_blob_bytes = 4194304
|
|
|
127
135
|
# parent directory before reporting success.
|
|
128
136
|
fsync = true
|
|
129
137
|
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
|
|
138
|
+
# Per-workspace state lives in <workspace>/.filekeeper/ by default. When the
|
|
139
|
+
# workspace root cannot host that directory (a read-only checkout, or a root at
|
|
140
|
+
# /), Filekeeper falls back to the global store under this home directory and
|
|
141
|
+
# says so in the response.
|
|
142
|
+
#
|
|
143
|
+
# Uncommenting the next line pins the choice instead of leaving it at the
|
|
144
|
+
# built-in default. "project" then fails loudly when the root cannot host the
|
|
145
|
+
# directory instead of being redirected; "global" always uses the shared store.
|
|
146
|
+
# An uncommented key is an operator decision, so keep it commented unless you
|
|
147
|
+
# mean it.
|
|
148
|
+
# state_location = "project"
|
|
133
149
|
"""
|
|
@@ -135,10 +135,21 @@ def uuid_token() -> str:
|
|
|
135
135
|
return uuid.uuid4().hex[:16]
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
def connect(path: Path) -> sqlite3.Connection:
|
|
139
|
-
"""Open a SQLite database with durable, foreign-key-enforcing settings.
|
|
138
|
+
def connect(path: Path, *, display: str = "the Filekeeper state store") -> sqlite3.Connection:
|
|
139
|
+
"""Open a SQLite database with durable, foreign-key-enforcing settings.
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
``display`` is the caller-facing name of the directory holding the database.
|
|
142
|
+
A public error envelope never carries the resolved host path; the reason
|
|
143
|
+
phrase is all the message needs.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
except OSError as exc:
|
|
149
|
+
raise StorageError(
|
|
150
|
+
f"cannot create state directory: {exc.strerror or type(exc).__name__}",
|
|
151
|
+
details={"state_dir": display},
|
|
152
|
+
) from exc
|
|
142
153
|
try:
|
|
143
154
|
os.chmod(path.parent, stat.S_IRWXU)
|
|
144
155
|
except OSError: # pragma: no cover - restricted filesystems
|
|
@@ -146,7 +157,7 @@ def connect(path: Path) -> sqlite3.Connection:
|
|
|
146
157
|
try:
|
|
147
158
|
connection = sqlite3.connect(path, timeout=10.0)
|
|
148
159
|
except sqlite3.Error as exc:
|
|
149
|
-
raise StorageError(f"cannot open database: {exc}", details={"path":
|
|
160
|
+
raise StorageError(f"cannot open database: {exc}", details={"path": display}) from exc
|
|
150
161
|
connection.row_factory = sqlite3.Row
|
|
151
162
|
connection.execute("PRAGMA journal_mode = WAL")
|
|
152
163
|
# Full synchronous mode: a committed record survives an OS crash, which the
|
|
@@ -210,14 +221,18 @@ def migrate_workspace(connection: sqlite3.Connection) -> int:
|
|
|
210
221
|
return migrate(connection, _WORKSPACE_MIGRATIONS, label="workspace")
|
|
211
222
|
|
|
212
223
|
|
|
213
|
-
def open_registry_database(
|
|
214
|
-
|
|
224
|
+
def open_registry_database(
|
|
225
|
+
path: Path, *, display: str = "the Filekeeper home directory"
|
|
226
|
+
) -> sqlite3.Connection:
|
|
227
|
+
connection = connect(path, display=display)
|
|
215
228
|
migrate_registry(connection)
|
|
216
229
|
return connection
|
|
217
230
|
|
|
218
231
|
|
|
219
|
-
def open_workspace_database(
|
|
220
|
-
|
|
232
|
+
def open_workspace_database(
|
|
233
|
+
path: Path, *, display: str = "the workspace state directory"
|
|
234
|
+
) -> sqlite3.Connection:
|
|
235
|
+
connection = connect(path, display=display)
|
|
221
236
|
migrate_workspace(connection)
|
|
222
237
|
try:
|
|
223
238
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
@@ -14,6 +14,7 @@ values inside results, never their fields.
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import hashlib
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
|
|
19
20
|
from . import database, operations
|
|
@@ -50,9 +51,26 @@ from .workspace import (
|
|
|
50
51
|
STATE_DATABASE_NAME,
|
|
51
52
|
WorkspaceHandle,
|
|
52
53
|
WorkspaceRegistry,
|
|
54
|
+
ensure_state_directory,
|
|
53
55
|
handle_for,
|
|
54
56
|
)
|
|
55
57
|
|
|
58
|
+
TRANSIENT_ID_PREFIX = "ws-transient-"
|
|
59
|
+
TRANSIENT_ID_LENGTH = 16
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def transient_workspace_id(root: str | Path) -> str:
|
|
63
|
+
"""A stable id for the transient workspace rooted at ``root``.
|
|
64
|
+
|
|
65
|
+
Durable state is keyed by workspace id, so the id must survive a process
|
|
66
|
+
restart: it is derived from the canonical absolute root rather than drawn
|
|
67
|
+
at random.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
canonical = str(Path(root).expanduser().resolve())
|
|
71
|
+
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
72
|
+
return f"{TRANSIENT_ID_PREFIX}{digest[:TRANSIENT_ID_LENGTH]}"
|
|
73
|
+
|
|
56
74
|
|
|
57
75
|
def init_home(home: Path | None = None) -> Path:
|
|
58
76
|
"""Create the home directory, default configuration and registry database."""
|
|
@@ -135,7 +153,7 @@ class Filekeeper:
|
|
|
135
153
|
if registered is not None:
|
|
136
154
|
return FilekeeperService(handle_for(registered, self.config, self.home), self)
|
|
137
155
|
transient = Workspace(
|
|
138
|
-
id=
|
|
156
|
+
id=transient_workspace_id(candidate),
|
|
139
157
|
name=candidate.name or "workspace",
|
|
140
158
|
root_path=str(candidate),
|
|
141
159
|
created_at=database.utc_now(),
|
|
@@ -201,16 +219,27 @@ class FilekeeperService:
|
|
|
201
219
|
if self.registered and self.handle.workspace_id is not None:
|
|
202
220
|
workspace = self.owner.registry.get(self.handle.name)
|
|
203
221
|
return self.owner.registry.ensure_state_dir(workspace)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
connection = database.open_workspace_database(target)
|
|
208
|
-
connection.close()
|
|
209
|
-
return target
|
|
222
|
+
return ensure_state_directory(
|
|
223
|
+
self.handle.state_dir, root=self.root, home=self.owner.home
|
|
224
|
+
)
|
|
210
225
|
|
|
211
226
|
def blob_store(self) -> BlobStore:
|
|
212
227
|
return BlobStore(self.state_database_path().parent / "blobs", fsync=self.config.fsync)
|
|
213
228
|
|
|
229
|
+
def _journal_for(self, dry_run: bool) -> OperationJournal | None:
|
|
230
|
+
"""The journal a mutation will record into, or ``None`` for a dry run.
|
|
231
|
+
|
|
232
|
+
A dry run has nothing to journal, so it touches no state at all: it does
|
|
233
|
+
not create the state directory, open the database or write a blob. That
|
|
234
|
+
makes a preview readable even where the real operation would be refused —
|
|
235
|
+
which is the point of a preview, and why a preview that failed on state
|
|
236
|
+
creation was not a preview.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
if dry_run:
|
|
240
|
+
return None
|
|
241
|
+
return self.history
|
|
242
|
+
|
|
214
243
|
# -- history ------------------------------------------------------------
|
|
215
244
|
def _open_journal(self, *, create: bool) -> OperationJournal | None:
|
|
216
245
|
"""Return the journal, reconciling pending work once per service instance.
|
|
@@ -413,7 +442,7 @@ class FilekeeperService:
|
|
|
413
442
|
encoding=encoding,
|
|
414
443
|
allow_sensitive_write=allow_sensitive_write,
|
|
415
444
|
dry_run=dry_run,
|
|
416
|
-
journal=
|
|
445
|
+
journal=self._journal_for(dry_run),
|
|
417
446
|
actor=actor,
|
|
418
447
|
task_id=task_id,
|
|
419
448
|
)
|
|
@@ -438,7 +467,7 @@ class FilekeeperService:
|
|
|
438
467
|
encoding=encoding,
|
|
439
468
|
allow_sensitive_write=allow_sensitive_write,
|
|
440
469
|
dry_run=dry_run,
|
|
441
|
-
journal=
|
|
470
|
+
journal=self._journal_for(dry_run),
|
|
442
471
|
actor=actor,
|
|
443
472
|
task_id=task_id,
|
|
444
473
|
)
|
|
@@ -461,7 +490,7 @@ class FilekeeperService:
|
|
|
461
490
|
expected_destination_hash=expected_destination_hash,
|
|
462
491
|
allow_sensitive_write=allow_sensitive_write,
|
|
463
492
|
dry_run=dry_run,
|
|
464
|
-
journal=
|
|
493
|
+
journal=self._journal_for(dry_run),
|
|
465
494
|
actor=actor,
|
|
466
495
|
task_id=task_id,
|
|
467
496
|
)
|
|
@@ -484,7 +513,7 @@ class FilekeeperService:
|
|
|
484
513
|
overwrite=overwrite,
|
|
485
514
|
allow_sensitive_write=allow_sensitive_write,
|
|
486
515
|
dry_run=dry_run,
|
|
487
|
-
journal=
|
|
516
|
+
journal=self._journal_for(dry_run),
|
|
488
517
|
actor=actor,
|
|
489
518
|
task_id=task_id,
|
|
490
519
|
)
|
|
@@ -503,7 +532,7 @@ class FilekeeperService:
|
|
|
503
532
|
path,
|
|
504
533
|
expected_hash=expected_hash,
|
|
505
534
|
dry_run=dry_run,
|
|
506
|
-
journal=
|
|
535
|
+
journal=self._journal_for(dry_run),
|
|
507
536
|
actor=actor,
|
|
508
537
|
task_id=task_id,
|
|
509
538
|
)
|
|
@@ -535,7 +564,7 @@ class FilekeeperService:
|
|
|
535
564
|
expected_match_count=expected_match_count,
|
|
536
565
|
allow_sensitive_write=allow_sensitive_write,
|
|
537
566
|
dry_run=dry_run,
|
|
538
|
-
journal=
|
|
567
|
+
journal=self._journal_for(dry_run),
|
|
539
568
|
actor=actor,
|
|
540
569
|
task_id=task_id,
|
|
541
570
|
)
|
|
@@ -562,7 +591,7 @@ class FilekeeperService:
|
|
|
562
591
|
expected_content=expected_content,
|
|
563
592
|
expected_hash=expected_hash,
|
|
564
593
|
dry_run=dry_run,
|
|
565
|
-
journal=
|
|
594
|
+
journal=self._journal_for(dry_run),
|
|
566
595
|
actor=actor,
|
|
567
596
|
task_id=task_id,
|
|
568
597
|
)
|
|
@@ -589,7 +618,7 @@ class FilekeeperService:
|
|
|
589
618
|
expected_content=expected_content,
|
|
590
619
|
expected_hash=expected_hash,
|
|
591
620
|
dry_run=dry_run,
|
|
592
|
-
journal=
|
|
621
|
+
journal=self._journal_for(dry_run),
|
|
593
622
|
actor=actor,
|
|
594
623
|
task_id=task_id,
|
|
595
624
|
)
|
|
@@ -614,7 +643,7 @@ class FilekeeperService:
|
|
|
614
643
|
expected_content=expected_content,
|
|
615
644
|
expected_hash=expected_hash,
|
|
616
645
|
dry_run=dry_run,
|
|
617
|
-
journal=
|
|
646
|
+
journal=self._journal_for(dry_run),
|
|
618
647
|
actor=actor,
|
|
619
648
|
task_id=task_id,
|
|
620
649
|
)
|
|
@@ -633,7 +662,7 @@ class FilekeeperService:
|
|
|
633
662
|
patch_text,
|
|
634
663
|
allow_sensitive_write=allow_sensitive_write,
|
|
635
664
|
dry_run=dry_run,
|
|
636
|
-
journal=
|
|
665
|
+
journal=self._journal_for(dry_run),
|
|
637
666
|
actor=actor,
|
|
638
667
|
task_id=task_id,
|
|
639
668
|
)
|
|
@@ -652,7 +681,7 @@ class FilekeeperService:
|
|
|
652
681
|
path,
|
|
653
682
|
parents=parents,
|
|
654
683
|
dry_run=dry_run,
|
|
655
|
-
journal=
|
|
684
|
+
journal=self._journal_for(dry_run),
|
|
656
685
|
actor=actor,
|
|
657
686
|
task_id=task_id,
|
|
658
687
|
)
|
|
@@ -671,7 +700,7 @@ class FilekeeperService:
|
|
|
671
700
|
source,
|
|
672
701
|
destination,
|
|
673
702
|
dry_run=dry_run,
|
|
674
|
-
journal=
|
|
703
|
+
journal=self._journal_for(dry_run),
|
|
675
704
|
actor=actor,
|
|
676
705
|
task_id=task_id,
|
|
677
706
|
)
|
|
@@ -692,7 +721,7 @@ class FilekeeperService:
|
|
|
692
721
|
recursive=recursive,
|
|
693
722
|
confirm_recursive=confirm_recursive,
|
|
694
723
|
dry_run=dry_run,
|
|
695
|
-
journal=
|
|
724
|
+
journal=self._journal_for(dry_run),
|
|
696
725
|
actor=actor,
|
|
697
726
|
task_id=task_id,
|
|
698
727
|
)
|
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
A workspace is a registered directory that bounds every operation. Registration
|
|
4
4
|
lives in the home database; the workspace's own state directory lives in the tree
|
|
5
5
|
by default so that history and blobs travel with the checkout.
|
|
6
|
+
|
|
7
|
+
When the tree cannot host that directory — a read-only checkout, or a workspace
|
|
8
|
+
root at ``/`` — the deployment still works: the state directory falls back to the
|
|
9
|
+
global store and the response says so. An operator who named ``state_location``
|
|
10
|
+
explicitly gets the failure instead, because that choice is theirs to make.
|
|
6
11
|
"""
|
|
7
12
|
|
|
8
13
|
from __future__ import annotations
|
|
9
14
|
|
|
15
|
+
import os
|
|
10
16
|
import re
|
|
11
17
|
import sqlite3
|
|
12
18
|
import stat
|
|
@@ -29,6 +35,13 @@ DEFAULT_WORKSPACE_KEY: Final = "default_workspace"
|
|
|
29
35
|
STATE_DATABASE_NAME: Final = "state.sqlite3"
|
|
30
36
|
GITIGNORE_ENTRY: Final = f"{STATE_DIR_NAME}/"
|
|
31
37
|
|
|
38
|
+
# Names the fallback without naming a host path: the message is rendered to
|
|
39
|
+
# callers who are confined to the workspace.
|
|
40
|
+
GLOBAL_FALLBACK_WARNING: Final = (
|
|
41
|
+
"the workspace root cannot host a state directory, so history is kept in "
|
|
42
|
+
"the global Filekeeper state store instead of a project-local directory"
|
|
43
|
+
)
|
|
44
|
+
|
|
32
45
|
|
|
33
46
|
@dataclass(frozen=True, slots=True)
|
|
34
47
|
class WorkspaceHandle:
|
|
@@ -39,31 +52,164 @@ class WorkspaceHandle:
|
|
|
39
52
|
workspace_id: str | None
|
|
40
53
|
config: Config
|
|
41
54
|
state_dir: Path
|
|
55
|
+
state_warning: str | None = None
|
|
42
56
|
|
|
43
57
|
|
|
44
|
-
|
|
45
|
-
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class StateLocation:
|
|
60
|
+
"""A resolved state directory plus the warning that explains a fallback."""
|
|
61
|
+
|
|
62
|
+
directory: Path
|
|
63
|
+
warning: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def global_workspace_state_dir(workspace_id: str, home: Path) -> Path:
|
|
67
|
+
"""The global store's directory for one workspace id."""
|
|
68
|
+
|
|
69
|
+
return Path(home) / "workspaces" / workspace_id
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def display_state_location(
|
|
73
|
+
state_dir: Path, *, root: Path | None, home: Path | None
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Render a state directory without naming an absolute host path.
|
|
76
|
+
|
|
77
|
+
A public error envelope is read by a caller confined to the workspace, so a
|
|
78
|
+
resolved host path there tells them nothing they can use and describes the
|
|
79
|
+
host tree. The workspace-relative form is preferred; a global store outside
|
|
80
|
+
the workspace is rendered relative to the home directory; anything else falls
|
|
81
|
+
back to prose. The value stays meaningful, which is what the operator needs
|
|
82
|
+
in order to fix the deployment.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
for base in (root, home):
|
|
86
|
+
if base is None:
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
relative = Path(state_dir).relative_to(Path(base)).as_posix()
|
|
90
|
+
except ValueError:
|
|
91
|
+
continue
|
|
92
|
+
return relative or "."
|
|
93
|
+
return "the global Filekeeper state store"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _directory_usable(directory: Path) -> bool:
|
|
97
|
+
"""Whether ``directory`` exists and is writable, or can be created."""
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
if directory.exists():
|
|
101
|
+
return directory.is_dir() and os.access(directory, os.W_OK | os.X_OK)
|
|
102
|
+
parent = directory.parent
|
|
103
|
+
return parent.is_dir() and os.access(parent, os.W_OK | os.X_OK)
|
|
104
|
+
except OSError: # pragma: no cover - unreadable ancestors
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve_state_location(workspace: Workspace, config: Config, home: Path) -> StateLocation:
|
|
109
|
+
"""Where this workspace's journal and blobs live, after checking usability.
|
|
46
110
|
|
|
47
111
|
Project-local is the default: ``<root>/.filekeeper``. Global mode keeps one
|
|
48
112
|
directory per workspace id under the home store, so projects can still be
|
|
49
|
-
attributed and pruned individually.
|
|
113
|
+
attributed and pruned individually. When the root cannot host the project
|
|
114
|
+
directory and the operator did not choose a location explicitly, the global
|
|
115
|
+
store is used and the returned warning says so.
|
|
50
116
|
"""
|
|
51
117
|
|
|
52
118
|
if config.state_location == "global":
|
|
53
|
-
return
|
|
54
|
-
|
|
119
|
+
return StateLocation(global_workspace_state_dir(workspace.id, home))
|
|
120
|
+
project_local = Path(workspace.root_path) / STATE_DIR_NAME
|
|
121
|
+
if _directory_usable(project_local):
|
|
122
|
+
return StateLocation(project_local)
|
|
123
|
+
if config.state_location_explicit:
|
|
124
|
+
# The operator asked for project-local state; do not redirect it.
|
|
125
|
+
return StateLocation(project_local)
|
|
126
|
+
return StateLocation(
|
|
127
|
+
global_workspace_state_dir(workspace.id, home), GLOBAL_FALLBACK_WARNING
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def workspace_state_dir(workspace: Workspace, config: Config, home: Path) -> Path:
|
|
132
|
+
"""The state directory only, without the fallback explanation."""
|
|
133
|
+
|
|
134
|
+
return resolve_state_location(workspace, config, home).directory
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _unattributed_history_note(workspace_id: str, state_dir: Path) -> str | None:
|
|
138
|
+
"""Name earlier per-process transient journals that cannot be adopted.
|
|
139
|
+
|
|
140
|
+
Before this release a transient workspace drew a random id per process, so
|
|
141
|
+
every process wrote its own directory. Those directories carry no record of
|
|
142
|
+
their root, which makes silent adoption unsafe; they are left untouched and
|
|
143
|
+
their existence is reported instead of being hidden.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
if not workspace_id.startswith("ws-transient-"):
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
siblings = [
|
|
150
|
+
entry
|
|
151
|
+
for entry in state_dir.parent.iterdir()
|
|
152
|
+
if entry.is_dir()
|
|
153
|
+
and entry != state_dir
|
|
154
|
+
and entry.name.startswith("ws-transient-")
|
|
155
|
+
and (entry / STATE_DATABASE_NAME).is_file()
|
|
156
|
+
]
|
|
157
|
+
except OSError: # pragma: no cover - unreadable store
|
|
158
|
+
return None
|
|
159
|
+
if not siblings:
|
|
160
|
+
return None
|
|
161
|
+
noun = "directory" if len(siblings) == 1 else "directories"
|
|
162
|
+
return (
|
|
163
|
+
f"{len(siblings)} earlier per-process state {noun} remain in the global store; "
|
|
164
|
+
"their history is not attributed to this workspace"
|
|
165
|
+
)
|
|
55
166
|
|
|
56
167
|
|
|
57
168
|
def handle_for(workspace: Workspace, config: Config, home: Path) -> WorkspaceHandle:
|
|
169
|
+
location = resolve_state_location(workspace, config, home)
|
|
170
|
+
warning = location.warning
|
|
171
|
+
legacy = _unattributed_history_note(workspace.id, location.directory)
|
|
172
|
+
if legacy is not None:
|
|
173
|
+
warning = legacy if warning is None else f"{warning}; {legacy}"
|
|
58
174
|
return WorkspaceHandle(
|
|
59
175
|
name=workspace.name,
|
|
60
176
|
root=Path(workspace.root_path),
|
|
61
177
|
workspace_id=workspace.id,
|
|
62
178
|
config=config,
|
|
63
|
-
state_dir=
|
|
179
|
+
state_dir=location.directory,
|
|
180
|
+
state_warning=warning,
|
|
64
181
|
)
|
|
65
182
|
|
|
66
183
|
|
|
184
|
+
def ensure_state_directory(
|
|
185
|
+
state_dir: Path, *, root: Path | None = None, home: Path | None = None
|
|
186
|
+
) -> Path:
|
|
187
|
+
"""Create the state directory and its migrated database; return the DB path.
|
|
188
|
+
|
|
189
|
+
Every filesystem failure here is a :class:`StorageError` naming the state
|
|
190
|
+
directory the operator chose. It is never an ``INTERNAL_ERROR``, and neither
|
|
191
|
+
the message nor the details carry a resolved host path: the location is
|
|
192
|
+
rendered workspace-relative (or home-relative for the global store).
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
display = display_state_location(state_dir, root=root, home=home)
|
|
196
|
+
try:
|
|
197
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
state_dir.chmod(stat.S_IRWXU)
|
|
199
|
+
except OSError as exc:
|
|
200
|
+
raise StorageError(
|
|
201
|
+
f"cannot create state directory: {exc.strerror or type(exc).__name__}",
|
|
202
|
+
details={"state_dir": display},
|
|
203
|
+
) from exc
|
|
204
|
+
if root is not None and state_dir.parent == root:
|
|
205
|
+
# Only a project-local directory belongs in the tree's ignore rules.
|
|
206
|
+
ensure_gitignore_entry(root)
|
|
207
|
+
database_path = state_dir / STATE_DATABASE_NAME
|
|
208
|
+
connection = database.open_workspace_database(database_path, display=display)
|
|
209
|
+
connection.close()
|
|
210
|
+
return database_path
|
|
211
|
+
|
|
212
|
+
|
|
67
213
|
def _inside_git_repository(root: Path) -> bool:
|
|
68
214
|
return any((candidate / ".git").exists() for candidate in (root, *root.parents))
|
|
69
215
|
|
|
@@ -104,7 +250,14 @@ class WorkspaceRegistry:
|
|
|
104
250
|
self.database_path = self.home / "metadata.sqlite3"
|
|
105
251
|
|
|
106
252
|
def _connect(self) -> sqlite3.Connection:
|
|
107
|
-
|
|
253
|
+
try:
|
|
254
|
+
self.home.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
except OSError as exc:
|
|
256
|
+
raise StorageError(
|
|
257
|
+
f"cannot create the Filekeeper home directory: "
|
|
258
|
+
f"{exc.strerror or type(exc).__name__}",
|
|
259
|
+
details={"state_dir": "the Filekeeper home directory"},
|
|
260
|
+
) from exc
|
|
108
261
|
try:
|
|
109
262
|
self.home.chmod(stat.S_IRWXU)
|
|
110
263
|
except OSError: # pragma: no cover - restricted filesystems
|
|
@@ -270,24 +423,14 @@ class WorkspaceRegistry:
|
|
|
270
423
|
"""Create the state directory and its migrated database; return the DB path.
|
|
271
424
|
|
|
272
425
|
Called before the first mutation that will need durable state. It also
|
|
273
|
-
makes sure a git repository will not track
|
|
426
|
+
makes sure a git repository will not track a project-local directory.
|
|
274
427
|
"""
|
|
275
428
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
raise StorageError(
|
|
282
|
-
f"cannot create state directory: "
|
|
283
|
-
f"{exc.strerror or type(exc).__name__}",
|
|
284
|
-
details={"state_dir": str(state_dir)},
|
|
285
|
-
) from exc
|
|
286
|
-
ensure_gitignore_entry(Path(workspace.root_path))
|
|
287
|
-
database_path = state_dir / STATE_DATABASE_NAME
|
|
288
|
-
connection = database.open_workspace_database(database_path)
|
|
289
|
-
connection.close()
|
|
290
|
-
return database_path
|
|
429
|
+
return ensure_state_directory(
|
|
430
|
+
self.state_dir_for(workspace),
|
|
431
|
+
root=Path(workspace.root_path),
|
|
432
|
+
home=self.home,
|
|
433
|
+
)
|
|
291
434
|
|
|
292
435
|
|
|
293
436
|
def _row_to_workspace(row: sqlite3.Row) -> Workspace:
|
|
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
|