From 892cd62dedebf642dfe2520fe3127d105b5543ad Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 3 Oct 2026 01:57:14 +0300 Subject: [PATCH 1/7] rebase: match incoming records across renumbering Signed-off-by: NovusEdge --- docket/rebase.py | 155 +++++++++++++++++++++++++++++-------------- tests/test_rebase.py | 89 ++++++++++++++++++++++++- 2 files changed, 195 insertions(+), 49 deletions(-) diff --git a/docket/rebase.py b/docket/rebase.py index fff390b..27ad7c1 100644 --- a/docket/rebase.py +++ b/docket/rebase.py @@ -2,10 +2,11 @@ Two branches that both record decisions allocate the same IDs, so a git merge of an append-only ledger produces either a conflict or a file that fails to -read. Renumbering resolves it: the tail after the common prefix takes fresh -IDs, and references inside that tail follow. References into the prefix stay -valid, because prefix IDs never move, and no prefix record can reference the -tail because validation forbids forward references. +read. Renumbering resolves it: each record after the common prefix is matched +against our tail, and one with no counterpart takes a fresh ID. References +inside the appended records follow. References into the prefix stay valid, +because prefix IDs never move, and no prefix record can reference the tail +because validation forbids forward references. Every function here takes raw read() output. project() adds derived fields that depend on the whole history, so two diverged branches disagree on those @@ -15,6 +16,7 @@ from __future__ import annotations import copy +import json from collections.abc import Mapping, Sequence from typing import Any @@ -60,61 +62,118 @@ def _retired_in(records: Sequence[Mapping[str, Any]]) -> set[str]: return retired -def renumber( - mine: Sequence[Mapping[str, Any]], theirs: Sequence[Mapping[str, Any]] -) -> tuple[list[dict[str, Any]], dict[str, str]]: - """Return their tail with fresh IDs, and the old-to-new ID map.""" +def _translated(record: Mapping[str, Any], mapping: Mapping[str, str]) -> dict[str, Any]: + """A copy of ``record`` with every ID it cites passed through ``mapping``.""" - shared = common_prefix(mine, theirs) - tail: list[dict[str, Any]] = [copy.deepcopy(dict(record)) for record in theirs[shared:]] - if not tail: - return [], {} + def get(value: Any) -> str: + return mapping.get(str(value), str(value)) - already_retired = _retired_in(mine) - for record in tail: - for target in record.get("supersedes") or (): + out = copy.deepcopy(dict(record)) + if out.get("kind") == corrections.KIND: + out["corrects"] = get(out.get("corrects")) + elif out.get("kind") == reviews.KIND: + out["reviews"] = get(out.get("reviews")) + out["grounds"] = {get(g): get(h) for g, h in (out.get("grounds") or {}).items()} + for field in _REFERENCE_FIELDS: + if out.get(field): + out[field] = [get(v) for v in out[field]] + if out.get("supports"): + out["supports"] = [[get(v) for v in group] for group in out["supports"]] + _rewrite_audit(out) + return out + + +def _cited(record: Mapping[str, Any]) -> set[str]: + cited = {str(v) for field in _REFERENCE_FIELDS for v in record.get(field) or ()} + cited |= {str(v) for group in record.get("supports") or () for v in group} + for field in ("corrects", "reviews"): + if record.get(field): + cited.add(str(record[field])) + for ground, head in (record.get("grounds") or {}).items(): + cited |= {str(ground), str(head)} + return cited + + +def _key(record: Mapping[str, Any]) -> str: + """The record minus its own id, so a renumbered copy compares equal.""" + + return json.dumps( + {k: v for k, v in record.items() if k != "id"}, sort_keys=True, ensure_ascii=False + ) + + +def merge( + base: Sequence[Mapping[str, Any]], + ours: Sequence[Mapping[str, Any]], + theirs: Sequence[Mapping[str, Any]], +) -> tuple[list[dict[str, Any]], dict[str, str]]: + """Return the records of ``theirs`` to append to ``ours``, and their old-to-new IDs. + + A THEIRS record that equals an OURS record once its citations are + translated is the same recording under another ID, so it maps to that ID + and is not appended: that is what lets a branch merge twice. One that + equals a BASE record but no OURS record was removed or rewritten on our + side and stays out, which keeps a cherry-pick from importing the picked + commit's whole history. Citations only point backwards, so one pass in + file order has every citation's mapping in hand. + """ + + shared = common_prefix(ours, theirs) + mapping: dict[str, str] = {} + waiting: dict[str, list[str]] = {} + for record in ours[shared:]: + waiting.setdefault(_key(record), []).append(str(record["id"])) + in_base = {_key(record) for record in base} + already_retired = _retired_in(ours) + allocated: list[dict[str, Any]] = [dict(record) for record in ours] + dropped: set[str] = set() + tail: list[dict[str, Any]] = [] + moved: dict[str, str] = {} + + for record in theirs[shared:]: + old = str(record.get("id", "")) + candidate = _translated(record, mapping) + matches = waiting.get(_key(candidate)) + if matches: + mapping[old] = matches.pop(0) + continue + if _key(record) in in_base: + dropped.add(old) + continue + missing = sorted(_cited(record) & dropped) + if missing: + raise RebaseError( + f"{old} cites {', '.join(missing)}, which this side does not have; merge by hand" + ) + for target in candidate.get("supersedes") or (): if str(target) in already_retired: raise RebaseError( f"both histories supersede {target}; resolve that by hand before rebasing" ) - - allocated = [dict(record) for record in mine] - mapping: dict[str, str] = {} - for record in tail: - old = str(record.get("id", "")) - if record.get("kind") == corrections.KIND: - # Counted against allocated, which holds mine and the tail so far, + if candidate.get("kind") == corrections.KIND: + # Counted against allocated, which holds ours and the tail so far, # so two incoming corrections of one record take distinct numbers. - target = mapping.get(str(record.get("corrects")), str(record.get("corrects"))) - record["corrects"] = target - new = corrections.allocate(allocated, target) - elif record.get("kind") == reviews.KIND: - target = mapping.get(str(record.get("reviews")), str(record.get("reviews"))) - record["reviews"] = target - new = reviews.allocate(allocated, target) + new = corrections.allocate(allocated, str(candidate["corrects"])) + elif candidate.get("kind") == reviews.KIND: + new = reviews.allocate(allocated, str(candidate["reviews"])) else: if not ID_RE.fullmatch(old): raise RebaseError(f"malformed id {old!r} in the incoming tail") - new = allocate_id(allocated, str(record.get("kind"))) + new = allocate_id(allocated, str(candidate.get("kind"))) + candidate["id"] = new mapping[old] = new - record["id"] = new - allocated.append(record) - - for record in tail: - if record.get("kind") == reviews.KIND: - record["grounds"] = { - mapping.get(str(g), str(g)): mapping.get(str(h), str(h)) - for g, h in record["grounds"].items() - } - for field in _REFERENCE_FIELDS: - values = record.get(field) - if values: - record[field] = [mapping.get(str(v), str(v)) for v in values] - groups = record.get("supports") - if groups: - record["supports"] = [[mapping.get(str(v), str(v)) for v in group] for group in groups] - _rewrite_audit(record) - return tail, mapping + moved[old] = new + allocated.append(candidate) + tail.append(candidate) + return tail, moved + + +def renumber( + mine: Sequence[Mapping[str, Any]], theirs: Sequence[Mapping[str, Any]] +) -> tuple[list[dict[str, Any]], dict[str, str]]: + """Return their tail with fresh IDs, and the old-to-new ID map.""" + + return merge([], mine, theirs) def _rewrite_audit(record: dict[str, Any]) -> None: diff --git a/tests/test_rebase.py b/tests/test_rebase.py index aff7ad7..38f9dbc 100644 --- a/tests/test_rebase.py +++ b/tests/test_rebase.py @@ -4,7 +4,7 @@ from pathlib import Path from docket.ledger import make_record, read -from docket.rebase import RebaseError, common_prefix, renumber +from docket.rebase import RebaseError, common_prefix, merge, renumber def claim(ident, text, **kwargs): @@ -109,6 +109,93 @@ def test_both_branches_corrected_one_record(self): self.assertEqual(mapping, {"c1.1": "c1.2", "c1.2": "c1.3"}) self.assertEqual([item["corrects"] for item in tail], ["c1", "c1"]) + def test_a_tail_already_absorbed_is_not_appended_again(self): + # main renumbered the branch's c2 to c3; merging the branch again must + # recognise c3 as the same recording. + shared = [claim("c1", "Shared premise")] + branch = shared + [claim("c2", "Branch claim", supports=[["c1"]])] + main = shared + [claim("c2", "Main claim")] + tail, mapping = renumber(main, branch) + merged_main = main + tail + self.assertEqual(mapping, {"c2": "c3"}) + again, mapping = renumber(merged_main, branch) + self.assertEqual((again, mapping), ([], {})) + + def test_merging_back_the_other_way_appends_only_the_missing_record(self): + shared = [claim("c1", "Shared premise")] + branch = shared + [claim("c2", "Branch claim")] + main = shared + [claim("c2", "Main claim")] + merged_main = main + renumber(main, branch)[0] + tail, mapping = renumber(branch, merged_main) + self.assertEqual([r["text"] for r in tail], ["Main claim"]) + self.assertEqual(mapping, {"c2": "c3"}) + + def test_a_record_matched_under_a_new_id_carries_that_id_into_later_references(self): + shared = [claim("c1", "Shared premise")] + branch = shared + [claim("c2", "Branch claim")] + main = shared + [claim("c2", "Main claim")] + merged_main = main + renumber(main, branch)[0] + branch_later = branch + [decision("d3", "Uses it", supports=[["c2"]])] + tail, mapping = renumber(merged_main, branch_later) + self.assertEqual(mapping, {"d3": "d4"}) + self.assertEqual(tail[0]["supports"], [["c3"]]) + + def test_two_identical_looking_reviews_of_different_records_stay_distinct(self): + from docket import reviews + + shared = [claim("c1", "One"), claim("c2", "Two")] + first = reviews.make("c1", author="t", ts="2026-10-03T00:00:00+00:00") + first["id"] = "c1.r1" + second = reviews.make("c2", author="t", ts="2026-10-03T00:00:00+00:00") + second["id"] = "c2.r1" + mine = shared + [first] + theirs = shared + [second] + tail, mapping = renumber(mine, theirs) + self.assertEqual(mapping, {"c2.r1": "c2.r1"}) + self.assertEqual(tail[0]["reviews"], "c2") + + def test_one_ours_record_absorbs_only_one_identical_theirs_record(self): + shared = [claim("c1", "Shared")] + mine = shared + [claim("c2", "Other"), claim("c3", "Same")] + theirs = shared + [claim("c2", "Same"), claim("c3", "Same")] + # make_record stamps ts per call, so pin it to make the copies equal. + for row in mine[1:] + theirs[1:]: + row["ts"] = "2026-10-03T00:00:00+00:00" + tail, mapping = renumber(mine, theirs) + # Their c2 consumes our c3; their c3 has nothing left to match. + self.assertEqual(mapping, {"c3": "c4"}) + self.assertEqual(len(tail), 1) + + def test_a_record_only_in_base_is_not_brought_back(self): + # Cherry-pick: THEIRS is the picked commit's whole ledger, BASE is its + # parent's. Only the picked commit's own record is new. + shared = [claim("c1", "Shared")] + earlier = claim("c2", "Earlier branch claim") + picked = claim("c3", "Picked claim") + main = shared + [claim("c2", "Main claim")] + tail, mapping = merge(shared + [earlier], main, shared + [earlier, picked]) + self.assertEqual([r["text"] for r in tail], ["Picked claim"]) + self.assertEqual(mapping, {"c3": "c3"}) + + def test_citing_a_record_only_in_base_is_refused(self): + shared = [claim("c1", "Shared")] + earlier = claim("c2", "Earlier branch claim") + picked = decision("d3", "Picked decision", supports=[["c2"]]) + main = shared + [claim("c2", "Main claim")] + with self.assertRaises(RebaseError) as caught: + merge(shared + [earlier], main, shared + [earlier, picked]) + self.assertIn("c2", str(caught.exception)) + + def test_a_matched_supersession_is_not_refused(self): + # Our side already absorbed their replacement as c3, so c1 is retired + # on our side by the very record that arrives again. + shared = [claim("c1", "Shared premise")] + replacement = claim("c2", "Replacement", supersedes=["c1"]) + absorbed = dict(replacement, id="c3") + mine = shared + [claim("c2", "Ours"), absorbed] + tail, mapping = renumber(mine, shared + [replacement]) + self.assertEqual((tail, mapping), ([], {})) + if __name__ == "__main__": unittest.main() From 53bf12d9c9b7ab05a33a282b10f540b9131730d2 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 3 Oct 2026 02:02:44 +0300 Subject: [PATCH 2/7] Add docket merge-driver, a git merge driver for the ledger Signed-off-by: NovusEdge --- docket/cli/__init__.py | 16 +++- docket/cli/admin.py | 9 +- docket/merge_driver.py | 70 ++++++++++++++ tests/test_merge_driver.py | 191 +++++++++++++++++++++++++++++++++++++ 4 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 docket/merge_driver.py create mode 100644 tests/test_merge_driver.py diff --git a/docket/cli/__init__.py b/docket/cli/__init__.py index 1005e84..2d3bd53 100644 --- a/docket/cli/__init__.py +++ b/docket/cli/__init__.py @@ -4,7 +4,13 @@ import sys from docket import features, version -from docket.cli.admin import cmd_check, cmd_init, cmd_migrate, cmd_rebase +from docket.cli.admin import ( + cmd_check, + cmd_init, + cmd_merge_driver, + cmd_migrate, + cmd_rebase, +) from docket.cli.completion import cmd_completion from docket.cli.construct import cmd_construct from docket.cli.context_cmd import CONTEXT_ENVELOPES, cmd_context @@ -191,6 +197,14 @@ def main(argv: list[str] | None = None) -> int: rb.add_argument("--emit-map", metavar="PATH", help="write the old-to-new id map as JSON") rb.set_defaults(func=cmd_rebase) + # No help text and absent from the metavar list above: git calls this, + # people do not. + md = sub.add_parser("merge-driver") + md.add_argument("base") + md.add_argument("ours") + md.add_argument("theirs") + md.set_defaults(func=cmd_merge_driver) + mg = sub.add_parser("migrate", help="convert a legacy ledger to the current schema") source = mg.add_mutually_exclusive_group() source.add_argument("--map", help="classification map to apply instead of the derived one") diff --git a/docket/cli/admin.py b/docket/cli/admin.py index 06faf25..7a6f56c 100644 --- a/docket/cli/admin.py +++ b/docket/cli/admin.py @@ -279,4 +279,11 @@ def cmd_init(args: argparse.Namespace) -> int: return 0 -__all__ = ["cmd_check", "cmd_init", "cmd_migrate", "cmd_rebase"] +def cmd_merge_driver(args: argparse.Namespace) -> int: + """Git merge driver entry point; see docket/merge_driver.py.""" + from docket import merge_driver + + return merge_driver.run(Path(args.base), Path(args.ours), Path(args.theirs)) + + +__all__ = ["cmd_check", "cmd_init", "cmd_merge_driver", "cmd_migrate", "cmd_rebase"] diff --git a/docket/merge_driver.py b/docket/merge_driver.py new file mode 100644 index 0000000..883d4fe --- /dev/null +++ b/docket/merge_driver.py @@ -0,0 +1,70 @@ +"""A git merge driver for the ledger. + +Git hands a merge driver three temp files and keeps whatever the second one +holds. On a non-zero exit it marks the path unmerged but still takes that +file as the worktree version, so a failure must leave conflict markers in it: +an untouched OURS would look like a clean ledger, and staging it would drop +every incoming record. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from docket import ledger, rebase + + +def _conflict(base: Path, ours: Path, theirs: Path) -> None: + try: + subprocess.run( + [ + "git", + "merge-file", + "-L", + "ours", + "-L", + "base", + "-L", + "theirs", + str(ours), + str(base), + str(theirs), + ], + capture_output=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + pass + + +def run(base: Path, ours: Path, theirs: Path) -> int: + """Merge THEIRS into OURS in place. 0 when merged, 1 when left in conflict.""" + + try: + # lock=False: the default lock creates .lock next to git's temp + # files at the repository root, where nothing ignores it. + old, mine, incoming = (ledger.read(p, lock=False) for p in (base, ours, theirs)) + tail, moved = rebase.merge(old, mine, incoming) + ledger.validate_entries(mine + tail) + except (ledger.LedgerError, rebase.RebaseError, OSError) as exc: + print(f"docket: cannot merge the ledger: {exc}", file=sys.stderr) + _conflict(base, ours, theirs) + return 1 + if tail: + text = ours.read_text(encoding="utf-8") + if text and not text.endswith("\n"): + text += "\n" + text += "".join( + json.dumps(r, ensure_ascii=False, separators=(",", ":")) + "\n" for r in tail + ) + ours.write_text(text, encoding="utf-8") + for old_id, new_id in moved.items(): + if old_id != new_id: + print(f"docket: {old_id} -> {new_id}", file=sys.stderr) + return 0 + + +__all__ = ["run"] diff --git a/tests/test_merge_driver.py b/tests/test_merge_driver.py new file mode 100644 index 0000000..320df9c --- /dev/null +++ b/tests/test_merge_driver.py @@ -0,0 +1,191 @@ +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(ROOT)) + +from docket import corrections, merge_driver # noqa: E402 +from docket.ledger import make_record, read # noqa: E402 + + +def claim(ident, text, **kwargs): + return make_record("claim", text, state="accepted", author="t", record_id=ident, **kwargs) + + +def write(path, records): + path.write_text("".join(json.dumps(r) + "\n" for r in records), encoding="utf-8") + + +class DriverTests(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp) + self.base, self.ours, self.theirs = (self.tmp / n for n in ("O", "A", "B")) + + def test_disjoint_tails_merge_and_existing_lines_keep_their_bytes(self): + shared = [claim("c1", "Shared")] + write(self.base, shared) + write(self.ours, shared + [claim("c2", "Ours")]) + write(self.theirs, shared + [claim("c2", "Theirs", supports=[["c1"]])]) + before = self.ours.read_text(encoding="utf-8") + self.assertEqual(merge_driver.run(self.base, self.ours, self.theirs), 0) + after = self.ours.read_text(encoding="utf-8") + self.assertTrue(after.startswith(before)) + merged = read(self.ours) + self.assertEqual([r["id"] for r in merged], ["c1", "c2", "c3"]) + self.assertEqual(merged[2]["text"], "Theirs") + + def test_an_incoming_correction_of_a_shared_record_merges(self): + shared = [claim("c1", "Shared")] + fix = corrections.make("c1", {"scope": ["a.py"]}, author="t") + fix["id"] = "c1.1" + write(self.base, shared) + write(self.ours, shared + [claim("c2", "Ours")]) + write(self.theirs, shared + [fix]) + self.assertEqual(merge_driver.run(self.base, self.ours, self.theirs), 0) + self.assertEqual(read(self.ours)[-1]["id"], "c1.1") + + def test_both_sides_superseding_one_record_leaves_conflict_markers(self): + shared = [claim("c1", "Shared")] + write(self.base, shared) + write(self.ours, shared + [claim("c2", "Ours", supersedes=["c1"])]) + write(self.theirs, shared + [claim("c2", "Theirs", supersedes=["c1"])]) + self.assertEqual(merge_driver.run(self.base, self.ours, self.theirs), 1) + self.assertIn("<<<<<<<", self.ours.read_text(encoding="utf-8")) + + def test_an_unreadable_theirs_leaves_conflict_markers(self): + shared = [claim("c1", "Shared")] + write(self.base, shared) + write(self.ours, shared + [claim("c2", "Ours")]) + self.theirs.write_text(json.dumps(shared[0]) + "\n{not json\n", encoding="utf-8") + self.assertEqual(merge_driver.run(self.base, self.ours, self.theirs), 1) + self.assertIn("<<<<<<<", self.ours.read_text(encoding="utf-8")) + + def test_no_lock_file_appears_beside_the_inputs(self): + shared = [claim("c1", "Shared")] + write(self.base, shared) + write(self.ours, shared) + write(self.theirs, shared + [claim("c2", "Theirs")]) + merge_driver.run(self.base, self.ours, self.theirs) + self.assertEqual(sorted(p.name for p in self.tmp.iterdir()), ["A", "B", "O"]) + + def test_an_empty_base_merges_two_new_ledgers(self): + self.base.write_text("", encoding="utf-8") + write(self.ours, [claim("c1", "Ours")]) + write(self.theirs, [claim("c1", "Theirs")]) + self.assertEqual(merge_driver.run(self.base, self.ours, self.theirs), 0) + self.assertEqual([r["text"] for r in read(self.ours)], ["Ours", "Theirs"]) + + +DOCKET = str(ROOT / "bin" / "docket") + + +@unittest.skipUnless(shutil.which("git"), "git is not installed") +class GitTests(unittest.TestCase): + """Real git, with the driver registered by absolute path to this checkout.""" + + def setUp(self): + self.repo = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.repo) + self.env = {**os.environ, "DOCKET_HOME": str(self.repo / ".home")} + self.git("init", "-q", "-b", "main") + self.git("config", "user.email", "t@t") + self.git("config", "user.name", "t") + (self.repo / ".gitattributes").write_text(".docket/ledger.jsonl merge=docket\n") + self.docket("init") + # After init: from Task 3 on, init registers the PATH form, and this + # test must run this checkout's docket. + self.git("config", "merge.docket.driver", f"{DOCKET} merge-driver %O %A %B") + self.docket("claim", "Shared", "--state", "accepted") + self.git("add", "-A") + self.git("commit", "-qm", "base") + + def git(self, *args, check=True): + return subprocess.run( + ["git", *args], + cwd=self.repo, + env=self.env, + capture_output=True, + text=True, + check=check, + ) + + def docket(self, *args): + return subprocess.run( + [DOCKET, *args], + cwd=self.repo, + env=self.env, + capture_output=True, + text=True, + check=True, + ) + + def record(self, text, message): + self.docket("claim", text, "--state", "accepted") + self.git("commit", "-qam", message) + + def ledger(self): + return read(self.repo / ".docket" / "ledger.jsonl") + + def texts(self): + return [r["text"] for r in self.ledger()] + + def test_merge_of_two_recording_branches_is_clean(self): + self.git("checkout", "-qb", "topic") + self.record("Topic claim", "topic") + self.git("checkout", "-q", "main") + self.record("Main claim", "main") + self.assertEqual(self.git("merge", "-q", "--no-edit", "topic").returncode, 0) + self.assertEqual(self.texts(), ["Shared", "Main claim", "Topic claim"]) + self.assertEqual(self.docket("check").returncode, 0) + + def test_repeated_merges_in_both_directions_never_duplicate(self): + self.git("checkout", "-qb", "topic") + self.record("Topic one", "t1") + self.git("checkout", "-q", "main") + self.record("Main one", "m1") + self.git("merge", "-q", "--no-edit", "topic") + self.git("checkout", "-q", "topic") + self.git("merge", "-q", "--no-edit", "main") + self.record("Topic two", "t2") + self.git("checkout", "-q", "main") + self.git("merge", "-q", "--no-edit", "topic") + texts = self.texts() + self.assertEqual(len(texts), len(set(texts)), texts) + self.assertEqual(set(texts), {"Shared", "Topic one", "Main one", "Topic two"}) + + def test_rebase_onto_a_recording_main_is_clean(self): + self.git("checkout", "-qb", "topic") + self.record("Topic claim", "topic") + self.git("checkout", "-q", "main") + self.record("Main claim", "main") + self.git("checkout", "-q", "topic") + self.assertEqual(self.git("rebase", "-q", "main").returncode, 0) + self.assertEqual(self.texts(), ["Shared", "Main claim", "Topic claim"]) + + def test_cherry_pick_appends_only_the_picked_commit(self): + self.git("checkout", "-qb", "topic") + self.record("Topic one", "t1") + self.record("Topic two", "t2") + self.record("Topic three", "t3") + picked = self.git("rev-parse", "HEAD~1").stdout.strip() + self.git("checkout", "-q", "main") + self.record("Main claim", "main") + self.assertEqual(self.git("cherry-pick", picked).returncode, 0) + self.assertEqual(self.texts(), ["Shared", "Main claim", "Topic two"]) + + def test_without_the_config_git_reports_a_conflict(self): + self.git("config", "--unset", "merge.docket.driver") + self.git("checkout", "-qb", "topic") + self.record("Topic claim", "topic") + self.git("checkout", "-q", "main") + self.record("Main claim", "main") + self.assertNotEqual(self.git("merge", "--no-edit", "topic", check=False).returncode, 0) + text = (self.repo / ".docket" / "ledger.jsonl").read_text(encoding="utf-8") + self.assertIn("<<<<<<<", text) From b273624539e288fc0f9940306c38e90ad3b9a274 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 3 Oct 2026 02:01:54 +0300 Subject: [PATCH 3/7] Use the merge driver for this repository's ledger and document it Signed-off-by: NovusEdge --- .gitattributes | 9 ++++++--- CHANGELOG.md | 6 ++++++ docs/commands.md | 2 +- docs/features.md | 9 ++------- docs/ledger.md | 18 +++++++++--------- skills/docket/SKILL.md | 2 ++ 6 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.gitattributes b/.gitattributes index ab54ef6..59a7bca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,7 @@ -# Both stores are append-only JSONL, so a union merge is the right resolution: -# it keeps every line from both sides. Duplicate ids that union produces are -# renumbered afterwards by `docket rebase` and `docket feature remap`. +# The ledger merges through `docket merge-driver`, which appends the other +# side's new records under fresh ids. Each clone registers the driver with +# `docket init`; without it git reports an ordinary conflict. +# features.jsonl stays on union until it has a driver of its own: duplicate +# feature ids after a union need renumbering by hand. .docket/*.jsonl merge=union +.docket/ledger.jsonl merge=docket diff --git a/CHANGELOG.md b/CHANGELOG.md index aa71749..4da8b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `docket graph --format mermaid|dot` draws a flagged record with a dashed border and an unsupported one with a dotted border. The csv node table gains a `support` column. +- A git merge driver for `.docket/ledger.jsonl`. Branches that both recorded merge without a conflict: the other side's new records take fresh IDs, references follow, and a record already merged is not added twice. When it cannot merge it leaves conflict markers. `docket init` now adds `.docket/ledger.jsonl merge=docket` to `.gitattributes` and, when `docket` is on PATH, registers the driver in the clone's git config; re-run it in a fresh clone. The session briefing notes a clone where the attribute is set but the driver is not. + +### Fixed + +- This repository's `.gitattributes` no longer applies a union merge to the ledger, which produced duplicate IDs. +- `docket rebase` no longer appends a tail it has already absorbed. ## [0.21.0] - 2026-10-02 diff --git a/docs/commands.md b/docs/commands.md index fe68d6b..ec0a6be 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -16,7 +16,7 @@ Ledger commands use the file that `docket where` reports. Run | `docket where` | Print which ledger file is in use | | `docket check` | Report what makes the ledger unreadable | | `docket feature ...` | Track a piece of work in flight; see below | -| `docket init` | Create a project ledger and copy any existing private records into it | +| `docket init` | Create a project ledger and copy any existing private records into it. It also adds the ledger merge driver to `.gitattributes` and, when `docket` is on PATH, registers it in the clone's git config; git invokes the driver during merges. Re-running it repairs a fresh clone | | `docket migrate` | Convert a pre-0.8 ledger to the current schema | | `docket rebase` | Renumber another branch's records onto this ledger | | `docket update` | Update this Docket installation; `--check` reports without changing anything | diff --git a/docs/features.md b/docs/features.md index d5a10e5..9c449f9 100644 --- a/docs/features.md +++ b/docs/features.md @@ -156,13 +156,8 @@ Two active features on different branches may declare overlapping paths. `done` prints an advisory when another open feature's declared paths overlap the actual changes. The advisory does not change the exit code. -`.gitattributes` marks both `.docket/*.jsonl` stores `merge=union`, so a -branch merge keeps every line from both sides instead of conflicting on -append-only files. A union merge can duplicate `f` IDs and leave an `include` -or `exclude` list naming a ledger ID a rebase renumbered. -`docket feature remap MAPFILE` repoints those lists through the ID map -`docket rebase --emit-map PATH` writes, one new `amend` event per feature that -needs one. Remapping preserves the original `start` and `amend` lines. +`features.jsonl` stays on `merge=union`, which keeps every line from both sides; the ledger has its own merge driver (see [Sharing a ledger](ledger.md#sharing-a-ledger)). Two branches that both start a feature duplicate an `f` ID, and then the store does not read and `feature remap` refuses it until the duplicate is renumbered by hand. +`docket feature remap MAPFILE` repoints `include` and `exclude` lists through the ID map `docket rebase --emit-map PATH` writes, one new `amend` event per feature that needs one. Remapping preserves the original `start` and `amend` lines. ## The briefing header diff --git a/docs/ledger.md b/docs/ledger.md index f118a5e..eec846d 100644 --- a/docs/ledger.md +++ b/docs/ledger.md @@ -331,15 +331,13 @@ exclusive lock on `ledger.jsonl.lock`. This prevents concurrent writers on one host from allocating the same sequence number. Readers take a shared lock on the same file so they do not read a partially written line. -Two branches that both record produce a git conflict on the ledger, because -each branch appends different records after the same last line. Resolve the -conflict with `docket rebase`, not by hand and not with a union merge driver. A -union merge keeps both branches' lines, which leaves two records holding one ID. -Every command then fails, including the session hook. +Two branches that both record append different lines after the same last line. `docket init` adds `.docket/ledger.jsonl merge=docket` to `.gitattributes` and registers `docket merge-driver` in the clone's git config, after which `git merge`, `git rebase` and `git cherry-pick` merge the ledger themselves: records on both sides are kept, the other side's new records take fresh IDs, and references follow. Git invokes `merge-driver`; it is not a command you run. -Before rebasing, recover each branch's complete ledger without conflict markers. -The active ledger must contain a valid version from one branch. Keep the other -branch's valid file separately, then run: +The driver recognises a record it already merged, even under its new ID, so merging the same branches again in either direction adds nothing twice. It leaves out a record that exists only in the common ancestor, so a cherry-pick brings only the picked commit's records. + +The driver needs `docket` on PATH. `init` registers nothing when it is absent and says so, and the session briefing prints a one-line notice in a clone where the attribute is set but the driver is not. Re-run `docket init` in a fresh clone to register it. + +When the driver cannot merge, git reports an ordinary conflict. This happens when both sides superseded one record, when a cherry-picked record cites one the target lacks, or when a file does not read. The driver leaves standard conflict markers and exits non-zero. Resolve it with `docket rebase`, not by hand: recover each side's complete ledger without conflict markers. The active ledger must contain a valid version from one side. Keep the other side's valid file separately, then run: ```sh docket rebase ../other-branch/.docket/ledger.jsonl --dry-run @@ -358,13 +356,15 @@ targets A's record. Validation passes because the target exists and has the right kind, even though it is not the record B cited. Use `docket rebase` to preserve these references. +Never use a union merge driver on the ledger. It keeps both branches' tails, which leaves two records holding one ID, and every command then fails, including the session hook. + When a ledger stops reading, run `docket check`. It reports every malformed, duplicate, out-of-order, and invalid record with its line number, where a normal read stops at the first fault. Docket does not merge divergent decision trees semantically. Two branches that decided the same question differently produce two adopted decisions after a -rebase. A person resolves that by superseding one of them. +rebase. A person resolves that by superseding one of them. Two branches that both correct one record keep both corrections, and the later-numbered one wins on any field both set. ## Migrating a schema-1 ledger diff --git a/skills/docket/SKILL.md b/skills/docket/SKILL.md index 623a71d..0d6f27d 100644 --- a/skills/docket/SKILL.md +++ b/skills/docket/SKILL.md @@ -272,6 +272,8 @@ procedure. ## Repair a shared ledger +With the merge driver registered (`docket init`), git merges the ledger itself; `rebase` is for when the driver reports a conflict. + ```sh docket check docket rebase ../other-branch/.docket/ledger.jsonl --dry-run From e62b890b75e96253d5088427f9cb0284a4f19a1e Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 3 Oct 2026 02:03:21 +0300 Subject: [PATCH 4/7] init: register the ledger merge driver; brief when a clone lacks it Signed-off-by: NovusEdge --- docket/cli/admin.py | 4 +- docket/cli/context_cmd.py | 11 +++- docket/merge_setup.py | 84 +++++++++++++++++++++++++++++ tests/test_docket.py | 9 ++++ tests/test_merge_setup.py | 108 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 docket/merge_setup.py create mode 100644 tests/test_merge_setup.py diff --git a/docket/cli/admin.py b/docket/cli/admin.py index 7a6f56c..174b3be 100644 --- a/docket/cli/admin.py +++ b/docket/cli/admin.py @@ -12,7 +12,7 @@ import sys from pathlib import Path -from docket import env, feature_archive, feature_project, features +from docket import env, feature_archive, feature_project, features, merge_setup from docket.ledger import ID_RE, LedgerError, _Prefix, append, validate_record @@ -251,6 +251,8 @@ def cmd_init(args: argparse.Namespace) -> int: # The append lock is local state. A team that commits .docket/ would # otherwise commit it. ignore.write_text("*.lock\n", encoding="utf-8") + for message in merge_setup.setup(root): + print(message) if target.exists(): print(f"docket: already project-local at {target}") return 0 diff --git a/docket/cli/context_cmd.py b/docket/cli/context_cmd.py index fb68ccb..cfb2516 100644 --- a/docket/cli/context_cmd.py +++ b/docket/cli/context_cmd.py @@ -50,6 +50,15 @@ def update_line() -> str | None: return None +def _merge_notice() -> str | None: + from docket import merge_setup + + try: + return merge_setup.notice(env.project_root()) + except Exception: + return None + + def _print_context(text: str, args: argparse.Namespace, notice: str | None = None) -> int: body = f"{notice}\n{text}" if notice else text if not body: @@ -120,7 +129,7 @@ def cmd_context(args: argparse.Namespace) -> int: except ConfigError as exc: print(f"docket: {exc}", file=sys.stderr) return 1 - line = update_line() + line = "\n".join(filter(None, (update_line(), _merge_notice()))) or None # Validate against the loaded minimum, not a literal. A config that raises # budget.minimum would otherwise let a too-small value through and surface # as an uncaught ValueError from the renderer. diff --git a/docket/merge_setup.py b/docket/merge_setup.py new file mode 100644 index 0000000..3146b99 --- /dev/null +++ b/docket/merge_setup.py @@ -0,0 +1,84 @@ +"""Wire the ledger merge driver into a clone. + +The attribute line is committed, but git reads a driver's command only from +git config, never from the repository, so every clone needs `docket init`. +""" + +from __future__ import annotations + +import shlex +import shutil +import subprocess +from pathlib import Path + +ATTRIBUTE = ".docket/ledger.jsonl merge=docket" +# docket on PATH, never an absolute path: the plugin cache path changes with +# every release, and a config naming a deleted version fails every merge. +DRIVER = "docket merge-driver %O %A %B" + + +def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str] | None: + try: + return subprocess.run( + ["git", "-C", str(root), *args], capture_output=True, text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return None + + +def _in_repository(root: Path) -> bool: + result = _git(root, "rev-parse", "--git-dir") + return result is not None and result.returncode == 0 + + +def _has_attribute(root: Path) -> bool: + path = root / ".gitattributes" + if not path.is_file(): + return False + return ATTRIBUTE in (line.strip() for line in path.read_text(encoding="utf-8").splitlines()) + + +def setup(root: Path) -> list[str]: + """Add the attribute line and register the driver; return what to tell the user.""" + + if not _in_repository(root): + return [] + messages: list[str] = [] + if not _has_attribute(root): + path = root / ".gitattributes" + text = path.read_text(encoding="utf-8") if path.is_file() else "" + if text and not text.endswith("\n"): + text += "\n" + # Appended, not substituted: a later matching line overrides an earlier + # one, so a .docket/*.jsonl union line keeps covering features.jsonl. + path.write_text(text + ATTRIBUTE + "\n", encoding="utf-8") + messages.append(f"docket: added the ledger merge driver to {path}; commit it") + if shutil.which("docket") is None: + messages.append( + "docket: merge driver not registered: docket is not on PATH, so git could not run it. " + "Put docket on PATH and run docket init again." + ) + return messages + _git(root, "config", "--local", "merge.docket.name", "docket ledger merge") + _git(root, "config", "--local", "merge.docket.driver", DRIVER) + messages.append("docket: registered the ledger merge driver in this clone's git config") + return messages + + +def notice(root: Path) -> str | None: + """One briefing line when the repository asks for the driver and this clone lacks it.""" + + try: + if not _has_attribute(root) or not _in_repository(root): + return None + result = _git(root, "config", "--get", "merge.docket.driver") + command = result.stdout.strip() if result is not None and result.returncode == 0 else "" + words = shlex.split(command) if command else [] + if words and shutil.which(words[0]): + return None + except (OSError, ValueError): + return None + return "# merge driver not set up in this clone; run docket init" + + +__all__ = ["ATTRIBUTE", "DRIVER", "notice", "setup"] diff --git a/tests/test_docket.py b/tests/test_docket.py index 4ac832a..978328a 100644 --- a/tests/test_docket.py +++ b/tests/test_docket.py @@ -650,6 +650,15 @@ def test_init_leaves_an_existing_ignore_file_alone(self): run(home, "init") self.assertEqual(ignore.read_text(encoding="utf-8"), "# mine\n") + def test_init_twice_writes_the_merge_attribute_once(self): + with tempfile.TemporaryDirectory() as home: + subprocess.run(["git", "init", "-q"], cwd=home, check=True) + self.assertEqual(run(home, "init").returncode, 0) + second = run(home, "init") + self.assertEqual(second.returncode, 0, second.stderr) + lines = (Path(home) / ".gitattributes").read_text(encoding="utf-8").splitlines() + self.assertEqual(lines.count(".docket/ledger.jsonl merge=docket"), 1) + class CheckTests(unittest.TestCase): def test_check_passes_on_a_good_ledger(self): diff --git a/tests/test_merge_setup.py b/tests/test_merge_setup.py new file mode 100644 index 0000000..0ec9733 --- /dev/null +++ b/tests/test_merge_setup.py @@ -0,0 +1,108 @@ +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from docket import merge_setup + + +@unittest.skipUnless(shutil.which("git"), "git is not installed") +class SetupTests(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.root) + subprocess.run(["git", "init", "-q"], cwd=self.root, check=True) + + def config(self): + r = subprocess.run( + ["git", "config", "--local", "--get", "merge.docket.driver"], + cwd=self.root, + capture_output=True, + text=True, + ) + return r.stdout.strip() + + def attributes(self): + return (self.root / ".gitattributes").read_text(encoding="utf-8").splitlines() + + def test_setup_writes_the_attribute_and_the_config(self): + with mock.patch("shutil.which", return_value="/usr/bin/docket"): + merge_setup.setup(self.root) + self.assertIn(merge_setup.ATTRIBUTE, self.attributes()) + self.assertEqual(self.config(), merge_setup.DRIVER) + + def test_running_setup_twice_leaves_one_attribute_line(self): + with mock.patch("shutil.which", return_value="/usr/bin/docket"): + merge_setup.setup(self.root) + merge_setup.setup(self.root) + self.assertEqual(self.attributes().count(merge_setup.ATTRIBUTE), 1) + + def test_a_union_line_stays_and_the_ledger_line_follows_it(self): + (self.root / ".gitattributes").write_text(".docket/*.jsonl merge=union\n") + with mock.patch("shutil.which", return_value="/usr/bin/docket"): + merge_setup.setup(self.root) + lines = self.attributes() + self.assertLess( + lines.index(".docket/*.jsonl merge=union"), lines.index(merge_setup.ATTRIBUTE) + ) + + def test_without_docket_on_path_nothing_is_registered(self): + with mock.patch("shutil.which", return_value=None): + messages = merge_setup.setup(self.root) + self.assertEqual(self.config(), "") + self.assertTrue(any("PATH" in m for m in messages), messages) + + def test_notice_when_the_attribute_is_set_but_the_config_is_missing(self): + (self.root / ".gitattributes").write_text(merge_setup.ATTRIBUTE + "\n") + self.assertIn("docket init", merge_setup.notice(self.root) or "") + + def test_notice_when_the_configured_command_does_not_resolve(self): + (self.root / ".gitattributes").write_text(merge_setup.ATTRIBUTE + "\n") + subprocess.run( + ["git", "config", "merge.docket.driver", "no-such-docket merge-driver %O %A %B"], + cwd=self.root, + check=True, + ) + self.assertIsNotNone(merge_setup.notice(self.root)) + + def test_no_notice_when_set_up(self): + with mock.patch("shutil.which", return_value="/usr/bin/docket"): + merge_setup.setup(self.root) + self.assertIsNone(merge_setup.notice(self.root)) + + def test_no_notice_without_the_attribute(self): + self.assertIsNone(merge_setup.notice(self.root)) + + def test_setup_works_in_a_linked_worktree(self): + subprocess.run( + [ + "git", + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-q", + "--allow-empty", + "-m", + "x", + ], + cwd=self.root, + check=True, + ) + linked = self.root / "wt" + subprocess.run(["git", "worktree", "add", "-q", str(linked)], cwd=self.root, check=True) + with mock.patch("shutil.which", return_value="/usr/bin/docket"): + merge_setup.setup(linked) + self.assertIn(merge_setup.ATTRIBUTE, (linked / ".gitattributes").read_text().splitlines()) + + +class OutsideGitTests(unittest.TestCase): + def test_setup_outside_a_repository_does_nothing(self): + root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, root) + self.assertEqual(merge_setup.setup(root), []) + self.assertFalse((root / ".gitattributes").exists()) + self.assertIsNone(merge_setup.notice(root)) From 6d259609d0930ef3729fcd6a56ed9b30ddbad34b Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 3 Oct 2026 02:21:11 +0300 Subject: [PATCH 5/7] init: register only merge.docket.driver, report a failed registration, survive a non-UTF-8 .gitattributes Signed-off-by: NovusEdge --- docket/merge_setup.py | 34 ++++++++++++++++++++++------------ tests/test_docket.py | 13 +++++++++++++ tests/test_merge_setup.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/docket/merge_setup.py b/docket/merge_setup.py index 3146b99..0b58352 100644 --- a/docket/merge_setup.py +++ b/docket/merge_setup.py @@ -44,24 +44,34 @@ def setup(root: Path) -> list[str]: if not _in_repository(root): return [] messages: list[str] = [] - if not _has_attribute(root): - path = root / ".gitattributes" - text = path.read_text(encoding="utf-8") if path.is_file() else "" - if text and not text.endswith("\n"): - text += "\n" - # Appended, not substituted: a later matching line overrides an earlier - # one, so a .docket/*.jsonl union line keeps covering features.jsonl. - path.write_text(text + ATTRIBUTE + "\n", encoding="utf-8") - messages.append(f"docket: added the ledger merge driver to {path}; commit it") + path = root / ".gitattributes" + try: + if not _has_attribute(root): + text = path.read_text(encoding="utf-8") if path.is_file() else "" + if text and not text.endswith("\n"): + text += "\n" + # Appended, not substituted: a later matching line overrides an earlier + # one, so a .docket/*.jsonl union line keeps covering features.jsonl. + path.write_text(text + ATTRIBUTE + "\n", encoding="utf-8") + messages.append(f"docket: added the ledger merge driver to {path}; commit it") + except (UnicodeError, OSError) as exc: + messages.append(f"docket: could not update {path}: {exc}") if shutil.which("docket") is None: messages.append( "docket: merge driver not registered: docket is not on PATH, so git could not run it. " "Put docket on PATH and run docket init again." ) return messages - _git(root, "config", "--local", "merge.docket.name", "docket ledger merge") - _git(root, "config", "--local", "merge.docket.driver", DRIVER) - messages.append("docket: registered the ledger merge driver in this clone's git config") + # Only the driver key: git aborts every merge ("lacks command line") when + # merge.docket.name exists without merge.docket.driver, and needs no name. + result = _git(root, "config", "--local", "merge.docket.driver", DRIVER) + if result is not None and result.returncode == 0: + messages.append("docket: registered the ledger merge driver in this clone's git config") + else: + reason = result.stderr.strip() if result is not None else "git could not run" + messages.append( + f"docket: merge driver not registered: git config failed ({reason}); nothing was changed" + ) return messages diff --git a/tests/test_docket.py b/tests/test_docket.py index 978328a..73726bc 100644 --- a/tests/test_docket.py +++ b/tests/test_docket.py @@ -5,6 +5,7 @@ import io import json import os +import shutil import subprocess import sys import tempfile @@ -659,6 +660,18 @@ def test_init_twice_writes_the_merge_attribute_once(self): lines = (Path(home) / ".gitattributes").read_text(encoding="utf-8").splitlines() self.assertEqual(lines.count(".docket/ledger.jsonl merge=docket"), 1) + def config(key): + return subprocess.run( + ["git", "config", "--local", "--get", key], + cwd=home, + capture_output=True, + text=True, + ).stdout.strip() + + expected = "docket merge-driver %O %A %B" if shutil.which("docket") else "" + self.assertEqual(config("merge.docket.driver"), expected) + self.assertEqual(config("merge.docket.name"), "") + class CheckTests(unittest.TestCase): def test_check_passes_on_a_good_ledger(self): diff --git a/tests/test_merge_setup.py b/tests/test_merge_setup.py index 0ec9733..f4f2266 100644 --- a/tests/test_merge_setup.py +++ b/tests/test_merge_setup.py @@ -33,6 +33,38 @@ def test_setup_writes_the_attribute_and_the_config(self): self.assertIn(merge_setup.ATTRIBUTE, self.attributes()) self.assertEqual(self.config(), merge_setup.DRIVER) + def test_setup_writes_no_driver_name(self): + with mock.patch("shutil.which", return_value="/usr/bin/docket"): + merge_setup.setup(self.root) + r = subprocess.run( + ["git", "config", "--local", "--get", "merge.docket.name"], + cwd=self.root, + capture_output=True, + text=True, + ) + self.assertNotEqual(r.returncode, 0) + + def test_a_failing_git_config_reports_failure_not_registration(self): + real = merge_setup._git + failed = subprocess.CompletedProcess([], 1, "", "error: could not lock config file") + + def fake(root, *args): + return failed if args and args[0] == "config" else real(root, *args) + + with ( + mock.patch("shutil.which", return_value="/usr/bin/docket"), + mock.patch.object(merge_setup, "_git", fake), + ): + messages = merge_setup.setup(self.root) + self.assertFalse(any("registered the" in m for m in messages), messages) + self.assertTrue(any("not registered" in m and "could not lock" in m for m in messages)) + + def test_a_non_utf8_gitattributes_does_not_raise(self): + (self.root / ".gitattributes").write_bytes(b"\xff\xfe\x00bad\n") + with mock.patch("shutil.which", return_value="/usr/bin/docket"): + messages = merge_setup.setup(self.root) + self.assertTrue(any(".gitattributes" in m for m in messages), messages) + def test_running_setup_twice_leaves_one_attribute_line(self): with mock.patch("shutil.which", return_value="/usr/bin/docket"): merge_setup.setup(self.root) From e527f89074b21c12a0ceabef8300c124e0d527b7 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 3 Oct 2026 02:21:11 +0300 Subject: [PATCH 6/7] merge-driver: always leave conflict markers and append without rewriting OURS Signed-off-by: NovusEdge --- docket/merge_driver.py | 41 ++++++++++++++++++++++++++++++-------- tests/test_merge_driver.py | 24 +++++++++++++++++++++- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/docket/merge_driver.py b/docket/merge_driver.py index 883d4fe..4d43fad 100644 --- a/docket/merge_driver.py +++ b/docket/merge_driver.py @@ -19,7 +19,13 @@ def _conflict(base: Path, ours: Path, theirs: Path) -> None: try: - subprocess.run( + original = ours.read_bytes() + incoming = theirs.read_bytes() + except OSError: + return + status = None + try: + status = subprocess.run( [ "git", "merge-file", @@ -35,9 +41,24 @@ def _conflict(base: Path, ours: Path, theirs: Path) -> None: ], capture_output=True, timeout=30, - ) + ).returncode except (OSError, subprocess.SubprocessError): - pass + print("docket: git merge-file could not run; wrote whole-file markers", file=sys.stderr) + # merge-file exits with the conflict count (1..127); 0 means it merged the + # text cleanly, which the driver's own refusal overrides, and anything else + # is a failure that may have left OURS untouched. + if status is not None and 0 < status < 128: + return + block = b"<<<<<<< ours\n" + _terminated(original) + b"=======\n" + block += _terminated(incoming) + b">>>>>>> theirs\n" + try: + ours.write_bytes(block) + except OSError as exc: + print(f"docket: cannot write conflict markers: {exc}", file=sys.stderr) + + +def _terminated(data: bytes) -> bytes: + return data if not data or data.endswith(b"\n") else data + b"\n" def run(base: Path, ours: Path, theirs: Path) -> int: @@ -54,13 +75,17 @@ def run(base: Path, ours: Path, theirs: Path) -> int: _conflict(base, ours, theirs) return 1 if tail: - text = ours.read_text(encoding="utf-8") - if text and not text.endswith("\n"): - text += "\n" - text += "".join( + lines = "".join( json.dumps(r, ensure_ascii=False, separators=(",", ":")) + "\n" for r in tail ) - ours.write_text(text, encoding="utf-8") + with ours.open("rb") as handle: + handle.seek(0, 2) + needs_newline = False + if handle.tell(): + handle.seek(-1, 2) + needs_newline = handle.read(1) != b"\n" + with ours.open("a", encoding="utf-8", newline="") as handle: + handle.write(("\n" if needs_newline else "") + lines) for old_id, new_id in moved.items(): if old_id != new_id: print(f"docket: {old_id} -> {new_id}", file=sys.stderr) diff --git a/tests/test_merge_driver.py b/tests/test_merge_driver.py index 320df9c..38948e4 100644 --- a/tests/test_merge_driver.py +++ b/tests/test_merge_driver.py @@ -59,6 +59,26 @@ def test_both_sides_superseding_one_record_leaves_conflict_markers(self): self.assertEqual(merge_driver.run(self.base, self.ours, self.theirs), 1) self.assertIn("<<<<<<<", self.ours.read_text(encoding="utf-8")) + def test_a_refusal_leaves_markers_even_when_the_text_merges_cleanly(self): + records = [claim("c1", "One"), claim("c2", "Two"), claim("c3", "Three")] + write(self.base, records) + edited = [records[0], claim("c2", "Two, reworded"), records[2]] + write(self.ours, edited) + decision = make_record( + "decision", + "Build the cache layer on the second record", + state="adopted", + choice="Cache", + alternatives=["No cache"], + rationale="Reads dominate and the second record shows the cost", + author="t", + record_id="d1", + supports=[["c2"]], + ) + write(self.theirs, records + [decision]) + self.assertEqual(merge_driver.run(self.base, self.ours, self.theirs), 1) + self.assertIn("<<<<<<<", self.ours.read_text(encoding="utf-8")) + def test_an_unreadable_theirs_leaves_conflict_markers(self): shared = [claim("c1", "Shared")] write(self.base, shared) @@ -181,7 +201,9 @@ def test_cherry_pick_appends_only_the_picked_commit(self): self.assertEqual(self.texts(), ["Shared", "Main claim", "Topic two"]) def test_without_the_config_git_reports_a_conflict(self): - self.git("config", "--unset", "merge.docket.driver") + # The whole section: a lone merge.docket.name makes git abort the merge + # ("lacks command line") instead of reporting a conflict. + self.git("config", "--remove-section", "merge.docket", check=False) self.git("checkout", "-qb", "topic") self.record("Topic claim", "topic") self.git("checkout", "-q", "main") From 71286e0d8af53e281ec4d4ccc3fe99b19760c6c6 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 3 Oct 2026 02:21:11 +0300 Subject: [PATCH 7/7] docs: merge driver remap limits, ancestor rule, unregistered clones Signed-off-by: NovusEdge --- docs/features.md | 2 +- docs/ledger.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features.md b/docs/features.md index 9c449f9..b5b8697 100644 --- a/docs/features.md +++ b/docs/features.md @@ -157,7 +157,7 @@ Two active features on different branches may declare overlapping paths. the actual changes. The advisory does not change the exit code. `features.jsonl` stays on `merge=union`, which keeps every line from both sides; the ledger has its own merge driver (see [Sharing a ledger](ledger.md#sharing-a-ledger)). Two branches that both start a feature duplicate an `f` ID, and then the store does not read and `feature remap` refuses it until the duplicate is renumbered by hand. -`docket feature remap MAPFILE` repoints `include` and `exclude` lists through the ID map `docket rebase --emit-map PATH` writes, one new `amend` event per feature that needs one. Remapping preserves the original `start` and `amend` lines. +`docket feature remap MAPFILE` repoints `include` and `exclude` lists through the ID map `docket rebase --emit-map PATH` writes, one new `amend` event per feature that needs one. Remapping preserves the original `start` and `amend` lines. It applies after a manual `docket rebase`: the merge driver writes no map file and only prints `old -> new` pairs on stderr, so after a driver merge, `include` and `exclude` lists from the other branch that name renumbered records must be repointed by hand until the feature store gets its own driver. ## The briefing header diff --git a/docs/ledger.md b/docs/ledger.md index eec846d..2d4eeb8 100644 --- a/docs/ledger.md +++ b/docs/ledger.md @@ -333,11 +333,11 @@ on the same file so they do not read a partially written line. Two branches that both record append different lines after the same last line. `docket init` adds `.docket/ledger.jsonl merge=docket` to `.gitattributes` and registers `docket merge-driver` in the clone's git config, after which `git merge`, `git rebase` and `git cherry-pick` merge the ledger themselves: records on both sides are kept, the other side's new records take fresh IDs, and references follow. Git invokes `merge-driver`; it is not a command you run. -The driver recognises a record it already merged, even under its new ID, so merging the same branches again in either direction adds nothing twice. It leaves out a record that exists only in the common ancestor, so a cherry-pick brings only the picked commit's records. +The driver recognises a record it already merged, even under its new ID, so merging the same branches again in either direction adds nothing twice. An incoming record that is found in the common ancestor but is no longer on our side was removed or rewritten by us, so it stays out; this is why a cherry-pick brings only the picked commit's records. -The driver needs `docket` on PATH. `init` registers nothing when it is absent and says so, and the session briefing prints a one-line notice in a clone where the attribute is set but the driver is not. Re-run `docket init` in a fresh clone to register it. +The driver needs `docket` on PATH. `init` registers nothing when it is absent and says so, and the session briefing prints a one-line notice in a clone where the attribute is set but the driver is not. Re-run `docket init` in a fresh clone to register it. A clone without the driver registered gets an ordinary git conflict on the ledger. -When the driver cannot merge, git reports an ordinary conflict. This happens when both sides superseded one record, when a cherry-picked record cites one the target lacks, or when a file does not read. The driver leaves standard conflict markers and exits non-zero. Resolve it with `docket rebase`, not by hand: recover each side's complete ledger without conflict markers. The active ledger must contain a valid version from one side. Keep the other side's valid file separately, then run: +When the driver cannot merge, git reports an ordinary conflict. This happens when both sides superseded one record, when a cherry-picked record cites one the target lacks, or when a file does not read. The driver always leaves conflict markers in the file and exits non-zero, falling back to one whole-file block when `git merge-file` cannot produce them. Resolve it with `docket rebase`, not by hand: recover each side's complete ledger without conflict markers. The active ledger must contain a valid version from one side. Keep the other side's valid file separately, then run: ```sh docket rebase ../other-branch/.docket/ledger.jsonl --dry-run