Skip to content

Commit ac4aa32

Browse files
Fix: Include staged changes in the git selector (#5624)
1 parent 563df69 commit ac4aa32

File tree

4 files changed

+266
-2
lines changed

4 files changed

+266
-2
lines changed

docs/guides/model_selection.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,9 @@ Models:
242242
#### Select with git changes
243243

244244
The git-based selector allows you to select models whose files have changed compared to a target branch (default: main). This includes:
245+
245246
- Untracked files (new files not in git)
246-
- Uncommitted changes in working directory
247+
- Uncommitted changes in working directory (both staged and unstaged)
247248
- Committed changes different from the target branch
248249

249250
For example:

sqlmesh/utils/git.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ def list_untracked_files(self) -> t.List[Path]:
1616
)
1717

1818
def list_uncommitted_changed_files(self) -> t.List[Path]:
19-
return self._execute_list_output(["diff", "--name-only", "--diff-filter=d"], self._git_root)
19+
return self._execute_list_output(
20+
["diff", "--name-only", "--diff-filter=d", "HEAD"], self._git_root
21+
)
2022

2123
def list_committed_changed_files(self, target_branch: str = "main") -> t.List[Path]:
2224
return self._execute_list_output(

tests/core/test_selector_native.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import pytest
88
from pytest_mock.plugin import MockerFixture
9+
import subprocess
910

1011
from sqlmesh.core import dialect as d
1112
from sqlmesh.core.audit import StandaloneAudit
@@ -16,6 +17,7 @@
1617
from sqlmesh.core.snapshot import SnapshotChangeCategory
1718
from sqlmesh.utils import UniqueKeyDict
1819
from sqlmesh.utils.date import now_timestamp
20+
from sqlmesh.utils.git import GitClient
1921

2022

2123
@pytest.mark.parametrize(
@@ -634,6 +636,92 @@ def test_expand_git_selection(
634636
git_client_mock.list_untracked_files.assert_called_once()
635637

636638

639+
def test_expand_git_selection_integration(tmp_path: Path, mocker: MockerFixture):
640+
repo_path = tmp_path / "test_repo"
641+
repo_path.mkdir()
642+
subprocess.run(["git", "init", "-b", "main"], cwd=repo_path, check=True, capture_output=True)
643+
644+
models: UniqueKeyDict[str, Model] = UniqueKeyDict("models")
645+
model_a_path = repo_path / "model_a.sql"
646+
model_a_path.write_text("SELECT 1 AS a")
647+
model_a = SqlModel(name="test_model_a", query=d.parse_one("SELECT 1 AS a"))
648+
model_a._path = model_a_path
649+
models[model_a.fqn] = model_a
650+
651+
model_b_path = repo_path / "model_b.sql"
652+
model_b_path.write_text("SELECT 2 AS b")
653+
model_b = SqlModel(name="test_model_b", query=d.parse_one("SELECT 2 AS b"))
654+
model_b._path = model_b_path
655+
models[model_b.fqn] = model_b
656+
657+
subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
658+
subprocess.run(
659+
[
660+
"git",
661+
"-c",
662+
"user.name=Max",
663+
"-c",
664+
"user.email=max@rb.com",
665+
"commit",
666+
"-m",
667+
"Initial commit",
668+
],
669+
cwd=repo_path,
670+
check=True,
671+
capture_output=True,
672+
)
673+
674+
# no changes should select nothing
675+
git_client = GitClient(repo_path)
676+
selector = NativeSelector(mocker.Mock(), models)
677+
selector._git_client = git_client
678+
assert selector.expand_model_selections([f"git:main"]) == set()
679+
680+
# modify A but dont stage it, should be only selected
681+
model_a_path.write_text("SELECT 10 AS a")
682+
assert selector.expand_model_selections([f"git:main"]) == {'"test_model_a"'}
683+
684+
# stage model A, should still select it
685+
subprocess.run(["git", "add", "model_a.sql"], cwd=repo_path, check=True, capture_output=True)
686+
assert selector.expand_model_selections([f"git:main"]) == {'"test_model_a"'}
687+
688+
# now add unstaged change to B and both should be selected
689+
model_b_path.write_text("SELECT 20 AS b")
690+
assert selector.expand_model_selections([f"git:main"]) == {
691+
'"test_model_a"',
692+
'"test_model_b"',
693+
}
694+
695+
subprocess.run(
696+
["git", "checkout", "-b", "dev"],
697+
cwd=repo_path,
698+
check=True,
699+
capture_output=True,
700+
)
701+
702+
subprocess.run(
703+
[
704+
"git",
705+
"-c",
706+
"user.name=Max",
707+
"-c",
708+
"user.email=max@rb.com",
709+
"commit",
710+
"-m",
711+
"Update model_a",
712+
],
713+
cwd=repo_path,
714+
check=True,
715+
capture_output=True,
716+
)
717+
718+
# now A is committed in the dev branch and B unstaged but should both be selected
719+
assert selector.expand_model_selections([f"git:main"]) == {
720+
'"test_model_a"',
721+
'"test_model_b"',
722+
}
723+
724+
637725
def test_select_models_with_external_parent(mocker: MockerFixture):
638726
default_catalog = "test_catalog"
639727
added_model = SqlModel(

tests/utils/test_git_client.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import subprocess
2+
from pathlib import Path
3+
import pytest
4+
from sqlmesh.utils.git import GitClient
5+
6+
7+
@pytest.fixture
8+
def git_repo(tmp_path: Path) -> Path:
9+
repo_path = tmp_path / "test_repo"
10+
repo_path.mkdir()
11+
subprocess.run(["git", "init", "-b", "main"], cwd=repo_path, check=True, capture_output=True)
12+
return repo_path
13+
14+
15+
def test_git_uncommitted_changes(git_repo: Path):
16+
git_client = GitClient(git_repo)
17+
18+
test_file = git_repo / "model.sql"
19+
test_file.write_text("SELECT 1 AS a")
20+
subprocess.run(["git", "add", "model.sql"], cwd=git_repo, check=True, capture_output=True)
21+
subprocess.run(
22+
[
23+
"git",
24+
"-c",
25+
"user.name=Max",
26+
"-c",
27+
"user.email=max@rb.com",
28+
"commit",
29+
"-m",
30+
"Initial commit",
31+
],
32+
cwd=git_repo,
33+
check=True,
34+
capture_output=True,
35+
)
36+
assert git_client.list_uncommitted_changed_files() == []
37+
38+
# make an unstaged change and see that it is listed
39+
test_file.write_text("SELECT 2 AS a")
40+
uncommitted = git_client.list_uncommitted_changed_files()
41+
assert len(uncommitted) == 1
42+
assert uncommitted[0].name == "model.sql"
43+
44+
# stage the change and test that it is still detected
45+
subprocess.run(["git", "add", "model.sql"], cwd=git_repo, check=True, capture_output=True)
46+
uncommitted = git_client.list_uncommitted_changed_files()
47+
assert len(uncommitted) == 1
48+
assert uncommitted[0].name == "model.sql"
49+
50+
51+
def test_git_both_staged_and_unstaged_changes(git_repo: Path):
52+
git_client = GitClient(git_repo)
53+
54+
file1 = git_repo / "model1.sql"
55+
file2 = git_repo / "model2.sql"
56+
file1.write_text("SELECT 1")
57+
file2.write_text("SELECT 2")
58+
subprocess.run(["git", "add", "."], cwd=git_repo, check=True, capture_output=True)
59+
subprocess.run(
60+
[
61+
"git",
62+
"-c",
63+
"user.name=Max",
64+
"-c",
65+
"user.email=max@rb.com",
66+
"commit",
67+
"-m",
68+
"Initial commit",
69+
],
70+
cwd=git_repo,
71+
check=True,
72+
capture_output=True,
73+
)
74+
75+
# stage file1
76+
file1.write_text("SELECT 10")
77+
subprocess.run(["git", "add", "model1.sql"], cwd=git_repo, check=True, capture_output=True)
78+
79+
# modify file2 but don't stage it!
80+
file2.write_text("SELECT 20")
81+
82+
# both should be detected
83+
uncommitted = git_client.list_uncommitted_changed_files()
84+
assert len(uncommitted) == 2
85+
file_names = {f.name for f in uncommitted}
86+
assert file_names == {"model1.sql", "model2.sql"}
87+
88+
89+
def test_git_untracked_files(git_repo: Path):
90+
git_client = GitClient(git_repo)
91+
initial_file = git_repo / "initial.sql"
92+
initial_file.write_text("SELECT 0")
93+
subprocess.run(["git", "add", "initial.sql"], cwd=git_repo, check=True, capture_output=True)
94+
subprocess.run(
95+
[
96+
"git",
97+
"-c",
98+
"user.name=Max",
99+
"-c",
100+
"user.email=max@rb.com",
101+
"commit",
102+
"-m",
103+
"Initial commit",
104+
],
105+
cwd=git_repo,
106+
check=True,
107+
capture_output=True,
108+
)
109+
110+
new_file = git_repo / "new_model.sql"
111+
new_file.write_text("SELECT 1")
112+
113+
# untracked file should not appear in uncommitted changes
114+
assert git_client.list_uncommitted_changed_files() == []
115+
116+
# but in untracked
117+
untracked = git_client.list_untracked_files()
118+
assert len(untracked) == 1
119+
assert untracked[0].name == "new_model.sql"
120+
121+
122+
def test_git_committed_changes(git_repo: Path):
123+
git_client = GitClient(git_repo)
124+
125+
test_file = git_repo / "model.sql"
126+
test_file.write_text("SELECT 1")
127+
subprocess.run(["git", "add", "model.sql"], cwd=git_repo, check=True, capture_output=True)
128+
subprocess.run(
129+
[
130+
"git",
131+
"-c",
132+
"user.name=Max",
133+
"-c",
134+
"user.email=max@rb.com",
135+
"commit",
136+
"-m",
137+
"Initial commit",
138+
],
139+
cwd=git_repo,
140+
check=True,
141+
capture_output=True,
142+
)
143+
144+
subprocess.run(
145+
["git", "checkout", "-b", "feature"],
146+
cwd=git_repo,
147+
check=True,
148+
capture_output=True,
149+
)
150+
151+
test_file.write_text("SELECT 2")
152+
subprocess.run(["git", "add", "model.sql"], cwd=git_repo, check=True, capture_output=True)
153+
subprocess.run(
154+
[
155+
"git",
156+
"-c",
157+
"user.name=Max",
158+
"-c",
159+
"user.email=max@rb.com",
160+
"commit",
161+
"-m",
162+
"Update on feature branch",
163+
],
164+
cwd=git_repo,
165+
check=True,
166+
capture_output=True,
167+
)
168+
169+
committed = git_client.list_committed_changed_files(target_branch="main")
170+
assert len(committed) == 1
171+
assert committed[0].name == "model.sql"
172+
173+
assert git_client.list_uncommitted_changed_files() == []

0 commit comments

Comments
 (0)