filekeeper-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ """Workspace registration, settings, and per-workspace state location.
2
+
3
+ A workspace is a registered directory that bounds every operation. Registration
4
+ lives in the home database; the workspace's own state directory lives in the tree
5
+ by default so that history and blobs travel with the checkout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import sqlite3
12
+ import stat
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Final
16
+
17
+ from . import database
18
+ from .config import STATE_DIR_NAME, Config
19
+ from .errors import (
20
+ StorageError,
21
+ ValidationError,
22
+ WorkspaceExistsError,
23
+ WorkspaceNotFoundError,
24
+ )
25
+ from .models import Workspace
26
+
27
+ _NAME_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
28
+ DEFAULT_WORKSPACE_KEY: Final = "default_workspace"
29
+ STATE_DATABASE_NAME: Final = "state.sqlite3"
30
+ GITIGNORE_ENTRY: Final = f"{STATE_DIR_NAME}/"
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class WorkspaceHandle:
35
+ """Everything an operation needs to know about where it is running."""
36
+
37
+ name: str
38
+ root: Path
39
+ workspace_id: str | None
40
+ config: Config
41
+ state_dir: Path
42
+
43
+
44
+ def workspace_state_dir(workspace: Workspace, config: Config, home: Path) -> Path:
45
+ """Where this workspace's journal and blobs live.
46
+
47
+ Project-local is the default: ``<root>/.filekeeper``. Global mode keeps one
48
+ directory per workspace id under the home store, so projects can still be
49
+ attributed and pruned individually.
50
+ """
51
+
52
+ if config.state_location == "global":
53
+ return home / "workspaces" / workspace.id
54
+ return Path(workspace.root_path) / STATE_DIR_NAME
55
+
56
+
57
+ def handle_for(workspace: Workspace, config: Config, home: Path) -> WorkspaceHandle:
58
+ return WorkspaceHandle(
59
+ name=workspace.name,
60
+ root=Path(workspace.root_path),
61
+ workspace_id=workspace.id,
62
+ config=config,
63
+ state_dir=workspace_state_dir(workspace, config, home),
64
+ )
65
+
66
+
67
+ def _inside_git_repository(root: Path) -> bool:
68
+ return any((candidate / ".git").exists() for candidate in (root, *root.parents))
69
+
70
+
71
+ def ensure_gitignore_entry(root: Path) -> str | None:
72
+ """Make sure the state directory is ignored, when the tree is a git repository.
73
+
74
+ Returns a note describing what was done, or ``None`` when nothing was needed.
75
+ Outside a repository nothing is written: creating a ``.gitignore`` in an
76
+ arbitrary directory would be noise.
77
+ """
78
+
79
+ if not _inside_git_repository(root):
80
+ return None
81
+ gitignore = root / ".gitignore"
82
+ try:
83
+ if gitignore.exists():
84
+ existing = gitignore.read_text(encoding="utf-8")
85
+ lines = {line.strip() for line in existing.splitlines()}
86
+ if GITIGNORE_ENTRY in lines or STATE_DIR_NAME in lines:
87
+ return None
88
+ separator = "" if existing.endswith(("\n", "")) else "\n"
89
+ with gitignore.open("a", encoding="utf-8") as handle:
90
+ handle.write(f"{separator}{GITIGNORE_ENTRY}\n")
91
+ return f"added {GITIGNORE_ENTRY} to .gitignore"
92
+ gitignore.write_text(f"{GITIGNORE_ENTRY}\n", encoding="utf-8")
93
+ except OSError as exc:
94
+ return f"could not update .gitignore ({exc}); add {GITIGNORE_ENTRY} manually"
95
+ return f"created .gitignore with {GITIGNORE_ENTRY}"
96
+
97
+
98
+ class WorkspaceRegistry:
99
+ """The home-scoped registry of workspaces and the default workspace setting."""
100
+
101
+ def __init__(self, home: Path, config: Config | None = None) -> None:
102
+ self.home = Path(home)
103
+ self.config = config or Config()
104
+ self.database_path = self.home / "metadata.sqlite3"
105
+
106
+ def _connect(self) -> sqlite3.Connection:
107
+ self.home.mkdir(parents=True, exist_ok=True)
108
+ try:
109
+ self.home.chmod(stat.S_IRWXU)
110
+ except OSError: # pragma: no cover - restricted filesystems
111
+ pass
112
+ return database.open_registry_database(self.database_path)
113
+
114
+ # -- workspaces ---------------------------------------------------------
115
+ def create(self, name: str, root_path: str | Path) -> Workspace:
116
+ if not _NAME_PATTERN.match(name):
117
+ raise ValidationError(
118
+ "workspace name must start with a letter or digit and contain only "
119
+ "letters, digits, '.', '_' or '-' (maximum 64 characters)",
120
+ details={"name": name},
121
+ )
122
+ root = Path(root_path).expanduser().resolve()
123
+ if not root.is_dir():
124
+ raise ValidationError(
125
+ "workspace root must be an existing directory", details={"root": str(root_path)}
126
+ )
127
+ now = database.utc_now()
128
+ workspace = Workspace(
129
+ id=f"ws_{database.uuid_token()}",
130
+ name=name,
131
+ root_path=str(root),
132
+ created_at=now,
133
+ updated_at=now,
134
+ )
135
+ existing = self._find_by_name_or_root(name, str(root))
136
+ if existing is not None:
137
+ raise WorkspaceExistsError(
138
+ f"a workspace already covers this name or root: {existing.name}",
139
+ details={"name": name, "existing_workspace": existing.name},
140
+ )
141
+ connection = self._connect()
142
+ try:
143
+ with connection:
144
+ connection.execute(
145
+ "INSERT INTO workspaces (id, name, root_path, created_at, updated_at) "
146
+ "VALUES (?, ?, ?, ?, ?)",
147
+ (
148
+ workspace.id,
149
+ workspace.name,
150
+ workspace.root_path,
151
+ workspace.created_at,
152
+ workspace.updated_at,
153
+ ),
154
+ )
155
+ except sqlite3.IntegrityError as exc:
156
+ existing = self._find_by_name_or_root(name, str(root))
157
+ if existing is not None:
158
+ raise WorkspaceExistsError(
159
+ f"a workspace already covers this name or root: {existing.name}",
160
+ details={"name": name, "existing_workspace": existing.name},
161
+ ) from exc
162
+ raise StorageError(f"cannot register workspace: {exc}") from exc
163
+ finally:
164
+ connection.close()
165
+ return workspace
166
+
167
+ def _find_by_name_or_root(self, name: str, root: str) -> Workspace | None:
168
+ connection = self._connect()
169
+ try:
170
+ row = connection.execute(
171
+ "SELECT * FROM workspaces WHERE name = ? OR root_path = ? LIMIT 1",
172
+ (name, root),
173
+ ).fetchone()
174
+ finally:
175
+ connection.close()
176
+ return _row_to_workspace(row) if row is not None else None
177
+
178
+ def list(self) -> tuple[Workspace, ...]:
179
+ connection = self._connect()
180
+ try:
181
+ rows = connection.execute(
182
+ "SELECT * FROM workspaces ORDER BY name COLLATE NOCASE"
183
+ ).fetchall()
184
+ finally:
185
+ connection.close()
186
+ return tuple(_row_to_workspace(row) for row in rows)
187
+
188
+ def get(self, name_or_id: str) -> Workspace:
189
+ connection = self._connect()
190
+ try:
191
+ row = connection.execute(
192
+ "SELECT * FROM workspaces WHERE name = ? OR id = ? LIMIT 1",
193
+ (name_or_id, name_or_id),
194
+ ).fetchone()
195
+ finally:
196
+ connection.close()
197
+ if row is None:
198
+ raise WorkspaceNotFoundError(
199
+ "no workspace with that name or id", details={"workspace": name_or_id}
200
+ )
201
+ return _row_to_workspace(row)
202
+
203
+ def find_by_root(self, root_path: str | Path) -> Workspace | None:
204
+ canonical = str(Path(root_path).expanduser().resolve())
205
+ connection = self._connect()
206
+ try:
207
+ row = connection.execute(
208
+ "SELECT * FROM workspaces WHERE root_path = ? LIMIT 1", (canonical,)
209
+ ).fetchone()
210
+ finally:
211
+ connection.close()
212
+ return _row_to_workspace(row) if row is not None else None
213
+
214
+ def remove(self, name_or_id: str, *, delete_files: bool = False) -> Workspace:
215
+ if delete_files:
216
+ raise ValidationError(
217
+ "removing a workspace never deletes its files; this release has no "
218
+ "file-deleting removal mode",
219
+ details={"workspace": name_or_id},
220
+ )
221
+ workspace = self.get(name_or_id)
222
+ connection = self._connect()
223
+ try:
224
+ with connection:
225
+ connection.execute("DELETE FROM workspaces WHERE id = ?", (workspace.id,))
226
+ connection.execute("DELETE FROM settings WHERE key = ?", (DEFAULT_WORKSPACE_KEY,))
227
+ finally:
228
+ connection.close()
229
+ return workspace
230
+
231
+ # -- settings -----------------------------------------------------------
232
+ def set_default(self, name_or_id: str) -> Workspace:
233
+ workspace = self.get(name_or_id)
234
+ connection = self._connect()
235
+ try:
236
+ with connection:
237
+ connection.execute(
238
+ "INSERT INTO settings (key, value) VALUES (?, ?) "
239
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
240
+ (DEFAULT_WORKSPACE_KEY, workspace.name),
241
+ )
242
+ finally:
243
+ connection.close()
244
+ return workspace
245
+
246
+ def get_default_name(self) -> str | None:
247
+ connection = self._connect()
248
+ try:
249
+ row = connection.execute(
250
+ "SELECT value FROM settings WHERE key = ?", (DEFAULT_WORKSPACE_KEY,)
251
+ ).fetchone()
252
+ finally:
253
+ connection.close()
254
+ return None if row is None else str(row["value"])
255
+
256
+ def get_default(self) -> Workspace | None:
257
+ name = self.get_default_name()
258
+ if name is None:
259
+ return None
260
+ try:
261
+ return self.get(name)
262
+ except WorkspaceNotFoundError:
263
+ return None
264
+
265
+ # -- state directory ----------------------------------------------------
266
+ def state_dir_for(self, workspace: Workspace) -> Path:
267
+ return workspace_state_dir(workspace, self.config, self.home)
268
+
269
+ def ensure_state_dir(self, workspace: Workspace) -> Path:
270
+ """Create the state directory and its migrated database; return the DB path.
271
+
272
+ Called before the first mutation that will need durable state. It also
273
+ makes sure a git repository will not track the directory.
274
+ """
275
+
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
291
+
292
+
293
+ def _row_to_workspace(row: sqlite3.Row) -> Workspace:
294
+ return Workspace(
295
+ id=str(row["id"]),
296
+ name=str(row["name"]),
297
+ root_path=str(row["root_path"]),
298
+ created_at=str(row["created_at"]),
299
+ updated_at=str(row["updated_at"]),
300
+ )