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.
@@ -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
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from git_db.backends import get_backend
4
+
5
+ from . import branch, hook, init, inspect, snapshot # noqa: F401
6
+ from ._console import app, hook_app
7
+
8
+ __all__ = ["app", "get_backend", "hook_app"]
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}")