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/snapshot.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
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.errors import GitDbError
|
|
10
|
+
from git_db.git import get_current_branch
|
|
11
|
+
from git_db.storage import has_snapshot
|
|
12
|
+
|
|
13
|
+
from ._common import debug_enabled, require_init
|
|
14
|
+
from ._console import app, console
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command()
|
|
18
|
+
def save(
|
|
19
|
+
branch: Annotated[str | None, typer.Argument()] = None,
|
|
20
|
+
database_url: Annotated[
|
|
21
|
+
str | None, typer.Option("--database-url", envvar="DATABASE_URL")
|
|
22
|
+
] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Manually snapshot the current database for a branch (shared mode only).
|
|
26
|
+
"""
|
|
27
|
+
require_init()
|
|
28
|
+
try:
|
|
29
|
+
config = load_config(cli_overrides={"database_url": database_url})
|
|
30
|
+
|
|
31
|
+
if config.mode == "per-branch":
|
|
32
|
+
console.print(
|
|
33
|
+
"Per-branch mode: your database persists automatically. "
|
|
34
|
+
"No save needed.\n"
|
|
35
|
+
"Use [cyan]git-db create[/] to proactively create a branch DB."
|
|
36
|
+
)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if branch is None:
|
|
40
|
+
branch = get_current_branch()
|
|
41
|
+
if branch is None:
|
|
42
|
+
console.print("[red]Error:[/] HEAD is detached. Specify a branch name.")
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
|
|
45
|
+
backend = get_backend(config.database_url)
|
|
46
|
+
detected = backend.detect_strategy(config)
|
|
47
|
+
|
|
48
|
+
detected.save(config.database_url, branch, config.snapshot_dir, config)
|
|
49
|
+
console.print(f"[green]Saved[/] ({detected.name}): {branch}")
|
|
50
|
+
except GitDbError as e:
|
|
51
|
+
console.print(f"[red]Error:[/] {e}")
|
|
52
|
+
raise typer.Exit(1) from e
|
|
53
|
+
except typer.Exit:
|
|
54
|
+
raise
|
|
55
|
+
except Exception as e:
|
|
56
|
+
if debug_enabled():
|
|
57
|
+
raise
|
|
58
|
+
console.print(
|
|
59
|
+
f"[red]Error:[/] Unexpected error: {e}\n"
|
|
60
|
+
"[dim]Set GIT_DB_DEBUG=1 to see the full traceback.[/]"
|
|
61
|
+
)
|
|
62
|
+
raise typer.Exit(1) from e
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def restore(
|
|
67
|
+
branch: Annotated[str | None, typer.Argument()] = None,
|
|
68
|
+
database_url: Annotated[
|
|
69
|
+
str | None, typer.Option("--database-url", envvar="DATABASE_URL")
|
|
70
|
+
] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Manually restore the snapshot for a branch (shared mode only).
|
|
74
|
+
"""
|
|
75
|
+
require_init()
|
|
76
|
+
try:
|
|
77
|
+
config = load_config(cli_overrides={"database_url": database_url})
|
|
78
|
+
|
|
79
|
+
if config.mode == "per-branch":
|
|
80
|
+
console.print(
|
|
81
|
+
"Per-branch mode: use [cyan]git-db reset[/] to recreate "
|
|
82
|
+
"a branch database from seed."
|
|
83
|
+
)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if branch is None:
|
|
87
|
+
branch = get_current_branch()
|
|
88
|
+
if branch is None:
|
|
89
|
+
console.print("[red]Error:[/] HEAD is detached. Specify a branch name.")
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
|
|
92
|
+
if not has_snapshot(config.snapshot_dir, branch):
|
|
93
|
+
console.print(
|
|
94
|
+
f"[yellow]No snapshot found for branch '{branch}'.[/] "
|
|
95
|
+
"Database left unchanged."
|
|
96
|
+
)
|
|
97
|
+
raise typer.Exit(1)
|
|
98
|
+
|
|
99
|
+
backend = get_backend(config.database_url)
|
|
100
|
+
detected = backend.detect_strategy(config)
|
|
101
|
+
detected.restore(config.database_url, branch, config.snapshot_dir, config)
|
|
102
|
+
console.print(f"[green]Restored[/] database for '{branch}'")
|
|
103
|
+
except GitDbError as e:
|
|
104
|
+
console.print(f"[red]Error:[/] {e}")
|
|
105
|
+
raise typer.Exit(1) from e
|
|
106
|
+
except typer.Exit:
|
|
107
|
+
raise
|
|
108
|
+
except Exception as e:
|
|
109
|
+
if debug_enabled():
|
|
110
|
+
raise
|
|
111
|
+
console.print(
|
|
112
|
+
f"[red]Error:[/] Unexpected error: {e}\n"
|
|
113
|
+
"[dim]Set GIT_DB_DEBUG=1 to see the full traceback.[/]"
|
|
114
|
+
)
|
|
115
|
+
raise typer.Exit(1) from e
|
git_db/config.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import tomlkit
|
|
9
|
+
|
|
10
|
+
from git_db.errors import ConfigError
|
|
11
|
+
|
|
12
|
+
VALID_CONNECTION_POLICIES = {"terminate", "fail"}
|
|
13
|
+
VALID_MODES = {"shared", "per-branch"}
|
|
14
|
+
VALID_STRATEGIES = {"template", "pgdump"}
|
|
15
|
+
|
|
16
|
+
_CONFIG_COMMENTS: dict[str, str] = {
|
|
17
|
+
"database_url": "Database connection URL.",
|
|
18
|
+
"mode": (
|
|
19
|
+
"How git-db manages databases across branches.\n"
|
|
20
|
+
'# "shared": one database, snapshot/restore on switch\n'
|
|
21
|
+
'# "per-branch": each branch gets its own database'
|
|
22
|
+
),
|
|
23
|
+
"default_branch": (
|
|
24
|
+
"The default branch whose database keeps the original name from DATABASE_URL.\n"
|
|
25
|
+
"# Other branches get suffixed names (e.g., myapp__feature_auth)."
|
|
26
|
+
),
|
|
27
|
+
"strategy": (
|
|
28
|
+
"Snapshot strategy for cloning databases.\n"
|
|
29
|
+
'# "template": uses CREATE DATABASE ... TEMPLATE '
|
|
30
|
+
"(fast, requires CREATEDB privilege)\n"
|
|
31
|
+
'# "pgdump": uses pg_dump/pg_restore (slower, works without special privileges)'
|
|
32
|
+
),
|
|
33
|
+
"on_active_connections": (
|
|
34
|
+
"What to do when active connections block a database operation.\n"
|
|
35
|
+
'# "terminate": kill connections and proceed '
|
|
36
|
+
"(needs superuser or pg_signal_backend)\n"
|
|
37
|
+
'# "fail": stop with an error'
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class GitDbConfig:
|
|
44
|
+
"""
|
|
45
|
+
git-db configuration.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
database_url: str = ""
|
|
49
|
+
mode: str = "shared"
|
|
50
|
+
default_branch: str = "main"
|
|
51
|
+
strategy: str = ""
|
|
52
|
+
on_active_connections: str = "terminate"
|
|
53
|
+
snapshot_dir: Path = field(default_factory=lambda: Path(".git/git-db/snapshots"))
|
|
54
|
+
max_snapshots: int = 20
|
|
55
|
+
force_terminate_timeout_ms: int = 5000
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_config(
|
|
59
|
+
cli_overrides: dict[str, object] | None = None,
|
|
60
|
+
project_root: Path | None = None,
|
|
61
|
+
) -> GitDbConfig:
|
|
62
|
+
"""
|
|
63
|
+
Load configuration with precedence: defaults < .git-db.toml < env vars < CLI.
|
|
64
|
+
"""
|
|
65
|
+
root = project_root or find_project_root()
|
|
66
|
+
merged: dict[str, object] = {}
|
|
67
|
+
|
|
68
|
+
# Layer 1: .git-db.toml
|
|
69
|
+
if root:
|
|
70
|
+
merged.update(load_dotfile_config(root))
|
|
71
|
+
|
|
72
|
+
# Layer 3: environment variables
|
|
73
|
+
merged.update(_load_env_vars())
|
|
74
|
+
|
|
75
|
+
# Layer 4: CLI overrides
|
|
76
|
+
if cli_overrides:
|
|
77
|
+
for key, value in cli_overrides.items():
|
|
78
|
+
if value is not None:
|
|
79
|
+
merged[key] = value
|
|
80
|
+
|
|
81
|
+
config = _build_config(merged)
|
|
82
|
+
_validate_config(config)
|
|
83
|
+
return config
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def find_project_root() -> Path | None:
|
|
87
|
+
"""
|
|
88
|
+
Walk up from cwd looking for a .git directory.
|
|
89
|
+
"""
|
|
90
|
+
current = Path.cwd()
|
|
91
|
+
for parent in [current, *current.parents]:
|
|
92
|
+
if (parent / ".git").exists():
|
|
93
|
+
return parent
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_dotfile_config(root: Path) -> dict[str, object]:
|
|
98
|
+
"""
|
|
99
|
+
Read raw key/value pairs from .git-db.toml.
|
|
100
|
+
|
|
101
|
+
Returns {} if absent or unparseable.
|
|
102
|
+
"""
|
|
103
|
+
dotfile = root / ".git-db.toml"
|
|
104
|
+
if not dotfile.exists():
|
|
105
|
+
return {}
|
|
106
|
+
try:
|
|
107
|
+
with open(dotfile, "rb") as f:
|
|
108
|
+
data = tomllib.load(f)
|
|
109
|
+
return dict(data)
|
|
110
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _load_env_vars() -> dict[str, object]:
|
|
115
|
+
"""
|
|
116
|
+
Load configuration from environment variables.
|
|
117
|
+
"""
|
|
118
|
+
env_map: list[tuple[str, str]] = [
|
|
119
|
+
("DATABASE_URL", "database_url"),
|
|
120
|
+
("GIT_DB_DATABASE_URL", "database_url"),
|
|
121
|
+
("GIT_DB_MODE", "mode"),
|
|
122
|
+
("GIT_DB_STRATEGY", "strategy"),
|
|
123
|
+
("GIT_DB_ON_ACTIVE_CONNECTIONS", "on_active_connections"),
|
|
124
|
+
("GIT_DB_SNAPSHOT_DIR", "snapshot_dir"),
|
|
125
|
+
("GIT_DB_MAX_SNAPSHOTS", "max_snapshots"),
|
|
126
|
+
("GIT_DB_FORCE_TERMINATE_TIMEOUT_MS", "force_terminate_timeout_ms"),
|
|
127
|
+
]
|
|
128
|
+
result: dict[str, object] = {}
|
|
129
|
+
for env_key, config_key in env_map:
|
|
130
|
+
value = os.environ.get(env_key)
|
|
131
|
+
if value is not None:
|
|
132
|
+
result[config_key] = value
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _build_config(merged: dict[str, object]) -> GitDbConfig:
|
|
137
|
+
"""
|
|
138
|
+
Build a GitDbConfig from the merged configuration dict.
|
|
139
|
+
"""
|
|
140
|
+
config = GitDbConfig()
|
|
141
|
+
|
|
142
|
+
if "database_url" in merged:
|
|
143
|
+
config.database_url = str(merged["database_url"])
|
|
144
|
+
|
|
145
|
+
if "mode" in merged:
|
|
146
|
+
config.mode = str(merged["mode"])
|
|
147
|
+
|
|
148
|
+
if "default_branch" in merged:
|
|
149
|
+
config.default_branch = str(merged["default_branch"])
|
|
150
|
+
|
|
151
|
+
if "strategy" in merged:
|
|
152
|
+
config.strategy = str(merged["strategy"])
|
|
153
|
+
|
|
154
|
+
if "on_active_connections" in merged:
|
|
155
|
+
config.on_active_connections = str(merged["on_active_connections"])
|
|
156
|
+
|
|
157
|
+
if "snapshot_dir" in merged:
|
|
158
|
+
config.snapshot_dir = Path(str(merged["snapshot_dir"]))
|
|
159
|
+
|
|
160
|
+
if "max_snapshots" in merged:
|
|
161
|
+
try:
|
|
162
|
+
config.max_snapshots = int(str(merged["max_snapshots"]))
|
|
163
|
+
except ValueError as e:
|
|
164
|
+
raise ConfigError(
|
|
165
|
+
f"Invalid max_snapshots value: {merged['max_snapshots']}"
|
|
166
|
+
) from e
|
|
167
|
+
|
|
168
|
+
if "force_terminate_timeout_ms" in merged:
|
|
169
|
+
try:
|
|
170
|
+
config.force_terminate_timeout_ms = int(
|
|
171
|
+
str(merged["force_terminate_timeout_ms"])
|
|
172
|
+
)
|
|
173
|
+
except ValueError as e:
|
|
174
|
+
raise ConfigError(
|
|
175
|
+
f"Invalid force_terminate_timeout_ms: "
|
|
176
|
+
f"{merged['force_terminate_timeout_ms']}"
|
|
177
|
+
) from e
|
|
178
|
+
|
|
179
|
+
return config
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _validate_config(config: GitDbConfig) -> None:
|
|
183
|
+
"""
|
|
184
|
+
Validate configuration values. Raises ConfigError on invalid values.
|
|
185
|
+
"""
|
|
186
|
+
if not config.database_url:
|
|
187
|
+
raise ConfigError(
|
|
188
|
+
"No database URL configured. Run 'git-db init' or set DATABASE_URL."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if config.mode not in VALID_MODES:
|
|
192
|
+
raise ConfigError(
|
|
193
|
+
f"Invalid mode '{config.mode}'. "
|
|
194
|
+
f"Must be one of: {', '.join(sorted(VALID_MODES))}."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if config.strategy not in VALID_STRATEGIES:
|
|
198
|
+
raise ConfigError(
|
|
199
|
+
"Strategy not configured. Run 'git-db init' to set up git-db."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if config.on_active_connections not in VALID_CONNECTION_POLICIES:
|
|
203
|
+
raise ConfigError(
|
|
204
|
+
f"Invalid on_active_connections '{config.on_active_connections}'. "
|
|
205
|
+
f"Must be one of: {', '.join(sorted(VALID_CONNECTION_POLICIES))}."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def write_config(project_root: Path, updates: dict[str, object]) -> None:
|
|
210
|
+
"""
|
|
211
|
+
Write or update .git-db.toml with the given key-value pairs.
|
|
212
|
+
"""
|
|
213
|
+
dotfile = project_root / ".git-db.toml"
|
|
214
|
+
|
|
215
|
+
doc = tomlkit.parse(dotfile.read_text()) if dotfile.exists() else tomlkit.document()
|
|
216
|
+
|
|
217
|
+
for key, value in updates.items():
|
|
218
|
+
if key not in doc and key in _CONFIG_COMMENTS:
|
|
219
|
+
doc.add(tomlkit.comment(_CONFIG_COMMENTS[key]))
|
|
220
|
+
doc[key] = value
|
|
221
|
+
|
|
222
|
+
dotfile.write_text(tomlkit.dumps(doc))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def ensure_config_ignored(project_root: Path) -> bool:
|
|
226
|
+
"""
|
|
227
|
+
Ensure the local git-db config file is ignored by git.
|
|
228
|
+
"""
|
|
229
|
+
gitignore = project_root / ".gitignore"
|
|
230
|
+
entry = ".git-db.toml"
|
|
231
|
+
|
|
232
|
+
if gitignore.exists():
|
|
233
|
+
lines = gitignore.read_text().splitlines()
|
|
234
|
+
if entry in {line.strip() for line in lines}:
|
|
235
|
+
return False
|
|
236
|
+
needs_leading_newline = bool(lines) and lines[-1] != ""
|
|
237
|
+
with gitignore.open("a") as f:
|
|
238
|
+
if needs_leading_newline:
|
|
239
|
+
f.write("\n")
|
|
240
|
+
f.write(f"# Local git-db configuration\n{entry}\n")
|
|
241
|
+
else:
|
|
242
|
+
gitignore.write_text(f"# Local git-db configuration\n{entry}\n")
|
|
243
|
+
return True
|
git_db/db.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import unquote, urlparse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_database_url(url: str) -> dict[str, str | int | None]:
|
|
7
|
+
"""
|
|
8
|
+
Parse a database connection URL into component parts.
|
|
9
|
+
"""
|
|
10
|
+
parsed = urlparse(url)
|
|
11
|
+
return {
|
|
12
|
+
"user": unquote(parsed.username) if parsed.username else None,
|
|
13
|
+
"password": unquote(parsed.password) if parsed.password else None,
|
|
14
|
+
"host": parsed.hostname or None,
|
|
15
|
+
"port": parsed.port,
|
|
16
|
+
"dbname": parsed.path.lstrip("/") or None,
|
|
17
|
+
}
|
git_db/errors.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
class GitDbError(Exception):
|
|
2
|
+
"""
|
|
3
|
+
Base exception for all git-db errors.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ConfigError(GitDbError):
|
|
8
|
+
"""
|
|
9
|
+
Invalid or missing configuration.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DatabaseError(GitDbError):
|
|
14
|
+
"""
|
|
15
|
+
Connection or query failures.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SnapshotError(GitDbError):
|
|
20
|
+
"""
|
|
21
|
+
Save/restore failures.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ToolNotFoundError(GitDbError):
|
|
26
|
+
"""
|
|
27
|
+
Database tool (pg_dump, pg_restore, etc.) not found in PATH.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ActiveConnectionsError(GitDbError):
|
|
32
|
+
"""
|
|
33
|
+
Active connections exist and policy is 'fail'.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TerminationTimeout(GitDbError):
|
|
38
|
+
"""
|
|
39
|
+
Connection terminate loop exceeded deadline.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HookError(GitDbError):
|
|
44
|
+
"""
|
|
45
|
+
Hook install/remove failures.
|
|
46
|
+
"""
|