Skip to content

Commit b9bf673

Browse files
chore(ci-visibility): unshallow repository when git >= 2.27 [backport 1.17] (#6495)
Backport d4d2f78 from #6268 to 1.17. An unshallow command compatible with git >= 2.27 is now used when a shallow repository is detected. Additionally, packfile upload is improved by searching for commits found in the repo that are not known to the ITR backend, rather than just HEAD. For tests, a shallow clone was created using `git clone --depth=1 https://github.com/pallets/flask.git`, with git log showing only one result: ``` % git log --pretty=oneline cb825687a592709f902f3d320d93987a0546fd28 (grafted, HEAD -> main, origin/main, origin/HEAD) Bump actions/checkout from 3.5.2 to 3.5.3 (#5186) ``` After: ``` cb825687a592709f902f3d320d93987a0546fd28 (HEAD -> main, origin/main, origin/HEAD) Bump actions/checkout from 3.5.2 to 3.5.3 (#5186) 51bf0fdd9018d63b7bc09db52a66b726c2c4bad8 Bump slsa-framework/slsa-github-generator from 1.6.0 to 1.7.0 (#5185) 0da5788efbda92b6b6239352c2b9a835efe0888f Bump dessant/lock-threads from 4.0.0 to 4.0.1 (#5184) # ... ~20 lines 32d2f47ed1bf3f27fd46e1a54dd136688de9ab81 [pre-commit.ci] pre-commit autoupdate c9b6110dec52ff483b42b1428221f903bf143788 (grafted) Merge branch '2.3.x' 7dbb2f7e05d2c0963e90016b1f7a3a45e98a865a (grafted) retarget pre-commit.ci ``` This is gated behind the unreleased Intelligent Test Runner (ITR) functionality, so there is no risk of unexpectedly unshallowing repositories. ## Checklist - [x] Change(s) are motivated and described in the PR description. - [x] Testing strategy is described if automated tests are not included in the PR. - [x] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) are followed. If no release note is required, add label `changelog/no-changelog`. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Title is accurate. - [x] No unnecessary changes are introduced. - [x] Description motivates each change. - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [x] Testing strategy adequately addresses listed risk(s). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] Release note makes sense to a user of the library. - [x] Reviewer has explicitly acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment. - [x] Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) Co-authored-by: Romain Komorn <[email protected]>
1 parent 3a84c3a commit b9bf673

File tree

6 files changed

+215
-9
lines changed

6 files changed

+215
-9
lines changed

ddtrace/contrib/pytest/plugin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ def pytest_configure(config):
272272

273273
def pytest_sessionstart(session):
274274
if _CIVisibility.enabled:
275+
log.debug("CI Visibility enabled - starting test session")
275276
test_session_span = _CIVisibility._instance.tracer.trace(
276277
"pytest.test_session",
277278
service=_CIVisibility._instance._service,
@@ -289,6 +290,7 @@ def pytest_sessionstart(session):
289290

290291
def pytest_sessionfinish(session, exitstatus):
291292
if _CIVisibility.enabled:
293+
log.debug("CI Visibility enabled - finishing test session")
292294
test_session_span = _extract_span(session)
293295
if test_session_span is not None:
294296
_mark_test_status(session, test_session_span)

ddtrace/ext/git.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,35 @@ def _git_subprocess_cmd(cmd, cwd=None, std_in=None):
9191
raise ValueError(compat.ensure_text(stderr).strip())
9292

9393

94+
def _extract_clone_defaultremotename(cwd=None):
95+
output = _git_subprocess_cmd("config --default origin --get clone.defaultRemoteName")
96+
return output
97+
98+
99+
def _is_shallow_repository(cwd=None):
100+
# type: (Optional[str]) -> bool
101+
output = _git_subprocess_cmd("rev-parse --is-shallow-repository", cwd=cwd)
102+
return output.strip() == "true"
103+
104+
105+
def _unshallow_repository(cwd=None):
106+
# type (Optional[str]) -> None
107+
remote_name = _extract_clone_defaultremotename(cwd)
108+
head_sha = extract_commit_sha(cwd)
109+
110+
cmd = [
111+
"fetch",
112+
"--update-shallow",
113+
"--filter=blob:none",
114+
"--recurse-submodules=no",
115+
'--shallow-since="1 month ago"',
116+
remote_name,
117+
head_sha,
118+
]
119+
120+
_git_subprocess_cmd(cmd, cwd=cwd)
121+
122+
94123
def extract_user_info(cwd=None):
95124
# type: (Optional[str]) -> Dict[str, Tuple[str, str, str]]
96125
"""Extract commit author info from the git repository in the current directory or one specified by ``cwd``."""
@@ -120,13 +149,22 @@ def extract_latest_commits(cwd=None):
120149

121150

122151
def get_rev_list_excluding_commits(commit_shas, cwd=None):
152+
return _get_rev_list(excluded_commit_shas=commit_shas, cwd=cwd)
153+
154+
155+
def _get_rev_list(excluded_commit_shas=None, included_commit_shas=None, cwd=None):
156+
# type: (Optional[list[str]], Optional[list[str]], Optional[str]) -> str
123157
command = ["rev-list", "--objects", "--filter=blob:none"]
124158
if extract_git_version(cwd=cwd) >= (2, 23, 0):
125159
command.append('--since="1 month ago"')
126160
command.append("--no-object-names")
127161
command.append("HEAD")
128-
exclusions = ["^%s" % sha for sha in commit_shas]
129-
command.extend(exclusions)
162+
if excluded_commit_shas:
163+
exclusions = ["^%s" % sha for sha in excluded_commit_shas]
164+
command.extend(exclusions)
165+
if included_commit_shas:
166+
inclusions = ["%s" % sha for sha in included_commit_shas]
167+
command.extend(inclusions)
130168
commits = _git_subprocess_cmd(command, cwd=cwd)
131169
return commits
132170

ddtrace/internal/ci_visibility/git_client.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
from typing import Tuple # noqa
88

99
from ddtrace.ext import ci
10+
from ddtrace.ext.git import _get_rev_list
11+
from ddtrace.ext.git import _is_shallow_repository
12+
from ddtrace.ext.git import _unshallow_repository
1013
from ddtrace.ext.git import build_git_packfiles
1114
from ddtrace.ext.git import extract_commit_sha
15+
from ddtrace.ext.git import extract_git_version
1216
from ddtrace.ext.git import extract_latest_commits
1317
from ddtrace.ext.git import extract_remote_url
14-
from ddtrace.ext.git import get_rev_list_excluding_commits
1518
from ddtrace.internal.agent import get_trace_url
1619
from ddtrace.internal.compat import JSONDecodeError
1720
from ddtrace.internal.logger import get_logger
@@ -76,9 +79,22 @@ def shutdown(self, timeout=None):
7679
def _run_protocol(cls, serializer, requests_mode, base_url, _tags={}, _response=None, cwd=None):
7780
# type: (CIVisibilityGitClientSerializerV1, int, str, Dict[str, str], Optional[Response], Optional[str]) -> None
7881
repo_url = cls._get_repository_url(tags=_tags, cwd=cwd)
82+
83+
if cls._is_shallow_repository(cwd=cwd) and extract_git_version(cwd=cwd) >= (2, 27, 0):
84+
log.debug("Shallow repository detected on git > 2.27 detected, unshallowing")
85+
try:
86+
cls._unshallow_repository(cwd=cwd)
87+
log.debug("Unshallowing done")
88+
except ValueError:
89+
log.warning("Failed to unshallow repository, continuing to send pack data", exc_info=True)
90+
7991
latest_commits = cls._get_latest_commits(cwd=cwd)
8092
backend_commits = cls._search_commits(requests_mode, base_url, repo_url, latest_commits, serializer, _response)
81-
rev_list = cls._get_filtered_revisions(backend_commits, cwd=cwd)
93+
commits_not_in_backend = list(set(latest_commits) - set(backend_commits))
94+
95+
rev_list = cls._get_filtered_revisions(
96+
excluded_commits=backend_commits, included_commits=commits_not_in_backend, cwd=cwd
97+
)
8298
if rev_list:
8399
with cls._build_packfiles(rev_list, cwd=cwd) as packfiles_prefix:
84100
cls._upload_packfiles(
@@ -128,9 +144,9 @@ def _do_request(cls, requests_mode, base_url, endpoint, payload, serializer, hea
128144
return result
129145

130146
@classmethod
131-
def _get_filtered_revisions(cls, excluded_commits, cwd=None):
132-
# type: (list[str], Optional[str]) -> list[str]
133-
return get_rev_list_excluding_commits(excluded_commits, cwd=cwd)
147+
def _get_filtered_revisions(cls, excluded_commits, included_commits=None, cwd=None):
148+
# type: (list[str], Optional[list[str]], Optional[str]) -> str
149+
return _get_rev_list(excluded_commits, included_commits, cwd=cwd)
134150

135151
@classmethod
136152
def _build_packfiles(cls, revisions, cwd=None):
@@ -156,6 +172,16 @@ def _upload_packfiles(cls, requests_mode, base_url, repo_url, packfiles_prefix,
156172
return False
157173
return True
158174

175+
@classmethod
176+
def _is_shallow_repository(cls, cwd=None):
177+
# type () -> bool
178+
return _is_shallow_repository(cwd=cwd)
179+
180+
@classmethod
181+
def _unshallow_repository(cls, cwd=None):
182+
# type () -> None
183+
_unshallow_repository(cwd=cwd)
184+
159185

160186
class CIVisibilityGitClientSerializerV1(object):
161187
def __init__(self, api_key, app_key):

ddtrace/internal/ci_visibility/recorder.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,8 @@ def _fetch_tests_to_skip(self):
292292
self._test_suites_to_skip = []
293293
return
294294

295+
self._test_suites_to_skip = []
296+
295297
if response.status >= 400:
296298
log.warning("Test skips request responded with status %d", response.status)
297299
return
@@ -301,7 +303,6 @@ def _fetch_tests_to_skip(self):
301303
log.warning("Test skips request responded with invalid JSON '%s'", response.body)
302304
return
303305

304-
self._test_suites_to_skip = []
305306
for item in parsed["data"]:
306307
if item["type"] == TEST_SKIPPING_LEVEL and "suite" in item["attributes"]:
307308
module = item["attributes"].get("configurations", {}).get("test.bundle", "").replace(".", "/")

tests/ci_visibility/test_ci_visibility.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ def _patch_dummy_writer():
5959

6060

6161
def test_ci_visibility_service_enable():
62-
6362
with override_env(
6463
dict(
6564
DD_API_KEY="foobar.baz",
@@ -716,3 +715,73 @@ def test_civisibility_check_enabled_features_itr_enabled_malformed_response(_do_
716715

717716
mock_log.warning.assert_called_with("Settings request responded with invalid JSON '%s'", "}")
718717
CIVisibility.disable()
718+
719+
720+
def test_run_protocol_unshallow_git_ge_227():
721+
with mock.patch("ddtrace.internal.ci_visibility.git_client.extract_git_version", return_value=(2, 27, 0)):
722+
with mock.patch.multiple(
723+
CIVisibilityGitClient,
724+
_get_repository_url=mock.DEFAULT,
725+
_is_shallow_repository=classmethod(lambda *args, **kwargs: True),
726+
_get_latest_commits=classmethod(lambda *args, **kwwargs: ["latest1", "latest2"]),
727+
_search_commits=classmethod(lambda *args: ["latest1", "searched1", "searched2"]),
728+
_get_filtered_revisions=mock.DEFAULT,
729+
_build_packfiles=mock.DEFAULT,
730+
_upload_packfiles=mock.DEFAULT,
731+
):
732+
with mock.patch.object(CIVisibilityGitClient, "_unshallow_repository") as mock_unshallow_repository:
733+
CIVisibilityGitClient._run_protocol(None, None, None)
734+
735+
mock_unshallow_repository.assert_called_once_with(cwd=None)
736+
737+
738+
def test_run_protocol_does_not_unshallow_git_lt_227():
739+
with mock.patch("ddtrace.internal.ci_visibility.git_client.extract_git_version", return_value=(2, 26, 0)):
740+
with mock.patch.multiple(
741+
CIVisibilityGitClient,
742+
_get_repository_url=mock.DEFAULT,
743+
_is_shallow_repository=classmethod(lambda *args, **kwargs: True),
744+
_get_latest_commits=classmethod(lambda *args, **kwargs: ["latest1", "latest2"]),
745+
_search_commits=classmethod(lambda *args: ["latest1", "searched1", "searched2"]),
746+
_get_filtered_revisions=mock.DEFAULT,
747+
_build_packfiles=mock.DEFAULT,
748+
_upload_packfiles=mock.DEFAULT,
749+
):
750+
with mock.patch.object(CIVisibilityGitClient, "_unshallow_repository") as mock_unshallow_repository:
751+
CIVisibilityGitClient._run_protocol(None, None, None)
752+
753+
mock_unshallow_repository.assert_not_called()
754+
755+
756+
def test_get_filtered_revisions():
757+
with mock.patch(
758+
"ddtrace.internal.ci_visibility.git_client._get_rev_list", return_value=["rev1", "rev2"]
759+
) as mock_get_rev_list:
760+
assert CIVisibilityGitClient._get_filtered_revisions(
761+
["excluded1", "excluded2"], included_commits=["included1", "included2"], cwd="/path/to/repo"
762+
) == ["rev1", "rev2"]
763+
mock_get_rev_list.assert_called_once_with(
764+
["excluded1", "excluded2"], ["included1", "included2"], cwd="/path/to/repo"
765+
)
766+
767+
768+
def test_is_shallow_repository_true():
769+
with mock.patch(
770+
"ddtrace.internal.ci_visibility.git_client._is_shallow_repository", return_value=True
771+
) as mock_is_shallow_repository:
772+
assert CIVisibilityGitClient._is_shallow_repository(cwd="/path/to/repo") is True
773+
mock_is_shallow_repository.assert_called_once_with(cwd="/path/to/repo")
774+
775+
776+
def test_is_shallow_repository_false():
777+
with mock.patch(
778+
"ddtrace.internal.ci_visibility.git_client._is_shallow_repository", return_value=False
779+
) as mock_is_shallow_repository:
780+
assert CIVisibilityGitClient._is_shallow_repository(cwd="/path/to/repo") is False
781+
mock_is_shallow_repository.assert_called_once_with(cwd="/path/to/repo")
782+
783+
784+
def test_unshallow_repository():
785+
with mock.patch("ddtrace.internal.ci_visibility.git_client._unshallow_repository") as mock_unshallow_repository:
786+
CIVisibilityGitClient._unshallow_repository(cwd="/path/to/repo")
787+
mock_unshallow_repository.assert_called_once_with(cwd="/path/to/repo")

tests/tracer/test_ci.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,73 @@ def test_os_runtime_metadata_tagging():
227227
assert extracted_tags.get(ci.OS_VERSION) is not None
228228
assert extracted_tags.get(ci.RUNTIME_NAME) is not None
229229
assert extracted_tags.get(ci.RUNTIME_VERSION) is not None
230+
231+
232+
def test_get_rev_list_no_args_git_ge_223(git_repo):
233+
with mock.patch("ddtrace.ext.git._git_subprocess_cmd") as mock_git_subprocess, mock.patch(
234+
"ddtrace.ext.git.extract_git_version", return_value=(2, 23, 0)
235+
):
236+
mock_git_subprocess.return_value = ["commithash1", "commithash2"]
237+
assert git._get_rev_list(cwd=git_repo) == ["commithash1", "commithash2"]
238+
mock_git_subprocess.assert_called_once_with(
239+
["rev-list", "--objects", "--filter=blob:none", '--since="1 month ago"', "--no-object-names", "HEAD"],
240+
cwd=git_repo,
241+
)
242+
243+
244+
def test_get_rev_list_git_lt_223(git_repo):
245+
with mock.patch("ddtrace.ext.git._git_subprocess_cmd") as mock_git_subprocess, mock.patch(
246+
"ddtrace.ext.git.extract_git_version", return_value=(2, 22, 0)
247+
):
248+
mock_git_subprocess.return_value = ["commithash1", "commithash2"]
249+
assert git._get_rev_list(
250+
excluded_commit_shas=["exclude1", "exclude2"], included_commit_shas=["include1", "include2"], cwd=git_repo
251+
) == ["commithash1", "commithash2"]
252+
mock_git_subprocess.assert_called_once_with(
253+
["rev-list", "--objects", "--filter=blob:none", "HEAD", "^exclude1", "^exclude2", "include1", "include2"],
254+
cwd=git_repo,
255+
)
256+
257+
258+
def test_is_shallow_repository_true(git_repo):
259+
with mock.patch("ddtrace.ext.git._git_subprocess_cmd", return_value="true") as mock_git_subprocess:
260+
assert git._is_shallow_repository(cwd=git_repo) is True
261+
mock_git_subprocess.assert_called_once_with("rev-parse --is-shallow-repository", cwd=git_repo)
262+
263+
264+
def test_is_shallow_repository_false(git_repo):
265+
with mock.patch("ddtrace.ext.git._git_subprocess_cmd", return_value="false") as mock_git_subprocess:
266+
assert git._is_shallow_repository(cwd=git_repo) is False
267+
mock_git_subprocess.assert_called_once_with("rev-parse --is-shallow-repository", cwd=git_repo)
268+
269+
270+
def test_unshallow_repository(git_repo):
271+
with mock.patch(
272+
"ddtrace.ext.git._extract_clone_defaultremotename", return_value="myremote"
273+
) as mock_defaultremotename:
274+
with mock.patch(
275+
"ddtrace.ext.git.extract_commit_sha", return_value="mycommitshaaaaaaaaaaaa123"
276+
) as mock_extract_sha:
277+
with mock.patch("ddtrace.ext.git._git_subprocess_cmd") as mock_git_subprocess:
278+
git._unshallow_repository(cwd=git_repo)
279+
280+
mock_defaultremotename.assert_called_once_with(git_repo)
281+
mock_extract_sha.assert_called_once_with(git_repo)
282+
mock_git_subprocess.assert_called_once_with(
283+
[
284+
"fetch",
285+
"--update-shallow",
286+
"--filter=blob:none",
287+
"--recurse-submodules=no",
288+
'--shallow-since="1 month ago"',
289+
"myremote",
290+
"mycommitshaaaaaaaaaaaa123",
291+
],
292+
cwd=git_repo,
293+
)
294+
295+
296+
def test_extract_clone_defaultremotename():
297+
with mock.patch("ddtrace.ext.git._git_subprocess_cmd", return_value="default_remote_name") as mock_git_subprocess:
298+
assert git._extract_clone_defaultremotename(cwd=git_repo) == "default_remote_name"
299+
mock_git_subprocess.assert_called_once_with("config --default origin --get clone.defaultRemoteName")

0 commit comments

Comments
 (0)