filekeeper-cli 0.1.2__tar.gz → 0.1.3__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.3}/PKG-INFO +1 -1
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/pyproject.toml +1 -1
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/pyproject.toml.orig +1 -1
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/__init__.py +1 -1
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/cli/app.py +20 -1
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/config.py +8 -2
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/database.py +7 -1
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/service.py +46 -20
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/workspace.py +136 -23
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/LICENSE +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/README.md +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/cli/__init__.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/cli/exit_codes.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/cli/output.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/__init__.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/diffing.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/errors.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/history/__init__.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/history/blobs.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/history/journal.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/history/repository.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/history/undo.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/models.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/__init__.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/directory.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/edit.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/read.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/search.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/write.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/paths.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/safety/__init__.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/safety/atomic.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/safety/hashing.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/safety/sensitive.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/safety/textdata.py +0 -0
- {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/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.3
|
|
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
|
|
@@ -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:
|
|
@@ -40,6 +40,11 @@ class Config:
|
|
|
40
40
|
max_history_blob_bytes: int = 4 * 1024 * 1024
|
|
41
41
|
fsync: bool = True
|
|
42
42
|
state_location: str = "project"
|
|
43
|
+
# True only when the configuration file named ``state_location`` explicitly.
|
|
44
|
+
# A workspace whose root cannot host its state directory may fall back to the
|
|
45
|
+
# global store when the setting was left at the default, but an explicit
|
|
46
|
+
# operator choice is honoured and fails loudly instead of being redirected.
|
|
47
|
+
state_location_explicit: bool = False
|
|
43
48
|
|
|
44
49
|
def __post_init__(self) -> None:
|
|
45
50
|
if self.state_location not in {"project", "global"}:
|
|
@@ -93,13 +98,14 @@ def load_config(home: Path) -> Config:
|
|
|
93
98
|
if not isinstance(section, dict):
|
|
94
99
|
raise StorageError("configuration file must contain a table", details={"path": str(path)})
|
|
95
100
|
|
|
96
|
-
known = {field_info.name for field_info in fields(Config)}
|
|
101
|
+
known = {field_info.name for field_info in fields(Config)} - {"state_location_explicit"}
|
|
97
102
|
values: dict[str, object] = {}
|
|
98
103
|
for key, value in section.items():
|
|
99
104
|
if key in known:
|
|
100
105
|
values[key] = value
|
|
101
106
|
try:
|
|
102
|
-
|
|
107
|
+
explicit = "state_location" in section
|
|
108
|
+
return Config(**values, state_location_explicit=explicit) # type: ignore[arg-type]
|
|
103
109
|
except TypeError as exc:
|
|
104
110
|
raise StorageError(
|
|
105
111
|
f"invalid configuration value: {exc}", details={"path": str(path)}
|
|
@@ -138,7 +138,13 @@ def uuid_token() -> str:
|
|
|
138
138
|
def connect(path: Path) -> sqlite3.Connection:
|
|
139
139
|
"""Open a SQLite database with durable, foreign-key-enforcing settings."""
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
try:
|
|
142
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
except OSError as exc:
|
|
144
|
+
raise StorageError(
|
|
145
|
+
f"cannot create state directory: {exc.strerror or type(exc).__name__}",
|
|
146
|
+
details={"state_dir": str(path.parent)},
|
|
147
|
+
) from exc
|
|
142
148
|
try:
|
|
143
149
|
os.chmod(path.parent, stat.S_IRWXU)
|
|
144
150
|
except OSError: # pragma: no cover - restricted filesystems
|
|
@@ -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,24 @@ 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
|
-
state_dir.mkdir(parents=True, exist_ok=True)
|
|
206
|
-
target = state_dir / STATE_DATABASE_NAME
|
|
207
|
-
connection = database.open_workspace_database(target)
|
|
208
|
-
connection.close()
|
|
209
|
-
return target
|
|
222
|
+
return ensure_state_directory(self.handle.state_dir, root=self.root)
|
|
210
223
|
|
|
211
224
|
def blob_store(self) -> BlobStore:
|
|
212
225
|
return BlobStore(self.state_database_path().parent / "blobs", fsync=self.config.fsync)
|
|
213
226
|
|
|
227
|
+
def _journal_for(self, dry_run: bool) -> OperationJournal | None:
|
|
228
|
+
"""The journal a mutation will record into, or a verified ``None``.
|
|
229
|
+
|
|
230
|
+
A dry run writes no history, but it still proves that the journal can be
|
|
231
|
+
opened: a preview must never report success for a call the real
|
|
232
|
+
operation could not perform.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
if dry_run:
|
|
236
|
+
self.state_database_path()
|
|
237
|
+
return None
|
|
238
|
+
return self.history
|
|
239
|
+
|
|
214
240
|
# -- history ------------------------------------------------------------
|
|
215
241
|
def _open_journal(self, *, create: bool) -> OperationJournal | None:
|
|
216
242
|
"""Return the journal, reconciling pending work once per service instance.
|
|
@@ -413,7 +439,7 @@ class FilekeeperService:
|
|
|
413
439
|
encoding=encoding,
|
|
414
440
|
allow_sensitive_write=allow_sensitive_write,
|
|
415
441
|
dry_run=dry_run,
|
|
416
|
-
journal=
|
|
442
|
+
journal=self._journal_for(dry_run),
|
|
417
443
|
actor=actor,
|
|
418
444
|
task_id=task_id,
|
|
419
445
|
)
|
|
@@ -438,7 +464,7 @@ class FilekeeperService:
|
|
|
438
464
|
encoding=encoding,
|
|
439
465
|
allow_sensitive_write=allow_sensitive_write,
|
|
440
466
|
dry_run=dry_run,
|
|
441
|
-
journal=
|
|
467
|
+
journal=self._journal_for(dry_run),
|
|
442
468
|
actor=actor,
|
|
443
469
|
task_id=task_id,
|
|
444
470
|
)
|
|
@@ -461,7 +487,7 @@ class FilekeeperService:
|
|
|
461
487
|
expected_destination_hash=expected_destination_hash,
|
|
462
488
|
allow_sensitive_write=allow_sensitive_write,
|
|
463
489
|
dry_run=dry_run,
|
|
464
|
-
journal=
|
|
490
|
+
journal=self._journal_for(dry_run),
|
|
465
491
|
actor=actor,
|
|
466
492
|
task_id=task_id,
|
|
467
493
|
)
|
|
@@ -484,7 +510,7 @@ class FilekeeperService:
|
|
|
484
510
|
overwrite=overwrite,
|
|
485
511
|
allow_sensitive_write=allow_sensitive_write,
|
|
486
512
|
dry_run=dry_run,
|
|
487
|
-
journal=
|
|
513
|
+
journal=self._journal_for(dry_run),
|
|
488
514
|
actor=actor,
|
|
489
515
|
task_id=task_id,
|
|
490
516
|
)
|
|
@@ -503,7 +529,7 @@ class FilekeeperService:
|
|
|
503
529
|
path,
|
|
504
530
|
expected_hash=expected_hash,
|
|
505
531
|
dry_run=dry_run,
|
|
506
|
-
journal=
|
|
532
|
+
journal=self._journal_for(dry_run),
|
|
507
533
|
actor=actor,
|
|
508
534
|
task_id=task_id,
|
|
509
535
|
)
|
|
@@ -535,7 +561,7 @@ class FilekeeperService:
|
|
|
535
561
|
expected_match_count=expected_match_count,
|
|
536
562
|
allow_sensitive_write=allow_sensitive_write,
|
|
537
563
|
dry_run=dry_run,
|
|
538
|
-
journal=
|
|
564
|
+
journal=self._journal_for(dry_run),
|
|
539
565
|
actor=actor,
|
|
540
566
|
task_id=task_id,
|
|
541
567
|
)
|
|
@@ -562,7 +588,7 @@ class FilekeeperService:
|
|
|
562
588
|
expected_content=expected_content,
|
|
563
589
|
expected_hash=expected_hash,
|
|
564
590
|
dry_run=dry_run,
|
|
565
|
-
journal=
|
|
591
|
+
journal=self._journal_for(dry_run),
|
|
566
592
|
actor=actor,
|
|
567
593
|
task_id=task_id,
|
|
568
594
|
)
|
|
@@ -589,7 +615,7 @@ class FilekeeperService:
|
|
|
589
615
|
expected_content=expected_content,
|
|
590
616
|
expected_hash=expected_hash,
|
|
591
617
|
dry_run=dry_run,
|
|
592
|
-
journal=
|
|
618
|
+
journal=self._journal_for(dry_run),
|
|
593
619
|
actor=actor,
|
|
594
620
|
task_id=task_id,
|
|
595
621
|
)
|
|
@@ -614,7 +640,7 @@ class FilekeeperService:
|
|
|
614
640
|
expected_content=expected_content,
|
|
615
641
|
expected_hash=expected_hash,
|
|
616
642
|
dry_run=dry_run,
|
|
617
|
-
journal=
|
|
643
|
+
journal=self._journal_for(dry_run),
|
|
618
644
|
actor=actor,
|
|
619
645
|
task_id=task_id,
|
|
620
646
|
)
|
|
@@ -633,7 +659,7 @@ class FilekeeperService:
|
|
|
633
659
|
patch_text,
|
|
634
660
|
allow_sensitive_write=allow_sensitive_write,
|
|
635
661
|
dry_run=dry_run,
|
|
636
|
-
journal=
|
|
662
|
+
journal=self._journal_for(dry_run),
|
|
637
663
|
actor=actor,
|
|
638
664
|
task_id=task_id,
|
|
639
665
|
)
|
|
@@ -652,7 +678,7 @@ class FilekeeperService:
|
|
|
652
678
|
path,
|
|
653
679
|
parents=parents,
|
|
654
680
|
dry_run=dry_run,
|
|
655
|
-
journal=
|
|
681
|
+
journal=self._journal_for(dry_run),
|
|
656
682
|
actor=actor,
|
|
657
683
|
task_id=task_id,
|
|
658
684
|
)
|
|
@@ -671,7 +697,7 @@ class FilekeeperService:
|
|
|
671
697
|
source,
|
|
672
698
|
destination,
|
|
673
699
|
dry_run=dry_run,
|
|
674
|
-
journal=
|
|
700
|
+
journal=self._journal_for(dry_run),
|
|
675
701
|
actor=actor,
|
|
676
702
|
task_id=task_id,
|
|
677
703
|
)
|
|
@@ -692,7 +718,7 @@ class FilekeeperService:
|
|
|
692
718
|
recursive=recursive,
|
|
693
719
|
confirm_recursive=confirm_recursive,
|
|
694
720
|
dry_run=dry_run,
|
|
695
|
-
journal=
|
|
721
|
+
journal=self._journal_for(dry_run),
|
|
696
722
|
actor=actor,
|
|
697
723
|
task_id=task_id,
|
|
698
724
|
)
|
|
@@ -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,136 @@ 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 _directory_usable(directory: Path) -> bool:
|
|
73
|
+
"""Whether ``directory`` exists and is writable, or can be created."""
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
if directory.exists():
|
|
77
|
+
return directory.is_dir() and os.access(directory, os.W_OK | os.X_OK)
|
|
78
|
+
parent = directory.parent
|
|
79
|
+
return parent.is_dir() and os.access(parent, os.W_OK | os.X_OK)
|
|
80
|
+
except OSError: # pragma: no cover - unreadable ancestors
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_state_location(workspace: Workspace, config: Config, home: Path) -> StateLocation:
|
|
85
|
+
"""Where this workspace's journal and blobs live, after checking usability.
|
|
46
86
|
|
|
47
87
|
Project-local is the default: ``<root>/.filekeeper``. Global mode keeps one
|
|
48
88
|
directory per workspace id under the home store, so projects can still be
|
|
49
|
-
attributed and pruned individually.
|
|
89
|
+
attributed and pruned individually. When the root cannot host the project
|
|
90
|
+
directory and the operator did not choose a location explicitly, the global
|
|
91
|
+
store is used and the returned warning says so.
|
|
50
92
|
"""
|
|
51
93
|
|
|
52
94
|
if config.state_location == "global":
|
|
53
|
-
return
|
|
54
|
-
|
|
95
|
+
return StateLocation(global_workspace_state_dir(workspace.id, home))
|
|
96
|
+
project_local = Path(workspace.root_path) / STATE_DIR_NAME
|
|
97
|
+
if _directory_usable(project_local):
|
|
98
|
+
return StateLocation(project_local)
|
|
99
|
+
if config.state_location_explicit:
|
|
100
|
+
# The operator asked for project-local state; do not redirect it.
|
|
101
|
+
return StateLocation(project_local)
|
|
102
|
+
return StateLocation(
|
|
103
|
+
global_workspace_state_dir(workspace.id, home), GLOBAL_FALLBACK_WARNING
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def workspace_state_dir(workspace: Workspace, config: Config, home: Path) -> Path:
|
|
108
|
+
"""The state directory only, without the fallback explanation."""
|
|
109
|
+
|
|
110
|
+
return resolve_state_location(workspace, config, home).directory
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _unattributed_history_note(workspace_id: str, state_dir: Path) -> str | None:
|
|
114
|
+
"""Name earlier per-process transient journals that cannot be adopted.
|
|
115
|
+
|
|
116
|
+
Before this release a transient workspace drew a random id per process, so
|
|
117
|
+
every process wrote its own directory. Those directories carry no record of
|
|
118
|
+
their root, which makes silent adoption unsafe; they are left untouched and
|
|
119
|
+
their existence is reported instead of being hidden.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
if not workspace_id.startswith("ws-transient-"):
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
siblings = [
|
|
126
|
+
entry
|
|
127
|
+
for entry in state_dir.parent.iterdir()
|
|
128
|
+
if entry.is_dir()
|
|
129
|
+
and entry != state_dir
|
|
130
|
+
and entry.name.startswith("ws-transient-")
|
|
131
|
+
and (entry / STATE_DATABASE_NAME).is_file()
|
|
132
|
+
]
|
|
133
|
+
except OSError: # pragma: no cover - unreadable store
|
|
134
|
+
return None
|
|
135
|
+
if not siblings:
|
|
136
|
+
return None
|
|
137
|
+
noun = "directory" if len(siblings) == 1 else "directories"
|
|
138
|
+
return (
|
|
139
|
+
f"{len(siblings)} earlier per-process state {noun} remain in the global store; "
|
|
140
|
+
"their history is not attributed to this workspace"
|
|
141
|
+
)
|
|
55
142
|
|
|
56
143
|
|
|
57
144
|
def handle_for(workspace: Workspace, config: Config, home: Path) -> WorkspaceHandle:
|
|
145
|
+
location = resolve_state_location(workspace, config, home)
|
|
146
|
+
warning = location.warning
|
|
147
|
+
legacy = _unattributed_history_note(workspace.id, location.directory)
|
|
148
|
+
if legacy is not None:
|
|
149
|
+
warning = legacy if warning is None else f"{warning}; {legacy}"
|
|
58
150
|
return WorkspaceHandle(
|
|
59
151
|
name=workspace.name,
|
|
60
152
|
root=Path(workspace.root_path),
|
|
61
153
|
workspace_id=workspace.id,
|
|
62
154
|
config=config,
|
|
63
|
-
state_dir=
|
|
155
|
+
state_dir=location.directory,
|
|
156
|
+
state_warning=warning,
|
|
64
157
|
)
|
|
65
158
|
|
|
66
159
|
|
|
160
|
+
def ensure_state_directory(state_dir: Path, *, root: Path | None = None) -> Path:
|
|
161
|
+
"""Create the state directory and its migrated database; return the DB path.
|
|
162
|
+
|
|
163
|
+
Every filesystem failure here is a :class:`StorageError` naming the state
|
|
164
|
+
directory the operator chose. It is never an ``INTERNAL_ERROR``, and the
|
|
165
|
+
message carries only the reason phrase, never a resolved host path.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
state_dir.chmod(stat.S_IRWXU)
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
raise StorageError(
|
|
173
|
+
f"cannot create state directory: {exc.strerror or type(exc).__name__}",
|
|
174
|
+
details={"state_dir": str(state_dir)},
|
|
175
|
+
) from exc
|
|
176
|
+
if root is not None and state_dir.parent == root:
|
|
177
|
+
# Only a project-local directory belongs in the tree's ignore rules.
|
|
178
|
+
ensure_gitignore_entry(root)
|
|
179
|
+
database_path = state_dir / STATE_DATABASE_NAME
|
|
180
|
+
connection = database.open_workspace_database(database_path)
|
|
181
|
+
connection.close()
|
|
182
|
+
return database_path
|
|
183
|
+
|
|
184
|
+
|
|
67
185
|
def _inside_git_repository(root: Path) -> bool:
|
|
68
186
|
return any((candidate / ".git").exists() for candidate in (root, *root.parents))
|
|
69
187
|
|
|
@@ -104,7 +222,14 @@ class WorkspaceRegistry:
|
|
|
104
222
|
self.database_path = self.home / "metadata.sqlite3"
|
|
105
223
|
|
|
106
224
|
def _connect(self) -> sqlite3.Connection:
|
|
107
|
-
|
|
225
|
+
try:
|
|
226
|
+
self.home.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
raise StorageError(
|
|
229
|
+
f"cannot create the Filekeeper home directory: "
|
|
230
|
+
f"{exc.strerror or type(exc).__name__}",
|
|
231
|
+
details={"state_dir": str(self.home)},
|
|
232
|
+
) from exc
|
|
108
233
|
try:
|
|
109
234
|
self.home.chmod(stat.S_IRWXU)
|
|
110
235
|
except OSError: # pragma: no cover - restricted filesystems
|
|
@@ -270,24 +395,12 @@ class WorkspaceRegistry:
|
|
|
270
395
|
"""Create the state directory and its migrated database; return the DB path.
|
|
271
396
|
|
|
272
397
|
Called before the first mutation that will need durable state. It also
|
|
273
|
-
makes sure a git repository will not track
|
|
398
|
+
makes sure a git repository will not track a project-local directory.
|
|
274
399
|
"""
|
|
275
400
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
state_dir.chmod(stat.S_IRWXU)
|
|
280
|
-
except OSError as exc:
|
|
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
|
|
401
|
+
return ensure_state_directory(
|
|
402
|
+
self.state_dir_for(workspace), root=Path(workspace.root_path)
|
|
403
|
+
)
|
|
291
404
|
|
|
292
405
|
|
|
293
406
|
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
|
|
File without changes
|