cohen_transform.py python
592 lines 22.3 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """cohen_transform.py — Three-way line-level merge with Manyana-style action labels.
2
3 Named in honour of Bram Cohen (creator of BitTorrent, inventor of the Manyana
4 CRDT weave algorithm), whose conflict-presentation insight is the direct
5 inspiration for this module.
6
7 The Cohen Transform observation
8 --------------------------------
9 Traditional VCS tools display a merge conflict as two opaque blobs — "ours"
10 and "theirs" — and leave the user to reconstruct *what each side actually did*.
11 Bram Cohen's Manyana project labels every conflict hunk with the action and the
12 side that performed it:
13
14 <<<<<<< ours [deleted]
15 def calculate(x):
16 a = x * 2
17 ||||||| base
18 def calculate(x):
19 a = x * 2
20 b = a + 1
21 return b
22 ======= theirs [inserted]
23 logger.debug(f"a={a}")
24 >>>>>>> end conflict
25
26 You can immediately see: ours deleted a function, theirs inserted a line into
27 the middle of it. That is a structurally different level of information from
28 two unlabelled blobs.
29
30 Public API
31 ----------
32 three_way_merge_lines(base, ours, theirs, ...)
33 Core merge function. Returns (merged_lines, has_conflict).
34
35 MergeRegion
36 Dataclass representing one segment of the three-way analysis.
37
38 classify_action(base_lines, other_lines)
39 Classify a change as 'inserted', 'deleted', or 'modified'.
40
41 annotate_hunk_action(hunk_lines, side_label)
42 Post-process a unified-diff hunk list, rewriting @@ headers with
43 action labels for use in ``muse diff --conflict`` output.
44
45 format_conflict_diff(path, root, base_manifest, ours_manifest, theirs_manifest,
46 read_object_fn, *, use_color, ours_label, theirs_label)
47 Render a Manyana-style two-sided labeled diff for a single conflicting file.
48 Used by ``muse diff --conflict``.
49
50 References
51 ----------
52 - Bram Cohen, Manyana: https://github.com/bramcohen/manyana
53 - Bram Cohen's blog: https://bramcohen.com/
54 """
55
56 from __future__ import annotations
57
58 import difflib
59 import pathlib
60 from dataclasses import dataclass, field
61 from typing import Callable, Sequence
62
63 # ── Public constants ──────────────────────────────────────────────────────────
64
65 #: Separator used between the two sides of a conflict-aware diff header.
66 CONFLICT_SEPARATOR = "═" * 54
67
68 #: Marker tokens — match git's diff3 style so editors recognise them, but
69 #: the action label suffix (e.g. ``[deleted]``) is the Cohen extension.
70 _MARKER_OURS_PREFIX = "<<<<<<< "
71 _MARKER_BASE = "||||||| base"
72 _MARKER_SEP_PREFIX = "======= "
73 _MARKER_END = ">>>>>>> end conflict"
74
75
76 # ── Data classes ──────────────────────────────────────────────────────────────
77
78
79 @dataclass
80 class MergeRegion:
81 """One segment of a three-way merge analysis.
82
83 Attributes:
84 kind:
85 ``'stable'`` — identical in all three versions; output base.
86 ``'ours_only'`` — only ours changed from base; output ours.
87 ``'theirs_only'`` — only theirs changed from base; output theirs.
88 ``'both_same'`` — both sides made the same change; output either.
89 ``'conflict'`` — both sides changed differently; needs markers.
90 base_lines: Lines from the common ancestor for this segment.
91 ours_lines: Lines from the ours version for this segment.
92 theirs_lines: Lines from the theirs version for this segment.
93 """
94
95 kind: str
96 base_lines: list[str] = field(default_factory=list)
97 ours_lines: list[str] = field(default_factory=list)
98 theirs_lines: list[str] = field(default_factory=list)
99
100
101 # ── Core algorithm ────────────────────────────────────────────────────────────
102
103
104 def _find_sync_regions(
105 base: Sequence[str],
106 ours: Sequence[str],
107 theirs: Sequence[str],
108 ) -> list[tuple[int, int, int, int, int, int]]:
109 """Return maximal regions identical in all three sequences.
110
111 Each entry is ``(base_s, base_e, ours_s, ours_e, theirs_s, theirs_e)``
112 representing a contiguous block where
113 ``base[base_s:base_e] == ours[ours_s:ours_e] == theirs[theirs_s:theirs_e]``.
114
115 The algorithm finds base positions matched in BOTH ``base→ours`` and
116 ``base→theirs`` LCS runs, then groups them into maximal consecutive
117 triples (consecutive in base, ours, *and* theirs simultaneously).
118 """
119 sm_a = difflib.SequenceMatcher(None, base, ours, autojunk=False)
120 sm_b = difflib.SequenceMatcher(None, base, theirs, autojunk=False)
121
122 a_map: dict[int, int] = {} # base_pos → ours_pos
123 b_map: dict[int, int] = {} # base_pos → theirs_pos
124
125 for bi, ai, n in sm_a.get_matching_blocks():
126 for k in range(n):
127 a_map[bi + k] = ai + k
128
129 for bi, ti, n in sm_b.get_matching_blocks():
130 for k in range(n):
131 b_map[bi + k] = ti + k
132
133 stable = sorted(set(a_map) & set(b_map))
134 if not stable:
135 return []
136
137 sync_regions: list[tuple[int, int, int, int, int, int]] = []
138 run_bp = run_ap = run_tp = -1
139 prev_bp = prev_ap = prev_tp = -1
140
141 for bp in stable:
142 ap = a_map[bp]
143 tp = b_map[bp]
144
145 if bp == prev_bp + 1 and ap == prev_ap + 1 and tp == prev_tp + 1:
146 # Extend current run.
147 prev_bp, prev_ap, prev_tp = bp, ap, tp
148 else:
149 # Flush previous run (if any) and start a new one.
150 if run_bp >= 0:
151 sync_regions.append((
152 run_bp, prev_bp + 1,
153 run_ap, prev_ap + 1,
154 run_tp, prev_tp + 1,
155 ))
156 run_bp, run_ap, run_tp = bp, ap, tp
157 prev_bp, prev_ap, prev_tp = bp, ap, tp
158
159 # Flush final run.
160 if run_bp >= 0:
161 sync_regions.append((
162 run_bp, prev_bp + 1,
163 run_ap, prev_ap + 1,
164 run_tp, prev_tp + 1,
165 ))
166
167 return sync_regions
168
169
170 def compute_regions(
171 base: Sequence[str],
172 ours: Sequence[str],
173 theirs: Sequence[str],
174 ) -> list[MergeRegion]:
175 """Decompose three sequences into a list of :class:`MergeRegion` objects.
176
177 Uses the sync-region algorithm: finds maximal blocks identical in all
178 three sequences (the "stable skeleton"), then classifies each gap between
179 stable blocks as one of:
180
181 - ``ours_only`` — only ours changed from base.
182 - ``theirs_only`` — only theirs changed from base.
183 - ``both_same`` — both changed to the same content (clean).
184 - ``conflict`` — both changed to different content.
185
186 Handles pure insertions (both sides insert different content at the same
187 base position) as conflicts with an empty base section.
188
189 Args:
190 base: Common ancestor lines (each ending with ``\\n`` or empty).
191 ours: Working-tree / our-branch lines.
192 theirs: Target-branch / their-branch lines.
193
194 Returns:
195 Ordered list of :class:`MergeRegion` objects covering the entire
196 three-way merge.
197 """
198 base = list(base)
199 ours = list(ours)
200 theirs = list(theirs)
201
202 sync_regions = _find_sync_regions(base, ours, theirs)
203
204 # Sentinel at each end so the loop below handles the leading and trailing
205 # unstable regions uniformly without special-casing.
206 sentinels: list[tuple[int, int, int, int, int, int]] = [
207 (0, 0, 0, 0, 0, 0),
208 *sync_regions,
209 (len(base), len(base), len(ours), len(ours), len(theirs), len(theirs)),
210 ]
211
212 regions: list[MergeRegion] = []
213
214 for idx in range(len(sentinels) - 1):
215 cur = sentinels[idx]
216 nxt = sentinels[idx + 1]
217
218 # ── Stable block (the sync region itself) ─────────────────────────
219 bs, be, as_, ae, ts, te = cur
220 if idx > 0 and be > bs:
221 regions.append(MergeRegion(
222 kind="stable",
223 base_lines=list(base[bs:be]),
224 ours_lines=list(ours[as_:ae]),
225 theirs_lines=list(theirs[ts:te]),
226 ))
227
228 # ── Unstable gap between this sync region and the next ────────────
229 nbs, nbe, nas, nae, nts, nte = nxt
230 b_chunk = list(base[be:nbs])
231 a_chunk = list(ours[ae:nas])
232 t_chunk = list(theirs[te:nts])
233
234 if not b_chunk and not a_chunk and not t_chunk:
235 continue
236
237 if a_chunk == b_chunk and t_chunk == b_chunk:
238 # All three identical — degenerate stable region (shouldn't
239 # normally occur given sync detection, but guard it).
240 regions.append(MergeRegion("stable", b_chunk, a_chunk, t_chunk))
241 elif a_chunk == b_chunk:
242 # Only theirs changed.
243 regions.append(MergeRegion("theirs_only", b_chunk, b_chunk, t_chunk))
244 elif t_chunk == b_chunk:
245 # Only ours changed.
246 regions.append(MergeRegion("ours_only", b_chunk, a_chunk, b_chunk))
247 elif a_chunk == t_chunk:
248 # Both sides made the same change — clean (take either).
249 regions.append(MergeRegion("both_same", b_chunk, a_chunk, t_chunk))
250 else:
251 # True conflict — both sides changed differently.
252 regions.append(MergeRegion("conflict", b_chunk, a_chunk, t_chunk))
253
254 return regions
255
256
257 # ── Action classification ─────────────────────────────────────────────────────
258
259
260 def classify_action(base_lines: list[str], other_lines: list[str]) -> str:
261 """Classify the action performed from *base_lines* to *other_lines*.
262
263 Returns one of:
264 - ``'inserted'`` — *other_lines* is non-empty, *base_lines* is empty.
265 - ``'deleted'`` — *base_lines* is non-empty, *other_lines* is empty.
266 - ``'modified'`` — both are non-empty but differ.
267
268 Used to annotate conflict markers with the Cohen-transform action label,
269 e.g. ``<<<<<<< ours [deleted]``.
270 """
271 if not base_lines and other_lines:
272 return "inserted"
273 if base_lines and not other_lines:
274 return "deleted"
275 return "modified"
276
277
278 def annotate_hunk_action(hunk_lines: list[str], side_label: str) -> list[str]:
279 """Rewrite ``@@`` hunk headers with a Manyana-style action annotation.
280
281 Scans the ``+``/``-`` lines in each hunk to classify the dominant action
282 on *side_label* (``'ours'`` or ``'theirs'``), then rewrites each ``@@``
283 header line from::
284
285 @@ -12,4 +12,4 @@
286
287 to::
288
289 @@ -12,4 +12,4 @@ [ours: deleted]
290 @@ -12,4 +12,4 @@ [theirs: inserted]
291 @@ -12,4 +12,4 @@ [ours: modified]
292
293 This is the direct translation of Bram Cohen's ``begin deleted left`` /
294 ``begin added right`` labeling into unified-diff format.
295
296 Args:
297 hunk_lines: Lines produced by :func:`difflib.unified_diff`, including
298 the ``---``/``+++`` header and all ``@@`` blocks.
299 side_label: Human-readable side identifier, e.g. ``'ours'`` or
300 ``'theirs'``.
301
302 Returns:
303 Annotated copy of *hunk_lines* with ``@@`` lines rewritten.
304 """
305 result: list[str] = []
306 # Collect lines in the current @@ block to classify when we see the next @@.
307 current_hunk_body: list[str] = []
308 pending_at: str | None = None # the @@ line waiting to be annotated
309
310 def _flush(body: list[str]) -> str:
311 """Classify and return the annotated @@ line."""
312 assert pending_at is not None
313 adds = sum(1 for ln in body if ln.startswith("+") and not ln.startswith("+++"))
314 dels = sum(1 for ln in body if ln.startswith("-") and not ln.startswith("---"))
315 if adds > 0 and dels == 0:
316 action = "inserted"
317 elif dels > 0 and adds == 0:
318 action = "deleted"
319 else:
320 action = "modified"
321 base_at = pending_at.rstrip()
322 # Remove any trailing existing annotation before appending a new one.
323 if " [" in base_at:
324 base_at = base_at[: base_at.rfind(" [")]
325 return f"{base_at} [{side_label}: {action}]"
326
327 for line in hunk_lines:
328 if line.startswith("@@"):
329 if pending_at is not None:
330 result.append(_flush(current_hunk_body))
331 result.extend(current_hunk_body)
332 current_hunk_body = []
333 pending_at = line
334 elif pending_at is not None:
335 current_hunk_body.append(line)
336 else:
337 result.append(line)
338
339 # Flush the final hunk.
340 if pending_at is not None:
341 result.append(_flush(current_hunk_body))
342 result.extend(current_hunk_body)
343
344 return result
345
346
347 # ── High-level merge function ─────────────────────────────────────────────────
348
349
350 def three_way_merge_lines(
351 base: Sequence[str],
352 ours: Sequence[str],
353 theirs: Sequence[str],
354 *,
355 label_ours: str = "ours",
356 label_base: str = "base",
357 label_theirs: str = "theirs",
358 ) -> tuple[list[str], bool]:
359 """Three-way line merge with Manyana-style labeled conflict markers.
360
361 Implements the Cohen Transform: conflict markers include an action label
362 (``[inserted]``, ``[deleted]``, or ``[modified]``) so the reader
363 immediately sees *what each side did*, not just *that they conflicted*.
364
365 Marker format (diff3 style with Cohen extensions)::
366
367 <<<<<<< ours [deleted]
368 [lines from ours — what ours changed base to]
369 ||||||| base
370 [lines from common ancestor — the original context]
371 ======= theirs [inserted]
372 [lines from theirs — what theirs changed base to]
373 >>>>>>> end conflict
374
375 The ``||||||| base`` section is always included (diff3 style) because it
376 gives both humans and agents the context needed to understand *why* each
377 side made its change.
378
379 Clean merge rules:
380 - Stable (no change on either side): output base unchanged.
381 - Ours-only change: output ours version.
382 - Theirs-only change: output theirs version.
383 - Both changed to the same content: output that content once.
384 - Both changed differently: emit conflict markers.
385
386 Args:
387 base: Lines from the common ancestor.
388 ours: Lines from our version (working tree or our branch).
389 theirs: Lines from their version (target branch).
390 label_ours: Label shown in the ``<<<<<<<`` marker. Defaults to
391 ``'ours'``.
392 label_base: Label shown in the ``|||||||`` marker. Defaults to
393 ``'base'``.
394 label_theirs: Label shown in the ``=======`` marker. Defaults to
395 ``'theirs'``.
396
397 Returns:
398 A ``(merged_lines, has_conflict)`` tuple. *merged_lines* is the
399 fully merged sequence; *has_conflict* is ``True`` when at least one
400 conflict block was written.
401 """
402 regions = compute_regions(base, ours, theirs)
403 merged: list[str] = []
404 has_conflict = False
405
406 for region in regions:
407 if region.kind == "stable":
408 merged.extend(region.base_lines)
409
410 elif region.kind == "ours_only":
411 merged.extend(region.ours_lines)
412
413 elif region.kind == "theirs_only":
414 merged.extend(region.theirs_lines)
415
416 elif region.kind == "both_same":
417 merged.extend(region.ours_lines)
418
419 else: # conflict
420 has_conflict = True
421 ours_action = classify_action(region.base_lines, region.ours_lines)
422 theirs_action = classify_action(region.base_lines, region.theirs_lines)
423
424 merged.append(f"{_MARKER_OURS_PREFIX}{label_ours} [{ours_action}]\n")
425 merged.extend(region.ours_lines)
426 merged.append(f"||||||| {label_base}\n")
427 merged.extend(region.base_lines)
428 merged.append(f"{_MARKER_SEP_PREFIX}{label_theirs} [{theirs_action}]\n")
429 merged.extend(region.theirs_lines)
430 merged.append(f"{_MARKER_END}\n")
431
432 return merged, has_conflict
433
434
435 # ── Conflict-diff renderer (for muse diff --conflict) ────────────────────────
436
437
438 def format_conflict_diff(
439 path: str,
440 root: pathlib.Path,
441 base_manifest: dict[str, str],
442 ours_manifest: dict[str, str],
443 theirs_manifest: dict[str, str],
444 read_object_fn: Callable[[pathlib.Path, str], bytes | None],
445 *,
446 use_color: bool = False,
447 ours_label: str = "ours",
448 theirs_label: str = "theirs",
449 ) -> list[str]:
450 """Render a labeled two-sided diff for a single conflicting file.
451
452 Produces a Cohen-transform conflict view: two separate unified diffs
453 (``base→ours`` and ``base→theirs``), each with hunk headers annotated
454 with the action performed by that side. This replaces the opaque
455 ``<<<<<<< / ======= / >>>>>>>`` blob with a clear narrative of *what
456 each side did*.
457
458 Example output::
459
460 ══════════════════════════════════════════════════════
461 CONFLICT src/utils.py
462 ══════════════════════════════════════════════════════
463 [ours] what ours changed from base
464 --- base/src/utils.py
465 +++ ours/src/utils.py
466 @@ -12,4 +12,4 @@ [ours: deleted]
467 - def calculate(x):
468 - return x * 2
469 + def compute(x):
470 + return x * 3
471
472 [theirs] what theirs changed from base
473 --- base/src/utils.py
474 +++ theirs/src/utils.py
475 @@ -12,4 +12,4 @@ [theirs: modified]
476 def calculate(x):
477 - return x * 2
478 + return x * 4
479
480 Args:
481 path: Workspace-relative POSIX path of the conflicting file.
482 root: Repository root (used to read objects from the store).
483 base_manifest: Manifest of the merge-base commit.
484 ours_manifest: Manifest of our branch at merge time.
485 theirs_manifest: Manifest of their branch.
486 read_object_fn: Callable ``(root, object_id) → bytes | None``.
487 use_color: When ``True``, emit ANSI colour escapes.
488 ours_label: Human-readable label for the ours side (e.g. the
489 branch name).
490 theirs_label: Human-readable label for the theirs side.
491
492 Returns:
493 A list of text lines (each *without* a trailing newline) ready to
494 print. Returns an empty list when no diff exists for this path.
495 """
496 def _read_lines(manifest: dict[str, str], fallback_disk: bool = False) -> list[str]:
497 oid = manifest.get(path)
498 if oid:
499 raw = read_object_fn(root, oid)
500 if raw is not None:
501 text = raw.decode("utf-8", errors="replace")
502 return text.splitlines(keepends=True)
503 if fallback_disk:
504 disk = root / path
505 if disk.is_file():
506 return disk.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
507 return []
508
509 safe_path = path.replace("\x1b", "?") # sanitize ANSI injection
510 base_lines = _read_lines(base_manifest)
511 ours_lines = _read_lines(ours_manifest, fallback_disk=True)
512 theirs_lines = _read_lines(theirs_manifest)
513
514 # ── ANSI colour helpers ───────────────────────────────────────────────────
515 def _c(code: str, text: str) -> str:
516 return f"\x1b[{code}m{text}\x1b[0m" if use_color else text
517
518 def _bold(t: str) -> str:
519 return _c("1", t)
520
521 def _cyan(t: str) -> str:
522 return _c("36", t)
523
524 def _green(t: str) -> str:
525 return _c("32", t)
526
527 def _red(t: str) -> str:
528 return _c("31", t)
529
530 def _yellow(t: str) -> str:
531 return _c("33", t)
532
533 # ── Header ────────────────────────────────────────────────────────────────
534 output: list[str] = []
535 output.append(_bold(CONFLICT_SEPARATOR))
536 output.append(_bold(f"CONFLICT {safe_path}"))
537 output.append(_bold(CONFLICT_SEPARATOR))
538
539 # ── Ours diff (base → ours) ───────────────────────────────────────────────
540 ours_hunks = list(difflib.unified_diff(
541 base_lines, ours_lines,
542 fromfile=f"base/{safe_path}",
543 tofile=f"{ours_label}/{safe_path}",
544 lineterm="",
545 ))
546 annotated_ours = annotate_hunk_action(ours_hunks, ours_label)
547
548 output.append("")
549 output.append(_yellow(f"[{ours_label}] what {ours_label} changed from base"))
550 if annotated_ours:
551 for line in annotated_ours:
552 if line.startswith("---") or line.startswith("+++"):
553 output.append(_bold(line))
554 elif line.startswith("@@"):
555 output.append(_cyan(line))
556 elif line.startswith("+"):
557 output.append(_green(line))
558 elif line.startswith("-"):
559 output.append(_red(line))
560 else:
561 output.append(line)
562 else:
563 output.append(" (no changes from base on this side)")
564
565 # ── Theirs diff (base → theirs) ───────────────────────────────────────────
566 theirs_hunks = list(difflib.unified_diff(
567 base_lines, theirs_lines,
568 fromfile=f"base/{safe_path}",
569 tofile=f"{theirs_label}/{safe_path}",
570 lineterm="",
571 ))
572 annotated_theirs = annotate_hunk_action(theirs_hunks, theirs_label)
573
574 output.append("")
575 output.append(_yellow(f"[{theirs_label}] what {theirs_label} changed from base"))
576 if annotated_theirs:
577 for line in annotated_theirs:
578 if line.startswith("---") or line.startswith("+++"):
579 output.append(_bold(line))
580 elif line.startswith("@@"):
581 output.append(_cyan(line))
582 elif line.startswith("+"):
583 output.append(_green(line))
584 elif line.startswith("-"):
585 output.append(_red(line))
586 else:
587 output.append(line)
588 else:
589 output.append(" (no changes from base on this side)")
590
591 output.append("")
592 return output
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago