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.
Files changed (36) hide show
  1. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/PKG-INFO +1 -1
  2. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/pyproject.toml +1 -1
  3. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/pyproject.toml.orig +1 -1
  4. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/__init__.py +1 -1
  5. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/cli/app.py +20 -1
  6. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/config.py +8 -2
  7. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/database.py +7 -1
  8. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/service.py +46 -20
  9. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/workspace.py +136 -23
  10. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/LICENSE +0 -0
  11. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/README.md +0 -0
  12. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/cli/__init__.py +0 -0
  13. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/cli/exit_codes.py +0 -0
  14. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/cli/output.py +0 -0
  15. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/__init__.py +0 -0
  16. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/diffing.py +0 -0
  17. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/errors.py +0 -0
  18. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/history/__init__.py +0 -0
  19. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/history/blobs.py +0 -0
  20. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/history/journal.py +0 -0
  21. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/history/repository.py +0 -0
  22. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/history/undo.py +0 -0
  23. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/models.py +0 -0
  24. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/__init__.py +0 -0
  25. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/directory.py +0 -0
  26. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/edit.py +0 -0
  27. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/read.py +0 -0
  28. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/search.py +0 -0
  29. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/operations/write.py +0 -0
  30. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/paths.py +0 -0
  31. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/safety/__init__.py +0 -0
  32. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/safety/atomic.py +0 -0
  33. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/safety/hashing.py +0 -0
  34. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/safety/sensitive.py +0 -0
  35. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.3}/src/filekeeper/core/safety/textdata.py +0 -0
  36. {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.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -21,7 +21,7 @@ from .core import (
21
21
  Workspace,
22
22
  )
23
23
 
24
- __version__ = "0.1.2"
24
+ __version__ = "0.1.3"
25
25
 
26
26
  __all__ = [
27
27
  "Config",
@@ -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
- elif text:
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
- return Config(**values) # type: ignore[arg-type]
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
- path.parent.mkdir(parents=True, exist_ok=True)
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=f"ws-transient-{database.uuid_token()}",
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
- state_dir = self.handle.state_dir
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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=None if dry_run else self.history,
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
- def workspace_state_dir(workspace: Workspace, config: Config, home: Path) -> Path:
45
- """Where this workspace's journal and blobs live.
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 home / "workspaces" / workspace.id
54
- return Path(workspace.root_path) / STATE_DIR_NAME
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=workspace_state_dir(workspace, config, home),
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
- self.home.mkdir(parents=True, exist_ok=True)
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 the directory.
398
+ makes sure a git repository will not track a project-local directory.
274
399
  """
275
400
 
276
- state_dir = self.state_dir_for(workspace)
277
- try:
278
- state_dir.mkdir(parents=True, exist_ok=True)
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