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
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import psycopg
|
|
7
|
+
from psycopg import sql
|
|
8
|
+
|
|
9
|
+
from git_db.backends import DatabaseBackend, DbConnection
|
|
10
|
+
from git_db.backends.postgresql.connections import handle_active_connections
|
|
11
|
+
from git_db.db import parse_database_url
|
|
12
|
+
from git_db.errors import DatabaseError, SnapshotError, TerminationTimeout
|
|
13
|
+
from git_db.storage import (
|
|
14
|
+
make_metadata,
|
|
15
|
+
metadata_path,
|
|
16
|
+
snapshot_db_name,
|
|
17
|
+
write_metadata,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from git_db.config import GitDbConfig
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TemplateStrategy:
|
|
25
|
+
"""
|
|
26
|
+
Snapshot strategy using PostgreSQL template databases.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name = "template"
|
|
30
|
+
|
|
31
|
+
def __init__(self, backend: DatabaseBackend) -> None:
|
|
32
|
+
self._backend = backend
|
|
33
|
+
|
|
34
|
+
def save(
|
|
35
|
+
self,
|
|
36
|
+
db_url: str,
|
|
37
|
+
branch: str,
|
|
38
|
+
snapshot_dir: Path,
|
|
39
|
+
config: GitDbConfig,
|
|
40
|
+
) -> None:
|
|
41
|
+
params = self._backend.apply_url_defaults(parse_database_url(db_url))
|
|
42
|
+
dbname = str(params["dbname"])
|
|
43
|
+
snapshot_name = snapshot_db_name(
|
|
44
|
+
branch,
|
|
45
|
+
dbname,
|
|
46
|
+
self._backend.max_identifier_length,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
conn = self._backend.connect_maintenance(params)
|
|
50
|
+
try:
|
|
51
|
+
handle_active_connections(conn, dbname, config)
|
|
52
|
+
handle_active_connections(conn, snapshot_name, config)
|
|
53
|
+
|
|
54
|
+
conn.execute(
|
|
55
|
+
sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
|
|
56
|
+
sql.Identifier(snapshot_name)
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_create_from_template(conn, snapshot_name, dbname)
|
|
61
|
+
except (psycopg.Error, DatabaseError) as e:
|
|
62
|
+
raise SnapshotError(f"Template save failed: {e}") from e
|
|
63
|
+
finally:
|
|
64
|
+
conn.close()
|
|
65
|
+
|
|
66
|
+
write_metadata(
|
|
67
|
+
snapshot_dir,
|
|
68
|
+
make_metadata(
|
|
69
|
+
branch=branch,
|
|
70
|
+
database=dbname,
|
|
71
|
+
strategy=self.name,
|
|
72
|
+
engine=self._backend.engine,
|
|
73
|
+
engine_version="",
|
|
74
|
+
file_size_bytes=None,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def restore(
|
|
79
|
+
self,
|
|
80
|
+
db_url: str,
|
|
81
|
+
branch: str,
|
|
82
|
+
snapshot_dir: Path,
|
|
83
|
+
config: GitDbConfig,
|
|
84
|
+
) -> None:
|
|
85
|
+
params = self._backend.apply_url_defaults(parse_database_url(db_url))
|
|
86
|
+
dbname = str(params["dbname"])
|
|
87
|
+
snapshot_name = snapshot_db_name(
|
|
88
|
+
branch,
|
|
89
|
+
dbname,
|
|
90
|
+
self._backend.max_identifier_length,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
conn = self._backend.connect_maintenance(params)
|
|
94
|
+
try:
|
|
95
|
+
handle_active_connections(conn, dbname, config)
|
|
96
|
+
|
|
97
|
+
conn.execute(
|
|
98
|
+
sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
|
|
99
|
+
sql.Identifier(dbname)
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
_create_from_template(conn, dbname, snapshot_name)
|
|
104
|
+
except (psycopg.Error, DatabaseError, TerminationTimeout) as e:
|
|
105
|
+
raise SnapshotError(f"Template restore failed: {e}") from e
|
|
106
|
+
finally:
|
|
107
|
+
conn.close()
|
|
108
|
+
|
|
109
|
+
def cleanup(
|
|
110
|
+
self,
|
|
111
|
+
branch: str,
|
|
112
|
+
snapshot_dir: Path,
|
|
113
|
+
config: GitDbConfig,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Drop the snapshot database and remove the metadata file.
|
|
117
|
+
"""
|
|
118
|
+
params = self._backend.apply_url_defaults(
|
|
119
|
+
parse_database_url(config.database_url)
|
|
120
|
+
)
|
|
121
|
+
dbname = str(params["dbname"])
|
|
122
|
+
snapshot_name = snapshot_db_name(
|
|
123
|
+
branch,
|
|
124
|
+
dbname,
|
|
125
|
+
self._backend.max_identifier_length,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
conn = self._backend.connect_maintenance(params)
|
|
130
|
+
try:
|
|
131
|
+
handle_active_connections(conn, snapshot_name, config)
|
|
132
|
+
conn.execute(
|
|
133
|
+
sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
|
|
134
|
+
sql.Identifier(snapshot_name)
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
finally:
|
|
138
|
+
conn.close()
|
|
139
|
+
except DatabaseError:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
meta = metadata_path(snapshot_dir, branch)
|
|
143
|
+
if meta.exists():
|
|
144
|
+
meta.unlink()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _create_from_template(
|
|
148
|
+
conn: DbConnection,
|
|
149
|
+
target: str,
|
|
150
|
+
template: str,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Issue CREATE DATABASE using PostgreSQL's default copy strategy.
|
|
154
|
+
"""
|
|
155
|
+
conn.execute(
|
|
156
|
+
sql.SQL("CREATE DATABASE {} TEMPLATE {}").format(
|
|
157
|
+
sql.Identifier(target),
|
|
158
|
+
sql.Identifier(template),
|
|
159
|
+
)
|
|
160
|
+
)
|
git_db/cli/__init__.py
ADDED
git_db/cli/_common.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from git_db.config import find_project_root
|
|
9
|
+
from git_db.git import get_git_dir
|
|
10
|
+
|
|
11
|
+
from ._console import console
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_enabled() -> str:
|
|
15
|
+
"""
|
|
16
|
+
Return a Rich-formatted string showing enabled/disabled status.
|
|
17
|
+
"""
|
|
18
|
+
git_dir = get_git_dir()
|
|
19
|
+
if git_dir and (git_dir / "git-db" / "disabled").exists():
|
|
20
|
+
return "[yellow]no[/] (run [cyan]git-db enable[/] to re-enable)"
|
|
21
|
+
return "[green]yes[/]"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def require_init() -> Path:
|
|
25
|
+
"""
|
|
26
|
+
Ensure git-db has been initialized. Returns project root.
|
|
27
|
+
"""
|
|
28
|
+
root = find_project_root()
|
|
29
|
+
if root is None or not (root / ".git-db.toml").exists():
|
|
30
|
+
console.print(
|
|
31
|
+
"[red]Error:[/] git-db is not initialized. Run [cyan]git-db init[/] first."
|
|
32
|
+
)
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
return root
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def debug_enabled() -> bool:
|
|
38
|
+
"""
|
|
39
|
+
Whether GIT_DB_DEBUG is set to a truthy value.
|
|
40
|
+
"""
|
|
41
|
+
return os.environ.get("GIT_DB_DEBUG", "").lower() in ("1", "true", "yes")
|
git_db/cli/_console.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(
|
|
7
|
+
name="git-db",
|
|
8
|
+
help=(
|
|
9
|
+
"Keep your database in sync with your git branches. "
|
|
10
|
+
"Currently supports PostgreSQL."
|
|
11
|
+
),
|
|
12
|
+
rich_markup_mode="rich",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
hook_app = typer.Typer(name="hook", help="Manage the post-checkout git hook.")
|
|
16
|
+
app.add_typer(hook_app)
|
|
17
|
+
|
|
18
|
+
console = Console(stderr=True)
|
git_db/cli/_format.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from urllib.parse import urlparse, urlunparse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def format_age(iso_timestamp: str) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Return human-readable age like '2h ago', '3d ago'.
|
|
10
|
+
"""
|
|
11
|
+
try:
|
|
12
|
+
created = datetime.fromisoformat(iso_timestamp)
|
|
13
|
+
now = datetime.now(UTC)
|
|
14
|
+
delta = now - created
|
|
15
|
+
seconds = int(delta.total_seconds())
|
|
16
|
+
|
|
17
|
+
if seconds < 60:
|
|
18
|
+
return "just now"
|
|
19
|
+
if seconds < 3600:
|
|
20
|
+
return f"{seconds // 60}m ago"
|
|
21
|
+
if seconds < 86400:
|
|
22
|
+
return f"{seconds // 3600}h ago"
|
|
23
|
+
if seconds < 604800:
|
|
24
|
+
return f"{seconds // 86400}d ago"
|
|
25
|
+
return f"{seconds // 604800}w ago"
|
|
26
|
+
except (ValueError, TypeError):
|
|
27
|
+
return "unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_size(size_bytes: int | None) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Return human-readable size like '1.2 MB', '340 KB'.
|
|
33
|
+
"""
|
|
34
|
+
if size_bytes is None:
|
|
35
|
+
return "template"
|
|
36
|
+
if size_bytes < 1024:
|
|
37
|
+
return f"{size_bytes} B"
|
|
38
|
+
if size_bytes < 1024 * 1024:
|
|
39
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
40
|
+
if size_bytes < 1024 * 1024 * 1024:
|
|
41
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
42
|
+
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mask_url(url: str) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Replace password in database URL with ****.
|
|
48
|
+
"""
|
|
49
|
+
parsed = urlparse(url)
|
|
50
|
+
if parsed.password:
|
|
51
|
+
netloc = f"{parsed.username}:****@{parsed.hostname}"
|
|
52
|
+
if parsed.port:
|
|
53
|
+
netloc += f":{parsed.port}"
|
|
54
|
+
masked = parsed._replace(netloc=netloc)
|
|
55
|
+
return urlunparse(masked)
|
|
56
|
+
return url
|
git_db/cli/_prompts.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from git_db.git import get_current_branch
|
|
9
|
+
|
|
10
|
+
from ._console import console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_with_prompt(
|
|
14
|
+
flag_value: str | None,
|
|
15
|
+
existing: object | None,
|
|
16
|
+
prompt_text: str,
|
|
17
|
+
required: bool = False,
|
|
18
|
+
) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Resolve a string value from flag, existing config, or prompt.
|
|
21
|
+
"""
|
|
22
|
+
if flag_value is not None:
|
|
23
|
+
return flag_value
|
|
24
|
+
if existing:
|
|
25
|
+
return str(existing)
|
|
26
|
+
if required and sys.stdin.isatty():
|
|
27
|
+
return typer.prompt(prompt_text)
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_choice(
|
|
32
|
+
flag_value: str | None,
|
|
33
|
+
existing: object | None,
|
|
34
|
+
prompt_text: str,
|
|
35
|
+
choices: dict[str, str],
|
|
36
|
+
labels: dict[str, str],
|
|
37
|
+
default: str,
|
|
38
|
+
) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Resolve a choice from flag, existing config, or interactive prompt.
|
|
41
|
+
"""
|
|
42
|
+
if flag_value is not None:
|
|
43
|
+
if flag_value in choices.values():
|
|
44
|
+
return flag_value
|
|
45
|
+
console.print(f"[red]Error:[/] Invalid value '{flag_value}'.")
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
|
|
48
|
+
reverse = {v: k for k, v in choices.items()}
|
|
49
|
+
existing_key = reverse.get(str(existing)) if existing else None
|
|
50
|
+
default_key = existing_key or default
|
|
51
|
+
default_value = choices[default_key]
|
|
52
|
+
|
|
53
|
+
if not sys.stdin.isatty():
|
|
54
|
+
return (
|
|
55
|
+
str(existing)
|
|
56
|
+
if existing and str(existing) in choices.values()
|
|
57
|
+
else default_value
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
console.print(f" {prompt_text}:\n")
|
|
61
|
+
for key, label in labels.items():
|
|
62
|
+
marker = " (current)" if key == existing_key else ""
|
|
63
|
+
console.print(f" {key}. {label}{marker}")
|
|
64
|
+
|
|
65
|
+
console.print()
|
|
66
|
+
result = typer.prompt(" Choice", default=default_key)
|
|
67
|
+
result = str(result).strip()
|
|
68
|
+
console.print()
|
|
69
|
+
|
|
70
|
+
if result in choices:
|
|
71
|
+
return choices[result]
|
|
72
|
+
if result in choices.values():
|
|
73
|
+
return result
|
|
74
|
+
return default_value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def confirm_prune(
|
|
78
|
+
header: str,
|
|
79
|
+
items: list[tuple[str, str | None]],
|
|
80
|
+
yes: bool,
|
|
81
|
+
) -> bool:
|
|
82
|
+
"""
|
|
83
|
+
Display items to be pruned and ask for confirmation.
|
|
84
|
+
"""
|
|
85
|
+
console.print(header)
|
|
86
|
+
for name, subtitle in items:
|
|
87
|
+
if subtitle:
|
|
88
|
+
console.print(f" {name} [dim]({subtitle})[/]")
|
|
89
|
+
else:
|
|
90
|
+
console.print(f" {name}")
|
|
91
|
+
console.print()
|
|
92
|
+
|
|
93
|
+
if yes:
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
if not sys.stdin.isatty():
|
|
97
|
+
console.print(
|
|
98
|
+
"[red]Error:[/] Refusing to prune in non-interactive mode. "
|
|
99
|
+
"Pass [cyan]--yes[/] to proceed, or [cyan]--dry-run[/] to preview."
|
|
100
|
+
)
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
return typer.confirm("This is irreversible. Continue?", default=False)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def detect_default_branch() -> str:
|
|
107
|
+
"""
|
|
108
|
+
Detect the default branch name from git.
|
|
109
|
+
"""
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
timeout=10,
|
|
115
|
+
)
|
|
116
|
+
if result.returncode == 0:
|
|
117
|
+
ref = result.stdout.strip()
|
|
118
|
+
return ref.split("/")[-1]
|
|
119
|
+
|
|
120
|
+
current = get_current_branch()
|
|
121
|
+
return current or "main"
|
git_db/cli/branch.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from git_db.backends import get_backend
|
|
8
|
+
from git_db.config import load_config
|
|
9
|
+
from git_db.db import parse_database_url
|
|
10
|
+
from git_db.errors import GitDbError
|
|
11
|
+
from git_db.git import get_current_branch, get_git_dir
|
|
12
|
+
from git_db.storage import branch_db_name
|
|
13
|
+
|
|
14
|
+
from ._common import debug_enabled, require_init
|
|
15
|
+
from ._console import app, console
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command()
|
|
19
|
+
def create(
|
|
20
|
+
branch: Annotated[str | None, typer.Argument()] = None,
|
|
21
|
+
database_url: Annotated[
|
|
22
|
+
str | None, typer.Option("--database-url", envvar="DATABASE_URL")
|
|
23
|
+
] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Proactively create a per-branch database before checkout.
|
|
27
|
+
|
|
28
|
+
Per-branch mode only.
|
|
29
|
+
"""
|
|
30
|
+
require_init()
|
|
31
|
+
try:
|
|
32
|
+
config = load_config(cli_overrides={"database_url": database_url})
|
|
33
|
+
|
|
34
|
+
if config.mode != "per-branch":
|
|
35
|
+
console.print(
|
|
36
|
+
"Shared mode: use [cyan]git-db save[/] to snapshot the database."
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
git_dir = get_git_dir()
|
|
41
|
+
if git_dir is None:
|
|
42
|
+
console.print("[red]Error:[/] Not inside a git repository.")
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
|
|
45
|
+
if branch is None:
|
|
46
|
+
branch = get_current_branch()
|
|
47
|
+
if branch is None:
|
|
48
|
+
console.print("[red]Error:[/] HEAD is detached. Specify a branch name.")
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
backend = get_backend(config.database_url)
|
|
52
|
+
params = backend.apply_url_defaults(parse_database_url(config.database_url))
|
|
53
|
+
dbname = str(params["dbname"])
|
|
54
|
+
manager = backend.branch_db_manager(config)
|
|
55
|
+
target_db = branch_db_name(
|
|
56
|
+
branch,
|
|
57
|
+
dbname,
|
|
58
|
+
config.default_branch,
|
|
59
|
+
backend.max_identifier_length,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if manager.exists(target_db):
|
|
63
|
+
console.print(
|
|
64
|
+
f"[yellow]Branch database '{target_db}' already exists.[/] "
|
|
65
|
+
"Use [cyan]git-db reset[/] to recreate from seed."
|
|
66
|
+
)
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
|
|
69
|
+
source_db = dbname
|
|
70
|
+
created_from = config.default_branch
|
|
71
|
+
|
|
72
|
+
current = get_current_branch()
|
|
73
|
+
if current:
|
|
74
|
+
candidate = branch_db_name(
|
|
75
|
+
current,
|
|
76
|
+
dbname,
|
|
77
|
+
config.default_branch,
|
|
78
|
+
backend.max_identifier_length,
|
|
79
|
+
)
|
|
80
|
+
if manager.exists(candidate):
|
|
81
|
+
source_db = candidate
|
|
82
|
+
created_from = current
|
|
83
|
+
|
|
84
|
+
manager.create(target_db, source_db, branch, created_from, git_dir)
|
|
85
|
+
console.print(f"[green]Created[/] database: {target_db}")
|
|
86
|
+
except GitDbError as e:
|
|
87
|
+
console.print(f"[red]Error:[/] {e}")
|
|
88
|
+
raise typer.Exit(1) from e
|
|
89
|
+
except typer.Exit:
|
|
90
|
+
raise
|
|
91
|
+
except Exception as e:
|
|
92
|
+
if debug_enabled():
|
|
93
|
+
raise
|
|
94
|
+
console.print(
|
|
95
|
+
f"[red]Error:[/] Unexpected error: {e}\n"
|
|
96
|
+
"[dim]Set GIT_DB_DEBUG=1 to see the full traceback.[/]"
|
|
97
|
+
)
|
|
98
|
+
raise typer.Exit(1) from e
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command()
|
|
102
|
+
def reset(
|
|
103
|
+
branch: Annotated[str | None, typer.Argument()] = None,
|
|
104
|
+
database_url: Annotated[
|
|
105
|
+
str | None, typer.Option("--database-url", envvar="DATABASE_URL")
|
|
106
|
+
] = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""
|
|
109
|
+
Drop and recreate a branch database from seed (per-branch mode only).
|
|
110
|
+
"""
|
|
111
|
+
require_init()
|
|
112
|
+
try:
|
|
113
|
+
config = load_config(cli_overrides={"database_url": database_url})
|
|
114
|
+
|
|
115
|
+
if config.mode != "per-branch":
|
|
116
|
+
console.print(
|
|
117
|
+
"Shared mode: use [cyan]git-db restore[/] to restore a snapshot."
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
git_dir = get_git_dir()
|
|
122
|
+
if git_dir is None:
|
|
123
|
+
console.print("[red]Error:[/] Not inside a git repository.")
|
|
124
|
+
raise typer.Exit(1)
|
|
125
|
+
|
|
126
|
+
if branch is None:
|
|
127
|
+
branch = get_current_branch()
|
|
128
|
+
if branch is None:
|
|
129
|
+
console.print("[red]Error:[/] HEAD is detached. Specify a branch name.")
|
|
130
|
+
raise typer.Exit(1)
|
|
131
|
+
|
|
132
|
+
backend = get_backend(config.database_url)
|
|
133
|
+
params = backend.apply_url_defaults(parse_database_url(config.database_url))
|
|
134
|
+
dbname = str(params["dbname"])
|
|
135
|
+
manager = backend.branch_db_manager(config)
|
|
136
|
+
target_db = branch_db_name(
|
|
137
|
+
branch,
|
|
138
|
+
dbname,
|
|
139
|
+
config.default_branch,
|
|
140
|
+
backend.max_identifier_length,
|
|
141
|
+
)
|
|
142
|
+
seed_db = dbname
|
|
143
|
+
|
|
144
|
+
if branch == config.default_branch:
|
|
145
|
+
console.print(
|
|
146
|
+
"[yellow]Cannot reset the default branch database.[/] "
|
|
147
|
+
"It is the seed for all other branches."
|
|
148
|
+
)
|
|
149
|
+
raise typer.Exit(1)
|
|
150
|
+
|
|
151
|
+
if manager.exists(target_db):
|
|
152
|
+
manager.drop(target_db, branch, git_dir)
|
|
153
|
+
|
|
154
|
+
manager.create(target_db, seed_db, branch, config.default_branch, git_dir)
|
|
155
|
+
console.print(f"[green]Reset[/] database '{target_db}' from seed '{seed_db}'")
|
|
156
|
+
except GitDbError as e:
|
|
157
|
+
console.print(f"[red]Error:[/] {e}")
|
|
158
|
+
raise typer.Exit(1) from e
|
|
159
|
+
except typer.Exit:
|
|
160
|
+
raise
|
|
161
|
+
except Exception as e:
|
|
162
|
+
if debug_enabled():
|
|
163
|
+
raise
|
|
164
|
+
console.print(
|
|
165
|
+
f"[red]Error:[/] Unexpected error: {e}\n"
|
|
166
|
+
"[dim]Set GIT_DB_DEBUG=1 to see the full traceback.[/]"
|
|
167
|
+
)
|
|
168
|
+
raise typer.Exit(1) from e
|
git_db/cli/hook.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from git_db.config import load_config
|
|
6
|
+
from git_db.errors import GitDbError
|
|
7
|
+
from git_db.git import get_git_dir, handle_post_checkout, install_hook, remove_hook
|
|
8
|
+
|
|
9
|
+
from ._console import app, console, hook_app
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@hook_app.command("install")
|
|
13
|
+
def hook_install() -> None:
|
|
14
|
+
"""
|
|
15
|
+
Install the post-checkout git hook.
|
|
16
|
+
"""
|
|
17
|
+
git_dir = get_git_dir()
|
|
18
|
+
if git_dir is None:
|
|
19
|
+
console.print("[red]Error:[/] Not inside a git repository.")
|
|
20
|
+
raise typer.Exit(1)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
install_hook(git_dir)
|
|
24
|
+
console.print("[green]Hook installed.[/]")
|
|
25
|
+
except GitDbError as e:
|
|
26
|
+
console.print(f"[red]Error:[/] {e}")
|
|
27
|
+
raise typer.Exit(1) from e
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@hook_app.command("remove")
|
|
31
|
+
def hook_remove() -> None:
|
|
32
|
+
"""
|
|
33
|
+
Remove the post-checkout git hook.
|
|
34
|
+
"""
|
|
35
|
+
git_dir = get_git_dir()
|
|
36
|
+
if git_dir is None:
|
|
37
|
+
console.print("[red]Error:[/] Not inside a git repository.")
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
remove_hook(git_dir)
|
|
42
|
+
console.print("[green]Hook removed.[/]")
|
|
43
|
+
except GitDbError as e:
|
|
44
|
+
console.print(f"[red]Error:[/] {e}")
|
|
45
|
+
raise typer.Exit(1) from e
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command()
|
|
49
|
+
def disable() -> None:
|
|
50
|
+
"""
|
|
51
|
+
Temporarily disable git-db. Hook will skip all operations.
|
|
52
|
+
"""
|
|
53
|
+
git_dir = get_git_dir()
|
|
54
|
+
if git_dir is None:
|
|
55
|
+
console.print("[red]Error:[/] Not inside a git repository.")
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
|
|
58
|
+
disabled_file = git_dir / "git-db" / "disabled"
|
|
59
|
+
disabled_file.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
disabled_file.touch()
|
|
61
|
+
console.print(
|
|
62
|
+
"[yellow]git-db disabled.[/] Run [cyan]git-db enable[/] to re-enable."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def enable() -> None:
|
|
68
|
+
"""
|
|
69
|
+
Re-enable git-db after it was disabled.
|
|
70
|
+
"""
|
|
71
|
+
git_dir = get_git_dir()
|
|
72
|
+
if git_dir is None:
|
|
73
|
+
console.print("[red]Error:[/] Not inside a git repository.")
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
disabled_file = git_dir / "git-db" / "disabled"
|
|
77
|
+
if disabled_file.exists():
|
|
78
|
+
disabled_file.unlink()
|
|
79
|
+
console.print("[green]git-db enabled.[/]")
|
|
80
|
+
else:
|
|
81
|
+
console.print("[dim]git-db is already enabled.[/]")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("_hook-dispatch", hidden=True)
|
|
85
|
+
def hook_dispatch(
|
|
86
|
+
prev_head: str,
|
|
87
|
+
new_head: str,
|
|
88
|
+
is_branch: str,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Internal command called by the post-checkout hook.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
git_dir = get_git_dir()
|
|
95
|
+
if git_dir is None:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
config = load_config()
|
|
99
|
+
handle_post_checkout(prev_head, is_branch, config)
|
|
100
|
+
|
|
101
|
+
except Exception as e:
|
|
102
|
+
console.print(f"[yellow]git-db warning:[/] {e}")
|