Skip to content

Commit 94c57cb

Browse files
authored
fix(ol_dbt_cli): impact reports BREAKING for deleted models still ref()'d (#2446)
* fix(ol_dbt_cli): use merge-base (three-dot) diff semantics for changed-model detection git_utils.get_changed_files diffed base_ref..HEAD directly (two-dot). Once base_ref advances past the PR's branch point (other PRs merging to main while this one is open), models changed BY OTHERS on main leaked into the PR's changed set, and get_file_at_ref fetched main's newer content as the "base" -- producing spurious BREAKING alerts for changes the PR never made, or masking real ones when main's later edit happened to match. Add resolve_merge_base() and diff/fetch against merge-base(base_ref, HEAD) instead, matching the three-dot semantics GitHub uses to render PR diffs. ol-dbt impact now resolves the merge-base once per run and threads it through to both changed-model detection and base-content fetching so the two stay consistent. validate --changed-only picks up the fix for free since it goes through the same get_changed_sql_models -> get_changed_files path. Also union in untracked new files (git status --porcelain) by default so local runs see in-progress new models that haven't been git-added yet; CI checkouts have none, so this doesn't affect the PR pipeline. git_utils.py had zero tests. Added a scripted fixture repo (branch, advance main independently, assert changed set + fetched content) covering resolve_merge_base, get_changed_files, get_changed_sql_models, and get_file_at_ref. * fix(ol_dbt_cli): use -z for git status/diff parsing, fix docstring mismatch Two issues from PR review: - Sentry: `git status --porcelain` (no -z) C-escapes/quotes paths with whitespace or non-ASCII bytes (e.g. `?? "my model.sql"`, `?? "caf\303\251.sql"`). The naive `line[3:].strip()` parse left the quotes/escapes in, producing a path that doesn't exist on disk and silently dropping the file from the changed set. Same class of bug applied to the `git diff --name-only` calls for tracked files. Switch all four git invocations in get_changed_files to `-z` (NUL-delimited, unquoted) output. - Copilot: get_changed_files' docstring said it returns "tracked files" but untracked files are included by default (include_untracked=True). Fixed the wording. Added a regression test with a space- and unicode-containing filename; confirmed it fails against the pre-fix parsing (returned mangled paths like 'my model.sql"') and passes with -z. * fix(ol_dbt_cli): impact reports BREAKING for deleted models still ref()'d impact.py's per-model loop looked up each changed stem in sql_file_map (built from files currently on disk) and silently `continue`d when a deleted model's stem wasn't found -- so deleting int__combined__users while three marts still ref() it produced "0 breaking", exit 0. Renames hit the same skip on the old stem. `ol-dbt impact --model <typo>` also silently reported success instead of erroring, unlike validate's --model handling. Add git_utils.get_deleted_sql_models(), which diffs --diff-filter=D against the merge-base to map deleted model stems to their last path. Add _analyse_deleted_model(), which reads the model's column set at the merge-base and flags BREAKING for every model whose refs still name it (scanned across all currently-parsed SQL, so it doesn't depend on a fresh manifest reflecting a state dbt would refuse to compile). Wire it into the main loop in place of the silent skip, and error loudly (exit 1) when --model doesn't match any on-disk or just-deleted model. impact.py had zero end-to-end tests. Added a scripted-repo test class covering: deletion with a surviving consumer (BREAKING, exit 1), deletion with no remaining consumers (not BREAKING), and an unknown --model value (exit 1). All three fail against the pre-fix code.
1 parent 435c10e commit 94c57cb

3 files changed

Lines changed: 190 additions & 0 deletions

File tree

src/ol_dbt_cli/ol_dbt_cli/commands/impact.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from ol_dbt_cli.lib.git_utils import (
2222
get_changed_sql_models,
23+
get_deleted_sql_models,
2324
get_file_at_ref,
2425
get_repo_root,
2526
resolve_merge_base,
@@ -366,6 +367,59 @@ def _analyse_model(
366367
)
367368

368369

370+
def _analyse_deleted_model(
371+
model_name: str,
372+
path_at_base: Path,
373+
merge_base: str,
374+
manifest: ManifestRegistry | None,
375+
sql_models_by_name: dict[str, ParsedModel],
376+
repo_root: Path,
377+
) -> ImpactAlert:
378+
"""Return an :class:`ImpactAlert` for *model_name*, deleted since *merge_base*.
379+
380+
The relation no longer exists, so any surviving ``ref()`` to it is broken
381+
outright — not just for specific columns. Consumers are found via textual
382+
``ref()`` scanning of every currently-parsed model (works even without a
383+
manifest, and even though a *fresh* manifest can never itself contain a
384+
dangling ref to a deleted model — dbt parse would already have failed on
385+
it) plus, if a stale manifest still has the deleted node registered, its
386+
recorded children.
387+
"""
388+
base_content = get_file_at_ref(path_at_base, merge_base, repo_root=repo_root)
389+
base_cols: set[str] = set()
390+
if base_content is not None:
391+
base_cols = parse_model_sql_at_content(model_name, base_content).output_columns
392+
393+
consumers = {name for name, parsed in sql_models_by_name.items() if model_name in parsed.refs}
394+
395+
manifest_available = False
396+
if manifest is not None:
397+
manifest_model = manifest.get_model(model_name)
398+
if manifest_model is not None:
399+
manifest_available = True
400+
consumers |= {c.name for c in manifest.get_children(manifest_model.unique_id)}
401+
402+
downstream = [
403+
DownstreamImpact(model_name=c, affected_columns=sorted(base_cols), depth=1) for c in sorted(consumers)
404+
]
405+
406+
if downstream:
407+
level = AlertLevel.BREAKING
408+
msg = f"Model '{model_name}' was deleted but is still ref()'d by {len(downstream)} downstream model(s)."
409+
else:
410+
level = AlertLevel.INFO
411+
msg = f"Model '{model_name}' was deleted — no remaining ref() to it detected."
412+
413+
return ImpactAlert(
414+
level=level,
415+
changed_model=model_name,
416+
column_changes=[ColumnChange(column=c, change_type="removed") for c in sorted(base_cols)],
417+
downstream=downstream,
418+
message=msg,
419+
manifest_available=manifest_available,
420+
)
421+
422+
369423
# ---------------------------------------------------------------------------
370424
# Output rendering
371425
# ---------------------------------------------------------------------------
@@ -618,8 +672,17 @@ def impact(
618672
except Exception as exc: # noqa: BLE001
619673
parse_errors[name] = str(exc)
620674

675+
try:
676+
deleted_models = get_deleted_sql_models(dbt_dir, base_ref=base_ref, repo_root=repo_root)
677+
except RuntimeError as exc:
678+
err_console.print(f"[red]Error resolving git diff:[/] {exc}")
679+
sys.exit(1)
680+
621681
# Determine which models to analyse
622682
if model:
683+
if model not in sql_file_map and model not in deleted_models:
684+
err_console.print(f"[red]Error:[/] --model value '{model}' did not match any model file.")
685+
sys.exit(1)
623686
target_names = [model]
624687
else:
625688
try:
@@ -680,6 +743,11 @@ def impact(
680743
for name in target_names:
681744
sql_file = sql_file_map.get(name)
682745
if sql_file is None:
746+
path_at_base = deleted_models.get(name)
747+
if path_at_base is not None:
748+
alerts.append(
749+
_analyse_deleted_model(name, path_at_base, merge_base, manifest, sql_models_by_name, repo_root)
750+
)
683751
continue
684752
alert = _analyse_model(
685753
name,

src/ol_dbt_cli/ol_dbt_cli/lib/git_utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,33 @@ def get_changed_sql_models(
9999
return names
100100

101101

102+
def get_deleted_sql_models(
103+
dbt_dir: Path,
104+
base_ref: str = "origin/main",
105+
repo_root: Path | None = None,
106+
) -> dict[str, Path]:
107+
"""Return ``{model_name: path_at_merge_base}`` for .sql files deleted since *base_ref*.
108+
109+
Scoped to *dbt_dir*/models/. The returned paths no longer exist on disk —
110+
pass them to :func:`get_file_at_ref` with the merge-base ref (see
111+
:func:`resolve_merge_base`) to read their last content.
112+
"""
113+
root = repo_root or get_repo_root(dbt_dir)
114+
models_dir = dbt_dir / "models"
115+
merge_base = resolve_merge_base(base_ref, repo_root=root)
116+
output = _run_git(["diff", "--name-status", "--diff-filter=D", merge_base, "HEAD"], cwd=root)
117+
deleted: dict[str, Path] = {}
118+
for line in output.splitlines():
119+
parts = line.split("\t")
120+
if len(parts) != 2: # noqa: PLR2004
121+
continue
122+
_status, rel_path = parts
123+
path = root / rel_path
124+
if path.suffix == ".sql" and _is_under(path, models_dir):
125+
deleted[path.stem] = path
126+
return deleted
127+
128+
102129
def get_changed_yaml_models(
103130
dbt_dir: Path,
104131
base_ref: str = "origin/main",

src/ol_dbt_cli/tests/test_impact.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
from __future__ import annotations
44

5+
import subprocess
56
from pathlib import Path
67

8+
import pytest
9+
710
from ol_dbt_cli.commands.impact import _diff_columns
811

912

@@ -481,3 +484,95 @@ def test_downstream_manifest_skips_child_when_sql_confirms_no_consumption(self)
481484
# SQL confirmed no consumption (output_columns doesn't include removed_col
482485
# and no SQL file for qualified-ref analysis) — child should NOT be flagged
483486
assert not any(r.model_name == "child" and r.affected_columns for r in results)
487+
488+
489+
def _git(args: list[str], cwd: Path) -> str:
490+
result = subprocess.run( # noqa: S603, S607
491+
["git", *args],
492+
cwd=cwd,
493+
capture_output=True,
494+
text=True,
495+
check=True,
496+
env={
497+
"GIT_AUTHOR_NAME": "Test",
498+
"GIT_AUTHOR_EMAIL": "test@example.com",
499+
"GIT_COMMITTER_NAME": "Test",
500+
"GIT_COMMITTER_EMAIL": "test@example.com",
501+
"PATH": "/usr/bin:/bin:/usr/local/bin",
502+
},
503+
)
504+
return result.stdout
505+
506+
507+
def _commit(repo: Path, message: str) -> None:
508+
_git(["add", "-A"], cwd=repo)
509+
_git(["commit", "-m", message], cwd=repo)
510+
511+
512+
@pytest.fixture()
513+
def dbt_repo(tmp_path: Path) -> Path:
514+
"""Build a scripted git repo containing a two-model dbt project, ready to have one deleted."""
515+
repo = tmp_path / "repo"
516+
repo.mkdir()
517+
_git(["init", "-b", "main"], cwd=repo)
518+
519+
dbt_dir = repo / "src" / "ol_dbt"
520+
models = dbt_dir / "models"
521+
models.mkdir(parents=True)
522+
(dbt_dir / "dbt_project.yml").write_text("name: open_learning\n")
523+
(models / "int__combined__users.sql").write_text("select 1 as user_id, 'x' as email\n")
524+
(models / "marts__reporting__thing.sql").write_text("select user_id from {{ ref('int__combined__users') }}\n")
525+
_commit(repo, "initial models")
526+
_git(["checkout", "-b", "feature"], cwd=repo)
527+
return repo
528+
529+
530+
class TestImpactCommandDeletedModels:
531+
"""End-to-end tests for the `impact` command's handling of deleted models."""
532+
533+
def test_deleted_model_still_ref_d_is_breaking(self, dbt_repo: Path, capsys: pytest.CaptureFixture[str]) -> None:
534+
"""Deleting a model still ref()'d elsewhere must alert BREAKING and exit 1."""
535+
from ol_dbt_cli.commands.impact import impact
536+
537+
dbt_dir = dbt_repo / "src" / "ol_dbt"
538+
_git(["rm", "models/int__combined__users.sql"], cwd=dbt_dir)
539+
_commit(dbt_repo, "delete int__combined__users")
540+
541+
with pytest.raises(SystemExit) as exc_info:
542+
impact(dbt_dir_path=str(dbt_dir), base_ref="main", output_format="json")
543+
assert exc_info.value.code == 1
544+
545+
import json
546+
547+
alerts = json.loads(capsys.readouterr().out)
548+
deleted_alert = next(a for a in alerts if a["changed_model"] == "int__combined__users")
549+
assert deleted_alert["level"] == "BREAKING"
550+
assert any(d["model"] == "marts__reporting__thing" for d in deleted_alert["downstream"])
551+
552+
def test_deleted_model_with_no_remaining_refs_is_not_breaking(
553+
self, dbt_repo: Path, capsys: pytest.CaptureFixture[str]
554+
) -> None:
555+
"""Deleting a model with no surviving ref() must not alert BREAKING."""
556+
from ol_dbt_cli.commands.impact import impact
557+
558+
dbt_dir = dbt_repo / "src" / "ol_dbt"
559+
# Remove the consumer too, so nothing references the deleted model anymore.
560+
_git(["rm", "models/int__combined__users.sql", "models/marts__reporting__thing.sql"], cwd=dbt_dir)
561+
_commit(dbt_repo, "delete both models")
562+
563+
impact(dbt_dir_path=str(dbt_dir), base_ref="main", output_format="json")
564+
565+
import json
566+
567+
alerts = json.loads(capsys.readouterr().out)
568+
deleted_alert = next(a for a in alerts if a["changed_model"] == "int__combined__users")
569+
assert deleted_alert["level"] != "BREAKING"
570+
571+
def test_unknown_model_flag_errors_loudly(self, dbt_repo: Path) -> None:
572+
"""`--model <typo>` must error and exit non-zero, not silently report success."""
573+
from ol_dbt_cli.commands.impact import impact
574+
575+
dbt_dir = dbt_repo / "src" / "ol_dbt"
576+
with pytest.raises(SystemExit) as exc_info:
577+
impact(dbt_dir_path=str(dbt_dir), model="does_not_exist_typo", base_ref="main", output_format="json")
578+
assert exc_info.value.code == 1

0 commit comments

Comments
 (0)