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/cli/init.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
from git_db.backends import get_backend
|
|
11
|
+
from git_db.backends.postgresql.backend import PgPermissions
|
|
12
|
+
from git_db.config import (
|
|
13
|
+
ensure_config_ignored,
|
|
14
|
+
find_project_root,
|
|
15
|
+
load_config,
|
|
16
|
+
load_dotfile_config,
|
|
17
|
+
write_config,
|
|
18
|
+
)
|
|
19
|
+
from git_db.db import parse_database_url
|
|
20
|
+
from git_db.errors import DatabaseError, GitDbError
|
|
21
|
+
from git_db.git import get_git_dir, install_hook
|
|
22
|
+
from git_db.storage import ensure_snapshot_dir
|
|
23
|
+
|
|
24
|
+
from ._common import debug_enabled
|
|
25
|
+
from ._console import app, console
|
|
26
|
+
from ._prompts import (
|
|
27
|
+
detect_default_branch,
|
|
28
|
+
resolve_choice,
|
|
29
|
+
resolve_with_prompt,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ModeChoice(StrEnum):
|
|
34
|
+
shared = "shared"
|
|
35
|
+
per_branch = "per-branch"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StrategyChoice(StrEnum):
|
|
39
|
+
template = "template"
|
|
40
|
+
pgdump = "pgdump"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConnectionPolicyChoice(StrEnum):
|
|
44
|
+
terminate = "terminate"
|
|
45
|
+
fail = "fail"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command()
|
|
49
|
+
def init(
|
|
50
|
+
database_url: Annotated[
|
|
51
|
+
str | None, typer.Option("--database-url", envvar="DATABASE_URL")
|
|
52
|
+
] = None,
|
|
53
|
+
mode: Annotated[ModeChoice | None, typer.Option("--mode")] = None,
|
|
54
|
+
strategy: Annotated[StrategyChoice | None, typer.Option("--strategy")] = None,
|
|
55
|
+
on_active_connections: Annotated[
|
|
56
|
+
ConnectionPolicyChoice | None, typer.Option("--on-active-connections")
|
|
57
|
+
] = None,
|
|
58
|
+
no_hook: Annotated[bool, typer.Option("--no-hook")] = False,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Initialize git-db in the current repo.
|
|
62
|
+
"""
|
|
63
|
+
git_dir = get_git_dir()
|
|
64
|
+
if git_dir is None:
|
|
65
|
+
console.print(
|
|
66
|
+
"[red]Error:[/] Not inside a git repository. "
|
|
67
|
+
"Run this from a git project root."
|
|
68
|
+
)
|
|
69
|
+
raise typer.Exit(1)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
project_root = find_project_root()
|
|
73
|
+
if project_root is None:
|
|
74
|
+
console.print("[red]Error:[/] Could not find project root.")
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|
|
77
|
+
existing_config = load_dotfile_config(project_root)
|
|
78
|
+
is_reinit = (project_root / ".git-db.toml").exists()
|
|
79
|
+
if is_reinit:
|
|
80
|
+
console.print("[dim]Updating existing configuration.[/]\n")
|
|
81
|
+
|
|
82
|
+
resolved_url = resolve_with_prompt(
|
|
83
|
+
flag_value=database_url,
|
|
84
|
+
existing=existing_config.get("database_url"),
|
|
85
|
+
prompt_text="Database URL",
|
|
86
|
+
required=True,
|
|
87
|
+
)
|
|
88
|
+
if not resolved_url:
|
|
89
|
+
console.print(
|
|
90
|
+
"[red]Error:[/] No database URL configured. "
|
|
91
|
+
"Pass --database-url or set DATABASE_URL."
|
|
92
|
+
)
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
|
|
95
|
+
backend = get_backend(resolved_url)
|
|
96
|
+
params = backend.apply_url_defaults(parse_database_url(resolved_url))
|
|
97
|
+
permissions: PgPermissions | None = None
|
|
98
|
+
version: int | None = None
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
version = backend.get_engine_version(resolved_url)
|
|
102
|
+
except DatabaseError as e:
|
|
103
|
+
console.print(
|
|
104
|
+
f"[red]Error:[/] Could not connect to database: {e}\n"
|
|
105
|
+
" Fix the database URL or database server, then rerun "
|
|
106
|
+
"[cyan]git-db init[/].\n"
|
|
107
|
+
)
|
|
108
|
+
raise typer.Exit(1) from e
|
|
109
|
+
|
|
110
|
+
console.print(
|
|
111
|
+
f" Detected: [cyan]PostgreSQL {version}[/] "
|
|
112
|
+
f"at {params['host']}:{params['port']}"
|
|
113
|
+
)
|
|
114
|
+
console.print(f" Database: [cyan]{params['dbname']}[/]")
|
|
115
|
+
console.print(f" User: [cyan]{params['user']}[/]")
|
|
116
|
+
|
|
117
|
+
result = backend.check_permissions(resolved_url)
|
|
118
|
+
assert isinstance(result, PgPermissions)
|
|
119
|
+
permissions = result
|
|
120
|
+
console.print("\n Checking permissions...")
|
|
121
|
+
console.print(
|
|
122
|
+
f" CREATEDB: "
|
|
123
|
+
f"{'[green]Yes[/]' if permissions.can_createdb else '[red]No[/]'}"
|
|
124
|
+
)
|
|
125
|
+
console.print(
|
|
126
|
+
f" Superuser: "
|
|
127
|
+
f"{'[green]Yes[/]' if permissions.is_superuser else '[red]No[/]'}"
|
|
128
|
+
)
|
|
129
|
+
sig_label = (
|
|
130
|
+
"[green]Yes[/]" if permissions.has_pg_signal_backend else "[red]No[/]"
|
|
131
|
+
)
|
|
132
|
+
console.print(f" pg_signal_backend: {sig_label}")
|
|
133
|
+
console.print()
|
|
134
|
+
|
|
135
|
+
resolved_mode = resolve_choice(
|
|
136
|
+
flag_value=mode.value if mode is not None else None,
|
|
137
|
+
existing=existing_config.get("mode"),
|
|
138
|
+
prompt_text="How should git-db manage database state across branches?",
|
|
139
|
+
choices={"1": "shared", "2": "per-branch"},
|
|
140
|
+
labels={
|
|
141
|
+
"1": "Single database: snapshot/restore on switch",
|
|
142
|
+
"2": "Per-branch databases: each branch gets its own DB",
|
|
143
|
+
},
|
|
144
|
+
default="1",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
resolved_default_branch = existing_config.get("default_branch", "")
|
|
148
|
+
if resolved_mode == "per-branch" and not resolved_default_branch:
|
|
149
|
+
resolved_default_branch = detect_default_branch()
|
|
150
|
+
|
|
151
|
+
strategy_labels = {
|
|
152
|
+
"1": "template: fast, uses CREATE DATABASE ... TEMPLATE",
|
|
153
|
+
"2": "pgdump: uses pg_dump/pg_restore",
|
|
154
|
+
}
|
|
155
|
+
strategy_default = "1"
|
|
156
|
+
|
|
157
|
+
if permissions and not permissions.can_createdb:
|
|
158
|
+
strategy_labels["1"] += " [red](requires CREATEDB)[/]"
|
|
159
|
+
|
|
160
|
+
resolved_strategy = resolve_choice(
|
|
161
|
+
flag_value=strategy.value if strategy is not None else None,
|
|
162
|
+
existing=existing_config.get("strategy"),
|
|
163
|
+
prompt_text="Snapshot strategy",
|
|
164
|
+
choices={"1": "template", "2": "pgdump"},
|
|
165
|
+
labels=strategy_labels,
|
|
166
|
+
default=strategy_default,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if (
|
|
170
|
+
resolved_strategy == "template"
|
|
171
|
+
and permissions
|
|
172
|
+
and not permissions.can_createdb
|
|
173
|
+
):
|
|
174
|
+
console.print(
|
|
175
|
+
"[yellow]Warning:[/] Your user lacks CREATEDB privilege. "
|
|
176
|
+
"Template strategy will fail.\n"
|
|
177
|
+
" Grant it with: ALTER ROLE {user} CREATEDB;\n"
|
|
178
|
+
" Or switch to pgdump strategy.\n"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
policy_labels = {
|
|
182
|
+
"1": "terminate: kill connections and proceed",
|
|
183
|
+
"2": "fail: stop with an error",
|
|
184
|
+
}
|
|
185
|
+
resolved_policy = resolve_choice(
|
|
186
|
+
flag_value=(
|
|
187
|
+
on_active_connections.value
|
|
188
|
+
if on_active_connections is not None
|
|
189
|
+
else None
|
|
190
|
+
),
|
|
191
|
+
existing=existing_config.get("on_active_connections"),
|
|
192
|
+
prompt_text="When active connections prevent database operations",
|
|
193
|
+
choices={"1": "terminate", "2": "fail"},
|
|
194
|
+
labels=policy_labels,
|
|
195
|
+
default="1",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if (
|
|
199
|
+
resolved_policy == "terminate"
|
|
200
|
+
and permissions
|
|
201
|
+
and not permissions.is_superuser
|
|
202
|
+
and not permissions.has_pg_signal_backend
|
|
203
|
+
):
|
|
204
|
+
console.print(
|
|
205
|
+
"[yellow]Warning:[/] Your user lacks superuser and "
|
|
206
|
+
"pg_signal_backend privileges.\n"
|
|
207
|
+
" Terminate may fail silently on connections owned "
|
|
208
|
+
"by other users.\n"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
install_hook_flag = not no_hook
|
|
212
|
+
if not no_hook and mode is None and sys.stdin.isatty():
|
|
213
|
+
install_hook_flag = typer.confirm(
|
|
214
|
+
"Install git post-checkout hook?", default=True
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
config_updates: dict[str, object] = {
|
|
218
|
+
"database_url": resolved_url,
|
|
219
|
+
"mode": resolved_mode,
|
|
220
|
+
"strategy": resolved_strategy,
|
|
221
|
+
"on_active_connections": resolved_policy,
|
|
222
|
+
}
|
|
223
|
+
if resolved_mode == "per-branch":
|
|
224
|
+
config_updates["default_branch"] = resolved_default_branch
|
|
225
|
+
|
|
226
|
+
write_config(project_root, config_updates)
|
|
227
|
+
console.print("[dim]Configuration saved to .git-db.toml[/]\n")
|
|
228
|
+
if ensure_config_ignored(project_root):
|
|
229
|
+
console.print("[dim]Added .git-db.toml to .gitignore[/]\n")
|
|
230
|
+
|
|
231
|
+
hook_status = "[dim]skipped[/]"
|
|
232
|
+
if install_hook_flag:
|
|
233
|
+
install_hook(git_dir)
|
|
234
|
+
ensure_snapshot_dir(
|
|
235
|
+
load_config(
|
|
236
|
+
cli_overrides={"database_url": resolved_url},
|
|
237
|
+
project_root=project_root,
|
|
238
|
+
).snapshot_dir
|
|
239
|
+
)
|
|
240
|
+
hook_status = "[green]installed[/]"
|
|
241
|
+
|
|
242
|
+
summary = (
|
|
243
|
+
f" Engine: [cyan]{backend.engine}[/]\n"
|
|
244
|
+
f" Version: [cyan]{version or 'unknown'}[/]\n"
|
|
245
|
+
f" Mode: [green]{resolved_mode}[/]\n"
|
|
246
|
+
f" Strategy: [green]{resolved_strategy}[/]\n"
|
|
247
|
+
f" Policy: [green]{resolved_policy}[/]\n"
|
|
248
|
+
f" Hook: {hook_status}"
|
|
249
|
+
)
|
|
250
|
+
if resolved_mode == "per-branch":
|
|
251
|
+
summary += f"\n Default: [cyan]{resolved_default_branch}[/]"
|
|
252
|
+
|
|
253
|
+
console.print(Panel(summary, title="git-db initialized", border_style="green"))
|
|
254
|
+
|
|
255
|
+
except GitDbError as e:
|
|
256
|
+
console.print(f"[red]Error:[/] {e}")
|
|
257
|
+
raise typer.Exit(1) from e
|
|
258
|
+
except typer.Exit:
|
|
259
|
+
raise
|
|
260
|
+
except Exception as e:
|
|
261
|
+
if debug_enabled():
|
|
262
|
+
raise
|
|
263
|
+
console.print(
|
|
264
|
+
f"[red]Error:[/] Unexpected error: {e}\n"
|
|
265
|
+
"[dim]Set GIT_DB_DEBUG=1 to see the full traceback.[/]"
|
|
266
|
+
)
|
|
267
|
+
raise typer.Exit(1) from e
|
git_db/cli/inspect.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from git_db.backends import DatabaseBackend, SnapshotStrategy, get_backend
|
|
10
|
+
from git_db.config import GitDbConfig, load_config
|
|
11
|
+
from git_db.db import parse_database_url
|
|
12
|
+
from git_db.errors import GitDbError
|
|
13
|
+
from git_db.git import get_current_branch, get_git_dir, list_branches
|
|
14
|
+
from git_db.state import load_state
|
|
15
|
+
from git_db.storage import (
|
|
16
|
+
branch_db_name,
|
|
17
|
+
has_snapshot,
|
|
18
|
+
identify_stale_snapshots,
|
|
19
|
+
list_snapshots,
|
|
20
|
+
snapshot_db_name,
|
|
21
|
+
snapshot_dump_path,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from ._common import check_enabled, debug_enabled, require_init
|
|
25
|
+
from ._console import app, console
|
|
26
|
+
from ._format import format_age, format_size, mask_url
|
|
27
|
+
from ._prompts import confirm_prune
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("list")
|
|
31
|
+
def list_cmd(
|
|
32
|
+
database_url: Annotated[
|
|
33
|
+
str | None, typer.Option("--database-url", envvar="DATABASE_URL")
|
|
34
|
+
] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Show all stored snapshots or branch databases.
|
|
38
|
+
"""
|
|
39
|
+
require_init()
|
|
40
|
+
try:
|
|
41
|
+
config = load_config(cli_overrides={"database_url": database_url})
|
|
42
|
+
except GitDbError as e:
|
|
43
|
+
console.print(f"[red]Error:[/] {e}")
|
|
44
|
+
raise typer.Exit(1) from e
|
|
45
|
+
|
|
46
|
+
if config.mode == "per-branch":
|
|
47
|
+
_list_per_branch(config)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
snapshots = list_snapshots(config.snapshot_dir)
|
|
51
|
+
if not snapshots:
|
|
52
|
+
console.print("No snapshots found.")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
table = Table(title="Snapshots")
|
|
56
|
+
table.add_column("Branch", style="cyan", no_wrap=True)
|
|
57
|
+
table.add_column("Strategy", style="green")
|
|
58
|
+
table.add_column("Size", justify="right", style="magenta")
|
|
59
|
+
table.add_column("Status", justify="center")
|
|
60
|
+
table.add_column("Age", justify="right")
|
|
61
|
+
table.add_column("Database", style="dim")
|
|
62
|
+
|
|
63
|
+
backend = get_backend(config.database_url)
|
|
64
|
+
|
|
65
|
+
for s in snapshots:
|
|
66
|
+
table.add_row(
|
|
67
|
+
s.branch,
|
|
68
|
+
s.strategy,
|
|
69
|
+
format_size(s.file_size_bytes),
|
|
70
|
+
_shared_snapshot_status(config, backend, s.branch, s.strategy),
|
|
71
|
+
format_age(s.created_at),
|
|
72
|
+
s.database,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
console.print(table)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def prune(
|
|
80
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
81
|
+
yes: Annotated[
|
|
82
|
+
bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")
|
|
83
|
+
] = False,
|
|
84
|
+
database_url: Annotated[
|
|
85
|
+
str | None, typer.Option("--database-url", envvar="DATABASE_URL")
|
|
86
|
+
] = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Remove snapshots or branch databases for deleted branches.
|
|
90
|
+
|
|
91
|
+
Snapshots are pruned in shared mode; branch databases in per-branch mode.
|
|
92
|
+
"""
|
|
93
|
+
require_init()
|
|
94
|
+
try:
|
|
95
|
+
config = load_config(cli_overrides={"database_url": database_url})
|
|
96
|
+
|
|
97
|
+
if config.mode == "per-branch":
|
|
98
|
+
_prune_per_branch(config, dry_run, yes)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
existing = list_branches()
|
|
102
|
+
stale = identify_stale_snapshots(
|
|
103
|
+
config.snapshot_dir,
|
|
104
|
+
config.max_snapshots,
|
|
105
|
+
existing,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if not stale:
|
|
109
|
+
console.print("Nothing to prune.")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if dry_run:
|
|
113
|
+
for meta in stale:
|
|
114
|
+
console.print(f" [dim]Would remove:[/] {meta.branch}")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
if not confirm_prune(
|
|
118
|
+
"The following snapshots will be removed:",
|
|
119
|
+
[(m.branch, None) for m in stale],
|
|
120
|
+
yes,
|
|
121
|
+
):
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
backend = get_backend(config.database_url)
|
|
125
|
+
strategy = backend.detect_strategy(config)
|
|
126
|
+
pruned = 0
|
|
127
|
+
|
|
128
|
+
for meta in stale:
|
|
129
|
+
try:
|
|
130
|
+
strategy.cleanup(meta.branch, config.snapshot_dir, config)
|
|
131
|
+
console.print(f" Pruned: {meta.branch}")
|
|
132
|
+
pruned += 1
|
|
133
|
+
except Exception as e:
|
|
134
|
+
console.print(f" [yellow]Failed to prune {meta.branch}:[/] {e}")
|
|
135
|
+
|
|
136
|
+
if pruned:
|
|
137
|
+
console.print(f"\nRemoved {pruned} snapshot(s).")
|
|
138
|
+
except GitDbError as e:
|
|
139
|
+
console.print(f"[red]Error:[/] {e}")
|
|
140
|
+
raise typer.Exit(1) from e
|
|
141
|
+
except typer.Exit:
|
|
142
|
+
raise
|
|
143
|
+
except Exception as e:
|
|
144
|
+
if debug_enabled():
|
|
145
|
+
raise
|
|
146
|
+
console.print(
|
|
147
|
+
f"[red]Error:[/] Unexpected error: {e}\n"
|
|
148
|
+
"[dim]Set GIT_DB_DEBUG=1 to see the full traceback.[/]"
|
|
149
|
+
)
|
|
150
|
+
raise typer.Exit(1) from e
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command()
|
|
154
|
+
def status(
|
|
155
|
+
database_url: Annotated[
|
|
156
|
+
str | None, typer.Option("--database-url", envvar="DATABASE_URL")
|
|
157
|
+
] = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Show current strategy, database, branch, and snapshot info.
|
|
161
|
+
"""
|
|
162
|
+
require_init()
|
|
163
|
+
try:
|
|
164
|
+
config = load_config(cli_overrides={"database_url": database_url})
|
|
165
|
+
|
|
166
|
+
current_branch = get_current_branch() or "(detached)"
|
|
167
|
+
backend = get_backend(config.database_url)
|
|
168
|
+
version = backend.get_engine_version(config.database_url)
|
|
169
|
+
detected = backend.detect_strategy(config)
|
|
170
|
+
|
|
171
|
+
if config.mode == "per-branch":
|
|
172
|
+
_status_per_branch(config, current_branch, backend, version, detected)
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
snapshots = list_snapshots(config.snapshot_dir)
|
|
176
|
+
current_status = _shared_current_status(config, backend, current_branch)
|
|
177
|
+
enabled_status = check_enabled()
|
|
178
|
+
summary = (
|
|
179
|
+
f" Branch: [cyan]{current_branch}[/]\n"
|
|
180
|
+
f" Database: {mask_url(config.database_url)}\n"
|
|
181
|
+
f" Engine: [cyan]{backend.engine} {version}[/]\n"
|
|
182
|
+
f" Strategy: [green]{detected.name}[/]\n"
|
|
183
|
+
f" Snapshots: {len(snapshots)}\n"
|
|
184
|
+
f" Current: {current_status}\n"
|
|
185
|
+
f" Enabled: {enabled_status}"
|
|
186
|
+
)
|
|
187
|
+
console.print(Panel(summary, title="git-db status", border_style="blue"))
|
|
188
|
+
except GitDbError as e:
|
|
189
|
+
console.print(f"[red]Error:[/] {e}")
|
|
190
|
+
raise typer.Exit(1) from e
|
|
191
|
+
except typer.Exit:
|
|
192
|
+
raise
|
|
193
|
+
except Exception as e:
|
|
194
|
+
if debug_enabled():
|
|
195
|
+
raise
|
|
196
|
+
console.print(
|
|
197
|
+
f"[red]Error:[/] Unexpected error: {e}\n"
|
|
198
|
+
"[dim]Set GIT_DB_DEBUG=1 to see the full traceback.[/]"
|
|
199
|
+
)
|
|
200
|
+
raise typer.Exit(1) from e
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _list_per_branch(config: GitDbConfig) -> None:
|
|
204
|
+
"""
|
|
205
|
+
List branch databases in per-branch mode.
|
|
206
|
+
"""
|
|
207
|
+
git_dir = get_git_dir()
|
|
208
|
+
if git_dir is None:
|
|
209
|
+
console.print("[red]Error:[/] Not inside a git repository.")
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
|
|
212
|
+
backend = get_backend(config.database_url)
|
|
213
|
+
manager = backend.branch_db_manager(config)
|
|
214
|
+
entries = manager.list(git_dir)
|
|
215
|
+
|
|
216
|
+
if not entries:
|
|
217
|
+
console.print("No branch databases found.")
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
table = Table(title="Branch Databases")
|
|
221
|
+
table.add_column("Branch", style="cyan", no_wrap=True)
|
|
222
|
+
table.add_column("Database", style="green")
|
|
223
|
+
table.add_column("Status", justify="center")
|
|
224
|
+
table.add_column("Created From", style="dim")
|
|
225
|
+
table.add_column("Age", justify="right")
|
|
226
|
+
|
|
227
|
+
for branch_name, entry, exists in entries:
|
|
228
|
+
status_str = "[green]exists[/]" if exists else "[red]missing[/]"
|
|
229
|
+
table.add_row(
|
|
230
|
+
branch_name,
|
|
231
|
+
entry.db_name,
|
|
232
|
+
status_str,
|
|
233
|
+
entry.created_from,
|
|
234
|
+
format_age(entry.created_at),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
params = backend.apply_url_defaults(parse_database_url(config.database_url))
|
|
238
|
+
seed_name = str(params["dbname"])
|
|
239
|
+
table.add_row(
|
|
240
|
+
f"{config.default_branch} (seed)",
|
|
241
|
+
seed_name,
|
|
242
|
+
"[green]seed[/]",
|
|
243
|
+
"n/a",
|
|
244
|
+
"n/a",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
console.print(table)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _prune_per_branch(config: GitDbConfig, dry_run: bool, yes: bool) -> None:
|
|
251
|
+
"""
|
|
252
|
+
Prune stale branch databases in per-branch mode.
|
|
253
|
+
"""
|
|
254
|
+
git_dir = get_git_dir()
|
|
255
|
+
if git_dir is None:
|
|
256
|
+
console.print("[red]Error:[/] Not inside a git repository.")
|
|
257
|
+
raise typer.Exit(1)
|
|
258
|
+
|
|
259
|
+
existing_branches = list_branches()
|
|
260
|
+
state = load_state(git_dir)
|
|
261
|
+
|
|
262
|
+
if not state.databases:
|
|
263
|
+
console.print("No branch databases to prune.")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
stale = [
|
|
267
|
+
(branch, entry)
|
|
268
|
+
for branch, entry in state.databases.items()
|
|
269
|
+
if branch not in existing_branches
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
if not stale:
|
|
273
|
+
console.print("Nothing to prune.")
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
if dry_run:
|
|
277
|
+
for branch_name, entry in stale:
|
|
278
|
+
console.print(f" [dim]Would drop:[/] {entry.db_name} ({branch_name})")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
if not confirm_prune(
|
|
282
|
+
"The following branch databases will be dropped:",
|
|
283
|
+
[
|
|
284
|
+
(entry.db_name, f"{branch_name}, created from {entry.created_from}")
|
|
285
|
+
for branch_name, entry in stale
|
|
286
|
+
],
|
|
287
|
+
yes,
|
|
288
|
+
):
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
backend = get_backend(config.database_url)
|
|
292
|
+
manager = backend.branch_db_manager(config)
|
|
293
|
+
|
|
294
|
+
pruned = 0
|
|
295
|
+
for branch_name, entry in stale:
|
|
296
|
+
try:
|
|
297
|
+
manager.drop(entry.db_name, branch_name, git_dir)
|
|
298
|
+
console.print(f" Dropped: {entry.db_name} ({branch_name})")
|
|
299
|
+
pruned += 1
|
|
300
|
+
except GitDbError as e:
|
|
301
|
+
console.print(f" [yellow]Failed to drop {entry.db_name}:[/] {e}")
|
|
302
|
+
|
|
303
|
+
if pruned:
|
|
304
|
+
console.print(f"\nDropped {pruned} database(s).")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _status_per_branch(
|
|
308
|
+
config: GitDbConfig,
|
|
309
|
+
current_branch: str,
|
|
310
|
+
backend: DatabaseBackend,
|
|
311
|
+
version: int,
|
|
312
|
+
detected: SnapshotStrategy,
|
|
313
|
+
) -> None:
|
|
314
|
+
"""
|
|
315
|
+
Show status in per-branch mode.
|
|
316
|
+
"""
|
|
317
|
+
git_dir = get_git_dir()
|
|
318
|
+
|
|
319
|
+
params = backend.apply_url_defaults(parse_database_url(config.database_url))
|
|
320
|
+
dbname = str(params["dbname"])
|
|
321
|
+
|
|
322
|
+
if current_branch != "(detached)":
|
|
323
|
+
current_db = branch_db_name(
|
|
324
|
+
current_branch,
|
|
325
|
+
dbname,
|
|
326
|
+
config.default_branch,
|
|
327
|
+
backend.max_identifier_length,
|
|
328
|
+
)
|
|
329
|
+
db_exists = backend.branch_db_manager(config).exists(current_db)
|
|
330
|
+
db_status = "[green]exists[/]" if db_exists else "[dim]not created[/]"
|
|
331
|
+
else:
|
|
332
|
+
current_db = "(detached)"
|
|
333
|
+
db_status = "[dim]N/A[/]"
|
|
334
|
+
|
|
335
|
+
total_dbs = 0
|
|
336
|
+
if git_dir:
|
|
337
|
+
state = load_state(git_dir)
|
|
338
|
+
total_dbs = len(state.databases)
|
|
339
|
+
|
|
340
|
+
count_warning = ""
|
|
341
|
+
if total_dbs > 20:
|
|
342
|
+
count_warning = " [yellow](consider running git-db prune)[/]"
|
|
343
|
+
|
|
344
|
+
enabled_status = check_enabled()
|
|
345
|
+
summary = (
|
|
346
|
+
f" Mode: [green]per-branch[/]\n"
|
|
347
|
+
f" Branch: [cyan]{current_branch}[/]\n"
|
|
348
|
+
f" Database: [cyan]{current_db}[/] {db_status}\n"
|
|
349
|
+
f" Seed: {dbname}\n"
|
|
350
|
+
f" Default: {config.default_branch}\n"
|
|
351
|
+
f" Engine: [cyan]{backend.engine} {version}[/]\n"
|
|
352
|
+
f" Strategy: [green]{detected.name}[/]\n"
|
|
353
|
+
f" Databases: {total_dbs}{count_warning}\n"
|
|
354
|
+
f" Enabled: {enabled_status}"
|
|
355
|
+
)
|
|
356
|
+
console.print(Panel(summary, title="git-db status", border_style="blue"))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _shared_snapshot_status(
|
|
360
|
+
config: GitDbConfig,
|
|
361
|
+
backend: DatabaseBackend,
|
|
362
|
+
branch: str,
|
|
363
|
+
strategy_name: str,
|
|
364
|
+
) -> str:
|
|
365
|
+
"""
|
|
366
|
+
Return whether shared-mode snapshot storage still exists.
|
|
367
|
+
"""
|
|
368
|
+
if strategy_name == "pgdump":
|
|
369
|
+
return (
|
|
370
|
+
"[green]exists[/]"
|
|
371
|
+
if snapshot_dump_path(config.snapshot_dir, branch).exists()
|
|
372
|
+
else "[red]missing[/]"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if strategy_name != "template":
|
|
376
|
+
return "[dim]unknown[/]"
|
|
377
|
+
|
|
378
|
+
params = backend.apply_url_defaults(parse_database_url(config.database_url))
|
|
379
|
+
dbname = str(params["dbname"])
|
|
380
|
+
name = snapshot_db_name(branch, dbname, backend.max_identifier_length)
|
|
381
|
+
try:
|
|
382
|
+
return (
|
|
383
|
+
"[green]exists[/]"
|
|
384
|
+
if backend.database_exists(config.database_url, name)
|
|
385
|
+
else "[red]missing[/]"
|
|
386
|
+
)
|
|
387
|
+
except Exception:
|
|
388
|
+
return "[yellow]unknown[/]"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _shared_current_status(
|
|
392
|
+
config: GitDbConfig,
|
|
393
|
+
backend: DatabaseBackend,
|
|
394
|
+
branch: str,
|
|
395
|
+
) -> str:
|
|
396
|
+
"""
|
|
397
|
+
Return whether the current branch has usable shared snapshot storage.
|
|
398
|
+
"""
|
|
399
|
+
if branch == "(detached)" or not has_snapshot(config.snapshot_dir, branch):
|
|
400
|
+
return "[dim]no snapshot[/]"
|
|
401
|
+
|
|
402
|
+
meta = next(
|
|
403
|
+
(s for s in list_snapshots(config.snapshot_dir) if s.branch == branch),
|
|
404
|
+
None,
|
|
405
|
+
)
|
|
406
|
+
if meta is None:
|
|
407
|
+
return "[dim]no snapshot[/]"
|
|
408
|
+
|
|
409
|
+
status = _shared_snapshot_status(config, backend, branch, meta.strategy)
|
|
410
|
+
if "exists" in status:
|
|
411
|
+
return "[green]yes[/]"
|
|
412
|
+
if "missing" in status:
|
|
413
|
+
return "[red]missing[/]"
|
|
414
|
+
return "[yellow]unknown[/]"
|