Skip to main content

mergeset

Which of these branches can be merged together?

Given a base commit and n candidate changes — branches, pull requests, commits — mergeset finds the maximal sets that merge cleanly and still pass validation, and the minimal conflicts that stop the rest.

pip install mergeset
python -m mergeset branches feat-a feat-b feat-c --base main

Driving it from an agent? mergeset ships the skill an agent needs to drive it well — ask the free oracles before spending a test run, what a green report does not mean, and why a run's output never goes into a repository.

gh skill install i2mint/mergeset mergeset --agent claude-code   # or copilot, cursor, codex, gemini

The same skill is inside the wheel, so pip install mergeset already has it — mergeset install-skills links it into an agent's directory, offline.

from mergeset import analyze, branch_changes, markdown_report

changes = list(branch_changes(".", ["feat-a", "feat-b", "feat-c"], base="main"))
print(markdown_report(analyze(".", changes)))

You get a merge plan: merge these, in this order, and here is what you lose — plus, for everything dropped, whether it was a textual conflict (and in which files) or a test failure (and which tests).

Why it is not just "try every subset"

Evaluating a subset means merging it and running a test suite. That is minutes, and there are 2**n subsets. So mergeset spends its evaluations the way a person would if each one cost real money.

Ask the cheap oracles first. Nothing expensive runs until everything free has had its say: a change whose own CI is red is a conflict of size one; git merge-tree --write-tree finds textual conflicts in milliseconds without ever creating a working tree; and changes touching disjoint files form independent subproblems that are solved separately and combined.

Never evaluate the same set twice. Every evaluation is appended to ~/.local/share/mergeset/evaluations/<repo>.jsonl, which is the single source of truth. Each row records the base and head shas it was merged at, and only rows about the current commits are reused, so rewriting a branch never answers from its old tree. Re-running is nearly free, a crashed run resumes, and reports regenerate without re-running anything. The log also propagates monotonicity: a passing set marks all its subsets good, a failing set marks all its supersets bad.

Search like a SAT solver, not like a loop. A failure is shrunk to a minimal conflict by QuickXplain (O(k log n) evaluations instead of O(n)), and the next candidate to try is the complement of the cheapest minimal hitting set of all conflicts found so far — Reiter's hitting-set duality, which says exactly that complements of maximal good sets are the minimal hitting sets of the conflicts.

Weights mean "cost of dropping this". When something has to go, the tool drops the cheapest work, not the first thing it thought of.

Where the output goes

Reports, evaluation logs, raw validation output and captured fixtures all land in an artifact store, never in a repository:

~/.local/share/mergeset/            # $MERGESET_DATA_DIR overrides the root
    reports/<repo>/<timestamp>/     # REPORT.md, report.html — one key per run
    evaluations/<repo>.jsonl        # the append-only log — the source of truth
    logs/                           # raw build/test/lint output
    fixtures/                       # captured failures kept as regression tests

This is not tidiness. Everything mergeset produces is captured from the repository it analysed — source paths, symbol names, stack traces with verbatim code, branch names, PR metadata. Written into that repository (or into mergeset's own), one git add . publishes it. This project has already published a private repository's internals to a public one exactly that way, so the default is now a location no git add can reach.

Each kind is a plain MutableMapping[str, str], so the backend is one keyword argument:

from mergeset import analyze, artifact_mall

# Local files, the default:
analyze(repo, changes)

# The same run, with every artifact in S3 — one keyword argument, no other change:
s3 = artifact_mall(store_factory=lambda kind, root: S3Store(bucket, prefix=kind))
analyze(repo, changes, artifacts=s3)

A factory is handed the kind and the root separately, never a joined filesystem path: a backend with no filesystem should not have to parse one out.

--report-dir still writes wherever you point it — an explicit choice, not a default. Without it, each run gets its own key, so re-analysing a repository never overwrites the answer you are comparing against.

Reports render as Markdown and HTML by default, and PDF on request:

python -m mergeset prs OWNER/REPO --report-format markdown html pdf   # needs mergeset[pdf]

The PDF is rendered from the Markdown, not the HTML — the HTML report draws itself from an embedded JSON blob at load time, so a print pipeline that does not run JavaScript would give you a blank page that looks fine until someone opens it.

Progressive disclosure

The call above takes no configuration and works. Every piece of it is one keyword argument away from being replaced.

you want to change keyword
where changes come from mergeset.sources — branch_changes, pr_changes, commit_changes, or any iterable of Change
where artifacts are kept mergeset.storage — artifact_store(kind, store_factory=...); local files by default, S3 by passing a different factory
what counts as "works" validate= — pytest_validation(), command_validation('make test'), js_validation(...), staged_validation([...]), act_validation(), callable_validation(my_func)
how much it may spend max_evaluations=, max_seconds=, max_sets= — partial results are always returned and labelled partial
what dropping a change costs weight= — any Change -> float
where the log lives log= / log_path= — anything with append and __iter__
conflict resolution resolver=claude_code_resolver() — flagged, never silent

Real repositories are messier than the theory

Three things break the clean picture, and mergeset handles each explicitly rather than pretending.

Stacked PRs. A PR opened against another PR's branch already contains it, so subsets are not free: a valid set must be downward-closed under the parent relation — and stacks branch, so this is a forest, not a chain. mergeset closes sets before evaluating them, merges only the tips (merging a tip brings its ancestors), and charges the cost of a change's whole descendant cone when deciding what to drop. On a 15-PR forest this also cuts the search space from 32768 subsets to 576.

A green PR that will not land. GitHub's mergeable and CI status are computed against the PR's own base branch. If that branch has moved on, a PR can be green and "CLEAN" on GitHub and still refuse to merge onto main. mergeset ignores the forge's verdict whenever the bases disagree, and checks by actually merging.

Monotonicity is a prior, not a law. A change can contain the fix that makes another one work, and flaky tests break closure outright. Violations are detected and reported loudly rather than silently trusted; flake_tolerant(validate, retries=1) is the cheap mitigation.

Validation is a sequence, not a command

Real projects do not have "the test command". They have an install step (slow, and only needed when the lockfile moved), a build step that is a prerequisite of testing rather than part of it, then tests, then lint. A single pass/fail bit throws away the distinction you most need — "failed to build" is not "tests failed".

From the CLI, the same thing, repeatable and ordered:

python -m mergeset prs OWNER/REPO --repo . \
    --validate-stage 'setup:pnpm install --frozen-lockfile' \
                     'build:pnpm run build' \
                     'test:pnpm run test' \
                     'lint:pnpm run lint' \
    --validate-fingerprint 'setup:pnpm-lock.yaml' \
    --validate-optional lint \
    --reuse-worktree /tmp/mergeset-wt

An optional stage's failure is recorded but does not veto the set — for a lint the project runs as a separate job rather than a gate.

The evaluation log reuses a verdict only when the commits and the validator are the same, and "the validator" means its command text. When the command runs a script that lives outside the repository (--validate-command 'python ~/checks/smoke.py'), add --validate-input ~/checks/smoke.py (repeatable; a directory works too): the script's contents then join the validator's fingerprint, and editing it re-runs the sets instead of reusing the old script's verdicts. In the library, command_validation, pytest_validation, act_validation, staged_validation and js_validation take inputs=[...]. The contents are read when the validator is made and checked again around every run: an input edited mid-run stops the run instead of recording a verdict under the old fingerprint.

From the library:

from mergeset import ValidationStage, staged_validation, file_fingerprint

validate = staged_validation(
    [
        ValidationStage(
            "setup",
            "pnpm install --frozen-lockfile",
            fingerprint=file_fingerprint("pnpm-lock.yaml"),
        ),
        ValidationStage("build", "pnpm run build"),
        ValidationStage("test", "pnpm run test"),
        ValidationStage("lint", "pnpm run lint", required=False),
    ]
)
analyze(repo, changes, validate=validate, reuse_worktree="/tmp/scratch-wt")

The fingerprinted stage runs only when its inputs actually change, and reuse_worktree keeps one tree across the whole run — so an expensive install is paid once, not once per evaluation.

Tiered validation: screen cheaply, confirm expensively

When the real check is too slow to run on every candidate set (a browser suite, a container build), give mergeset a ladder of validators, cheapest first. The first tier screens every set the search touches; each later tier only confirms the sets its predecessor left standing, so the expensive suite runs on a handful of sets instead of dozens. Each tier keeps its own evaluation log, because a cheap PASS is not evidence about an expensive tier.

python -m mergeset prs OWNER/REPO --repo . \
    --validate-stage 'setup:pnpm install --frozen-lockfile' 'test:pnpm run test' \
    --validate-fingerprint 'setup:pnpm-lock.yaml' \
    --tier unit 'e2e:pnpm run e2e' \
    --tier-cost 'e2e:300' \
    --confirm-budget-seconds 1800 \
    --must-include pr42

--tier 'name:command' is a tier that runs one command. A bare name (unit above) is the tier the --validate-* flags describe: stages compose inside a validator, tiers compose across validators. --tier-cost declares what an untimed tier costs per run, so it is not scheduled as if it were cheap; --confirm-budget-seconds and --confirm-max-runs bound what the deeper tiers may spend, admitting runs on the pessimistic estimate. --must-include also reports the best plan that keeps a given change, which is a different question from the maximal sets and is answered alongside them. The report says how deep validation got, what each tier spent, and whether each price was measured or declared. The library entry point is analyze_tiered.

Output

  • Markdown report — the merge plans first, then what stops the rest, then the evidence.
  • Self-contained HTML — one file, no build step, no network: the conflict graph (textual vs validation conflicts distinguished), the plans, and the evaluation log.
  • Integration branches — --integration-branches creates a local integration/<date>-<k> per maximal set so you can test one by hand. It only ever creates new local branches; it never touches an existing branch and never pushes.

Requirements

  • Python ≥ 3.10, git ≥ 2.38 (for git merge-tree --write-tree; checked at startup with a message telling you how to fix it).
  • gh, authenticated, only if you use the pull-request source.
  • Whatever your validation command needs. Capabilities are checked up front rather than failing halfway through a long run.

Reading further

docs/adr/ records every non-obvious design choice and why — one numbered ADR per decision, with an index. docs/RESEARCH.md covers the MUS/MSS literature this is built on, the merge-queue prior art, and what was deliberately not depended on.

Metadata

Release files for mergeset 0.0.25

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mergeset 0.0.25
File Size Uploaded
mergeset-0.0.25.tar.gz 205.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mergeset 0.0.25
File Interpreter ABI Platform
mergeset-0.0.25-py3-none-any.whl Python 3 none any Details

Total release size: 333.7 kB

Release files / mergeset-0.0.25.tar.gz

Download URL mergeset-0.0.25.tar.gz
Size 205.3 kB
Tags Source
SHA-256 checksum
How to use checksums
b9a2e73ab691141534dc1671efd69d5bc8f783b38be618a330916d7edd733724
BLAKE2b-256 checksum
How to use checksums
26c0792307797014dcb9389182832b049ac7a6bbcba0a59a93c756877796e354
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / mergeset-0.0.25-py3-none-any.whl

Download URL mergeset-0.0.25-py3-none-any.whl
Size 128.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
20cb50b8a012937c5c0b92cfbef91286d3809ab17f9c21a2cca0e85569001130
BLAKE2b-256 checksum
How to use checksums
b50ddade9661223360a6be3c329aa6274bb8d7ccfcf68ff911bda46c135e31c8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page