db-git 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.
- db_git-0.1.0.dist-info/METADATA +363 -0
- db_git-0.1.0.dist-info/RECORD +30 -0
- db_git-0.1.0.dist-info/WHEEL +4 -0
- db_git-0.1.0.dist-info/entry_points.txt +2 -0
- db_git-0.1.0.dist-info/licenses/LICENSE +21 -0
- git_db/__init__.py +1 -0
- git_db/backends/__init__.py +173 -0
- git_db/backends/postgresql/__init__.py +0 -0
- git_db/backends/postgresql/backend.py +176 -0
- git_db/backends/postgresql/branch_db.py +203 -0
- git_db/backends/postgresql/connections.py +82 -0
- git_db/backends/postgresql/pgdump.py +212 -0
- git_db/backends/postgresql/template.py +160 -0
- git_db/cli/__init__.py +8 -0
- git_db/cli/_common.py +41 -0
- git_db/cli/_console.py +18 -0
- git_db/cli/_format.py +56 -0
- git_db/cli/_prompts.py +121 -0
- git_db/cli/branch.py +168 -0
- git_db/cli/hook.py +102 -0
- git_db/cli/init.py +267 -0
- git_db/cli/inspect.py +414 -0
- git_db/cli/snapshot.py +115 -0
- git_db/config.py +243 -0
- git_db/db.py +17 -0
- git_db/errors.py +46 -0
- git_db/git.py +349 -0
- git_db/hook_script.py +49 -0
- git_db/state.py +105 -0
- git_db/storage.py +203 -0
git_db/git.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from git_db.backends import DatabaseBackend, get_backend
|
|
14
|
+
from git_db.db import parse_database_url
|
|
15
|
+
from git_db.errors import HookError
|
|
16
|
+
from git_db.hook_script import HOOK_IDENTIFIER, render_hook_script
|
|
17
|
+
from git_db.state import get_branch_db
|
|
18
|
+
from git_db.storage import branch_db_name, has_snapshot
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from git_db.config import GitDbConfig
|
|
22
|
+
|
|
23
|
+
console = Console(stderr=True)
|
|
24
|
+
|
|
25
|
+
_NULL_REF = "0" * 40
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_current_branch() -> str | None:
|
|
29
|
+
"""
|
|
30
|
+
Return the current branch name, or None if HEAD is detached.
|
|
31
|
+
"""
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
["git", "symbolic-ref", "--short", "HEAD"],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
timeout=10,
|
|
37
|
+
)
|
|
38
|
+
if result.returncode != 0:
|
|
39
|
+
return None
|
|
40
|
+
return result.stdout.strip()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_previous_branch() -> str | None:
|
|
44
|
+
"""
|
|
45
|
+
Return the branch that was checked out before the current one.
|
|
46
|
+
"""
|
|
47
|
+
# Primary: @{-1}
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["git", "rev-parse", "--abbrev-ref", "@{-1}"],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=10,
|
|
53
|
+
)
|
|
54
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
55
|
+
branch = result.stdout.strip()
|
|
56
|
+
if branch != "HEAD":
|
|
57
|
+
return branch
|
|
58
|
+
|
|
59
|
+
# Fallback: parse the reflog entry
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
["git", "reflog", "--format=%gs", "-1", "HEAD@{0}"],
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
timeout=10,
|
|
65
|
+
)
|
|
66
|
+
if result.returncode == 0:
|
|
67
|
+
match = re.match(r"checkout: moving from (.+) to .+", result.stdout.strip())
|
|
68
|
+
if match:
|
|
69
|
+
name = match.group(1)
|
|
70
|
+
if len(name) >= 40 and all(c in "0123456789abcdef" for c in name[:40]):
|
|
71
|
+
return None
|
|
72
|
+
return name
|
|
73
|
+
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_detached_head() -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Return True if HEAD is detached.
|
|
80
|
+
"""
|
|
81
|
+
return get_current_branch() is None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_rebase_in_progress(git_dir: Path) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Return True if a rebase is in progress.
|
|
87
|
+
"""
|
|
88
|
+
return (git_dir / "rebase-merge").exists() or (git_dir / "rebase-apply").exists()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_null_ref(ref: str) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Return True if the ref is a null ref (40 zeros) indicating a fresh clone.
|
|
94
|
+
"""
|
|
95
|
+
return ref == _NULL_REF
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_git_dir() -> Path | None:
|
|
99
|
+
"""
|
|
100
|
+
Return the .git directory path, or None if not in a git repo.
|
|
101
|
+
"""
|
|
102
|
+
result = subprocess.run(
|
|
103
|
+
["git", "rev-parse", "--git-dir"],
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True,
|
|
106
|
+
timeout=10,
|
|
107
|
+
)
|
|
108
|
+
if result.returncode != 0:
|
|
109
|
+
return None
|
|
110
|
+
return Path(result.stdout.strip()).resolve()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def list_branches() -> list[str]:
|
|
114
|
+
"""
|
|
115
|
+
Return a list of all local branch names.
|
|
116
|
+
"""
|
|
117
|
+
result = subprocess.run(
|
|
118
|
+
["git", "branch", "--format=%(refname:short)"],
|
|
119
|
+
capture_output=True,
|
|
120
|
+
text=True,
|
|
121
|
+
timeout=10,
|
|
122
|
+
)
|
|
123
|
+
if result.returncode != 0:
|
|
124
|
+
return []
|
|
125
|
+
return [b for b in result.stdout.strip().splitlines() if b]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def install_hook(git_dir: Path) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Install the post-checkout hook.
|
|
131
|
+
"""
|
|
132
|
+
hooks_dir = git_dir / "hooks"
|
|
133
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
git_db_executable = _resolve_git_db_executable()
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
_write_managed_hook(
|
|
138
|
+
hooks_dir,
|
|
139
|
+
"post-checkout",
|
|
140
|
+
render_hook_script(git_db_executable),
|
|
141
|
+
)
|
|
142
|
+
except OSError as e:
|
|
143
|
+
raise HookError(f"Failed to install hook: {e}") from e
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def remove_hook(git_dir: Path) -> None:
|
|
147
|
+
"""
|
|
148
|
+
Remove the git-db post-checkout hook.
|
|
149
|
+
"""
|
|
150
|
+
hooks_dir = git_dir / "hooks"
|
|
151
|
+
hook_path = hooks_dir / "post-checkout"
|
|
152
|
+
legacy_path = hooks_dir / "post-checkout.legacy"
|
|
153
|
+
|
|
154
|
+
if not hook_path.exists() or HOOK_IDENTIFIER not in hook_path.read_text():
|
|
155
|
+
raise HookError("No git-db hook found in .git/hooks/post-checkout.")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
hook_path.unlink()
|
|
159
|
+
if legacy_path.exists():
|
|
160
|
+
legacy_path.rename(hook_path)
|
|
161
|
+
console.print("Restored original post-checkout hook")
|
|
162
|
+
except OSError as e:
|
|
163
|
+
raise HookError(f"Failed to remove hook: {e}") from e
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def handle_post_checkout(
|
|
167
|
+
prev_head: str,
|
|
168
|
+
is_branch: str,
|
|
169
|
+
config: GitDbConfig,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Handle a post-checkout event. Called by the _hook-dispatch CLI command.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
if is_branch != "1":
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
git_dir = get_git_dir()
|
|
179
|
+
if git_dir is None:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
if is_null_ref(prev_head):
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
if is_rebase_in_progress(git_dir):
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
if is_detached_head():
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
prev_branch = get_previous_branch()
|
|
192
|
+
curr_branch = get_current_branch()
|
|
193
|
+
|
|
194
|
+
if prev_branch and curr_branch and prev_branch == curr_branch:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
if config.mode == "per-branch":
|
|
198
|
+
_handle_per_branch_checkout(config, git_dir, prev_branch, curr_branch)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
backend = get_backend(config.database_url)
|
|
202
|
+
|
|
203
|
+
if prev_branch:
|
|
204
|
+
_try_save(backend, config, prev_branch)
|
|
205
|
+
|
|
206
|
+
if curr_branch:
|
|
207
|
+
if has_snapshot(config.snapshot_dir, curr_branch):
|
|
208
|
+
_try_restore(backend, config, curr_branch)
|
|
209
|
+
else:
|
|
210
|
+
console.print(
|
|
211
|
+
f"[dim]No snapshot for '{curr_branch}': database unchanged[/]"
|
|
212
|
+
)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
console.print(f"[yellow]git-db warning:[/] {e}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _try_save(backend: DatabaseBackend, config: GitDbConfig, branch: str) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Attempt to save a snapshot.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
strategy = backend.detect_strategy(config)
|
|
223
|
+
strategy.save(config.database_url, branch, config.snapshot_dir, config)
|
|
224
|
+
console.print(f"[green]Saved[/] ({strategy.name}): {branch}")
|
|
225
|
+
except Exception as e:
|
|
226
|
+
console.print(f"[yellow]Warning:[/] Could not save '{branch}': {e}")
|
|
227
|
+
_print_superuser_hint(e, config.database_url)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _try_restore(backend: DatabaseBackend, config: GitDbConfig, branch: str) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Attempt to restore a snapshot.
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
strategy = backend.detect_strategy(config)
|
|
236
|
+
strategy.restore(config.database_url, branch, config.snapshot_dir, config)
|
|
237
|
+
console.print(f"[green]Restored[/] database for '{branch}'")
|
|
238
|
+
except Exception as e:
|
|
239
|
+
console.print(f"[yellow]Warning:[/] Could not restore '{branch}': {e}")
|
|
240
|
+
_print_superuser_hint(e, config.database_url)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _print_superuser_hint(error: Exception, db_url: str) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Print actionable hints for common permission errors.
|
|
246
|
+
"""
|
|
247
|
+
msg = str(error)
|
|
248
|
+
if "superuser" not in msg or "terminate" not in msg:
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
params = parse_database_url(db_url)
|
|
252
|
+
username = params.get("user") or "your_user"
|
|
253
|
+
dbname = params.get("dbname") or "your_db"
|
|
254
|
+
|
|
255
|
+
console.print(
|
|
256
|
+
" [dim]A superuser connection (e.g., pgAdmin) is blocking this operation.[/]\n"
|
|
257
|
+
" [dim]Fix options:[/]\n"
|
|
258
|
+
f" [dim] 1. Terminate other sessions:[/]\n"
|
|
259
|
+
f' [dim] psql -c "SELECT pg_terminate_backend(pid) '
|
|
260
|
+
f"FROM pg_stat_activity WHERE datname = '{dbname}' "
|
|
261
|
+
f'AND pid <> pg_backend_pid();"[/]\n'
|
|
262
|
+
f" [dim] 2. Grant terminate privilege (permanent fix):[/]\n"
|
|
263
|
+
f' [dim] psql -c "GRANT pg_signal_backend TO {username};"[/]'
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _handle_per_branch_checkout(
|
|
268
|
+
config: GitDbConfig,
|
|
269
|
+
git_dir: Path,
|
|
270
|
+
prev_branch: str | None,
|
|
271
|
+
curr_branch: str | None,
|
|
272
|
+
) -> None:
|
|
273
|
+
"""
|
|
274
|
+
Handle post-checkout in per-branch mode.
|
|
275
|
+
"""
|
|
276
|
+
if not curr_branch:
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
backend = get_backend(config.database_url)
|
|
280
|
+
params = backend.apply_url_defaults(parse_database_url(config.database_url))
|
|
281
|
+
dbname = str(params["dbname"])
|
|
282
|
+
manager = backend.branch_db_manager(config)
|
|
283
|
+
|
|
284
|
+
target_db = branch_db_name(
|
|
285
|
+
curr_branch,
|
|
286
|
+
dbname,
|
|
287
|
+
config.default_branch,
|
|
288
|
+
backend.max_identifier_length,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Default branch uses the seed DB directly
|
|
292
|
+
if curr_branch == config.default_branch:
|
|
293
|
+
console.print(f"[dim]Branch database:[/] {target_db}")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
# Check if branch DB already exists
|
|
297
|
+
entry = get_branch_db(git_dir, curr_branch)
|
|
298
|
+
if entry and manager.exists(entry.db_name):
|
|
299
|
+
console.print(f"[dim]Branch database:[/] {target_db}")
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
source_db = dbname
|
|
303
|
+
created_from = config.default_branch
|
|
304
|
+
|
|
305
|
+
if prev_branch:
|
|
306
|
+
prev_db = branch_db_name(
|
|
307
|
+
prev_branch,
|
|
308
|
+
dbname,
|
|
309
|
+
config.default_branch,
|
|
310
|
+
backend.max_identifier_length,
|
|
311
|
+
)
|
|
312
|
+
if manager.exists(prev_db):
|
|
313
|
+
source_db = prev_db
|
|
314
|
+
created_from = prev_branch
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
manager.create(target_db, source_db, curr_branch, created_from, git_dir)
|
|
318
|
+
console.print(f"[green]Created database:[/] {target_db}")
|
|
319
|
+
except Exception as e:
|
|
320
|
+
console.print(f"[yellow]git-db warning:[/] Could not create '{target_db}': {e}")
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
console.print(f"[dim]Branch database:[/] {target_db}")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _resolve_git_db_executable() -> str:
|
|
327
|
+
"""
|
|
328
|
+
Return the executable path the hook should call.
|
|
329
|
+
"""
|
|
330
|
+
current = Path(sys.argv[0])
|
|
331
|
+
if current.name == "git-db" and current.exists():
|
|
332
|
+
return str(current.resolve())
|
|
333
|
+
found = shutil.which("git-db")
|
|
334
|
+
if found:
|
|
335
|
+
return str(Path(found).resolve())
|
|
336
|
+
return ""
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _write_managed_hook(hooks_dir: Path, hook_name: str, content: str) -> None:
|
|
340
|
+
hook_path = hooks_dir / hook_name
|
|
341
|
+
if hook_path.exists():
|
|
342
|
+
existing = hook_path.read_text()
|
|
343
|
+
if HOOK_IDENTIFIER not in existing:
|
|
344
|
+
legacy_path = hooks_dir / f"{hook_name}.legacy"
|
|
345
|
+
hook_path.rename(legacy_path)
|
|
346
|
+
console.print(f"Existing {hook_name} hook preserved as {hook_name}.legacy")
|
|
347
|
+
|
|
348
|
+
hook_path.write_text(content)
|
|
349
|
+
os.chmod(hook_path, 0o755)
|
git_db/hook_script.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
HOOK_IDENTIFIER = "git-db-hook-v1"
|
|
2
|
+
|
|
3
|
+
_HOOK_TEMPLATE = """\
|
|
4
|
+
#!/bin/sh
|
|
5
|
+
# git-db post-checkout hook: do not edit (managed by git-db)
|
|
6
|
+
# Identifier: {identifier}
|
|
7
|
+
|
|
8
|
+
PREV_HEAD="$1"
|
|
9
|
+
NEW_HEAD="$2"
|
|
10
|
+
IS_BRANCH_CHECKOUT="$3"
|
|
11
|
+
|
|
12
|
+
# Skip file checkouts immediately (no Python startup cost)
|
|
13
|
+
[ "$IS_BRANCH_CHECKOUT" = "0" ] && exit 0
|
|
14
|
+
|
|
15
|
+
GIT_DIR="$(git rev-parse --git-dir)"
|
|
16
|
+
|
|
17
|
+
# Skip if git-db is disabled (flag file or env var)
|
|
18
|
+
[ "$GIT_DB_SKIP" = "1" ] && exit 0
|
|
19
|
+
[ -f "$GIT_DIR/git-db/disabled" ] && exit 0
|
|
20
|
+
|
|
21
|
+
# Run legacy hook if it exists
|
|
22
|
+
if [ -f "$GIT_DIR/hooks/post-checkout.legacy" ]; then
|
|
23
|
+
"$GIT_DIR/hooks/post-checkout.legacy" "$@"
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
# Call git-db. Prefer the executable resolved when the hook was installed so
|
|
27
|
+
# project-local venv installs keep working in Git's sparse hook environment.
|
|
28
|
+
GIT_DB_BIN="{git_db_executable}"
|
|
29
|
+
if [ -n "$GIT_DB_BIN" ] && [ -x "$GIT_DB_BIN" ]; then
|
|
30
|
+
"$GIT_DB_BIN" _hook-dispatch "$PREV_HEAD" "$NEW_HEAD" "$IS_BRANCH_CHECKOUT"
|
|
31
|
+
elif command -v git-db >/dev/null 2>&1; then
|
|
32
|
+
git-db _hook-dispatch "$PREV_HEAD" "$NEW_HEAD" "$IS_BRANCH_CHECKOUT"
|
|
33
|
+
else
|
|
34
|
+
echo "git-db warning: executable not found; run 'git-db hook install'" >&2
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# NEVER block git checkout
|
|
38
|
+
exit 0
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render_hook_script(git_db_executable: str = "") -> str:
|
|
43
|
+
"""
|
|
44
|
+
Return the complete post-checkout hook script.
|
|
45
|
+
"""
|
|
46
|
+
return _HOOK_TEMPLATE.format(
|
|
47
|
+
identifier=HOOK_IDENTIFIER,
|
|
48
|
+
git_db_executable=git_db_executable,
|
|
49
|
+
)
|
git_db/state.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class BranchDbEntry:
|
|
11
|
+
"""
|
|
12
|
+
Record of a per-branch database created by git-db.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
db_name: str
|
|
16
|
+
created_at: str
|
|
17
|
+
created_from: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class GitDbState:
|
|
22
|
+
"""
|
|
23
|
+
Persistent state tracked in .git/git-db/state.json.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
mode: str = "per-branch"
|
|
27
|
+
databases: dict[str, BranchDbEntry] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_STATE_DIR = "git-db"
|
|
31
|
+
_STATE_FILE = "state.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _state_path(git_dir: Path) -> Path:
|
|
35
|
+
return git_dir / _STATE_DIR / _STATE_FILE
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_state(git_dir: Path) -> GitDbState:
|
|
39
|
+
"""
|
|
40
|
+
Read state from .git/git-db/state.json.
|
|
41
|
+
"""
|
|
42
|
+
path = _state_path(git_dir)
|
|
43
|
+
if not path.exists():
|
|
44
|
+
return GitDbState()
|
|
45
|
+
try:
|
|
46
|
+
data = json.loads(path.read_text())
|
|
47
|
+
databases: dict[str, BranchDbEntry] = {}
|
|
48
|
+
for branch, entry in data.get("databases", {}).items():
|
|
49
|
+
databases[branch] = BranchDbEntry(**entry)
|
|
50
|
+
return GitDbState(
|
|
51
|
+
mode=data.get("mode", "per-branch"),
|
|
52
|
+
databases=databases,
|
|
53
|
+
)
|
|
54
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
55
|
+
return GitDbState()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_state(git_dir: Path, state: GitDbState) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Write state to .git/git-db/state.json.
|
|
61
|
+
"""
|
|
62
|
+
path = _state_path(git_dir)
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
data = {
|
|
65
|
+
"mode": state.mode,
|
|
66
|
+
"databases": {
|
|
67
|
+
branch: asdict(entry) for branch, entry in state.databases.items()
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def record_branch_db(
|
|
74
|
+
git_dir: Path,
|
|
75
|
+
branch: str,
|
|
76
|
+
db_name: str,
|
|
77
|
+
created_from: str,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Add or update a branch database entry in the state file.
|
|
81
|
+
"""
|
|
82
|
+
state = load_state(git_dir)
|
|
83
|
+
state.databases[branch] = BranchDbEntry(
|
|
84
|
+
db_name=db_name,
|
|
85
|
+
created_at=datetime.now(UTC).isoformat(),
|
|
86
|
+
created_from=created_from,
|
|
87
|
+
)
|
|
88
|
+
save_state(git_dir, state)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def remove_branch_db(git_dir: Path, branch: str) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Remove a branch database entry from the state file.
|
|
94
|
+
"""
|
|
95
|
+
state = load_state(git_dir)
|
|
96
|
+
state.databases.pop(branch, None)
|
|
97
|
+
save_state(git_dir, state)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_branch_db(git_dir: Path, branch: str) -> BranchDbEntry | None:
|
|
101
|
+
"""
|
|
102
|
+
Look up a branch database entry. Returns None if not found.
|
|
103
|
+
"""
|
|
104
|
+
state = load_state(git_dir)
|
|
105
|
+
return state.databases.get(branch)
|
git_db/storage.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from git_db import __version__
|
|
11
|
+
|
|
12
|
+
_DEFAULT_MAX_IDENTIFIER = 63
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SnapshotMetadata:
|
|
17
|
+
"""
|
|
18
|
+
Metadata sidecar for a database snapshot.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
branch: str
|
|
22
|
+
database: str
|
|
23
|
+
strategy: str
|
|
24
|
+
created_at: str
|
|
25
|
+
engine: str
|
|
26
|
+
engine_version: str
|
|
27
|
+
git_db_version: str
|
|
28
|
+
file_size_bytes: int | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sanitize_branch_name(branch: str, max_length: int = _DEFAULT_MAX_IDENTIFIER) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Sanitize a git branch name for use as a DB identifier or filename.
|
|
34
|
+
"""
|
|
35
|
+
sanitized = branch.replace("/", "__")
|
|
36
|
+
sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", sanitized)
|
|
37
|
+
sanitized = sanitized.strip("_") or "unnamed"
|
|
38
|
+
return sanitized[:max_length]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def branch_db_name(
|
|
42
|
+
branch: str,
|
|
43
|
+
dbname: str,
|
|
44
|
+
default_branch: str,
|
|
45
|
+
max_length: int = _DEFAULT_MAX_IDENTIFIER,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Build the per-branch database name.
|
|
49
|
+
"""
|
|
50
|
+
if branch == default_branch:
|
|
51
|
+
return dbname
|
|
52
|
+
|
|
53
|
+
sanitized = sanitize_branch_name(branch, max_length)
|
|
54
|
+
name = f"{dbname}__{sanitized}"
|
|
55
|
+
if len(name) <= max_length:
|
|
56
|
+
return name
|
|
57
|
+
|
|
58
|
+
branch_hash = hashlib.sha256(branch.encode("utf-8")).hexdigest()[:8]
|
|
59
|
+
suffix = f"__h{branch_hash}"
|
|
60
|
+
max_prefix_len = max_length - len(suffix)
|
|
61
|
+
if max_prefix_len <= 0:
|
|
62
|
+
return f"h{branch_hash}"[:max_length]
|
|
63
|
+
return dbname[:max_prefix_len] + suffix
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def snapshot_db_name(
|
|
67
|
+
branch: str,
|
|
68
|
+
dbname: str,
|
|
69
|
+
max_length: int = _DEFAULT_MAX_IDENTIFIER,
|
|
70
|
+
) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Build the snapshot database name: _gitdb_{dbname}_{sanitized_branch}.
|
|
73
|
+
"""
|
|
74
|
+
sanitized = sanitize_branch_name(branch, max_length)
|
|
75
|
+
name = f"_gitdb_{dbname}_{sanitized}"
|
|
76
|
+
if len(name) <= max_length:
|
|
77
|
+
return name
|
|
78
|
+
|
|
79
|
+
branch_hash = hashlib.sha256(branch.encode("utf-8")).hexdigest()[:8]
|
|
80
|
+
suffix = f"_h{branch_hash}"
|
|
81
|
+
prefix = f"_gitdb_{dbname}"
|
|
82
|
+
max_prefix_len = max_length - len(suffix)
|
|
83
|
+
if max_prefix_len <= 0:
|
|
84
|
+
return f"_gitdb_h{branch_hash}"[:max_length]
|
|
85
|
+
return prefix[:max_prefix_len] + suffix
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def snapshot_dump_path(snapshot_dir: Path, branch: str) -> Path:
|
|
89
|
+
"""
|
|
90
|
+
Return the path to the dump file for a branch.
|
|
91
|
+
"""
|
|
92
|
+
return snapshot_dir / f"{sanitize_branch_name(branch)}.dump"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def metadata_path(snapshot_dir: Path, branch: str) -> Path:
|
|
96
|
+
"""
|
|
97
|
+
Return the path to the metadata JSON file for a branch.
|
|
98
|
+
"""
|
|
99
|
+
return snapshot_dir / f"{sanitize_branch_name(branch)}.meta.json"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def ensure_snapshot_dir(snapshot_dir: Path) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Create the snapshot directory if it doesn't exist.
|
|
105
|
+
"""
|
|
106
|
+
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def write_metadata(snapshot_dir: Path, metadata: SnapshotMetadata) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Write snapshot metadata to a JSON sidecar file.
|
|
112
|
+
"""
|
|
113
|
+
ensure_snapshot_dir(snapshot_dir)
|
|
114
|
+
path = metadata_path(snapshot_dir, metadata.branch)
|
|
115
|
+
path.write_text(json.dumps(asdict(metadata), indent=2) + "\n")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_metadata(snapshot_dir: Path, branch: str) -> SnapshotMetadata | None:
|
|
119
|
+
"""
|
|
120
|
+
Read snapshot metadata from a JSON sidecar file.
|
|
121
|
+
"""
|
|
122
|
+
path = metadata_path(snapshot_dir, branch)
|
|
123
|
+
if not path.exists():
|
|
124
|
+
return None
|
|
125
|
+
try:
|
|
126
|
+
data = json.loads(path.read_text())
|
|
127
|
+
return SnapshotMetadata(**data)
|
|
128
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def list_snapshots(snapshot_dir: Path) -> list[SnapshotMetadata]:
|
|
133
|
+
"""
|
|
134
|
+
Read all snapshot metadata files in the snapshot directory.
|
|
135
|
+
"""
|
|
136
|
+
if not snapshot_dir.exists():
|
|
137
|
+
return []
|
|
138
|
+
snapshots = []
|
|
139
|
+
for path in sorted(snapshot_dir.glob("*.meta.json")):
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(path.read_text())
|
|
142
|
+
snapshots.append(SnapshotMetadata(**data))
|
|
143
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
144
|
+
continue
|
|
145
|
+
return snapshots
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def has_snapshot(snapshot_dir: Path, branch: str) -> bool:
|
|
149
|
+
"""
|
|
150
|
+
Check whether a snapshot exists for the given branch.
|
|
151
|
+
"""
|
|
152
|
+
return metadata_path(snapshot_dir, branch).exists()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def make_metadata(
|
|
156
|
+
branch: str,
|
|
157
|
+
database: str,
|
|
158
|
+
strategy: str,
|
|
159
|
+
engine: str,
|
|
160
|
+
engine_version: str,
|
|
161
|
+
file_size_bytes: int | None = None,
|
|
162
|
+
) -> SnapshotMetadata:
|
|
163
|
+
"""
|
|
164
|
+
Create a SnapshotMetadata with current timestamp and version.
|
|
165
|
+
"""
|
|
166
|
+
return SnapshotMetadata(
|
|
167
|
+
branch=branch,
|
|
168
|
+
database=database,
|
|
169
|
+
strategy=strategy,
|
|
170
|
+
created_at=datetime.now(UTC).isoformat(),
|
|
171
|
+
engine=engine,
|
|
172
|
+
engine_version=engine_version,
|
|
173
|
+
git_db_version=__version__,
|
|
174
|
+
file_size_bytes=file_size_bytes,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def identify_stale_snapshots(
|
|
179
|
+
snapshot_dir: Path,
|
|
180
|
+
max_snapshots: int,
|
|
181
|
+
existing_branches: list[str],
|
|
182
|
+
) -> list[SnapshotMetadata]:
|
|
183
|
+
"""
|
|
184
|
+
Identify snapshots that should be pruned.
|
|
185
|
+
"""
|
|
186
|
+
snapshots = list_snapshots(snapshot_dir)
|
|
187
|
+
if not snapshots:
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
result: list[SnapshotMetadata] = []
|
|
191
|
+
|
|
192
|
+
# Phase 1: snapshots for branches that no longer exist
|
|
193
|
+
stale = [s for s in snapshots if s.branch not in existing_branches]
|
|
194
|
+
result.extend(stale)
|
|
195
|
+
|
|
196
|
+
# Phase 2: enforce max count on remaining snapshots
|
|
197
|
+
stale_branches = {s.branch for s in stale}
|
|
198
|
+
remaining = [s for s in snapshots if s.branch not in stale_branches]
|
|
199
|
+
if len(remaining) > max_snapshots:
|
|
200
|
+
remaining.sort(key=lambda s: s.created_at)
|
|
201
|
+
result.extend(remaining[: len(remaining) - max_snapshots])
|
|
202
|
+
|
|
203
|
+
return result
|