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/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[/]"