Skip to content

Commit 435c10e

Browse files
authored
fix(ol_dbt_cli): use merge-base (three-dot) diff semantics for changed-model detection (#2445)
* 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.
1 parent 859b233 commit 435c10e

3 files changed

Lines changed: 204 additions & 26 deletions

File tree

src/ol_dbt_cli/ol_dbt_cli/commands/impact.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
get_changed_sql_models,
2323
get_file_at_ref,
2424
get_repo_root,
25+
resolve_merge_base,
2526
)
2627
from ol_dbt_cli.lib.manifest import (
2728
ManifestRegistry,
@@ -282,7 +283,7 @@ def _get_downstream_yaml(
282283
def _analyse_model(
283284
model_name: str,
284285
sql_file: Path,
285-
base_ref: str,
286+
merge_base: str,
286287
yaml_registry: YamlRegistry,
287288
manifest: ManifestRegistry | None,
288289
sql_models_by_name: dict[str, ParsedModel],
@@ -300,8 +301,10 @@ def _analyse_model(
300301

301302
current_cols = current_parsed.output_columns
302303

303-
# Base (origin/main) column set
304-
base_content = get_file_at_ref(sql_file, base_ref, repo_root=repo_root)
304+
# Base column set, read at the merge-base commit (branch point) rather than
305+
# base_ref's current tip, so commits landed on base_ref after the branch
306+
# point don't leak into the "base" content (see resolve_merge_base).
307+
base_content = get_file_at_ref(sql_file, merge_base, repo_root=repo_root)
305308
if base_content is None:
306309
# New model — no base to compare against
307310
return ImpactAlert(
@@ -565,6 +568,12 @@ def impact(
565568
err_console.print(f"[red]Error:[/] {exc}")
566569
sys.exit(1)
567570

571+
try:
572+
merge_base = resolve_merge_base(base_ref, repo_root=repo_root)
573+
except RuntimeError as exc:
574+
err_console.print(f"[red]Error resolving merge-base vs {base_ref}:[/] {exc}")
575+
sys.exit(1)
576+
568577
models_dir = dbt_dir / "models"
569578

570579
# Resolve compiled SQL directory (Jinja-free, most accurate)
@@ -675,7 +684,7 @@ def impact(
675684
alert = _analyse_model(
676685
name,
677686
sql_file,
678-
base_ref,
687+
merge_base,
679688
yaml_registry,
680689
manifest,
681690
sql_models_by_name,

src/ol_dbt_cli/ol_dbt_cli/lib/git_utils.py

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,34 +31,55 @@ def get_repo_root(start: Path | None = None) -> Path:
3131
raise RuntimeError(msg) from exc
3232

3333

34+
def resolve_merge_base(base_ref: str, repo_root: Path | None = None) -> str:
35+
"""Return the merge-base commit SHA of *base_ref* and HEAD.
36+
37+
This is the fork point where the current branch diverged from *base_ref* —
38+
diffing/fetching against it (rather than *base_ref* directly) gives
39+
three-dot diff semantics, matching what a GitHub PR diff shows. Once
40+
*base_ref* advances past the branch point (e.g. other PRs merge to main
41+
while this one is open), a plain two-dot diff against *base_ref* would
42+
incorrectly include those unrelated changes in the changed set.
43+
"""
44+
root = repo_root or get_repo_root()
45+
return _run_git(["merge-base", base_ref, "HEAD"], cwd=root).strip()
46+
47+
3448
def get_changed_files(
3549
base_ref: str = "origin/main",
3650
repo_root: Path | None = None,
51+
*,
52+
include_untracked: bool = True,
3753
) -> list[Path]:
38-
"""Return absolute paths of tracked files changed vs *base_ref*.
39-
40-
Includes files changed relative to *base_ref* (committed), files staged for
41-
commit, and unstaged modifications to tracked files. Untracked (new) files
42-
that have not yet been ``git add``-ed are **not** included.
54+
"""Return absolute paths of files changed since diverging from *base_ref*.
55+
56+
Diffs against ``merge-base(base_ref, HEAD)`` rather than *base_ref* directly
57+
(three-dot semantics; see :func:`resolve_merge_base`), so commits landed on
58+
*base_ref* after the branch point are excluded. Includes tracked files
59+
changed since the branch point (committed), staged for commit, or with
60+
unstaged modifications. New files that have not yet been ``git add``-ed are
61+
also included by default (*include_untracked*) so local runs see
62+
in-progress new models; set ``include_untracked=False`` to match a strict
63+
porcelain diff (e.g. CI, where checkouts have no untracked files).
4364
"""
4465
root = repo_root or get_repo_root()
45-
# Files changed relative to base ref (committed + staged)
46-
committed = _run_git(
47-
["diff", "--name-only", base_ref, "HEAD"],
48-
cwd=root,
49-
).splitlines()
50-
# Staged but not yet committed
51-
staged = _run_git(
52-
["diff", "--name-only", "--cached"],
53-
cwd=root,
54-
).splitlines()
55-
# Unstaged modifications (tracked files)
56-
unstaged = _run_git(
57-
["diff", "--name-only"],
58-
cwd=root,
59-
).splitlines()
60-
61-
all_relative = set(committed) | set(staged) | set(unstaged)
66+
merge_base = resolve_merge_base(base_ref, repo_root=root)
67+
68+
def _diff_names(*args: str) -> set[str]:
69+
# -z: NUL-delimited, unquoted paths. Without it, git's default porcelain
70+
# quoting (C-style escapes for whitespace/non-ASCII, e.g. "my model.sql"
71+
# or "caf\303\251.sql") corrupts any such path into one that doesn't
72+
# exist on disk, silently dropping it from the changed set.
73+
raw = _run_git(["diff", "--name-only", "-z", *args], cwd=root)
74+
return {p for p in raw.split("\0") if p}
75+
76+
# Files changed since the branch point (committed + staged + unstaged)
77+
all_relative = _diff_names(merge_base, "HEAD") | _diff_names("--cached") | _diff_names()
78+
79+
if include_untracked:
80+
status = _run_git(["status", "--porcelain", "--untracked-files=all", "-z"], cwd=root)
81+
all_relative.update(entry[3:] for entry in status.split("\0") if entry.startswith("??"))
82+
6283
return [root / p for p in sorted(all_relative)]
6384

6485

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Tests for lib/git_utils.py — git diff helpers for changed-model detection."""
2+
3+
from __future__ import annotations
4+
5+
import subprocess
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
from ol_dbt_cli.lib.git_utils import (
11+
get_changed_files,
12+
get_changed_sql_models,
13+
get_file_at_ref,
14+
resolve_merge_base,
15+
)
16+
17+
18+
def _git(args: list[str], cwd: Path) -> str:
19+
result = subprocess.run( # noqa: S603, S607
20+
["git", *args],
21+
cwd=cwd,
22+
capture_output=True,
23+
text=True,
24+
check=True,
25+
env={
26+
"GIT_AUTHOR_NAME": "Test",
27+
"GIT_AUTHOR_EMAIL": "test@example.com",
28+
"GIT_COMMITTER_NAME": "Test",
29+
"GIT_COMMITTER_EMAIL": "test@example.com",
30+
"PATH": "/usr/bin:/bin:/usr/local/bin",
31+
},
32+
)
33+
return result.stdout
34+
35+
36+
def _commit(repo: Path, message: str) -> str:
37+
_git(["add", "-A"], cwd=repo)
38+
_git(["commit", "-m", message], cwd=repo)
39+
return _git(["rev-parse", "HEAD"], cwd=repo).strip()
40+
41+
42+
@pytest.fixture()
43+
def scripted_repo(tmp_path: Path) -> Path:
44+
r"""Build a repo with a PR branch forked from main, plus later main-only commits.
45+
46+
History shape:
47+
main: C0 --- C1 (models/other.sql added; models/foo.sql gets an
48+
\ unrelated edit -- both AFTER the fork)
49+
feature: C0a (models/foo.sql: A,B -> A,C; models/bar.sql added)
50+
51+
C0 is the merge-base of main and feature. C1 exists only on main, after the
52+
branch point:
53+
- a two-dot diff (main..feature) would incorrectly show other.sql as
54+
changed (it exists on main's tip but not on feature); a three-dot /
55+
merge-base diff correctly excludes it.
56+
- fetching foo.sql's "base" content via the raw base_ref tip (main) would
57+
read C1's content (main's own later edit), not the C0 fork-point content
58+
feature actually diverged from.
59+
"""
60+
repo = tmp_path / "repo"
61+
repo.mkdir()
62+
_git(["init", "-b", "main"], cwd=repo)
63+
64+
models = repo / "models"
65+
models.mkdir()
66+
(models / "foo.sql").write_text("select a, b\n")
67+
(models / "_schema.yml").write_text("version: 2\n")
68+
merge_base = _commit(repo, "C0: initial models")
69+
70+
_git(["checkout", "-b", "feature"], cwd=repo)
71+
(models / "foo.sql").write_text("select a, c\n")
72+
(models / "bar.sql").write_text("select x\n")
73+
_commit(repo, "C0a: feature changes foo.sql, adds bar.sql")
74+
75+
_git(["checkout", "main"], cwd=repo)
76+
(models / "other.sql").write_text("select y\n")
77+
(models / "foo.sql").write_text("select a, b, extra\n")
78+
_commit(repo, "C1: main-only changes after the fork")
79+
80+
_git(["checkout", "feature"], cwd=repo)
81+
repo.joinpath(".merge_base_sha").write_text(merge_base)
82+
return repo
83+
84+
85+
class TestResolveMergeBase:
86+
def test_returns_fork_point_not_base_tip(self, scripted_repo: Path) -> None:
87+
expected = scripted_repo.joinpath(".merge_base_sha").read_text()
88+
assert resolve_merge_base("main", repo_root=scripted_repo) == expected
89+
90+
def test_differs_from_base_ref_tip(self, scripted_repo: Path) -> None:
91+
base_tip = _git(["rev-parse", "main"], cwd=scripted_repo).strip()
92+
merge_base = resolve_merge_base("main", repo_root=scripted_repo)
93+
assert merge_base != base_tip
94+
95+
96+
class TestGetChangedFiles:
97+
def test_excludes_main_only_changes_after_fork(self, scripted_repo: Path) -> None:
98+
changed = get_changed_files(base_ref="main", repo_root=scripted_repo, include_untracked=False)
99+
names = {p.name for p in changed}
100+
assert "other.sql" not in names, "main-only commit after the fork leaked into the changed set"
101+
102+
def test_includes_feature_branch_changes(self, scripted_repo: Path) -> None:
103+
changed = get_changed_files(base_ref="main", repo_root=scripted_repo, include_untracked=False)
104+
names = {p.name for p in changed}
105+
assert "foo.sql" in names
106+
assert "bar.sql" in names
107+
108+
def test_includes_untracked_files_by_default(self, scripted_repo: Path) -> None:
109+
(scripted_repo / "models" / "baz.sql").write_text("select z\n")
110+
changed = get_changed_files(base_ref="main", repo_root=scripted_repo)
111+
assert "baz.sql" in {p.name for p in changed}
112+
113+
def test_excludes_untracked_files_when_disabled(self, scripted_repo: Path) -> None:
114+
(scripted_repo / "models" / "baz.sql").write_text("select z\n")
115+
changed = get_changed_files(base_ref="main", repo_root=scripted_repo, include_untracked=False)
116+
assert "baz.sql" not in {p.name for p in changed}
117+
118+
def test_includes_untracked_files_with_special_characters_in_name(self, scripted_repo: Path) -> None:
119+
# git's default porcelain quoting C-escapes paths containing whitespace or
120+
# non-ASCII bytes (e.g. `?? "my model.sql"`); without -z that garbles the
121+
# path into one that doesn't exist on disk and silently drops the file.
122+
(scripted_repo / "models" / "my model.sql").write_text("select 1\n")
123+
(scripted_repo / "models" / "café.sql").write_text("select 2\n")
124+
changed = get_changed_files(base_ref="main", repo_root=scripted_repo)
125+
names = {p.name for p in changed}
126+
assert "my model.sql" in names
127+
assert "café.sql" in names
128+
129+
130+
class TestGetChangedSqlModels:
131+
def test_changed_set_matches_three_dot_semantics(self, scripted_repo: Path) -> None:
132+
names = get_changed_sql_models(scripted_repo, base_ref="main", repo_root=scripted_repo)
133+
assert set(names) == {"foo", "bar"}
134+
135+
136+
class TestGetFileAtRef:
137+
def test_merge_base_content_is_fork_point_not_base_tip(self, scripted_repo: Path) -> None:
138+
merge_base = resolve_merge_base("main", repo_root=scripted_repo)
139+
content = get_file_at_ref(scripted_repo / "models" / "foo.sql", merge_base, repo_root=scripted_repo)
140+
assert content == "select a, b\n"
141+
142+
def test_base_ref_tip_content_is_polluted_by_later_main_commits(self, scripted_repo: Path) -> None:
143+
# Demonstrates the bug this task fixes: fetching against the raw
144+
# base_ref tip (the old behaviour) reads main's own later edit to
145+
# foo.sql (C1), not the C0 fork-point content feature actually
146+
# diverged from -- masking or misrepresenting the PR's real diff.
147+
content = get_file_at_ref(scripted_repo / "models" / "foo.sql", "main", repo_root=scripted_repo)
148+
assert content == "select a, b, extra\n"

0 commit comments

Comments
 (0)