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

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.

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.16.tar.gz (157.7 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.16-py3-none-any.whl (104.5 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: mergeset-0.0.16.tar.gz
  • Upload date:
  • Size: 157.7 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.16.tar.gz
Algorithm Hash digest
SHA256 792ebfb128efcdf832a744d6d2ab381712d70a05c9eea44d56c665886ae2f72a
MD5 3b8482a65b4253616a85fc3a28ebb70b
BLAKE2b-256 e0f9d2fd7c7b7a0f037ef5095236e2fa5df26fd6fbc67aca2ca696ae3b6bdfc8

See more details on using hashes here.

File details

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

File metadata

  • Download URL: mergeset-0.0.16-py3-none-any.whl
  • Upload date:
  • Size: 104.5 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.16-py3-none-any.whl
Algorithm Hash digest
SHA256 fc232cba2608143e969d7020d8ac267ea562e39305b062682ecc3af0c27ccc8e
MD5 ce6baed2f0faa672d18b1b720c1961ee
BLAKE2b-256 5d3f85ab9d24fe3bf193df4080c387b0a816616abafaf70c41d1233df456f1cd

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.0.16 This release

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

0.0.8

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