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 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
@@ -0,0 +1,5 @@
1
+ """``python -m mergeset`` entry point."""
2
+
3
+ from mergeset.cli import main
4
+
5
+ raise SystemExit(main())
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
+ )