mergeset 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mergeset/__init__.py +150 -0
- mergeset/__main__.py +5 -0
- mergeset/analysis.py +465 -0
- mergeset/attribution.py +190 -0
- mergeset/base.py +257 -0
- mergeset/cli.py +338 -0
- mergeset/gitops.py +406 -0
- mergeset/log.py +249 -0
- mergeset/misc/CHANGELOG.md +20 -0
- mergeset/oracle.py +268 -0
- mergeset/report.py +382 -0
- mergeset/solve.py +475 -0
- mergeset/sources.py +231 -0
- mergeset/stacks.py +193 -0
- mergeset/validation.py +401 -0
- mergeset-0.0.2.dist-info/METADATA +115 -0
- mergeset-0.0.2.dist-info/RECORD +20 -0
- mergeset-0.0.2.dist-info/WHEEL +4 -0
- mergeset-0.0.2.dist-info/entry_points.txt +2 -0
- mergeset-0.0.2.dist-info/licenses/LICENSE +21 -0
mergeset/__init__.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Which of these branches can be merged together?
|
|
2
|
+
|
|
3
|
+
Given a base commit and *n* candidate changes (branches, PRs, commits),
|
|
4
|
+
``mergeset`` finds the **maximal sets** that merge cleanly and still pass
|
|
5
|
+
validation — and the minimal **conflicts** that stop the rest.
|
|
6
|
+
|
|
7
|
+
The simple case is one call::
|
|
8
|
+
|
|
9
|
+
from mergeset import analyze, branch_changes, markdown_report
|
|
10
|
+
|
|
11
|
+
changes = list(branch_changes('.', ['feature-a', 'feature-b'], base='main'))
|
|
12
|
+
analysis = analyze('.', changes)
|
|
13
|
+
print(markdown_report(analysis))
|
|
14
|
+
|
|
15
|
+
Everything expensive is cached in an append-only JSONL log, so a second run
|
|
16
|
+
costs nothing for sets already decided, and the cheap oracles (per-change CI
|
|
17
|
+
status, ``git merge-tree`` textual conflicts, file-overlap decomposition) run
|
|
18
|
+
first so that most answers never cost a test run at all.
|
|
19
|
+
|
|
20
|
+
Every piece is one keyword argument away from being replaced: where changes come
|
|
21
|
+
from (``mergeset.sources``), how a set is merged and validated
|
|
22
|
+
(``mergeset.oracle``, ``mergeset.validation``), how the search spends its budget
|
|
23
|
+
(``mergeset.solve``), and how results are rendered (``mergeset.report``).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from mergeset.base import (
|
|
27
|
+
CapabilityError,
|
|
28
|
+
Change,
|
|
29
|
+
ChangeSet,
|
|
30
|
+
Evaluation,
|
|
31
|
+
MergeOutcome,
|
|
32
|
+
MergesetError,
|
|
33
|
+
ValidationOutcome,
|
|
34
|
+
Verdict,
|
|
35
|
+
change_set,
|
|
36
|
+
set_key,
|
|
37
|
+
)
|
|
38
|
+
from mergeset.log import EvaluationLog, JsonlLines, MemoryLines
|
|
39
|
+
from mergeset.solve import (
|
|
40
|
+
combine_components,
|
|
41
|
+
find_maximal_good_sets,
|
|
42
|
+
independent_components,
|
|
43
|
+
minimal_hitting_sets,
|
|
44
|
+
quickxplain,
|
|
45
|
+
)
|
|
46
|
+
from mergeset.gitops import (
|
|
47
|
+
changed_files,
|
|
48
|
+
create_integration_branch,
|
|
49
|
+
merge_sequence,
|
|
50
|
+
merged_worktree,
|
|
51
|
+
pairwise_textual_conflicts,
|
|
52
|
+
persistent_worktree,
|
|
53
|
+
singleton_textual_conflicts,
|
|
54
|
+
textual_conflict,
|
|
55
|
+
)
|
|
56
|
+
from mergeset.stacks import (
|
|
57
|
+
ancestors,
|
|
58
|
+
close_down,
|
|
59
|
+
close_up,
|
|
60
|
+
cone_weights,
|
|
61
|
+
count_closed_subsets,
|
|
62
|
+
descendants,
|
|
63
|
+
largest_closed_subset,
|
|
64
|
+
tips,
|
|
65
|
+
)
|
|
66
|
+
from mergeset.sources import (
|
|
67
|
+
branch_changes,
|
|
68
|
+
commit_changes,
|
|
69
|
+
detect_stacks,
|
|
70
|
+
fetch_pull_requests,
|
|
71
|
+
local_branch_names,
|
|
72
|
+
pr_changes,
|
|
73
|
+
)
|
|
74
|
+
from mergeset.validation import (
|
|
75
|
+
ValidationStage,
|
|
76
|
+
act_validation,
|
|
77
|
+
callable_validation,
|
|
78
|
+
command_validation,
|
|
79
|
+
file_fingerprint,
|
|
80
|
+
flake_tolerant,
|
|
81
|
+
js_validation,
|
|
82
|
+
merge_only_validation,
|
|
83
|
+
pytest_validation,
|
|
84
|
+
staged_validation,
|
|
85
|
+
)
|
|
86
|
+
from mergeset.oracle import claude_code_resolver, git_oracle, merge_order
|
|
87
|
+
from mergeset.analysis import Analysis, analyze
|
|
88
|
+
from mergeset.report import html_report, markdown_report
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"analyze",
|
|
92
|
+
"Analysis",
|
|
93
|
+
"Change",
|
|
94
|
+
"ChangeSet",
|
|
95
|
+
"Evaluation",
|
|
96
|
+
"Verdict",
|
|
97
|
+
"MergeOutcome",
|
|
98
|
+
"ValidationOutcome",
|
|
99
|
+
"change_set",
|
|
100
|
+
"set_key",
|
|
101
|
+
"EvaluationLog",
|
|
102
|
+
"JsonlLines",
|
|
103
|
+
"MemoryLines",
|
|
104
|
+
"branch_changes",
|
|
105
|
+
"commit_changes",
|
|
106
|
+
"pr_changes",
|
|
107
|
+
"fetch_pull_requests",
|
|
108
|
+
"local_branch_names",
|
|
109
|
+
"detect_stacks",
|
|
110
|
+
"pytest_validation",
|
|
111
|
+
"command_validation",
|
|
112
|
+
"act_validation",
|
|
113
|
+
"callable_validation",
|
|
114
|
+
"merge_only_validation",
|
|
115
|
+
"flake_tolerant",
|
|
116
|
+
"staged_validation",
|
|
117
|
+
"ValidationStage",
|
|
118
|
+
"js_validation",
|
|
119
|
+
"file_fingerprint",
|
|
120
|
+
"tips",
|
|
121
|
+
"close_down",
|
|
122
|
+
"close_up",
|
|
123
|
+
"largest_closed_subset",
|
|
124
|
+
"cone_weights",
|
|
125
|
+
"ancestors",
|
|
126
|
+
"descendants",
|
|
127
|
+
"count_closed_subsets",
|
|
128
|
+
"git_oracle",
|
|
129
|
+
"merge_order",
|
|
130
|
+
"claude_code_resolver",
|
|
131
|
+
"find_maximal_good_sets",
|
|
132
|
+
"quickxplain",
|
|
133
|
+
"minimal_hitting_sets",
|
|
134
|
+
"independent_components",
|
|
135
|
+
"combine_components",
|
|
136
|
+
"textual_conflict",
|
|
137
|
+
"pairwise_textual_conflicts",
|
|
138
|
+
"singleton_textual_conflicts",
|
|
139
|
+
"changed_files",
|
|
140
|
+
"merge_sequence",
|
|
141
|
+
"merged_worktree",
|
|
142
|
+
"persistent_worktree",
|
|
143
|
+
"create_integration_branch",
|
|
144
|
+
"markdown_report",
|
|
145
|
+
"html_report",
|
|
146
|
+
"MergesetError",
|
|
147
|
+
"CapabilityError",
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
__version__ = "0.0.1"
|
mergeset/__main__.py
ADDED
mergeset/analysis.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""The facade: one call that goes from a repository to a merge plan.
|
|
2
|
+
|
|
3
|
+
``analyze`` is the "simple thing made simple" entry point. It wires together the
|
|
4
|
+
pieces — sources, cheap pre-oracles, file-overlap decomposition, the expensive
|
|
5
|
+
oracle, the log, the search — with defaults that work out of the box, and every
|
|
6
|
+
piece stays replaceable by one keyword argument.
|
|
7
|
+
|
|
8
|
+
The order of operations is the whole point, because it is the order of *cost*:
|
|
9
|
+
|
|
10
|
+
1. free: per-change CI status (a red singleton is a conflict of size one);
|
|
11
|
+
2. milliseconds: pairwise ``git merge-tree`` textual conflicts;
|
|
12
|
+
3. milliseconds: file-overlap components — independent subproblems;
|
|
13
|
+
4. minutes each: the real merge-and-test oracle, only on what is left.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import (
|
|
21
|
+
Callable,
|
|
22
|
+
Dict,
|
|
23
|
+
Iterable,
|
|
24
|
+
List,
|
|
25
|
+
Optional,
|
|
26
|
+
Sequence,
|
|
27
|
+
Tuple,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from mergeset.base import (
|
|
31
|
+
Change,
|
|
32
|
+
ChangeId,
|
|
33
|
+
ChangeSet,
|
|
34
|
+
MergesetError,
|
|
35
|
+
ValidationOutcome,
|
|
36
|
+
Verdict,
|
|
37
|
+
set_key,
|
|
38
|
+
)
|
|
39
|
+
from mergeset.gitops import (
|
|
40
|
+
changed_files,
|
|
41
|
+
singleton_textual_conflicts,
|
|
42
|
+
check_git_capability,
|
|
43
|
+
merge_base,
|
|
44
|
+
pairwise_textual_conflicts,
|
|
45
|
+
resolve,
|
|
46
|
+
)
|
|
47
|
+
from mergeset.attribution import git_diff_reader, suspects as attribute_suspects
|
|
48
|
+
from mergeset.log import EvaluationLog
|
|
49
|
+
from mergeset.oracle import Resolver, git_oracle, merge_order
|
|
50
|
+
from mergeset.solve import (
|
|
51
|
+
SearchState,
|
|
52
|
+
combine_components,
|
|
53
|
+
find_maximal_good_sets,
|
|
54
|
+
independent_components,
|
|
55
|
+
)
|
|
56
|
+
from mergeset.sources import detect_stacks, size_weight
|
|
57
|
+
from mergeset.stacks import (
|
|
58
|
+
close_up,
|
|
59
|
+
cone_weights,
|
|
60
|
+
count_closed_subsets,
|
|
61
|
+
stack_roots,
|
|
62
|
+
tips,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Analysis:
|
|
68
|
+
"""Everything a run learned. Every field is derivable from ``log`` + git."""
|
|
69
|
+
|
|
70
|
+
repo: str
|
|
71
|
+
base: str
|
|
72
|
+
base_sha: str
|
|
73
|
+
changes: List[Change]
|
|
74
|
+
maximal_sets: List[ChangeSet] = field(default_factory=list)
|
|
75
|
+
conflicts: List[ChangeSet] = field(default_factory=list)
|
|
76
|
+
textual_conflicts: List[Tuple[ChangeId, ChangeId, List[str]]] = field(
|
|
77
|
+
default_factory=list
|
|
78
|
+
)
|
|
79
|
+
components: List[ChangeSet] = field(default_factory=list)
|
|
80
|
+
files_by_change: Dict[ChangeId, List[str]] = field(default_factory=dict)
|
|
81
|
+
stacks: Dict[ChangeId, ChangeId] = field(default_factory=dict)
|
|
82
|
+
#: Cost of dropping each change *including its descendant cone*.
|
|
83
|
+
weights: Dict[ChangeId, float] = field(default_factory=dict)
|
|
84
|
+
#: Cost of the change on its own, before the cone is accounted for.
|
|
85
|
+
own_weights: Dict[ChangeId, float] = field(default_factory=dict)
|
|
86
|
+
#: Changes that will not even merge onto base alone -> conflicting files.
|
|
87
|
+
singleton_conflicts: Dict[ChangeId, List[str]] = field(default_factory=dict)
|
|
88
|
+
#: Whether the per-component answers were trusted to combine, or whether a
|
|
89
|
+
#: global search had to verify them. See ``analyze(component_local=...)``.
|
|
90
|
+
components_combined: bool = False
|
|
91
|
+
log: Optional[EvaluationLog] = None
|
|
92
|
+
searches: List[SearchState] = field(default_factory=list)
|
|
93
|
+
notes: List[str] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def by_id(self) -> Dict[ChangeId, Change]:
|
|
97
|
+
"""Changes keyed by id."""
|
|
98
|
+
return {c.id: c for c in self.changes}
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def evaluations(self) -> int:
|
|
102
|
+
"""How many expensive evaluations this run actually spent."""
|
|
103
|
+
return sum(s.evaluations for s in self.searches)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def exhausted(self) -> bool:
|
|
107
|
+
"""True iff every component's search finished rather than hit a budget."""
|
|
108
|
+
return all(s.exhausted for s in self.searches) if self.searches else False
|
|
109
|
+
|
|
110
|
+
def merge_plan(self) -> List[dict]:
|
|
111
|
+
"""One entry per maximal set: what to merge, in what order, what it drops.
|
|
112
|
+
|
|
113
|
+
This is the deliverable — "merge these, in this order, and you lose these".
|
|
114
|
+
"""
|
|
115
|
+
all_ids = frozenset(c.id for c in self.changes)
|
|
116
|
+
by_id = self.by_id
|
|
117
|
+
plans = []
|
|
118
|
+
for rank, subset in enumerate(self.maximal_sets, start=1):
|
|
119
|
+
dropped = all_ids - subset
|
|
120
|
+
merge_these = tips(subset, self.stacks)
|
|
121
|
+
plans.append(
|
|
122
|
+
{
|
|
123
|
+
"rank": rank,
|
|
124
|
+
"changes": list(merge_order(subset, by_id)),
|
|
125
|
+
# Merging a stack's tip brings its ancestors along, so these
|
|
126
|
+
# are the merges you actually perform.
|
|
127
|
+
"merge": list(merge_order(merge_these, by_id)),
|
|
128
|
+
"dropped": list(set_key(dropped)),
|
|
129
|
+
"dropped_weight": round(
|
|
130
|
+
sum(self.weights.get(i, 1.0) for i in dropped), 3
|
|
131
|
+
),
|
|
132
|
+
"kept_weight": round(
|
|
133
|
+
sum(self.weights.get(i, 1.0) for i in subset), 3
|
|
134
|
+
),
|
|
135
|
+
"size": len(subset),
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
return sorted(plans, key=lambda p: (p["dropped_weight"], -p["size"]))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def analyze(
|
|
142
|
+
repo: str,
|
|
143
|
+
changes: Sequence[Change],
|
|
144
|
+
*,
|
|
145
|
+
base: Optional[str] = None,
|
|
146
|
+
validate: Optional[Callable[[str], ValidationOutcome]] = None,
|
|
147
|
+
resolver: Optional[Resolver] = None,
|
|
148
|
+
log: Optional[EvaluationLog] = None,
|
|
149
|
+
log_path: Optional[str] = None,
|
|
150
|
+
weight: Optional[Callable[[Change], float]] = None,
|
|
151
|
+
pairwise_preoracle: bool = True,
|
|
152
|
+
decompose: bool = True,
|
|
153
|
+
component_local: Optional[bool] = None,
|
|
154
|
+
use_ci_status: bool = True,
|
|
155
|
+
check_base: bool = True,
|
|
156
|
+
max_evaluations: Optional[int] = None,
|
|
157
|
+
max_seconds: Optional[float] = None,
|
|
158
|
+
max_sets: Optional[int] = None,
|
|
159
|
+
worktree_root: Optional[str] = None,
|
|
160
|
+
reuse_worktree: Optional[str] = None,
|
|
161
|
+
on_event: Optional[Callable[[str, dict], None]] = None,
|
|
162
|
+
) -> Analysis:
|
|
163
|
+
"""Find the maximal sets of ``changes`` that merge and validate on ``base``.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
repo: Path to the git repository.
|
|
167
|
+
changes: Candidate changes (see :mod:`mergeset.sources`).
|
|
168
|
+
base: What to merge onto. Default: the merge base of every change's own
|
|
169
|
+
base, which is the only choice that is fair to all of them.
|
|
170
|
+
validate: ``worktree -> ValidationOutcome``; default is a fail-fast
|
|
171
|
+
pytest run. Pass ``merge_only_validation()`` for a free first pass.
|
|
172
|
+
resolver: Optional AI-assisted conflict resolver (results are flagged).
|
|
173
|
+
log / log_path: The evaluation log — the single source of truth. Reusing
|
|
174
|
+
an existing one makes a re-run nearly free.
|
|
175
|
+
weight: ``Change -> cost of dropping it``; default is size-based.
|
|
176
|
+
pairwise_preoracle: Use ``git merge-tree`` to find textual conflicts for
|
|
177
|
+
free before spending any evaluation.
|
|
178
|
+
decompose: Use file-overlap components to find conflicts cheaply. The
|
|
179
|
+
components are always searched first, because a conflict found
|
|
180
|
+
inside a small component is a conflict found for a fraction of the
|
|
181
|
+
price; whether their answers are then *trusted* to combine is
|
|
182
|
+
``component_local``.
|
|
183
|
+
component_local: Assert that the validator cannot see across
|
|
184
|
+
components — i.e. that two changes touching disjoint files can never
|
|
185
|
+
fail together. True for a merge-only or per-file validator; **false
|
|
186
|
+
for any whole-repo test run**, which can fail on generated
|
|
187
|
+
artifacts, barrel exports, snapshots, type checks or project-wide
|
|
188
|
+
lint that no changed-file overlap predicts. When false (the
|
|
189
|
+
default), the combined result is verified by a global search seeded
|
|
190
|
+
with every conflict the components found. ``None`` (the default)
|
|
191
|
+
asks the validator: one may set ``validate.component_local = True``
|
|
192
|
+
to declare it, as :func:`~mergeset.validation.merge_only_validation`
|
|
193
|
+
does.
|
|
194
|
+
check_base: Evaluate the base commit on its own before anything else, so
|
|
195
|
+
a broken base or a misconfigured validator is a refusal rather than
|
|
196
|
+
15 confident failures.
|
|
197
|
+
use_ci_status: Treat a change whose own CI is red as a size-1 conflict.
|
|
198
|
+
max_evaluations, max_seconds, max_sets: Budgets; partial results are
|
|
199
|
+
always returned.
|
|
200
|
+
reuse_worktree: Reuse one worktree for every evaluation instead of a
|
|
201
|
+
fresh one, so an expensive setup step (``npm install``, a build
|
|
202
|
+
cache) is paid once rather than per evaluation. Pair it with
|
|
203
|
+
:func:`mergeset.validation.staged_validation`.
|
|
204
|
+
on_event: ``(event, payload)`` progress callback.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
An :class:`Analysis`.
|
|
208
|
+
"""
|
|
209
|
+
check_git_capability()
|
|
210
|
+
changes = list(changes)
|
|
211
|
+
emit = on_event or (lambda name, payload: None)
|
|
212
|
+
if not changes:
|
|
213
|
+
raise ValueError("No candidate changes were given; nothing to analyze.")
|
|
214
|
+
|
|
215
|
+
base = base or _common_base(repo, changes)
|
|
216
|
+
base_sha = resolve(repo, base)
|
|
217
|
+
if log is None:
|
|
218
|
+
log = EvaluationLog(
|
|
219
|
+
log_path or os.path.join(repo, ".mergeset", "evaluations.jsonl")
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# A validator may declare that it cannot see across components (the
|
|
223
|
+
# merge-only one does); otherwise assume it can, which is the safe default.
|
|
224
|
+
if component_local is None:
|
|
225
|
+
component_local = bool(getattr(validate, "component_local", False))
|
|
226
|
+
|
|
227
|
+
weight = weight or (lambda c: size_weight(repo, c))
|
|
228
|
+
own_weights = {
|
|
229
|
+
c.id: (c.weight if c.weight is not None else weight(c)) for c in changes
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
stacks = detect_stacks(changes)
|
|
233
|
+
# Dropping a change drops everything stacked on it, so that is what dropping
|
|
234
|
+
# it costs. Weighting changes independently lets the hitting set discard a
|
|
235
|
+
# whole stack while believing it discarded one small PR.
|
|
236
|
+
weights = cone_weights(own_weights, stacks)
|
|
237
|
+
|
|
238
|
+
analysis = Analysis(
|
|
239
|
+
repo=repo,
|
|
240
|
+
base=base,
|
|
241
|
+
base_sha=base_sha,
|
|
242
|
+
changes=changes,
|
|
243
|
+
weights=weights,
|
|
244
|
+
own_weights=own_weights,
|
|
245
|
+
log=log,
|
|
246
|
+
stacks=stacks,
|
|
247
|
+
)
|
|
248
|
+
if stacks:
|
|
249
|
+
ids = [c.id for c in changes]
|
|
250
|
+
analysis.notes.append(
|
|
251
|
+
f"{len(stacks)} of {len(changes)} changes are stacked on another "
|
|
252
|
+
f"candidate. Only downward-closed sets are considered, which reduces "
|
|
253
|
+
f"the search space from {2 ** len(ids)} subsets to "
|
|
254
|
+
f"{count_closed_subsets(stacks, ids)} valid ones."
|
|
255
|
+
)
|
|
256
|
+
for child, parent in stacks.items():
|
|
257
|
+
analysis.notes.append(
|
|
258
|
+
f"{child} is stacked on {parent}: it already contains it, so a set "
|
|
259
|
+
f"holding {child} must hold {parent}, and dropping {parent} drops "
|
|
260
|
+
f"{child} too."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
analysis.files_by_change = {
|
|
264
|
+
c.id: changed_files(repo, base_sha, c.head) for c in changes
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
known_conflicts: List[ChangeSet] = []
|
|
268
|
+
if use_ci_status:
|
|
269
|
+
for c in changes:
|
|
270
|
+
# A forge's CI/mergeable verdict is about the PR's OWN base branch.
|
|
271
|
+
# When that is not the base we are merging onto, the verdict says
|
|
272
|
+
# nothing about this analysis -- a PR can be green and "MERGEABLE"
|
|
273
|
+
# on GitHub and still refuse to merge onto main, because its base
|
|
274
|
+
# branch moved on without it. Trust the signal only when the bases
|
|
275
|
+
# agree, and say so when they do not.
|
|
276
|
+
own_base = c.meta.get("base_ref")
|
|
277
|
+
same_base = own_base is None or base.endswith(own_base)
|
|
278
|
+
if c.meta.get("ci") == "failure" and same_base:
|
|
279
|
+
known_conflicts.append(frozenset({c.id}))
|
|
280
|
+
analysis.notes.append(
|
|
281
|
+
f"{c.id} is excluded for free: its own CI is red against "
|
|
282
|
+
f"`{own_base}`, which is the base we are merging onto."
|
|
283
|
+
)
|
|
284
|
+
elif c.meta.get("ci") and not same_base:
|
|
285
|
+
analysis.notes.append(
|
|
286
|
+
f"{c.id}'s CI status (`{c.meta['ci']}`) is against `{own_base}`, "
|
|
287
|
+
f"not `{base}`, so it is ignored as a pre-oracle. Its "
|
|
288
|
+
"mergeability here is decided by actually merging it."
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# The cheapest oracle of all: does each change even merge onto base alone?
|
|
292
|
+
heads_by_id = {c.id: c.head for c in changes}
|
|
293
|
+
if pairwise_preoracle:
|
|
294
|
+
for change_id, files in singleton_textual_conflicts(
|
|
295
|
+
repo, base_sha, heads_by_id
|
|
296
|
+
):
|
|
297
|
+
known_conflicts.append(frozenset({change_id}))
|
|
298
|
+
analysis.singleton_conflicts[change_id] = files
|
|
299
|
+
analysis.notes.append(
|
|
300
|
+
f"{change_id} does not merge onto the base at all "
|
|
301
|
+
f"({len(files)} conflicting files). Excluded before any test ran."
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if pairwise_preoracle:
|
|
305
|
+
viable = {
|
|
306
|
+
cid: head
|
|
307
|
+
for cid, head in heads_by_id.items()
|
|
308
|
+
if cid not in analysis.singleton_conflicts
|
|
309
|
+
}
|
|
310
|
+
analysis.textual_conflicts = list(
|
|
311
|
+
pairwise_textual_conflicts(repo, base_sha, viable)
|
|
312
|
+
)
|
|
313
|
+
for a, b, files in analysis.textual_conflicts:
|
|
314
|
+
known_conflicts.append(frozenset({a, b}))
|
|
315
|
+
emit("textual_conflict", {"pair": (a, b), "files": files})
|
|
316
|
+
|
|
317
|
+
ids = [c.id for c in changes]
|
|
318
|
+
if decompose:
|
|
319
|
+
analysis.components = independent_components(
|
|
320
|
+
ids, lambda cid: analysis.files_by_change.get(cid, ())
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
analysis.components = [frozenset(ids)]
|
|
324
|
+
|
|
325
|
+
evaluate = log.caching(
|
|
326
|
+
git_oracle(
|
|
327
|
+
repo,
|
|
328
|
+
base_sha,
|
|
329
|
+
changes,
|
|
330
|
+
validate=validate,
|
|
331
|
+
resolver=resolver,
|
|
332
|
+
depends_on=analysis.stacks,
|
|
333
|
+
reuse_worktree=reuse_worktree,
|
|
334
|
+
worktree_root=worktree_root,
|
|
335
|
+
on_event=on_event,
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if check_base:
|
|
340
|
+
# One evaluation of the empty set. If the base does not validate, every
|
|
341
|
+
# subsequent failure is meaningless and "nothing can be merged" would be
|
|
342
|
+
# reported as a finding rather than as the misconfiguration it is.
|
|
343
|
+
baseline = evaluate(frozenset())
|
|
344
|
+
if baseline.verdict is not Verdict.PASS:
|
|
345
|
+
detail = baseline.note or ""
|
|
346
|
+
if baseline.validation is not None:
|
|
347
|
+
detail = "; ".join(baseline.validation.failing_tests[:5]) or detail
|
|
348
|
+
raise MergesetError(
|
|
349
|
+
f"The base ({base} @ {base_sha[:8]}) does not pass validation on "
|
|
350
|
+
f"its own: {detail}\n\n"
|
|
351
|
+
"Every result would be meaningless, so nothing was evaluated. "
|
|
352
|
+
"Either the base is genuinely broken, or the validation command "
|
|
353
|
+
"is wrong for this repository. Check it by hand, then re-run "
|
|
354
|
+
"(pass check_base=False to proceed anyway)."
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
diff_of = git_diff_reader(repo, base_sha, heads_by_id)
|
|
358
|
+
|
|
359
|
+
def suspects_from_failure(result, subset: ChangeSet) -> ChangeSet:
|
|
360
|
+
"""Which changes in ``subset`` the failure output points at."""
|
|
361
|
+
if result.validation is None:
|
|
362
|
+
return frozenset()
|
|
363
|
+
return attribute_suspects(
|
|
364
|
+
result.validation,
|
|
365
|
+
files_by_change=analysis.files_by_change,
|
|
366
|
+
within=subset,
|
|
367
|
+
diff_of=diff_of,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
per_component: List[List[ChangeSet]] = []
|
|
371
|
+
for component in analysis.components:
|
|
372
|
+
component_conflicts = [c for c in known_conflicts if c <= component]
|
|
373
|
+
emit("component", {"changes": set_key(component)})
|
|
374
|
+
state = find_maximal_good_sets(
|
|
375
|
+
component,
|
|
376
|
+
evaluate,
|
|
377
|
+
known_conflicts=component_conflicts,
|
|
378
|
+
depends_on=analysis.stacks,
|
|
379
|
+
weight=lambda cid: weights.get(cid, 1.0),
|
|
380
|
+
max_evaluations=max_evaluations,
|
|
381
|
+
max_seconds=max_seconds,
|
|
382
|
+
max_sets=max_sets,
|
|
383
|
+
suspects=suspects_from_failure,
|
|
384
|
+
on_event=on_event,
|
|
385
|
+
)
|
|
386
|
+
analysis.searches.append(state)
|
|
387
|
+
analysis.conflicts.extend(state.conflicts)
|
|
388
|
+
per_component.append(state.maximal_good_sets or [frozenset()])
|
|
389
|
+
|
|
390
|
+
combined = combine_components(per_component)
|
|
391
|
+
if component_local or len(analysis.components) <= 1:
|
|
392
|
+
analysis.components_combined = True
|
|
393
|
+
analysis.maximal_sets = combined
|
|
394
|
+
else:
|
|
395
|
+
# Decomposition is sound for *textual* conflicts and unsound for
|
|
396
|
+
# anything a whole-repo run can see: a drift test in one component can
|
|
397
|
+
# read generated artifacts derived from sources another component edits,
|
|
398
|
+
# with no changed file in common. So the components buy cheap conflicts,
|
|
399
|
+
# and then one global search -- seeded with every one of them, so it
|
|
400
|
+
# starts almost finished -- decides the answer.
|
|
401
|
+
analysis.notes.append(
|
|
402
|
+
f"{len(analysis.components)} file-overlap components were searched "
|
|
403
|
+
"first to find conflicts cheaply, then verified globally: a whole-repo "
|
|
404
|
+
"validator can fail on changes that share no file, so component "
|
|
405
|
+
"results are not assumed to combine freely "
|
|
406
|
+
"(pass component_local=True if your validator cannot see across them)."
|
|
407
|
+
)
|
|
408
|
+
global_state = find_maximal_good_sets(
|
|
409
|
+
[c.id for c in changes],
|
|
410
|
+
evaluate,
|
|
411
|
+
known_conflicts=analysis.conflicts,
|
|
412
|
+
depends_on=analysis.stacks,
|
|
413
|
+
weight=lambda cid: weights.get(cid, 1.0),
|
|
414
|
+
max_evaluations=max_evaluations,
|
|
415
|
+
max_seconds=max_seconds,
|
|
416
|
+
max_sets=max_sets,
|
|
417
|
+
suspects=suspects_from_failure,
|
|
418
|
+
on_event=on_event,
|
|
419
|
+
)
|
|
420
|
+
analysis.searches.append(global_state)
|
|
421
|
+
analysis.conflicts = _dedupe_conflicts(
|
|
422
|
+
analysis.conflicts + global_state.conflicts
|
|
423
|
+
)
|
|
424
|
+
analysis.maximal_sets = global_state.maximal_good_sets or combined
|
|
425
|
+
|
|
426
|
+
for state in analysis.searches:
|
|
427
|
+
if state.error:
|
|
428
|
+
analysis.notes.append(
|
|
429
|
+
f"ABORTED: {state.error}. The sets below are whatever had been "
|
|
430
|
+
"established before that point; they are not an answer."
|
|
431
|
+
)
|
|
432
|
+
violations = log.monotonicity_violations()
|
|
433
|
+
for good, bad in violations:
|
|
434
|
+
analysis.notes.append(
|
|
435
|
+
f"MONOTONICITY VIOLATION: {set_key(bad)} failed but {set_key(good)} "
|
|
436
|
+
"passed, though it contains it. Suspect a flaky test, or a change "
|
|
437
|
+
"that fixes another. Results below are not fully trustworthy."
|
|
438
|
+
)
|
|
439
|
+
return analysis
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _common_base(repo: str, changes: Sequence[Change]) -> str:
|
|
443
|
+
"""The commit every change can fairly be measured against.
|
|
444
|
+
|
|
445
|
+
When all candidates already share a base, that is it. When they do not
|
|
446
|
+
(stacked PRs, PRs opened against a non-default branch), fall back to the
|
|
447
|
+
merge base of all of them — and the caller is told, because "which base"
|
|
448
|
+
silently differing is the classic way this analysis becomes meaningless.
|
|
449
|
+
"""
|
|
450
|
+
bases = {c.base for c in changes}
|
|
451
|
+
if len(bases) == 1:
|
|
452
|
+
return next(iter(bases))
|
|
453
|
+
current = resolve(repo, sorted(bases)[0])
|
|
454
|
+
for other in sorted(bases)[1:]:
|
|
455
|
+
current = merge_base(repo, current, resolve(repo, other))
|
|
456
|
+
return current
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _dedupe_conflicts(conflicts: Sequence[ChangeSet]) -> List[ChangeSet]:
|
|
460
|
+
"""Keep only the minimal conflicts; a superset of a conflict says nothing new."""
|
|
461
|
+
unique = {frozenset(c) for c in conflicts if c}
|
|
462
|
+
return sorted(
|
|
463
|
+
(c for c in unique if not any(other < c for other in unique)),
|
|
464
|
+
key=lambda c: (len(c), set_key(c)),
|
|
465
|
+
)
|