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/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
+ """