xlgit
Git diff and merge for Excel. Two people edit the same workbook on their own branches, and git merges it cell by cell. Charts, tables, pivot tables and macros come through intact, and only a cell you both changed is a conflict.
Try it in 30 seconds
pip install xlgit
xlgit demo
xlgit demo works in a throwaway folder and touches nothing else: Anna inserts a line in a cost estimate, Ben changes a rate and a quantity on his branch, and git merges them, with Ben's edits following their rows down. No Python? Grab the program for your computer from the latest release and run xlgit demo with it.
When you're ready to use it on your own files, run xlgit install once. Get started has the details.
Beta (0.1.1). Every release is fuzz-tested against thousands of real-world workbooks, but you'll still find cases it gets wrong. Git keeps every version, so a bad merge can always be undone. Please open an issue when something looks wrong.
What you get
| Without xlgit | With xlgit | |
|---|---|---|
git diff |
Binary files differ |
changed Budget!B2 1000 -> 1100, plus inserted, deleted and moved rows, and chart, image and comment changes |
| Seeing changes | n/a | xlgit diff --html: the sheet as a grid in your browser, changes highlighted |
| Merge, different cells edited | conflict, pick one whole file | merges cleanly |
| Merge, one side inserted rows | conflict | the other side's edits follow their rows |
| Merge, same cell edited | conflict | conflict on just that cell, listed in a _merge_conflicts sheet with a link to it |
| Charts, images, comments, formatting, macros | n/a | kept, and their edits to them carried over |
| Tables and pivot tables | n/a | merged: you add rows, they add a column, you get both |
| Pull request on GitHub | "binary file not shown" | bot comment with a table of every changed cell, chart, table and pivot |
Formulas are compared as formulas (=B2+C2), not their cached results.
Get started
You need git. Then, once per computer, either:
With Python (3.9 or newer):
pip install xlgit
xlgit install
Without Python: download the program for your computer from the latest release (xlgit-windows.exe, xlgit-macos-arm64 for Apple silicon, xlgit-macos-intel, xlgit-linux), rename it to xlgit (xlgit.exe on Windows), put it somewhere it will stay, and run xlgit install. On Windows you can also just double-click it and it offers to set itself up. The downloads aren't code-signed yet: on Windows click "More info" then "Run anyway"; on a Mac, right-click it, choose Open, then Open again. If you move the program later, run xlgit install again.
That's it. Every git repository on this computer now understands .xlsx and .xlsm files, including ones you clone or create later. Keep using git the way you already do.
To also get a comment listing the changed cells on every GitHub pull request, run this once inside the repository and push:
xlgit install --github
Run xlgit on its own at any time to see the commands and check that everything is set up.
Everyday use
See what changed. xlgit diff lists every cell that changed in your workbooks since the last commit. Inserted, deleted and moved rows show up as rows, not as every cell below them changing. git diff, git log -p and git show show cell changes too.
$ xlgit diff
=== budget.xlsx ===
changed Budget!C3 350 -> 999
row inserted Budget row 4 A: 'Gas', B: 60, C: 70, D: =B4+C4
changed Budget!D8 =SUM(D2:D6) -> =SUM(D2:D7)
xlgit diff --html opens the same changes in your browser, laid out like the spreadsheet:
Merge. git merge and git pull combine edits from both branches cell by cell. If you changed different cells, there's nothing to do.
When both of you changed the same cell, git stops and xlgit tells you which cells:
xlgit merged budget.xlsx: took 3 cell(s) from the other branch, but 1 change(s) clash.
Budget B2: yours 1100, theirs 1200 (was 1000)
Your values were kept. Every clash is listed, with a link, on the sheet '_merge_conflicts'.
Open the workbook, go through the _merge_conflicts sheet (each row links to its cell), fix the cells, delete that sheet, save, then run git add budget.xlsx and git commit. Git keeps every version, so nothing is ever lost: git merge --abort undoes the whole merge.
Found a problem?
Please open an issue. Bug reports on real workbooks are the most useful thing you can give this project, and you don't have to share your data to do it:
xlgit scrub --merge budget.xlsx # during a merge that went wrong: base, yours, theirs, in one zip
xlgit scrub old.xlsx new.xlsx # any workbooks, e.g. for a wrong diff
scrub makes copies where every number, piece of text, comment, chart label and file property is replaced with made-up values, keeping formulas, layout, charts, tables and pivots, so the problem still shows up. Equal values stay equal across the files scrubbed together. Sheet names and named ranges are kept (formulas refer to them), macros are removed, and images are replaced with blank ones. Open the copies and check them before you share them.
How the merge works
A workbook is a zip of XML files: one per sheet, one per chart, one per image and so on. Instead of re-saving the whole thing through a spreadsheet library (which is how charts used to get lost), xlgit starts from your copy's zip and only rewrites the XML that has to change:
- Cells: 3-way merge cell by cell. A cell only one side changed takes that side's value.
- Rows: if one branch inserted, deleted or moved rows and the other edited cells, the edits land on the rows where their cells ended up, with formula references renumbered the way Excel does it. An edit to a row the other branch deleted is a conflict.
- Charts, images, comments, macros: 3-way merge object by object. If only their branch changed a chart, you get their version. If both did, yours is kept and it's flagged as a conflict. Chart edits caused by cell changes (Excel caches plotted values inside the chart) don't count as edits.
- Tables: merged field by field. Their new column plus your new rows gives a table with both. Tables added on their branch come over, and if both branches added a
Table2, theirs is renamedTable3and their formulas are updated to match. - Pivot tables: a pivot, its data cache and the cached records merge as one bundle. A branch that only refreshed a pivot (same layout, new data) doesn't count as an edit, so your data change and their "switch to Average" merge cleanly. New pivots from their branch come over, sharing an existing cache when they used one.
- Sheets: new sheets on their branch come over whole, with their charts, tables and pivots. Renames and deletes merge too.
- Named ranges: merged by name.
Writers renumber a workbook's internal files on every save (add a chart to an early sheet and every later chart1.xml becomes chart2.xml). xlgit matches objects by what they are, like "the chart called Chart 1 on sheet Notes" or "table id 3", not by file name, so renumbering doesn't cause false conflicts.
Excel recalculates every formula and refreshes affected pivot tables when it opens the merged file.
Commands
xlgit help, and whether everything is set up
xlgit install set up every repository on this computer (once)
xlgit install --github add pull request comments to this repository
xlgit install --repo set up only this repository
xlgit uninstall [--repo] undo the setup
xlgit diff what changed in your workbooks since the last commit
xlgit diff FILE ... in one workbook
xlgit diff OLD NEW compare any two workbooks (--markdown for a table)
xlgit diff --html [FILES] open the changes in your browser (--out=page.html to save it)
xlgit scrub FILE... copies with every value made up, safe to attach to a bug report
xlgit scrub --merge FILE the three versions of a merge that went wrong, scrubbed, in one zip
xlgit demo [FOLDER] two people edit one workbook and git merges it, in a throwaway folder
xlgit --version
Git runs xlgit textconv and xlgit merge itself; you don't need to.
Don't want a package? xlgit.py is a single file. Copy it in, pip install openpyxl lxml, and run python xlgit.py install.
The GitHub Action
xlgit install --github writes this workflow. You can also add it by hand:
# .github/workflows/excel-diff.yml
name: Excel diff
on:
pull_request:
paths: ["**/*.xlsx", "**/*.xlsm"]
permissions:
contents: read
pull-requests: write
jobs:
excel-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: MichaelFowler1/excel-git@v0.1.1
It keeps one comment per pull request up to date as you push. Pull requests from forks can't be commented on with GitHub's default token, so for those the changed cells go in the run's summary page instead.
Tests
pip install -r requirements-dev.txt
python -m pytest tests
The tests build workbook versions with charts, comments and named ranges, run real git merge through the driver, and check every object survived. Set XLGIT_KEEP=some/dir to keep the merged files and open them in Excel yourself.
Fuzzing against real workbooks
fuzz/merge_fuzz.py runs the merge over a folder of real spreadsheets. For each one it makes two branches with random cell edits, merges them, and checks the result: it opens, every edit from both sides is there with its exact type and value, nothing else changed, no chart, table or pivot was lost, and the conflicts reported are exactly the cells both sides changed.
pip install py7zr
fuzz/fetch_corpus.sh corpus # ~17,000 workbooks: Enron corpus + open-source test suites
python fuzz/merge_fuzz.py corpus --keep failed/
--keep saves the base, ours, theirs and merged files of every failure. --rounds N tries N different random edits per workbook.
Limits
- Formatting changes their branch made to existing cells come over only when both branches have the same set of styles. Otherwise your formatting is kept.
- Column widths, merged cells and conditional formatting on existing sheets aren't merged. Yours are kept.
- Until Excel refreshes a merged pivot, the numbers in its cells are the old ones. Excel does this on open, but tools that read the file without Excel (pandas, openpyxl) see the stale values.
- Slicers, timelines and tables linked to external data connections aren't merged. They're reported as conflicts so nothing disappears silently.
- If both branches added a chart to a sheet that had none, only yours is kept (flagged).
- Inserted and deleted rows are followed when only one branch changed a sheet's rows. If both branches inserted or deleted rows on the same sheet, that sheet merges cell by cell, and cells that only moved can show up as conflicts.
- Rows are recognised by their contents. A row change can't always be recognised (say, deleting one of many identical rows); then the sheet merges cell by cell and anything unclear is reported as a conflict, never guessed.
- Inserted columns show as changed cells for now.
.xls(the old pre-2007 format) isn't supported. Save as.xlsx.
Roadmap
Next up: testing against thousands of real workbooks, understanding inserted and moved rows, then code review for spreadsheets: showing what a change does to the numbers, tests that run on every pull request, and a linter. See ROADMAP.md.
License
Apache License 2.0. See LICENSE.
You can use, change and ship xlgit, including commercially. If you pass it on, modified or not, keep the NOTICE file and the copyright line at the top of xlgit.py with it. The license doesn't grant use of the xlgit name for your own version.
Created by Michael Fowler.
Release files for xlgit 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xlgit-0.1.1.tar.gz | 78.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xlgit-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 134.6 kB
Release files / xlgit-0.1.1.tar.gz
| Download URL | xlgit-0.1.1.tar.gz |
|---|---|
| Size | 78.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f5bb7de8f4b2dbd8cb77a4a67a4b4959ce132289fb759c146b6399915531a431
|
|
BLAKE2b-256 checksum How to use checksums |
fa5df9f7a79cf535e197645713e3a600d27c5ba555bcdaac1e672bbbb551e0b6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.
Transparency logRelease files / xlgit-0.1.1-py3-none-any.whl
| Download URL | xlgit-0.1.1-py3-none-any.whl |
|---|---|
| Size | 56.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8c2f914cb1c6ab25f4f093fde22d435659996a84cec22071b44c2f51fd162f5e
|
|
BLAKE2b-256 checksum How to use checksums |
553c8a2e86488c6b14854f46efe270638fd8b152ea8041e16d306f7eb096628c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.
Transparency log