diff --git a/README.md b/README.md index 8598e2b..55d8511 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ``` diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..190ad90 Binary files /dev/null and b/docs/demo.gif differ diff --git a/tests/test_cli.py b/tests/test_cli.py index 87d469d..3a898e7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,7 @@ import pytest +import xlgit from test_cell_types import book XLGIT = Path(__file__).resolve().parents[1] / "xlgit.py" @@ -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" diff --git a/tools/make_demo_gif.py b/tools/make_demo_gif.py new file mode 100644 index 0000000..95ee02a --- /dev/null +++ b/tools/make_demo_gif.py @@ -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")) diff --git a/xlgit.py b/xlgit.py index 646cdd4..a76a7d9 100644 --- a/xlgit.py +++ b/xlgit.py @@ -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" @@ -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"): @@ -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 @@ -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