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.
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