dbly 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (39) hide show
  1. dbly-0.3.0/.github/FUNDING.yml +1 -0
  2. dbly-0.3.0/CHANGELOG.md +92 -0
  3. {dbly-0.2.0 → dbly-0.3.0}/PKG-INFO +2 -1
  4. dbly-0.3.0/TODO.md +70 -0
  5. {dbly-0.2.0 → dbly-0.3.0}/pyproject.toml +2 -1
  6. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/__init__.py +1 -1
  7. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/base.py +9 -0
  8. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/mssql.py +4 -0
  9. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/oracle.py +22 -2
  10. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/postgres.py +3 -0
  11. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/sqlite.py +8 -0
  12. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/cli.py +51 -12
  13. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/parsing.py +36 -0
  14. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/planner.py +22 -0
  15. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/repo.py +53 -6
  16. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/report.py +19 -3
  17. {dbly-0.2.0 → dbly-0.3.0}/tests/test_integration.py +100 -0
  18. {dbly-0.2.0 → dbly-0.3.0}/tests/test_parsing.py +36 -0
  19. {dbly-0.2.0 → dbly-0.3.0}/.github/workflows/ci.yml +0 -0
  20. {dbly-0.2.0 → dbly-0.3.0}/.github/workflows/publish.yml +0 -0
  21. {dbly-0.2.0 → dbly-0.3.0}/.gitignore +0 -0
  22. {dbly-0.2.0 → dbly-0.3.0}/README.md +0 -0
  23. {dbly-0.2.0 → dbly-0.3.0}/docs/dbly_head.png +0 -0
  24. {dbly-0.2.0 → dbly-0.3.0}/examples/ci/README.md +0 -0
  25. {dbly-0.2.0 → dbly-0.3.0}/examples/ci/bitbucket-pipelines.yml +0 -0
  26. {dbly-0.2.0 → dbly-0.3.0}/examples/ci/github-actions.yml +0 -0
  27. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/__init__.py +0 -0
  28. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/config.py +0 -0
  29. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/drift.py +0 -0
  30. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/engine.py +0 -0
  31. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/hooks.py +0 -0
  32. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/initializer.py +0 -0
  33. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/model.py +0 -0
  34. {dbly-0.2.0 → dbly-0.3.0}/src/dbly/py.typed +0 -0
  35. {dbly-0.2.0 → dbly-0.3.0}/tests/test_mssql.py +0 -0
  36. {dbly-0.2.0 → dbly-0.3.0}/tests/test_mssql_e2e.py +0 -0
  37. {dbly-0.2.0 → dbly-0.3.0}/tests/test_oracle.py +0 -0
  38. {dbly-0.2.0 → dbly-0.3.0}/tests/test_oracle_e2e.py +0 -0
  39. {dbly-0.2.0 → dbly-0.3.0}/uv.lock +0 -0
@@ -0,0 +1 @@
1
+ ko_fi: angrydata
@@ -0,0 +1,92 @@
1
+ # Changelog
2
+
3
+ All notable changes to `dbly` are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
6
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] — 2026-07-21
11
+
12
+ ### Added
13
+
14
+ - **Plan against the working tree.** `dbly plan --worktree` (alias `--dirty`) and
15
+ `dbly check --worktree` diff the *working directory* — including uncommitted edits and
16
+ untracked new object files — instead of a git ref, for the fast edit→plan loop. Preview
17
+ only; `apply` still requires a real committed ref for the ledger.
18
+ - **Git-style ref decoration.** `dbly status` and the plan header now show the tag/branch
19
+ names pointing at a SHA next to it — e.g. `deployed ref: v0.1, main (a712b631)`. The
20
+ ledger still stores only the SHA (for stable diffs); the names are resolved at display time.
21
+
22
+ ### Fixed
23
+
24
+ - **Column type changes are now detected.** A changed column type (e.g. Oracle `NUMBER` →
25
+ `NUMBER(10)`) previously produced "nothing to do" — the diff matched columns by name only.
26
+ It now emits an `ALTER TABLE … MODIFY`/`ALTER COLUMN` step (flagged destructive, never
27
+ auto-applied) with a data-compatibility warning. Comparison is precision/scale-aware and
28
+ normalizes introspection noise (e.g. SQL Server `COLLATE`) to avoid false positives.
29
+ - **`check` no longer reports live Oracle objects as "missing".** The Oracle adapter now
30
+ resolves its `default_schema` to the connected user and carries the owner on introspected
31
+ objects, so drift keys align with the desired side (previously the owner was dropped on the
32
+ live side, so every object looked missing even right after a successful `apply`).
33
+
34
+ ### Changed
35
+
36
+ - The plan output now surfaces each step's **note inline** (e.g. *"NOT NULL without default
37
+ on existing table — unsafe"*), so *why* a step is flagged is visible without cross-checking
38
+ the warnings block.
39
+
40
+ ## [0.2.0] — 2026-06-28
41
+
42
+ ### Added
43
+
44
+ - **Explicit, run-once migrations.** Drop ordered SQL scripts in `migrations/` (`0001_…sql`)
45
+ for changes the additive diff cannot do safely — renaming a column, type changes with data
46
+ transformation, backfills. Each is tracked by id in the `dbly_state` ledger and runs
47
+ exactly once.
48
+ - On **upgrade**, pending migrations run *before* the object reconciliation, so they reshape
49
+ the schema first. On a **fresh database** they are *baselined* (recorded, not run) since the
50
+ canonical object files already describe the end state.
51
+
52
+ ### Changed
53
+
54
+ - A table touched by a pending migration **defers its additive diff** for that deploy, so an
55
+ explicit rename no longer collides with an auto-generated `ADD COLUMN`.
56
+ - `migrations/` files are excluded from object and drift discovery (they are imperative
57
+ scripts, not declarative objects).
58
+
59
+ ## [0.1.0] — 2026-06-27
60
+
61
+ ### Added
62
+
63
+ - **Indexes and sequences as first-class managed objects.** Correct identity (index name +
64
+ indexed-table schema), dependency-safe ordering (sequences → tables → indexes), and
65
+ create-if-missing handling (no more erroneous re-apply of non-idempotent
66
+ `CREATE INDEX`/`CREATE SEQUENCE`).
67
+ - **Live inventory introspection** across tables, views, functions, procedures, triggers,
68
+ indexes and sequences for all four engines, with a canonical source hash for procedural
69
+ objects.
70
+ - **Real drift detection in `dbly check`** — reports missing (in repo, not in DB), orphaned
71
+ (`--orphans`), table column drift, and advisory definition drift; schema-normalized so an
72
+ unqualified repo object matches the engine's implicit schema.
73
+
74
+ ## [0.0.1] — 2026-06-27
75
+
76
+ ### Added
77
+
78
+ - Initial release. **State-based, cross-engine database deployment** (PostgreSQL, SQL Server,
79
+ Oracle, SQLite), git-driven and parser-assisted (sqlglot).
80
+ - `plan` / `apply` workflow with additive table diffs generated from
81
+ `CREATE TABLE IF NOT EXISTS` (destructive changes flagged, never auto-applied).
82
+ - `plan --sql` — exports a self-contained SQL script for a hand/offline deploy.
83
+ - `dbly init` — privileged greenfield groundwork (`CREATE DATABASE`/roles/extensions).
84
+ - Pre-/post-deploy hooks accepting `.sql` and `.py` (configurable interpreter, e.g. ArcPy).
85
+ - Connection profiles (DBFit-compatible `connection.properties`) with `${ENV}` placeholders
86
+ for CI/CD.
87
+ - CI and PyPI publish workflows (trusted publishing).
88
+
89
+ [Unreleased]: https://github.com/angrydat/dbly/compare/v0.2.0...HEAD
90
+ [0.2.0]: https://github.com/angrydat/dbly/compare/v0.1.0...v0.2.0
91
+ [0.1.0]: https://github.com/angrydat/dbly/compare/v0.0.1...v0.1.0
92
+ [0.0.1]: https://github.com/angrydat/dbly/releases/tag/v0.0.1
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbly
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: State-based, cross-engine database deployment — git-driven, parser-assisted, SQL-first.
5
5
  Project-URL: Homepage, https://angrydata.info/dbly
6
6
  Project-URL: Repository, https://github.com/angrydat/dbly
7
7
  Project-URL: Issues, https://github.com/angrydat/dbly/issues
8
+ Project-URL: Changelog, https://github.com/angrydat/dbly/blob/main/CHANGELOG.md
8
9
  Author-email: Jürgen Zornig <info@angrydata.info>
9
10
  License: MIT
10
11
  Keywords: ci-cd,database,deployment,git,migration,oracle,postgres,schema,sql,sqlite,sqlserver,state-based
dbly-0.3.0/TODO.md ADDED
@@ -0,0 +1,70 @@
1
+ # dbly — TODO / Ideen-Backlog
2
+
3
+ Kurzliste möglicher Verbesserungen, entstanden aus dem Vergleich mit **Atlas**
4
+ (atlasgo.io). Das sind Funktionen, die Atlas (teils nur in der kommerziellen
5
+ Pro-Edition) bietet und die dbly aktuell **nicht** hat — als Anregung, nicht als
6
+ Roadmap. dbly-Stärken (frei, MIT, Oracle/MSSQL nativ, `.py`-Hooks, `plan --sql`)
7
+ bleiben der Ausgangspunkt.
8
+
9
+ ## Migrations- & Sicherheits-Analyse
10
+ - **Migration-Linting / Analyzer**: destruktive/riskante Schritte erkennen und
11
+ warnen bzw. blocken (Atlas `migrate lint`). dbly kennt bisher nur additiv vs.
12
+ destruktiv + `--allow-destructive`.
13
+ - **Custom Schema Rules / Policies** (z. B. „jede Tabelle braucht PK", Namens-
14
+ konventionen, Boolean = NUMBER(1) CHECK (0,-1) erzwingen).
15
+ - **Pre-Migration-Checks** (Vorbedingungen prüfen, z. B. Spalte leer vor NOT NULL).
16
+ - **PII-/Security-as-Code**: Spalten als sensibel markieren; RLS deklarativ.
17
+
18
+ ## Sichtbarkeit
19
+ - **ERD / Schema-Visualisierung** + Auto-Doku aus dem Objektmodell.
20
+ - **Schema-Registry** / zentrale „source of truth" mit Versionsvergleich über
21
+ Umgebungen hinweg.
22
+ - **Kontinuierliches Drift-Monitoring**: dbly hat `check` on-demand; es fehlt ein
23
+ laufendes/alarmierendes Monitoring.
24
+ - ~~**Ref-Dekoration in `status` / Plan-Header** (Komfort)~~ ✅ **erledigt (0.3.0)**:
25
+ `status` und der Plan-Header zeigen jetzt Tag-/Branch-Namen neben dem SHA
26
+ (`deployed ref: v0.1, main (9ff5e440)`), aufgelöst via `git tag/branch --points-at`.
27
+ Der Ledger speichert weiterhin nur den SHA.
28
+
29
+ ## Autoren-Erlebnis
30
+ - Zusätzliche **Schema-Sprache (HCL o. ä.)** und/oder **ORM-Loader** — DB-agnostische
31
+ Definition neben plain SQL.
32
+ - **Testing-Framework** für Schema/Objekte (Atlas `atlas test`).
33
+ - **Interaktive Migrations / Checkpoints** für lange Migrationsketten.
34
+ - ~~**Plan gegen den Working-Tree** (uncommittete Änderungen)~~ ✅ **erledigt (0.3.0)**:
35
+ `dbly plan --worktree` (Alias `--dirty`) und `dbly check --worktree` planen/prüfen
36
+ den Arbeitsstand inkl. ungetrackter neuer Objektdateien. Preview-only — `apply`
37
+ braucht weiterhin einen committeten Ref für den Ledger.
38
+
39
+ ## Integration
40
+ - Breitere fertige **CI-Rezepte** (GitLab, Azure DevOps, CircleCI) — aktuell nur
41
+ GH-Actions/Bitbucket-Beispiele.
42
+ - **Terraform-Provider / K8s-Operator** (GitOps).
43
+ - **Rollout-/Approval-Flow** (Freigabe vor Apply in Prod).
44
+
45
+ ## Bekannte Design-Frage (aus dem MA31-PoC)
46
+ - Im Planner werden **Tabellen in Changeset-Reihenfolge** emittiert;
47
+ `topological_order` wird nur auf *replaceable* (Views/Functions) angewandt. Bei
48
+ Inline-FKs zwischen Tabellen ist auf einem frischen Ziel die Anlege-Reihenfolge
49
+ dann nicht FK-sicher — prüfen, ob Tabellen ebenfalls topologisch sortiert werden
50
+ sollten (oder ob der Adapter das per retry-until-stable abfängt).
51
+
52
+ ## Bugs (aus MA31-PoC, Oracle)
53
+
54
+ - ~~**Spalten-Typänderung wird nicht erkannt.**~~ ✅ **erledigt (0.3.0)**: Der
55
+ Spalten-Diff verglich nur den Namen; `NUMBER` → `NUMBER(10)` blieb unbemerkt. Jetzt
56
+ wird bei geändertem Typ ein `ALTER TABLE … MODIFY`/`ALTER COLUMN`-Schritt erzeugt
57
+ (als `destructive` markiert, nie auto-appliziert, mit Datenkompatibilitäts-Warnung).
58
+ Der Vergleich ist Precision/Scale-bewusst (`NUMBER(10)` ≡ `NUMBER(10,0)`) und
59
+ normalisiert Introspektions-Rauschen (z. B. SQL-Server `COLLATE`) gegen False Positives.
60
+ - ~~**ADD COLUMN fälschlich als „destructive" klassifiziert.**~~ ✅ **geklärt (0.3.0)**:
61
+ `TESTFELD` war `NOT NULL` **ohne** Default — die `destructive`-Einstufung ist damit
62
+ korrekt (kann auf einer befüllten Tabelle nicht sicher hinzugefügt werden). Verbessert:
63
+ der Grund (*„NOT NULL without default … — unsafe"*) wird jetzt als Hinweis direkt am
64
+ Plan-Schritt angezeigt, nicht nur im Warnungsblock.
65
+ - ~~**`check` meldet vorhandene Objekte als „missing".**~~ ✅ **erledigt (0.3.0)**:
66
+ Ursache war eine asymmetrische Key-Normalisierung — der Oracle-Adapter setzte kein
67
+ `default_schema` und ließ den Owner in `inventory()` weg, sodass die Live-Keys
68
+ (`table:<name>`) nicht zu den Desired-Keys (`table:dbb.<name>`) passten. Der Adapter
69
+ löst `default_schema` jetzt zum verbundenen `USER` auf und führt den Owner mit; beide
70
+ Seiten normalisieren identisch.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dbly"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "State-based, cross-engine database deployment — git-driven, parser-assisted, SQL-first."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -54,6 +54,7 @@ all = ["oracledb>=2.0", "pymssql>=2.3"]
54
54
  Homepage = "https://angrydata.info/dbly"
55
55
  Repository = "https://github.com/angrydat/dbly"
56
56
  Issues = "https://github.com/angrydat/dbly/issues"
57
+ Changelog = "https://github.com/angrydat/dbly/blob/main/CHANGELOG.md"
57
58
 
58
59
  [project.scripts]
59
60
  dbly = "dbly.cli:main"
@@ -4,4 +4,4 @@ git (what changed) + sqlglot (what it is) + live introspection (what's really th
4
4
  See CONCEPT.md for the design rationale.
5
5
  """
6
6
 
7
- __version__ = "0.2.0"
7
+ __version__ = "0.3.0"
@@ -71,6 +71,15 @@ class Adapter(abc.ABC):
71
71
  dialect-agnostic and delegates the rendering here.
72
72
  """
73
73
 
74
+ @abc.abstractmethod
75
+ def modify_column_sql(self, table: ObjectId, col: Column) -> str:
76
+ """Generate the ``ALTER TABLE … <MODIFY|ALTER COLUMN>`` that changes a column's type.
77
+
78
+ Oracle uses ``MODIFY``; Postgres uses ``ALTER COLUMN … TYPE``; T-SQL uses
79
+ ``ALTER COLUMN``. SQLite cannot change a column's type in place and returns a
80
+ commented no-op (a type change there needs a manual table rebuild).
81
+ """
82
+
74
83
  # --- execution ---------------------------------------------------------------------
75
84
  @abc.abstractmethod
76
85
  def apply(self, statements: list[str]) -> None:
@@ -125,6 +125,10 @@ class MssqlAdapter(Adapter):
125
125
  parts.append(f"DEFAULT {col.default}")
126
126
  return " ".join(parts) + ";"
127
127
 
128
+ def modify_column_sql(self, table: ObjectId, col: Column) -> str:
129
+ # T-SQL ALTER COLUMN preserves nullability unless restated; type only here.
130
+ return f"ALTER TABLE {table} ALTER COLUMN {col.name} {col.type};"
131
+
128
132
  def apply(self, statements: list[str]) -> None:
129
133
  with self.engine.begin() as conn:
130
134
  for stmt in statements:
@@ -89,6 +89,22 @@ class OracleAdapter(Adapter):
89
89
  # unquoted Oracle identifiers are upper-cased on storage
90
90
  return ident.upper() if ident else ident
91
91
 
92
+ @property
93
+ def default_schema(self) -> str | None: # type: ignore[override]
94
+ """The connected user — Oracle's implicit schema for unqualified objects.
95
+
96
+ Resolved from the live session (``SELECT USER``) and cached. Drift matching uses this
97
+ so a repo object under folder ``dbb`` aligns with the live ``DBB``-owned object;
98
+ without it every object read back by ``inventory()`` looked "missing" (owner dropped
99
+ on one side of the key). Same source as ``inventory()``'s ``owner = USER`` filter.
100
+ """
101
+ cached = getattr(self, "_ds_cache", None)
102
+ if cached is None:
103
+ with self.engine.connect() as conn:
104
+ cached = conn.exec_driver_sql("SELECT USER FROM dual").scalar()
105
+ self._ds_cache = cached
106
+ return cached
107
+
92
108
  def table_exists(self, schema: str | None, name: str) -> bool:
93
109
  return inspect(self.engine).has_table(self._norm(name), schema=self._norm(schema))
94
110
 
@@ -139,6 +155,7 @@ class OracleAdapter(Adapter):
139
155
  "('FUNCTION','PROCEDURE','TRIGGER','PACKAGE','PACKAGE BODY','TYPE') "
140
156
  "ORDER BY name, type, line"
141
157
  )
158
+ owner = self.default_schema # objects are filtered by owner = USER — carry it on the id
142
159
  found: dict[str, LiveObject] = {}
143
160
  src_text: dict[str, list[str]] = {}
144
161
  with self.engine.connect() as conn:
@@ -146,13 +163,13 @@ class OracleAdapter(Adapter):
146
163
  kind = self._TYPEMAP.get(otype)
147
164
  if kind is None:
148
165
  continue
149
- obj = LiveObject(kind, ObjectId(None, name))
166
+ obj = LiveObject(kind, ObjectId(owner, name))
150
167
  found[obj.key()] = obj
151
168
  for name, otype, line in conn.execute(sources):
152
169
  kind = self._TYPEMAP.get(otype)
153
170
  if kind is None:
154
171
  continue
155
- key = LiveObject(kind, ObjectId(None, name)).key()
172
+ key = LiveObject(kind, ObjectId(owner, name)).key()
156
173
  src_text.setdefault(key, []).append(line or "")
157
174
  for key, lines in src_text.items():
158
175
  if key in found:
@@ -167,6 +184,9 @@ class OracleAdapter(Adapter):
167
184
  parts.append("NOT NULL")
168
185
  return " ".join(parts) + ";"
169
186
 
187
+ def modify_column_sql(self, table: ObjectId, col: Column) -> str:
188
+ return f"ALTER TABLE {table} MODIFY {col.name} {col.type};"
189
+
170
190
  def apply(self, statements: list[str]) -> None:
171
191
  # No transactional rollback (DDL auto-commits). Sequential; commit covers any DML.
172
192
  with self.engine.connect() as conn:
@@ -111,6 +111,9 @@ class PostgresAdapter(Adapter):
111
111
  parts.append(f"DEFAULT {col.default}")
112
112
  return " ".join(parts) + ";"
113
113
 
114
+ def modify_column_sql(self, table: ObjectId, col: Column) -> str:
115
+ return f"ALTER TABLE {table} ALTER COLUMN {col.name} TYPE {col.type};"
116
+
114
117
  def apply(self, statements: list[str]) -> None:
115
118
  # transactional DDL → one atomic transaction
116
119
  with self.engine.begin() as conn:
@@ -80,6 +80,14 @@ class SqliteAdapter(Adapter):
80
80
  parts.append(f"DEFAULT {col.default}")
81
81
  return " ".join(parts) + ";"
82
82
 
83
+ def modify_column_sql(self, table: ObjectId, col: Column) -> str:
84
+ # SQLite cannot change a column's type in place — a rebuild is required. Emit a
85
+ # commented no-op so the (destructive, never auto-applied) step documents the limit.
86
+ return (
87
+ f"-- SQLite cannot ALTER COLUMN {table.name}.{col.name} to {col.type} "
88
+ "in place; rebuild the table manually."
89
+ )
90
+
83
91
  def apply(self, statements: list[str]) -> None:
84
92
  with self.engine.begin() as conn:
85
93
  for stmt in statements:
@@ -21,7 +21,7 @@ from dbly.engine import detect_dialect
21
21
  from dbly.parsing import sqlglot_dialect
22
22
  from dbly.planner import build_plan
23
23
  from dbly.model import Plan, Severity
24
- from dbly.repo import Repo
24
+ from dbly.repo import WORKTREE, Repo
25
25
 
26
26
  app = typer.Typer(
27
27
  name="dbly",
@@ -48,13 +48,32 @@ def _main(
48
48
  pass
49
49
 
50
50
 
51
- def _make_plan(repo_path: Path, target: str, from_ref: Optional[str], to_ref: str) -> Plan:
51
+ def _decorations(repo_path: Path, plan: Plan) -> dict[str, str]:
52
+ """Map each real ref in the plan to its git tag/branch names (for the plan header)."""
53
+ try:
54
+ repo = Repo(repo_path)
55
+ except ValueError:
56
+ return {}
57
+ out: dict[str, str] = {}
58
+ for ref in {plan.from_ref, plan.to_ref}:
59
+ if not ref or ref == WORKTREE:
60
+ continue
61
+ names = repo.ref_names(ref)
62
+ if names:
63
+ out[ref] = ", ".join(names)
64
+ return out
65
+
66
+
67
+ def _make_plan(
68
+ repo_path: Path, target: str, from_ref: Optional[str], to_ref: str,
69
+ *, worktree: bool = False,
70
+ ) -> Plan:
52
71
  repo = Repo(repo_path)
53
72
  cfg = resolve_target(target)
54
73
  dialect = sqlglot_dialect(detect_dialect(cfg))
55
74
  adapter = get_adapter(cfg)
56
75
  try:
57
- resolved_to = repo.resolve_ref(to_ref)
76
+ resolved_to = repo.resolve_ref(WORKTREE if worktree else to_ref)
58
77
  if from_ref is not None:
59
78
  resolved_from = repo.resolve_ref(from_ref)
60
79
  else:
@@ -80,10 +99,15 @@ def plan(
80
99
  sql: Optional[Path] = typer.Option(
81
100
  None, "--sql", help="write an executable SQL script for a hand/offline deploy."
82
101
  ),
102
+ worktree: bool = typer.Option(
103
+ False, "--worktree", "--dirty",
104
+ help="plan against the working tree (uncommitted + untracked object files), "
105
+ "not a git ref — for the fast edit→plan loop.",
106
+ ),
83
107
  ) -> None:
84
108
  """Compute and show the deployment plan."""
85
- plan_obj = _make_plan(repo_path, target, from_ref, to)
86
- report.render_plan(plan_obj, console)
109
+ plan_obj = _make_plan(repo_path, target, from_ref, to, worktree=worktree)
110
+ report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
87
111
  if out:
88
112
  out.write_text(report.plan_to_yaml(plan_obj), encoding="utf-8")
89
113
  console.print(f"\n[dim]plan (YAML) written to {out}[/dim]")
@@ -123,7 +147,7 @@ def apply(
123
147
  else:
124
148
  plan_obj = _make_plan(repo_path, target, from_ref, to)
125
149
 
126
- report.render_plan(plan_obj, console)
150
+ report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
127
151
 
128
152
  destructive = [s for s in plan_obj.steps if s.severity is Severity.DESTRUCTIVE]
129
153
  if destructive and not allow_destructive:
@@ -203,12 +227,15 @@ def bootstrap(
203
227
  ) -> None:
204
228
  """Install into an empty database (no baseline — full apply)."""
205
229
  plan_obj = _make_plan(repo_path, target, None, to)
206
- report.render_plan(plan_obj, console)
230
+ report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
207
231
  console.print("\n[dim]review, then run `dbly apply` to execute.[/dim]")
208
232
 
209
233
 
210
234
  @app.command()
211
- def status(target: str = typer.Option(..., "--target")) -> None:
235
+ def status(
236
+ target: str = typer.Option(..., "--target"),
237
+ repo_path: Path = typer.Option(Path("."), "--repo", help="repository root (for ref names)."),
238
+ ) -> None:
212
239
  """Show the deployed ref recorded on the target."""
213
240
  cfg = resolve_target(target)
214
241
  adapter = get_adapter(cfg)
@@ -216,10 +243,17 @@ def status(target: str = typer.Option(..., "--target")) -> None:
216
243
  ref = adapter.get_deployed_ref()
217
244
  finally:
218
245
  adapter.dispose()
219
- if ref:
220
- console.print(f"deployed ref: [cyan]{ref}[/cyan]")
221
- else:
246
+ if not ref:
222
247
  console.print("[yellow]no deploy recorded — database is unmanaged or empty.[/yellow]")
248
+ return
249
+ try:
250
+ names = Repo(repo_path).ref_names(ref)
251
+ except ValueError:
252
+ names = [] # not a git repo — SHA only
253
+ if names:
254
+ console.print(f"deployed ref: [cyan]{', '.join(names)}[/cyan] [dim]({ref[:8]})[/dim]")
255
+ else:
256
+ console.print(f"deployed ref: [cyan]{ref}[/cyan]")
223
257
 
224
258
 
225
259
  @app.command()
@@ -230,6 +264,10 @@ def check(
230
264
  orphans: bool = typer.Option(
231
265
  False, "--orphans", help="also report objects in the DB but not in the repo."
232
266
  ),
267
+ worktree: bool = typer.Option(
268
+ False, "--worktree", "--dirty",
269
+ help="compare the working tree (uncommitted + untracked) against the DB, not a git ref.",
270
+ ),
233
271
  ) -> None:
234
272
  """Detect drift: compare the desired state at <to> against the live database."""
235
273
  repo = Repo(repo_path)
@@ -239,7 +277,8 @@ def check(
239
277
  try:
240
278
  rep = drift.compute_drift(
241
279
  repo, adapter,
242
- to_ref=repo.resolve_ref(to), dialect=dialect, include_orphans=orphans,
280
+ to_ref=repo.resolve_ref(WORKTREE if worktree else to),
281
+ dialect=dialect, include_orphans=orphans,
243
282
  )
244
283
  finally:
245
284
  adapter.dispose()
@@ -11,6 +11,7 @@ applied verbatim per dialect by the adapter.
11
11
  from __future__ import annotations
12
12
 
13
13
  import hashlib
14
+ import re
14
15
  from pathlib import Path
15
16
 
16
17
  import sqlglot
@@ -193,6 +194,41 @@ def desired_columns(sql: str, *, dialect: str | None = None) -> list[Column]:
193
194
  return columns
194
195
 
195
196
 
197
+ _NUMERIC_TYPES = {"NUMBER", "NUMERIC", "DECIMAL", "DEC"}
198
+
199
+
200
+ def canonical_type(type_str: str) -> str:
201
+ """Normalize a column type for cross-boundary comparison (desired vs. introspected).
202
+
203
+ The desired side is rendered by sqlglot (e.g. ``NUMBER(10)``); the actual side comes from
204
+ SQLAlchemy introspection (e.g. ``NUMBER(10, 0)``). Whitespace/case and an *omitted numeric
205
+ scale* are normalized so semantically-equal types compare equal — while a genuine
206
+ precision/scale change (``NUMBER`` → ``NUMBER(10)``) is preserved.
207
+
208
+ Honest scope: this is a string-level normalizer, not a type system. Exotic spellings that
209
+ differ across the sqlglot↔SQLAlchemy boundary may still compare unequal; a resulting
210
+ MODIFY step is flagged destructive (never auto-applied) and carries a warning, so a false
211
+ positive surfaces as a reviewable note rather than silent data loss.
212
+ """
213
+ s = " ".join(type_str.strip().upper().split())
214
+ # Drop introspection decorations that carry no shape info (e.g. SQL Server appends
215
+ # `COLLATE "SQL_Latin1_General_CP1_CI_AS"` to reflected string types).
216
+ s = re.split(r"\s+COLLATE\s+", s, maxsplit=1)[0].strip()
217
+ m = re.match(r"^([A-Z0-9_ ]+?)\s*\(([^)]*)\)\s*$", s)
218
+ if not m:
219
+ return s.replace(" ", "")
220
+ base = m.group(1).strip().replace(" ", "")
221
+ params = [p.strip() for p in m.group(2).split(",") if p.strip()]
222
+ if base in _NUMERIC_TYPES and len(params) == 1:
223
+ params.append("0") # NUMBER(p) ≡ NUMBER(p,0) — scale defaults to 0
224
+ return f"{base}({','.join(params)})"
225
+
226
+
227
+ def types_differ(desired: str, actual: str) -> bool:
228
+ """Whether a declared (desired) column type differs from the live (actual) one."""
229
+ return canonical_type(desired) != canonical_type(actual)
230
+
231
+
196
232
  def topological_order(objects: list[ParsedObject]) -> list[ParsedObject]:
197
233
  """Order replaceable objects so dependencies come first (CONCEPT.md §8).
198
234
 
@@ -174,6 +174,28 @@ def _plan_table(adapter: Adapter, plan: Plan, obj: ParsedObject, dialect: str |
174
174
  )
175
175
  )
176
176
 
177
+ # type changes: column present on both sides, but the declared type differs. Never
178
+ # auto-applied (a narrowing/incompatible change can truncate or fail) — flagged for review.
179
+ for col in desired:
180
+ actual_col = actual_by_key.get(col.key())
181
+ if actual_col is None or not parsing.types_differ(col.type, actual_col.type):
182
+ continue
183
+ plan.steps.append(
184
+ Step(
185
+ title=f"modify column {obj.id}.{col.name} type → {col.type}",
186
+ object_id=obj.id,
187
+ kind=ObjectKind.TABLE,
188
+ severity=Severity.DESTRUCTIVE,
189
+ sql=adapter.modify_column_sql(obj.id, col),
190
+ source_file=obj.source_file,
191
+ note=f"type change {actual_col.type} → {col.type} — may truncate/convert data",
192
+ )
193
+ )
194
+ plan.warnings.append(
195
+ f"{obj.id}.{col.name}: type change {actual_col.type} → {col.type} is not "
196
+ "auto-applied — review for data compatibility, then use --allow-destructive"
197
+ )
198
+
177
199
  # destructive: columns present in actual, gone from desired
178
200
  for col in actual:
179
201
  if col.key() not in desired_by_key:
@@ -17,6 +17,7 @@ from dbly.model import ChangeType
17
17
 
18
18
  _SQL_SUFFIXES = {".sql", ".tbl", ".vw", ".prc", ".fnc", ".pkg", ".trg", ".typ", ".ddl"}
19
19
  MIGRATIONS_DIR = "migrations" # run-once scripts — not declarative objects
20
+ WORKTREE = "WORKTREE" # sentinel `to_ref`: plan/check against the working tree, not a git ref
20
21
 
21
22
 
22
23
  @dataclass(slots=True)
@@ -61,15 +62,39 @@ class Repo:
61
62
  """A deployable declarative object file (SQL, not a migration, not ignored)."""
62
63
  return self._is_sql(rel) and not self._is_migration(rel) and not self.is_ignored(rel)
63
64
 
65
+ def _untracked_files(self) -> list[Path]:
66
+ """New files in the working tree not yet added to git (``git status`` "??")."""
67
+ raw = self._git("ls-files", "--others", "--exclude-standard", "-z")
68
+ return [Path(n) for n in filter(None, raw.split("\0"))]
69
+
70
+ def _worktree_paths(self) -> list[Path]:
71
+ """Every file in the working tree: tracked (``ls-files``) + untracked new files."""
72
+ raw = self._git("ls-files", "-z")
73
+ tracked = [Path(n) for n in filter(None, raw.split("\0"))]
74
+ return tracked + self._untracked_files()
75
+
64
76
  def changed_files(self, from_ref: str | None, to_ref: str) -> list[FileChange]:
65
77
  """Files changed between two refs (or the full tree at ``to_ref`` for bootstrap)."""
66
78
  if from_ref is None:
67
79
  return [FileChange(p, ChangeType.ADDED) for p in self.list_files(to_ref)]
80
+ if to_ref == WORKTREE:
81
+ # `git diff <from>` (no second ref) diffs the ref against the working tree
82
+ # (staged + unstaged tracked changes); untracked new files are added separately.
83
+ changes = self._parse_name_status(
84
+ self._git("diff", "--name-status", "-z", from_ref)
85
+ )
86
+ known = {c.path for c in changes}
87
+ for p in self._untracked_files():
88
+ if self._is_object(p) and p not in known:
89
+ changes.append(FileChange(p, ChangeType.ADDED))
90
+ return changes
68
91
  raw = self._git("diff", "--name-status", "-z", f"{from_ref}..{to_ref}")
69
92
  return self._parse_name_status(raw)
70
93
 
71
94
  def list_files(self, ref: str) -> list[Path]:
72
95
  """All deployable declarative object files present at ``ref`` (excludes migrations)."""
96
+ if ref == WORKTREE:
97
+ return [p for p in self._worktree_paths() if self._is_object(p)]
73
98
  raw = self._git("ls-tree", "-r", "--name-only", "-z", ref)
74
99
  return [Path(n) for n in filter(None, raw.split("\0")) if self._is_object(Path(n))]
75
100
 
@@ -78,11 +103,12 @@ class Repo:
78
103
 
79
104
  Id is the filename; order is lexicographic, so prefix files ``0001_…`` / a timestamp.
80
105
  """
81
- raw = self._git("ls-tree", "-r", "--name-only", "-z", ref)
82
- out = [
83
- Path(n) for n in filter(None, raw.split("\0"))
84
- if self._is_migration(Path(n)) and Path(n).suffix.lower() == ".sql"
85
- ]
106
+ if ref == WORKTREE:
107
+ names = self._worktree_paths()
108
+ else:
109
+ raw = self._git("ls-tree", "-r", "--name-only", "-z", ref)
110
+ names = [Path(n) for n in filter(None, raw.split("\0"))]
111
+ out = [p for p in names if self._is_migration(p) and p.suffix.lower() == ".sql"]
86
112
  return [(p.name, p) for p in sorted(out, key=lambda p: p.as_posix())]
87
113
 
88
114
  def _parse_name_status(self, raw: str) -> list[FileChange]:
@@ -111,12 +137,33 @@ class Repo:
111
137
  """Resolve a symbolic ref (HEAD, a tag, a branch) to its immutable commit SHA.
112
138
 
113
139
  The ledger and plan headers store the SHA, not ``HEAD`` — so a later ``--from``
114
- diff is stable regardless of where HEAD has since moved.
140
+ diff is stable regardless of where HEAD has since moved. The ``WORKTREE`` sentinel
141
+ has no SHA and is returned unchanged.
115
142
  """
143
+ if ref == WORKTREE:
144
+ return WORKTREE
116
145
  return self._git("rev-parse", ref).strip()
117
146
 
147
+ def ref_names(self, sha: str) -> list[str]:
148
+ """Tag and branch names pointing exactly at ``sha`` — for git-style plan/status
149
+ decorations (e.g. ``v0.1, main``). Display-only; the ledger still stores the SHA."""
150
+ names: list[str] = []
151
+ try:
152
+ names += [n for n in self._git("tag", "--points-at", sha).split() if n]
153
+ names += [
154
+ n for n in self._git(
155
+ "branch", "--points-at", sha, "--format=%(refname:short)"
156
+ ).split() if n
157
+ ]
158
+ except subprocess.CalledProcessError:
159
+ return []
160
+ seen: set[str] = set()
161
+ return [n for n in names if not (n in seen or seen.add(n))]
162
+
118
163
  def read_at(self, ref: str, rel: Path) -> str:
119
164
  """File content at a given ref (the *desired* state)."""
165
+ if ref == WORKTREE:
166
+ return (self.root / rel).read_text(encoding="utf-8")
120
167
  return self._git("show", f"{ref}:{rel.as_posix()}")
121
168
 
122
169
  def schema_for(self, rel: Path) -> str | None:
@@ -14,10 +14,23 @@ from rich.table import Table
14
14
  from dbly.model import Migration, ObjectId, ObjectKind, Plan, Severity, Step
15
15
 
16
16
 
17
- def render_plan(plan: Plan, console: Console) -> None:
17
+ def _decorate_ref(ref: str | None, ref_names: dict[str, str] | None) -> str:
18
+ """Render a ref for the plan header: git-style ``<names> (<short-sha>)`` when known."""
19
+ if not ref:
20
+ return "∅"
21
+ if ref == "WORKTREE":
22
+ return "working tree"
23
+ if ref_names and ref_names.get(ref):
24
+ return f"{ref_names[ref]} ({ref[:8]})"
25
+ return ref
26
+
27
+
28
+ def render_plan(
29
+ plan: Plan, console: Console, *, ref_names: dict[str, str] | None = None
30
+ ) -> None:
18
31
  console.print(
19
32
  f"[bold]Plan[/bold] for [cyan]{plan.target}[/cyan] "
20
- f"{plan.from_ref or '∅'} → {plan.to_ref}"
33
+ f"{_decorate_ref(plan.from_ref, ref_names)} → {_decorate_ref(plan.to_ref, ref_names)}"
21
34
  )
22
35
  if not plan.steps and not plan.warnings and not plan.migrations and not plan.baselined:
23
36
  console.print("[green]nothing to do — target is up to date[/green]")
@@ -38,11 +51,14 @@ def render_plan(plan: Plan, console: Console) -> None:
38
51
  table.add_column("step")
39
52
  for i, step in enumerate(plan.steps, 1):
40
53
  sev_style = "red" if step.severity is Severity.DESTRUCTIVE else "green"
54
+ title = step.title
55
+ if step.note: # surface *why* a step is flagged (e.g. NOT NULL without default)
56
+ title += f"\n[dim]↳ {step.note}[/dim]"
41
57
  table.add_row(
42
58
  str(i),
43
59
  f"[{sev_style}]{step.severity.value}[/{sev_style}]",
44
60
  step.kind.value,
45
- step.title,
61
+ title,
46
62
  )
47
63
  console.print(table)
48
64
 
@@ -268,3 +268,103 @@ def test_init_runs_ordered_multistatement_scripts(tmp_path: Path):
268
268
  assert adapter.table_exists(None, "meta")
269
269
  assert adapter.table_exists(None, "audit")
270
270
  adapter.dispose()
271
+
272
+
273
+ def test_type_change_produces_destructive_modify_step(tmp_path: Path):
274
+ from dbly.repo import WORKTREE # noqa: F401 — import guard; used indirectly below
275
+ repo_root = tmp_path / "db"
276
+ repo_root.mkdir()
277
+ _init_repo(repo_root)
278
+ (repo_root / "kunde.tbl").write_text(
279
+ "CREATE TABLE IF NOT EXISTS kunde (id INTEGER, code VARCHAR(10));", encoding="utf-8"
280
+ )
281
+ ref1 = _commit(repo_root, "v1")
282
+
283
+ db = tmp_path / "types.db"
284
+ adapter = SqliteAdapter(ConnectionConfig(environment="sqlite", service=str(db)))
285
+ repo = Repo(repo_root)
286
+ plan1 = build_plan(repo, adapter, from_ref=None, to_ref=ref1,
287
+ target="sqlite", dialect="sqlite")
288
+ adapter.apply([s.sql for s in plan1.steps])
289
+ adapter.record_deploy(ref1, [])
290
+
291
+ # widen code VARCHAR(10) -> VARCHAR(20): a type change, must surface as a MODIFY step
292
+ (repo_root / "kunde.tbl").write_text(
293
+ "CREATE TABLE IF NOT EXISTS kunde (id INTEGER, code VARCHAR(20));", encoding="utf-8"
294
+ )
295
+ ref2 = _commit(repo_root, "v2")
296
+ plan2 = build_plan(repo, adapter, from_ref=ref1, to_ref=ref2,
297
+ target="sqlite", dialect="sqlite")
298
+ mod = [s for s in plan2.steps if "modify column" in s.title]
299
+ assert len(mod) == 1
300
+ assert mod[0].severity is Severity.DESTRUCTIVE # never auto-applied
301
+ assert "code" in mod[0].title
302
+ assert "→" in mod[0].note and "10" in mod[0].note and "20" in mod[0].note
303
+ adapter.dispose()
304
+
305
+
306
+ def test_type_change_no_false_positive_when_unchanged(tmp_path: Path):
307
+ repo_root = tmp_path / "db"
308
+ repo_root.mkdir()
309
+ _init_repo(repo_root)
310
+ (repo_root / "kunde.tbl").write_text(
311
+ "CREATE TABLE IF NOT EXISTS kunde (id INTEGER, code VARCHAR(10));", encoding="utf-8"
312
+ )
313
+ ref1 = _commit(repo_root, "v1")
314
+ db = tmp_path / "stable.db"
315
+ adapter = SqliteAdapter(ConnectionConfig(environment="sqlite", service=str(db)))
316
+ repo = Repo(repo_root)
317
+ plan1 = build_plan(repo, adapter, from_ref=None, to_ref=ref1,
318
+ target="sqlite", dialect="sqlite")
319
+ adapter.apply([s.sql for s in plan1.steps])
320
+ adapter.record_deploy(ref1, [])
321
+ # re-plan the same schema against the live DB → no phantom modify step
322
+ plan2 = build_plan(repo, adapter, from_ref=None, to_ref=ref1,
323
+ target="sqlite", dialect="sqlite")
324
+ assert not [s for s in plan2.steps if "modify column" in s.title]
325
+ adapter.dispose()
326
+
327
+
328
+ def test_plan_against_worktree_sees_uncommitted_and_untracked(tmp_path: Path):
329
+ from dbly.repo import WORKTREE
330
+ repo_root = tmp_path / "db"
331
+ repo_root.mkdir()
332
+ _init_repo(repo_root)
333
+ (repo_root / "kunde.tbl").write_text(
334
+ "CREATE TABLE IF NOT EXISTS kunde (id INTEGER, name TEXT);", encoding="utf-8"
335
+ )
336
+ ref1 = _commit(repo_root, "v1")
337
+
338
+ db = tmp_path / "wt.db"
339
+ adapter = SqliteAdapter(ConnectionConfig(environment="sqlite", service=str(db)))
340
+ repo = Repo(repo_root)
341
+ adapter.apply([s.sql for s in build_plan(
342
+ repo, adapter, from_ref=None, to_ref=ref1, target="sqlite", dialect="sqlite").steps])
343
+ adapter.record_deploy(ref1, [])
344
+
345
+ # edit an existing file (uncommitted) + add a brand-new untracked object file
346
+ (repo_root / "kunde.tbl").write_text(
347
+ "CREATE TABLE IF NOT EXISTS kunde (id INTEGER, name TEXT, email TEXT);", encoding="utf-8"
348
+ )
349
+ (repo_root / "v_kunde.vw").write_text(
350
+ "CREATE VIEW v_kunde AS SELECT id FROM kunde;", encoding="utf-8"
351
+ )
352
+ plan = build_plan(repo, adapter, from_ref=ref1, to_ref=WORKTREE,
353
+ target="sqlite", dialect="sqlite")
354
+ titles = " ".join(s.title for s in plan.steps)
355
+ assert "email" in titles # uncommitted column edit is planned
356
+ assert any(s.kind.value == "view" for s in plan.steps) # untracked new object is planned
357
+ adapter.dispose()
358
+
359
+
360
+ def test_repo_ref_names_decorates_tag_and_branch(tmp_path: Path):
361
+ repo_root = tmp_path / "db"
362
+ repo_root.mkdir()
363
+ _init_repo(repo_root)
364
+ (repo_root / "kunde.tbl").write_text("CREATE TABLE kunde (id INTEGER);", encoding="utf-8")
365
+ sha = _commit(repo_root, "v1")
366
+ _git(repo_root, "tag", "v0.1")
367
+ repo = Repo(repo_root)
368
+ names = repo.ref_names(sha)
369
+ assert "v0.1" in names
370
+ assert any(n in ("main", "master") for n in names) # the current branch points here too
@@ -124,3 +124,39 @@ def test_plan_yaml_roundtrip():
124
124
  assert back.to_ref == "HEAD"
125
125
  assert back.steps[0].severity is Severity.ADDITIVE
126
126
  assert back.warnings == ["something"]
127
+
128
+
129
+ def test_canonical_type_normalizes_numeric_scale_and_collation():
130
+ # omitted scale ≡ scale 0 (Oracle NUMBER(p) == NUMBER(p,0)); case/space-insensitive
131
+ assert parsing.canonical_type("NUMBER(10)") == parsing.canonical_type("number(10, 0)")
132
+ # a genuine precision change is preserved (bug: NUMBER → NUMBER(10) was missed)
133
+ assert parsing.types_differ("NUMBER", "NUMBER(10)")
134
+ assert parsing.types_differ("NUMBER(10)", "NUMBER(12)")
135
+ # SQL Server appends a COLLATE clause on reflected string types — not a real change
136
+ assert not parsing.types_differ("NVARCHAR(100)", 'NVARCHAR(100) COLLATE "SQL_Latin1_CI_AS"')
137
+ assert not parsing.types_differ("VARCHAR2(200)", "varchar2(200)")
138
+
139
+
140
+ def test_render_plan_shows_step_note(capsys):
141
+ from rich.console import Console
142
+ plan = Plan(target="t", from_ref="abc", to_ref="HEAD")
143
+ plan.steps.append(
144
+ Step(
145
+ title="add NOT NULL column app.kunde.flag",
146
+ object_id=None,
147
+ kind=ObjectKind.TABLE,
148
+ severity=Severity.DESTRUCTIVE,
149
+ sql="ALTER TABLE app.kunde ADD flag NUMBER(1) NOT NULL;",
150
+ note="NOT NULL without default on existing table — unsafe",
151
+ )
152
+ )
153
+ report.render_plan(plan, Console(force_terminal=False, width=200))
154
+ out = capsys.readouterr().out
155
+ assert "NOT NULL without default" in out # the hint is surfaced, not just stored
156
+
157
+
158
+ def test_decorate_ref_git_style():
159
+ assert report._decorate_ref(None, None) == "∅"
160
+ assert report._decorate_ref("WORKTREE", None) == "working tree"
161
+ assert report._decorate_ref("9ff5e440abc", {"9ff5e440abc": "v0.1, main"}) == "v0.1, main (9ff5e440)"
162
+ assert report._decorate_ref("deadbeef", None) == "deadbeef" # no names → raw sha
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes