Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
# xlgit

> Beta (0.1.0). 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](https://github.com/MichaelFowler1/excel-git/issues) when something looks wrong.
**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.

![xlgit demo: two estimators edit the same workbook and git merges it](docs/demo.gif)

## Try it in 30 seconds

```bash
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](https://github.com/MichaelFowler1/excel-git/releases/latest) and run `xlgit demo` with it.

Git and GitHub treat `.xlsx` as an opaque binary blob. You can commit, fork and branch it, but a diff just says "binary file changed" and any merge where both sides touched the file is a conflict. This fixes that.
When you're ready to use it on your own files, run `xlgit install` once. [Get started](#get-started) has the details.

> Beta (0.1.0). 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](https://github.com/MichaelFowler1/excel-git/issues) when something looks wrong.

## What you get

Expand Down Expand Up @@ -111,6 +124,7 @@ 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
```

Expand Down
Binary file added docs/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import pytest

import xlgit
from test_cell_types import book

XLGIT = Path(__file__).resolve().parents[1] / "xlgit.py"
Expand Down Expand Up @@ -204,3 +205,29 @@ def test_any_characters_reach_git_intact(env, tmp_path):
cwd=tmp_path, env=dict(env, PYTHONIOENCODING="ascii"), capture_output=True)
assert r.returncode in (0, 1), r.stderr.decode("utf-8", "replace")
assert "done \u2713 caf\u00e9 \u6771\u4eac" in r.stdout.decode("utf-8")


def test_demo_merges_both_estimators(env, tmp_path):
folder = tmp_path / "demo"
r = run(env, tmp_path, "demo", str(folder), "--no-open")
assert r.returncode == 0, r.stdout + r.stderr
assert "took 2 cell(s) from the other branch, no conflicts" in r.stdout
assert "$140,340" in r.stdout and "$93,900" in r.stdout
cells = xlgit.read_cells(str(folder / "estimate.xlsx"))["Estimate"]
assert cells["A4"] == "Roofing" # Anna's inserted line
assert cells["A5"] == "Electrical" and cells["D5"] == 4.6 # Ben's rate, moved down a row
assert cells["A6"] == "Plumbing" and cells["C6"] == 22 # Ben's quantity
assert cells["E8"] == "=SUM(E2:E7)"
assert any(o.startswith("chart:") for o in xlgit.describe_objects(
xlgit.Package.open(str(folder / "estimate.xlsx")))["Estimate"])
assert (folder / "changes.html").exists()
# The demo stays inside its folder: the real global git config is untouched.
assert not os.path.exists(env["GIT_CONFIG_GLOBAL"]) or "xlsx" not in Path(env["GIT_CONFIG_GLOBAL"]).read_text()


def test_demo_refuses_a_folder_with_files(env, tmp_path):
(tmp_path / "busy").mkdir()
(tmp_path / "busy" / "keep.txt").write_text("mine")
r = run(env, tmp_path, "demo", str(tmp_path / "busy"), "--no-open")
assert r.returncode == 2 and "isn't empty" in r.stderr
assert (tmp_path / "busy" / "keep.txt").read_text() == "mine"
95 changes: 95 additions & 0 deletions tools/make_demo_gif.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Record `xlgit demo` as the animated terminal at the top of the README.

python tools/make_demo_gif.py [docs/demo.gif]

Runs the real demo and draws its real output; the only edit is shortening
the throwaway folder's path. Needs Pillow and a monospace font (Consolas on
Windows, DejaVu Sans Mono elsewhere). Regenerate it when the demo changes.
"""
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont

ROOT = Path(__file__).resolve().parents[1]
COLS, SIZE, PAD, LINE = 104, 15, 18, 20
BG, FG, DIM = (24, 26, 31), (214, 218, 224), (128, 135, 148)
GREEN, YELLOW, BLUE, WHITE = (120, 200, 120), (230, 196, 100), (110, 170, 240), (245, 246, 248)


def font(bold=False):
names = (["consolab.ttf", "DejaVuSansMono-Bold.ttf"] if bold else ["consola.ttf", "DejaVuSansMono.ttf"])
for folder in ("C:/Windows/Fonts", "/usr/share/fonts/truetype/dejavu", "/Library/Fonts"):
for n in names:
if os.path.exists(os.path.join(folder, n)):
return ImageFont.truetype(os.path.join(folder, n), SIZE)
sys.exit("no monospace font found (install DejaVu Sans Mono)")


def demo_output():
with tempfile.TemporaryDirectory() as tmp:
folder = os.path.join(tmp, "xlgit-demo")
r = subprocess.run([sys.executable, str(ROOT / "xlgit.py"), "demo", folder, "--no-open"],
capture_output=True, text=True, encoding="utf-8")
if r.returncode:
sys.exit(r.stdout + r.stderr)
text = r.stdout.replace(os.path.abspath(folder), "~/xlgit-demo").replace("\\", "/")
lines = []
for line in text.rstrip().splitlines():
if len(line) > COLS:
line = line[:COLS - 1] + "…"
lines.append(line)
return lines


def colour(line):
if re.match(r"^\d\. ", line):
return WHITE, True
if "<-" in line or "no conflicts" in line:
return GREEN, False
if line.startswith((" row inserted", " changed", " object changed")):
return YELLOW, False
if line.startswith(("Visual diff", "Poke around", "To use", "A throwaway")):
return DIM, False
return FG, False


def main(out):
regular, bold = font(), font(bold=True)
char_w = regular.getbbox("M")[2]
lines = demo_output()
width = PAD * 2 + char_w * COLS
height = PAD * 2 + LINE * (len(lines) + 2)
frames, durations = [], []

def frame(prompt_text, shown, cursor, ms):
img = Image.new("RGB", (width, height), BG)
d = ImageDraw.Draw(img)
d.text((PAD, PAD), "$ ", font=bold, fill=GREEN)
d.text((PAD + char_w * 2, PAD), prompt_text + ("_" if cursor else ""), font=bold, fill=WHITE)
for i, line in enumerate(shown, start=1):
fill, is_bold = colour(line)
d.text((PAD, PAD + LINE * (i + 0.5)), line, font=bold if is_bold else regular, fill=fill)
frames.append(img)
durations.append(ms)

command = "xlgit demo"
frame("", [], True, 700)
for i in range(1, len(command) + 1):
frame(command[:i], [], True, 90)
frame(command, [], False, 500)
for n in range(1, len(lines) + 1):
line = lines[n - 1]
pause = 1400 if re.match(r"^\d\. ", line) else 2600 if "no conflicts" in line else 130
frame(command, lines[:n], False, pause)
durations[-1] = 6000
frames[0].save(out, save_all=True, append_images=frames[1:], duration=durations, loop=0, optimize=True)
print(f"{out}: {len(frames)} frames, {os.path.getsize(out) // 1024} KB, {width}x{height}")


if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else str(ROOT / "docs" / "demo.gif"))
141 changes: 135 additions & 6 deletions xlgit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3016,23 +3016,27 @@ def _remove_lines(path, lines):
f.write("".join(ln + "\n" for ln in kept))


def install(scope="global"):
def _set_drivers(where):
"""Point git's diff and merge drivers for workbooks at this program."""
if getattr(sys, "frozen", False): # the standalone download: git runs the program itself
cmd = '"' + sys.executable.replace("\\", "/") + '"'
else:
here = os.path.abspath(__file__).replace("\\", "/")
py = sys.executable.replace("\\", "/")
cmd = f'"{py}" "{here}"'
where = ["--global"] if scope == "global" else []
root = repo_root()
if scope == "repo" and not root:
raise UserError("this folder isn't inside a git repository. Run it inside one, "
"or run `xlgit install` to set up every repository on this computer.")
for key, value in (("diff.xlsx.textconv", f"{cmd} textconv"), ("diff.xlsx.binary", "true"),
("diff.xlsx.command", f"{cmd} gitdiff"),
("merge.xlsx.name", "xlgit cell-level merge"),
("merge.xlsx.driver", f"{cmd} merge %O %A %B %P")):
git("config", *where, key, value)


def install(scope="global"):
root = repo_root()
if scope == "repo" and not root:
raise UserError("this folder isn't inside a git repository. Run it inside one, "
"or run `xlgit install` to set up every repository on this computer.")
_set_drivers(["--global"] if scope == "global" else [])
if scope == "global":
_add_lines(global_attributes_file(), ATTR_LINES)
print("xlgit is set up for every git repository on this computer.\n"
Expand Down Expand Up @@ -3065,6 +3069,125 @@ def install_github():
f" Every pull request that changes a workbook then gets a comment listing the changed cells.")


# ---------- demo ----------

DEMO_TRADES = [("Concrete", "m3", 120, 185), ("Framing", "m2", 2400, 6.5), ("Electrical", "m2", 2400, 4.25),
("Plumbing", "fixture", 18, 950), ("Finishes", "m2", 2400, 12)]


def _demo_book(path, trades):
"""A small cost estimate with a total and a chart, built the same way
every time so the versions differ only where the story says."""
from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference
from openpyxl.styles import Font
wb = Workbook()
ws = wb.active
ws.title = "Estimate"
ws.append(["Trade", "Unit", "Qty", "Rate", "Amount"])
for c in ws[1]:
c.font = Font(bold=True)
for r, (trade, unit, qty, rate) in enumerate(trades, start=2):
ws.append([trade, unit, qty, rate, f"=C{r}*D{r}"])
ws[f"E{r}"].number_format = '"$"#,##0'
total = len(trades) + 2
ws[f"A{total}"] = "Total"
ws[f"A{total}"].font = Font(bold=True)
ws[f"E{total}"] = f"=SUM(E2:E{total - 1})"
ws[f"E{total}"].number_format = '"$"#,##0'
ws[f"E{total}"].font = Font(bold=True)
ws.column_dimensions["A"].width = 14
chart = BarChart()
chart.title = "Cost by trade"
chart.legend = None
chart.add_data(Reference(ws, min_col=5, min_row=1, max_row=total - 1), titles_from_data=True)
chart.set_categories(Reference(ws, min_col=1, min_row=2, max_row=total - 1))
ws.add_chart(chart, "G2")
wb.save(path)


def demo(folder=None, open_it=True):
"""Two estimators edit the same workbook on their own branches, and git
merges them, in a throwaway repository. Nothing outside it is touched."""
import shutil
import tempfile
if not shutil.which("git"):
raise UserError("the demo needs git. Install it from https://git-scm.com and run `xlgit demo` again.")
if folder:
if os.path.exists(folder) and os.listdir(folder):
raise UserError(f"{folder} isn't empty. Pick a new folder, or run `xlgit demo` without one.")
os.makedirs(folder, exist_ok=True)
root = os.path.abspath(folder or tempfile.mkdtemp(prefix="xlgit-demo-"))
book = "estimate.xlsx"
here = os.getcwd()
os.chdir(root)
try:
git("init", "-q")
git("symbolic-ref", "HEAD", "refs/heads/main")
git("config", "user.name", "xlgit demo")
git("config", "user.email", "demo@example.invalid")
_set_drivers([]) # this repository only
_add_lines(".gitattributes", ATTR_LINES)

def commit(msg, trades):
_demo_book(book, trades)
git("add", "-A")
git("commit", "-q", "-m", msg)

def show(*args):
out = git(*args).decode(errors="replace").rstrip()
for line in out.splitlines():
print(" " + line)

print(f"A throwaway repository in {root}\n")
print("1. Anna and Ben share estimate.xlsx: five trades, a total and a chart.")
commit("Estimate for bid", DEMO_TRADES)

roofing = ("Roofing", "m2", 1100, 38)
anna = DEMO_TRADES[:2] + [roofing] + DEMO_TRADES[2:]
git("checkout", "-q", "-b", "anna")
commit("Add roofing", anna)
print("\n2. Anna inserts a Roofing line on her branch. git diff main anna:")
show("--no-pager", "diff", "main", "anna", "--", book)

ben = [(t, u, 22 if t == "Plumbing" else q, 4.6 if t == "Electrical" else r) for t, u, q, r in DEMO_TRADES]
git("checkout", "-q", "main")
git("checkout", "-q", "-b", "ben")
commit("New electrical rate, more fixtures", ben)
print("\n3. Meanwhile Ben updates two numbers on his branch. git diff main ben:")
show("--no-pager", "diff", "main", "ben", "--", book)

print("\n4. Anna merges Ben's branch. Without xlgit this is a conflict on the whole file:")
git("checkout", "-q", "anna")
r = subprocess.run(["git", "merge", "--no-edit", "ben"], capture_output=True, text=True)
for line in (r.stdout + r.stderr).splitlines():
if line.strip():
print(" " + line.strip())
if r.returncode:
raise UserError("the demo merge didn't go through cleanly, which it should. Please report it: " + ISSUES)

cells = read_cells(book)["Estimate"]
print("\n5. The merged estimate has both people's work. Ben's edits followed their rows down:")
for row in range(2, len(anna) + 2):
trade, qty, rate = cells.get(f"A{row}"), cells.get(f"C{row}"), cells.get(f"D{row}")
note = {"Roofing": " <- Anna", "Electrical": " <- Ben's rate", "Plumbing": " <- Ben's qty"}.get(trade, "")
print(f" row {row} {trade:<11} qty {qty:>5} rate {rate:>6}{note}")
before = sum(q * r for _, _, q, r in DEMO_TRADES)
after = sum(cells[f"C{row}"] * cells[f"D{row}"] for row in range(2, len(anna) + 2))
print(f" row {len(anna) + 2} Total {cells.get(f'E{len(anna) + 2}')} "
f"= ${after:,.0f} when Excel calculates it (was ${before:,.0f})")
print(" The chart came through too. Open estimate.xlsx to see it.")

print("\n6. The whole change as a grid, in your browser:")
page = os.path.join(root, "changes.html")
write_html([(book, Package(_git_blob("main", book)), Package.open(book))], page, open_it=open_it)
print(f"\nPoke around in {root}: git log, xlgit diff, or open estimate.xlsx in Excel.\n"
"To use xlgit on your own files, run `xlgit install` once.")
finally:
os.chdir(here)
return root


def uninstall(scope="global"):
where = ["--global"] if scope == "global" else []
for section in ("diff.xlsx", "merge.xlsx"):
Expand Down Expand Up @@ -3107,6 +3230,9 @@ def status_lines():
HELP = f"""\
xlgit {__version__}: see and merge changes inside Excel files with git.

Try it (30 seconds, in a throwaway folder):
xlgit demo two people edit one workbook, git merges it

Set up (once per computer):
xlgit install git diff and git merge understand .xlsx/.xlsm in every repository
xlgit install --github also comment the changed cells on this repository's pull requests
Expand Down Expand Up @@ -3293,6 +3419,9 @@ def main(argv):
else:
install("repo" if "--repo" in flags else "global")
return 0
if cmd == "demo":
demo(args[0] if args else None, open_it="--no-open" not in flags)
return 0
if cmd == "uninstall":
uninstall("repo" if "--repo" in flags else "global")
return 0
Expand Down
Loading