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
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. 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.

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.sourcesbranch_changes, pr_changes, commit_changes, or any iterable of Change
where artifacts are kept mergeset.storageartifact_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. 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.

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

DECISIONS.md records every non-obvious design choice and why. RESEARCH.md covers the MUS/MSS literature this is built on, the merge-queue prior art, and what was deliberately not depended on.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mergeset-0.0.8.tar.gz (81.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mergeset-0.0.8-py3-none-any.whl (73.3 kB view details)

Uploaded Python 3

File details

Details for the file mergeset-0.0.8.tar.gz.

File metadata

  • Download URL: mergeset-0.0.8.tar.gz
  • Upload date:
  • Size: 81.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.11 {"installer":{"name":"uv","version":"0.12.11","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}

File hashes

Hashes for mergeset-0.0.8.tar.gz
Algorithm Hash digest
SHA256 06f3484e7976652086add8ee3c2d88127fc05329f53ac9462c0f7febb37e68e3
MD5 6da33efb0db436c6e502e41e79366855
BLAKE2b-256 c76d33464174ad0b76ee7dce4f38af0827e9cef42076413f4c5894f16abb9034

See more details on using hashes here.

File details

Details for the file mergeset-0.0.8-py3-none-any.whl.

File metadata

  • Download URL: mergeset-0.0.8-py3-none-any.whl
  • Upload date:
  • Size: 73.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.11 {"installer":{"name":"uv","version":"0.12.11","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}

File hashes

Hashes for mergeset-0.0.8-py3-none-any.whl
Algorithm Hash digest
SHA256 ad4365a16fb6f610379c3aa2f2a897c2ec775c10de8769a89f4fe3d626266695
MD5 f9d03c02e52e6b4b05bf52996399733d
BLAKE2b-256 9d83f65ffe952c474d1c8ee72d0f648590e0e6fec81c492859a30a8aef849a8e

See more details on using hashes here.

Release history Release notifications | RSS feed

0.0.16

2 files

0.0.15

2 files

0.0.14

2 files

0.0.13

2 files

0.0.12

2 files

0.0.11

2 files

0.0.10

2 files

0.0.9

2 files

This release

0.0.8 This release

2 files

0.0.7

2 files

0.0.6

2 files

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