Skip to content

Commit e05b1b0

Browse files
authored
feat(scopes): fetch commits until we found the base sha (#804)
When doing the git diff we need have all intermediates objects. This change fetches them.
1 parent da2248b commit e05b1b0

File tree

6 files changed

+193
-30
lines changed

6 files changed

+193
-30
lines changed

mergify_cli/ci/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,5 +207,5 @@ def scopes(
207207
) -> None:
208208
try:
209209
scopes_cli.detect(config_path=config_path)
210-
except scopes_cli.ConfigInvalidError as e:
210+
except scopes_cli.ScopesError as e:
211211
raise click.ClickException(str(e)) from e
Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,74 @@
11
from __future__ import annotations
22

33
import subprocess
4+
import sys
45

5-
import click
6+
7+
COMMITS_BATCH_SIZE = 100
8+
9+
10+
class ChangedFilesError(Exception):
11+
pass
612

713

814
def _run(cmd: list[str]) -> str:
15+
return subprocess.check_output(cmd, text=True, encoding="utf-8").strip()
16+
17+
18+
def has_merge_base(base: str, head: str) -> bool:
919
try:
10-
return subprocess.check_output(cmd, text=True, encoding="utf-8").strip()
11-
except subprocess.CalledProcessError as e:
12-
msg = f"Command failed: {' '.join(cmd)}\n{e}"
13-
raise click.ClickException(msg) from e
20+
_run(["git", "merge-base", base, head])
21+
except subprocess.CalledProcessError:
22+
return False
23+
return True
24+
25+
26+
def get_commits_count() -> int:
27+
return int(_run(["git", "rev-list", "--count", "--all"]))
28+
29+
30+
def ensure_git_history(base: str, head: str) -> None:
31+
fetch_depth = COMMITS_BATCH_SIZE
32+
33+
if not has_merge_base(base, head):
34+
_run(
35+
[
36+
"git",
37+
"fetch",
38+
"--no-tags",
39+
f"--depth={fetch_depth}",
40+
"origin",
41+
base,
42+
head,
43+
],
44+
)
45+
46+
last_commits_count = get_commits_count()
47+
while not has_merge_base(base, head):
48+
fetch_depth = min(fetch_depth * 2, sys.maxsize)
49+
_run(["git", "fetch", f"--deepen={fetch_depth}", "origin", base, "HEAD"])
50+
commits_count = get_commits_count()
51+
if commits_count == last_commits_count:
52+
if not has_merge_base(base, head):
53+
msg = f"Cannot find a common ancestor between {base} and {head}"
54+
raise ChangedFilesError(msg)
55+
56+
break
57+
last_commits_count = commits_count
1458

1559

1660
def git_changed_files(base: str) -> list[str]:
61+
head = "HEAD"
62+
ensure_git_history(base, head)
1763
# Committed changes only between base_sha and HEAD.
1864
# Includes: Added (A), Copied (C), Modified (M), Renamed (R), Type-changed (T), Deleted (D)
1965
# Excludes: Unmerged (U), Unknown (X), Broken (B)
20-
out = _run(
21-
["git", "diff", "--name-only", "--diff-filter=ACMRTD", f"{base}...HEAD"],
22-
)
66+
try:
67+
out = _run(
68+
["git", "diff", "--name-only", "--diff-filter=ACMRTD", f"{base}...{head}"],
69+
)
70+
except subprocess.CalledProcessError as e:
71+
msg = f"Command failed: {' '.join(e.cmd)}\n{e}"
72+
raise ChangedFilesError(msg)
73+
2374
return [line for line in out.splitlines() if line]

mergify_cli/ci/scopes/cli.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@
2020
SCOPE_NAME_RE = r"^[A-Za-z0-9_-]+$"
2121

2222

23-
class ConfigInvalidError(Exception):
23+
class ScopesError(Exception):
24+
pass
25+
26+
27+
class ConfigInvalidError(ScopesError):
28+
pass
29+
30+
31+
class ChangedFilesError(ScopesError):
2432
pass
2533

2634

@@ -104,7 +112,10 @@ def maybe_write_github_outputs(
104112
def detect(config_path: str) -> None:
105113
cfg = Config.from_yaml(config_path)
106114
base = base_detector.detect()
107-
changed = changed_files.git_changed_files(base)
115+
try:
116+
changed = changed_files.git_changed_files(base)
117+
except changed_files.ChangedFilesError as e:
118+
raise ChangedFilesError(str(e))
108119
scopes_hit, per_scope = match_scopes(cfg, changed)
109120

110121
click.echo(f"Base: {base}")
Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,86 @@
1-
import subprocess
2-
from unittest import mock
3-
4-
import click
51
import pytest
62

73
from mergify_cli.ci.scopes import changed_files
4+
from mergify_cli.tests import utils as test_utils
85

96

10-
@mock.patch("mergify_cli.ci.scopes.changed_files.subprocess.check_output")
11-
def test_git_changed_files(mock_subprocess: mock.Mock) -> None:
12-
mock_subprocess.return_value = "file1.py\nfile2.js\n"
7+
def test_git_changed_files(mock_subprocess: test_utils.SubprocessMocks) -> None:
8+
mock_subprocess.register(["git", "merge-base", "main", "HEAD"])
9+
mock_subprocess.register(["git", "rev-list", "--count", "--all"], "100")
10+
mock_subprocess.register(["git", "merge-base", "main", "HEAD"])
11+
mock_subprocess.register(
12+
["git", "diff", "--name-only", "--diff-filter=ACMRTD", "main...HEAD"],
13+
"file1.py\nfile2.js\n",
14+
)
1315

1416
result = changed_files.git_changed_files("main")
1517

16-
mock_subprocess.assert_called_once_with(
17-
["git", "diff", "--name-only", "--diff-filter=ACMRTD", "main...HEAD"],
18-
text=True,
19-
encoding="utf-8",
18+
assert result == ["file1.py", "file2.js"]
19+
20+
21+
def test_git_changed_files_fetch_alot_of_history(
22+
mock_subprocess: test_utils.SubprocessMocks,
23+
) -> None:
24+
sha = "b3deb84c4befe1918995b18eb06fa05f9074636d"
25+
26+
mock_subprocess.register(
27+
["git", "merge-base", sha, "HEAD"],
28+
"No such git object",
29+
1,
2030
)
31+
mock_subprocess.register(
32+
["git", "fetch", "--no-tags", "--depth=100", "origin", sha, "HEAD"],
33+
)
34+
mock_subprocess.register(["git", "rev-list", "--count", "--all"], "100")
35+
36+
# Loop until we find it
37+
for count in (200, 400, 800, 1600):
38+
mock_subprocess.register(
39+
["git", "merge-base", sha, "HEAD"],
40+
"No such git object",
41+
1,
42+
)
43+
mock_subprocess.register(
44+
["git", "fetch", f"--deepen={count}", "origin", sha, "HEAD"],
45+
)
46+
mock_subprocess.register(["git", "rev-list", "--count", "--all"], f"{count}")
47+
48+
# We found it!
49+
mock_subprocess.register(["git", "merge-base", sha, "HEAD"])
50+
51+
mock_subprocess.register(
52+
["git", "diff", "--name-only", "--diff-filter=ACMRTD", f"{sha}...HEAD"],
53+
"file1.py\nfile2.js\n",
54+
)
55+
56+
result = changed_files.git_changed_files(sha)
57+
2158
assert result == ["file1.py", "file2.js"]
2259

2360

24-
@mock.patch("mergify_cli.ci.scopes.changed_files.subprocess.check_output")
25-
def test_git_changed_files_empty(mock_subprocess: mock.Mock) -> None:
26-
mock_subprocess.return_value = ""
61+
def test_git_changed_files_empty(mock_subprocess: test_utils.SubprocessMocks) -> None:
62+
mock_subprocess.register(["git", "merge-base", "main", "HEAD"])
63+
mock_subprocess.register(["git", "rev-list", "--count", "--all"], "100")
64+
mock_subprocess.register(["git", "merge-base", "main", "HEAD"])
65+
mock_subprocess.register(
66+
["git", "diff", "--name-only", "--diff-filter=ACMRTD", "main...HEAD"],
67+
"",
68+
)
2769

2870
result = changed_files.git_changed_files("main")
2971

3072
assert result == []
3173

3274

33-
@mock.patch("mergify_cli.ci.scopes.changed_files.subprocess.check_output")
34-
def test_run_command_failure(mock_subprocess: mock.Mock) -> None:
35-
mock_subprocess.side_effect = subprocess.CalledProcessError(1, ["git", "diff"])
75+
def test_run_command_failure(mock_subprocess: test_utils.SubprocessMocks) -> None:
76+
mock_subprocess.register(["git", "merge-base", "main", "HEAD"])
77+
mock_subprocess.register(["git", "rev-list", "--count", "--all"], "100")
78+
mock_subprocess.register(["git", "merge-base", "main", "HEAD"])
79+
mock_subprocess.register(
80+
["git", "diff", "--name-only", "--diff-filter=ACMRTD", "main...HEAD"],
81+
"No such git object",
82+
1,
83+
)
3684

37-
with pytest.raises(click.ClickException, match="Command failed"):
38-
changed_files._run(["git", "diff"])
85+
with pytest.raises(changed_files.ChangedFilesError, match="Command failed"):
86+
changed_files.git_changed_files("main")

mergify_cli/tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1313
# License for the specific language governing permissions and limitations
1414
# under the License.
15+
from collections import abc
1516
from collections.abc import Generator
1617
import pathlib
1718
import subprocess
@@ -77,3 +78,8 @@ def git_mock(
7778

7879
with mock.patch("mergify_cli.utils.git", git_mock_object):
7980
yield git_mock_object
81+
82+
83+
@pytest.fixture
84+
def mock_subprocess() -> abc.Generator[test_utils.SubprocessMocks]:
85+
yield from test_utils.subprocess_mocked()

mergify_cli/tests/utils.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1313
# License for the specific language governing permissions and limitations
1414
# under the License.
15+
from collections import abc
1516
import dataclasses
17+
import subprocess
1618
import typing
19+
from unittest import mock
1720

1821

1922
class Commit(typing.TypedDict):
@@ -86,3 +89,47 @@ def commit(self, commit: Commit) -> None:
8689
f"mergify-cli-tmp:current-branch/{commit['change_id']}",
8790
output="",
8891
)
92+
93+
94+
@dataclasses.dataclass
95+
class SubprocessMock:
96+
cmd: list[str]
97+
output: str = ""
98+
exit_code: int = 0
99+
100+
101+
@dataclasses.dataclass
102+
class SubprocessMocks:
103+
calls: list[SubprocessMock] = dataclasses.field(default_factory=list)
104+
105+
def register(self, cmd: list[str], output: str = "", exit_code: int = 0) -> None:
106+
self.calls.append(SubprocessMock(cmd=cmd, output=output, exit_code=exit_code))
107+
108+
109+
def subprocess_mocked() -> abc.Generator[SubprocessMocks]:
110+
mocks = SubprocessMocks()
111+
112+
with mock.patch("subprocess.check_output") as mock_run:
113+
114+
def check_output(
115+
cmd: list[str],
116+
**kwargs: typing.Any, # noqa: ARG001 ANN401
117+
) -> str:
118+
try:
119+
m = mocks.calls.pop(0)
120+
except IndexError:
121+
msg = f"Unexpected command: {cmd}"
122+
raise ValueError(msg)
123+
if m.cmd == cmd:
124+
if m.exit_code != 0:
125+
raise subprocess.CalledProcessError(
126+
m.exit_code,
127+
cmd,
128+
output=m.output,
129+
)
130+
return m.output
131+
msg = f"Unexpected command: {m.cmd} != {cmd}"
132+
raise ValueError(msg)
133+
134+
mock_run.side_effect = check_output
135+
yield mocks

0 commit comments

Comments
 (0)