Skip to content

Commit f69bf4e

Browse files
leliaclaude
andcommitted
feat(gitlab): fall back to the nearest scanned ancestor for --base-commit-sha
A merge base can have no full scan even when default-branch scanning is configured and running: squash merges and rebases rewrite commits, and a multi-commit push produces one scan for the tip while leaving the commits in between unscanned. Any of those turned every open merge request into a failed pipeline, because a missing baseline was a hard stop with no degraded mode. The requested commit is still preferred. When it has no scan, one listing of recent scans is matched against local first-parent history and the nearest scanned ancestor is used instead, logged at warning with the commit chosen and its distance. Only an unreachable ancestor now fails the run. Both bounds are fixed and neither costs an extra request: the listing is fetched once, and the walk stops at a set depth. Following first parents keeps a merge commit from contributing everything merged into it, and a shallow checkout simply narrows the search rather than breaking it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9bb0700 commit f69bf4e

3 files changed

Lines changed: 185 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
introducing chain is unavailable, instead of reporting the location as
1717
`unknown`, and report whether a dependency is direct from the package record
1818
rather than inferring it from a dependency-path string that is never produced.
19+
- `--base-commit-sha` degrades to the nearest scanned ancestor of the requested
20+
commit instead of failing the run, and logs which commit was used and how far
21+
back it is. Squash merges, rebases, and multi-commit pushes all leave a merge
22+
base unscanned even when default-branch scanning is configured correctly. The
23+
run still fails when no scanned ancestor is reachable.
1924
- Implicit diff baselines are selected from the same workspace, scan type,
2025
repository, and default branch. A baseline lookup that fails is reported as an
2126
API error instead of resolving to an empty baseline, and temporary scans are

socketsecurity/core/__init__.py

Lines changed: 132 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
if TYPE_CHECKING:
2020
from socketsecurity.config import CliConfig
21+
from git import Repo
2122
from socketdev import socketdev
2223
from socketdev.exceptions import APIFailure
2324
from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact
@@ -56,6 +57,12 @@
5657
# Core.newest_persisted_scan_id), so a single result is not enough.
5758
SCAN_LOOKUP_PAGE_SIZE = 10
5859

60+
# Bounds on the search for a scanned ancestor when the requested baseline commit has
61+
# no full scan of its own. The scan listing is fetched once and matched against local
62+
# history, so neither bound costs an extra request.
63+
ANCESTOR_SCAN_LOOKUP_LIMIT = 100
64+
ANCESTOR_WALK_MAX_DEPTH = 100
65+
5966
# Reachability facts-file upload compression.
6067
#
6168
# The Socket full-scan endpoint transparently brotli-decompresses any multipart part
@@ -1555,6 +1562,102 @@ def newest_persisted_scan_id(results: List[dict]) -> Optional[str]:
15551562
return scan_id
15561563
return None
15571564

1565+
def first_parent_commits(self, start_commit_sha: str, max_count: int) -> List[str]:
1566+
"""
1567+
Lists a commit and its first-parent ancestors, newest first.
1568+
1569+
Follows only first parents so a merge commit contributes the branch's own
1570+
history rather than everything merged into it. A shallow checkout simply
1571+
yields fewer commits, which narrows the search rather than failing it.
1572+
1573+
Args:
1574+
start_commit_sha: Commit to walk back from, included in the result
1575+
max_count: Maximum number of commits to return
1576+
1577+
Returns:
1578+
Commit SHAs, newest first. Empty when the repository or commit is
1579+
unavailable locally.
1580+
"""
1581+
target_path = self.cli_config.target_path if self.cli_config else None
1582+
if not target_path:
1583+
return []
1584+
try:
1585+
repo = Repo(target_path)
1586+
output = repo.git.rev_list(
1587+
"--first-parent",
1588+
f"--max-count={max_count}",
1589+
start_commit_sha,
1590+
)
1591+
except Exception as error:
1592+
log.debug(f"Unable to walk history back from {start_commit_sha}: {error}")
1593+
return []
1594+
return [line.strip() for line in output.splitlines() if line.strip()]
1595+
1596+
def find_baseline_scan_for_ancestor(
1597+
self,
1598+
repo_slug: str,
1599+
commit_sha: str,
1600+
workspace: Optional[str] = None,
1601+
scan_type: Optional[str] = None,
1602+
) -> Tuple[Optional[str], Optional[str], int]:
1603+
"""
1604+
Finds the nearest ancestor of a commit that does have a full scan.
1605+
1606+
Used when --base-commit-sha names a commit that was never scanned. Squash
1607+
merges and rebases rewrite commits, and a multi-commit push produces one scan
1608+
for the tip, so a merge base can be unscanned even when default-branch
1609+
scanning is configured correctly. Diffing against a slightly older ancestor
1610+
is a wider diff; failing outright is no diff at all.
1611+
1612+
One scan listing is fetched and matched against local first-parent history,
1613+
so the walk costs no additional requests.
1614+
1615+
Args:
1616+
repo_slug: Repository slug the scan belongs to
1617+
commit_sha: Commit that has no full scan of its own
1618+
workspace: Socket workspace the scan belongs to, if any
1619+
scan_type: Socket scan type to match, if any
1620+
1621+
Returns:
1622+
(scan_id, ancestor_commit_sha, commits_back), or (None, None, 0) when no
1623+
scanned ancestor is reachable.
1624+
"""
1625+
query_params = {
1626+
"repo": repo_slug,
1627+
"sort": "created_at",
1628+
"direction": "desc",
1629+
"per_page": ANCESTOR_SCAN_LOOKUP_LIMIT,
1630+
}
1631+
if workspace:
1632+
query_params["workspace"] = workspace
1633+
if scan_type:
1634+
query_params["scan_type"] = Core.query_param_value(scan_type)
1635+
1636+
response = self.sdk.fullscans.get(self.config.org_slug, query_params)
1637+
results = response.get("results") if isinstance(response, dict) else None
1638+
if not results:
1639+
return None, None, 0
1640+
1641+
scans_by_commit = {}
1642+
for result in results:
1643+
if not isinstance(result, dict) or result.get("tmp"):
1644+
continue
1645+
result_commit = result.get("commit_hash")
1646+
scan_id = result.get("id")
1647+
# Newest first, so the first scan seen for a commit is the one to keep.
1648+
if result_commit and scan_id and result_commit not in scans_by_commit:
1649+
scans_by_commit[result_commit] = scan_id
1650+
1651+
if not scans_by_commit:
1652+
return None, None, 0
1653+
1654+
ancestors = self.first_parent_commits(commit_sha, ANCESTOR_WALK_MAX_DEPTH)
1655+
for distance, ancestor in enumerate(ancestors):
1656+
scan_id = scans_by_commit.get(ancestor)
1657+
if scan_id:
1658+
return scan_id, ancestor, distance
1659+
return None, None, 0
1660+
15581661
def get_full_scan_id_by_commit(
15591662
self,
15601663
repo_slug: str,
@@ -1628,19 +1731,38 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]:
16281731
workspace=params.workspace,
16291732
scan_type=params.scan_type,
16301733
)
1734+
baseline_source = "explicit-commit"
1735+
baseline_commit = commit_sha
16311736
if scan_id is None:
1632-
log.error(
1633-
f"No full scan found for commit {commit_sha} in repo {params.repo} "
1634-
"(--base-commit-sha). Ensure a scan was created for that commit "
1635-
"(e.g. the CLI runs on default-branch pushes), or pass "
1636-
"--base-scan-id instead."
1737+
scan_id, ancestor_sha, commits_back = self.find_baseline_scan_for_ancestor(
1738+
params.repo,
1739+
commit_sha,
1740+
workspace=params.workspace,
1741+
scan_type=params.scan_type,
16371742
)
1638-
if self.cli_config.disable_blocking:
1639-
sys.exit(0)
1640-
sys.exit(self.cli_config.exit_code_on_api_error)
1743+
if scan_id:
1744+
baseline_source = "explicit-commit-ancestor"
1745+
baseline_commit = ancestor_sha
1746+
log.warning(
1747+
f"No full scan for commit {commit_sha} (--base-commit-sha). "
1748+
f"Diffing against its nearest scanned ancestor {ancestor_sha}, "
1749+
f"{commits_back} commit(s) earlier, so the diff is wider than "
1750+
"the merge base."
1751+
)
1752+
else:
1753+
log.error(
1754+
f"No full scan found for commit {commit_sha} in repo {params.repo} "
1755+
"(--base-commit-sha), and no scanned ancestor within "
1756+
f"{ANCESTOR_WALK_MAX_DEPTH} commits of it. Ensure a scan was "
1757+
"created for that commit (e.g. the CLI runs on default-branch "
1758+
"pushes), or pass --base-scan-id instead."
1759+
)
1760+
if self.cli_config.disable_blocking:
1761+
sys.exit(0)
1762+
sys.exit(self.cli_config.exit_code_on_api_error)
16411763
log.info(
1642-
"Baseline selected: source=explicit-commit "
1643-
f"scan_id={json.dumps(scan_id)} commit={json.dumps(commit_sha)}"
1764+
f"Baseline selected: source={baseline_source} "
1765+
f"scan_id={json.dumps(scan_id)} commit={json.dumps(baseline_commit)}"
16441766
)
16451767
return scan_id
16461768

tests/core/test_sdk_methods.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,54 @@ def test_resolve_base_full_scan_id_uses_base_commit_sha(core):
304304
},
305305
)
306306

307+
def test_resolve_base_full_scan_id_falls_back_to_scanned_ancestor(core, monkeypatch):
308+
"""An unscanned merge base degrades to the nearest scanned ancestor"""
309+
core.cli_config = make_cli_config("--base-commit-sha", "unscanned-sha")
310+
core.sdk.fullscans.get.side_effect = [
311+
{"results": [], "nextPage": None}, # exact commit
312+
{"results": [ # recent scans
313+
{"id": "tmp-scan", "commit_hash": "ancestor-1", "tmp": True},
314+
{"id": "ancestor-scan", "commit_hash": "ancestor-2"},
315+
], "nextPage": None},
316+
]
317+
monkeypatch.setattr(
318+
Core, "first_parent_commits",
319+
lambda self, sha, depth: ["unscanned-sha", "ancestor-1", "ancestor-2"],
320+
)
321+
322+
params = make_full_scan_params()
323+
assert core.resolve_base_full_scan_id(params) == "ancestor-scan"
324+
325+
326+
def test_resolve_base_full_scan_id_ancestor_fallback_skips_temporary_scans(core, monkeypatch):
327+
"""A tmp scan on an ancestor is not a usable baseline either"""
328+
core.cli_config = make_cli_config("--base-commit-sha", "unscanned-sha")
329+
core.sdk.fullscans.get.side_effect = [
330+
{"results": [], "nextPage": None},
331+
{"results": [{"id": "tmp-scan", "commit_hash": "ancestor-1", "tmp": True}], "nextPage": None},
332+
]
333+
monkeypatch.setattr(
334+
Core, "first_parent_commits",
335+
lambda self, sha, depth: ["unscanned-sha", "ancestor-1"],
336+
)
337+
338+
with pytest.raises(SystemExit):
339+
core.resolve_base_full_scan_id(make_full_scan_params())
340+
341+
342+
def test_resolve_base_full_scan_id_ancestor_fallback_needs_local_history(core, monkeypatch):
343+
"""Without local history there is nothing to match scans against"""
344+
core.cli_config = make_cli_config("--base-commit-sha", "unscanned-sha")
345+
core.sdk.fullscans.get.side_effect = [
346+
{"results": [], "nextPage": None},
347+
{"results": [{"id": "ancestor-scan", "commit_hash": "ancestor-2"}], "nextPage": None},
348+
]
349+
monkeypatch.setattr(Core, "first_parent_commits", lambda self, sha, depth: [])
350+
351+
with pytest.raises(SystemExit):
352+
core.resolve_base_full_scan_id(make_full_scan_params())
353+
354+
307355
def test_resolve_base_full_scan_id_commit_sha_not_found_exits(core):
308356
"""A --base-commit-sha with no scan is a hard error (exit_code_on_api_error)"""
309357
core.cli_config = make_cli_config("--base-commit-sha", "abc123")

0 commit comments

Comments
 (0)