diff --git a/minigit/index.py b/minigit/index.py index f842c8f..d06f824 100644 --- a/minigit/index.py +++ b/minigit/index.py @@ -10,7 +10,8 @@ """ import os -from dataclasses import dataclass +import re +from dataclasses import dataclass, field from typing import NamedTuple from minigit.errors import MiniGitError, ObjectCorruptError @@ -22,11 +23,147 @@ class IndexEntry(NamedTuple): path: str +class LineEdit(NamedTuple): + """One change, in the coordinates of the OLD (base) list of lines. + + Replace `old_count` lines starting at index `start` with `new_lines`. + A pure insert has old_count == 0; a pure delete has new_lines == []. + """ + + start: int + old_count: int + new_lines: list + + @dataclass class DiffResult: added: list deleted: list modified: list + # path -> list[LineEdit] for text files, or None for binary / undecodable files. + # A mode-only change has an empty list. Ignored by == so filename-list checks still work. + line_diffs: dict = field(default_factory=dict, compare=False) + + +class _FileChange(NamedTuple): + path: str + status: str # "added" | "deleted" | "modified" + old_mode: str | None + new_mode: str | None + old_lines: list # [] when binary + edits: list # [] when binary + binary: bool + content_changed: bool + + +# --------------------------------------------------------------------------- +# Myers line diff (pure functions - no files, no store) +# --------------------------------------------------------------------------- + + +def split_lines(text: str) -> list[str]: + """Split on "\n" only, keeping the newline on each line. + + "a\nb" -> ["a\n", "b"]; a missing final newline stays visible as a last + line without "\n". (str.splitlines would also split on \r, \x0c, etc.) + """ + return re.findall(r"[^\n]*\n|[^\n]+", text) + + +def myers_diff(a: list, b: list) -> list[LineEdit]: + """Return the shortest list of LineEdit that turns `a` into `b`.""" + return _group_ops(_shortest_path(a, b)) + + +def _shortest_path(a: list, b: list) -> list: + n, m = len(a), len(b) + if n == 0 and m == 0: + return [] + + # v[k] = furthest x reached on diagonal k, where k = x - y. + v = {1: 0} + trace = [] # trace[d] = copy of v at the start of round d + + for d in range(n + m + 1): + trace.append(dict(v)) + for k in range(-d, d + 1, 2): + if k == -d or (k != d and v[k - 1] < v[k + 1]): + x = v[k + 1] # step down: insert a line from b + else: + x = v[k - 1] + 1 # step right: delete a line from a + y = x - k + while x < n and y < m and a[x] == b[y]: # matching lines are free + x += 1 + y += 1 + v[k] = x + if x >= n and y >= m: + return _backtrack(b, trace, d, n, m) + raise AssertionError("unreachable") # pragma: no cover + + +def _backtrack(b: list, trace: list, last_d: int, n: int, m: int) -> list: + x, y = n, m + ops = [] # ("del", old_index) or ("ins", old_index, line) + + for d in range(last_d, 0, -1): + v = trace[d] + k = x - y + if k == -d or (k != d and v[k - 1] < v[k + 1]): + prev_k = k + 1 # came from above: an insertion + else: + prev_k = k - 1 # came from the left: a deletion + prev_x = v[prev_k] + prev_y = prev_x - prev_k + + while x > prev_x and y > prev_y: # walk back over matching lines + x -= 1 + y -= 1 + + if prev_k == k + 1: + ops.append(("ins", prev_x, b[prev_y])) + else: + ops.append(("del", prev_x)) + x, y = prev_x, prev_y + + ops.reverse() + return ops + + +def _group_ops(ops: list) -> list[LineEdit]: + """Merge neighbouring del/ins ops into LineEdit(start, old_count, new_lines).""" + edits = [] + current = None # [start, old_count, new_lines] + for op in ops: + pos = op[1] + if current is None or pos != current[0] + current[1]: + if current is not None: + edits.append(LineEdit(*current)) + current = [pos, 0, []] + if op[0] == "del": + current[1] += 1 + else: + current[2].append(op[2]) + if current is not None: + edits.append(LineEdit(*current)) + return edits + + +def apply_edits(base: list, edits: list[LineEdit]) -> list: + """Apply LineEdits (base coordinates) to `base`; the inverse of myers_diff.""" + result = list(base) + for edit in sorted(edits, key=lambda e: e.start, reverse=True): + result[edit.start : edit.start + edit.old_count] = edit.new_lines + return result + + +def _bytes_to_lines(data: bytes) -> list[str] | None: + """Decode file bytes into lines, or None if the file looks binary.""" + if b"\0" in data: + return None + try: + return split_lines(data.decode("utf-8")) + except UnicodeDecodeError: + return None class WorkingTree: @@ -134,34 +271,73 @@ def walk(hash_, prefix): walk(tree_hash, "") return sorted(entries, key=lambda e: e.path) - def diff_working_tree_vs(self, tree_hash) -> DiffResult: - result = DiffResult([], [], []) - tree_entries = self.read_tree_entries(tree_hash) + def _disk_mode(self, full_path: str) -> str: + return "100755" if os.access(full_path, os.X_OK) else "100644" + + def _make_change(self, path, status, old_mode, new_mode, old_data, new_data) -> _FileChange: + old_lines = _bytes_to_lines(old_data) + new_lines = _bytes_to_lines(new_data) + binary = old_lines is None or new_lines is None + edits = [] if binary else myers_diff(old_lines, new_lines) + return _FileChange( + path=path, + status=status, + old_mode=old_mode, + new_mode=new_mode, + old_lines=[] if binary else old_lines, + edits=edits, + binary=binary, + content_changed=old_data != new_data, + ) + + def _compare_with_tree(self, tree_hash) -> list[_FileChange]: + """Compare a tree (None = empty tree) with the files on disk. Read-only.""" + tree_entries = self.read_tree_entries(tree_hash) if tree_hash else [] known_paths = {e.path for e in tree_entries} + changes = [] for entry in tree_entries: full_path = os.path.join(self.root, entry.path) if not os.path.isfile(full_path): - result.deleted.append(entry.path) - else: - with open(full_path, "rb") as f: - data = f.read() - current_hash = self.store.hash_object(data, "blob") - current_mode = "100755" if os.access(full_path, os.X_OK) else "100644" - if current_hash != entry.hash or current_mode != entry.mode: - result.modified.append(entry.path) + _, old_data = self.store.read_object(entry.hash) + changes.append( + self._make_change(entry.path, "deleted", entry.mode, None, old_data, b"") + ) + continue + with open(full_path, "rb") as f: + new_data = f.read() + new_mode = self._disk_mode(full_path) + if self.store.hash_object(new_data, "blob") == entry.hash and new_mode == entry.mode: + continue + _, old_data = self.store.read_object(entry.hash) + changes.append( + self._make_change(entry.path, "modified", entry.mode, new_mode, old_data, new_data) + ) for dirpath, dirnames, filenames in os.walk(self.root): dirnames[:] = [d for d in dirnames if d != ".minigit"] for filename in filenames: full_path = os.path.join(dirpath, filename) rel_path = os.path.relpath(full_path, self.root).replace(os.sep, "/") - if rel_path not in known_paths: - result.added.append(rel_path) + if rel_path in known_paths: + continue + with open(full_path, "rb") as f: + new_data = f.read() + new_mode = self._disk_mode(full_path) + changes.append(self._make_change(rel_path, "added", None, new_mode, b"", new_data)) + + return sorted(changes, key=lambda c: c.path) + + def diff_working_tree_vs(self, tree_hash) -> DiffResult: + """Compare the working files with a tree (None = empty tree). - result.added.sort() - result.deleted.sort() - result.modified.sort() + added / deleted / modified stay plain sorted path lists. line_diffs adds + path -> list[LineEdit] (base = the tree's version), or None for binary files. + """ + result = DiffResult([], [], []) + for change in self._compare_with_tree(tree_hash): + getattr(result, change.status).append(change.path) + result.line_diffs[change.path] = None if change.binary else change.edits return result def _working_status(self) -> DiffResult: @@ -380,6 +556,66 @@ def cmd_status(args) -> int: return 0 +def _hunk_range(start: int, count: int) -> str: + return f"{start if count == 0 else start + 1},{count}" + + +def _format_change(change: _FileChange) -> list[str]: + """Render one file's changes as printable lines.""" + path = change.path + out = [f"diff --minigit a/{path} b/{path}"] + + if change.status == "added": + out.append(f"new file mode {change.new_mode}") + elif change.status == "deleted": + out.append(f"deleted file mode {change.old_mode}") + elif change.old_mode != change.new_mode: + out.append(f"old mode {change.old_mode}") + out.append(f"new mode {change.new_mode}") + + if change.binary: + if change.content_changed: + out.append(f"binary file changed: {path}") + return out + if not change.edits: + return out # mode-only change, or an empty file added / deleted + + out.append("--- /dev/null" if change.status == "added" else f"--- a/{path}") + out.append("+++ /dev/null" if change.status == "deleted" else f"+++ b/{path}") + + def emit(prefix: str, line: str) -> None: + out.append(prefix + line.removesuffix("\n")) + if not line.endswith("\n"): + out.append("\\ No newline at end of file") + + offset = 0 + for edit in change.edits: + new_start = edit.start + offset + out.append( + f"@@ -{_hunk_range(edit.start, edit.old_count)} " + f"+{_hunk_range(new_start, len(edit.new_lines))} @@" + ) + for line in change.old_lines[edit.start : edit.start + edit.old_count]: + emit("-", line) + for line in edit.new_lines: + emit("+", line) + offset += len(edit.new_lines) - edit.old_count + return out + + +def cmd_diff(args) -> int: + """minigit diff: HEAD tree vs the working files (unborn HEAD = empty tree).""" + wt = WorkingTree() + + from minigit.commits import CommitManager + + head_tree_hash = CommitManager(wt.root, store=wt.store, tree=wt).get_head_tree() + for change in wt._compare_with_tree(head_tree_hash): + for line in _format_change(change): + print(line) + return 0 + + def register_index_commands(subparsers) -> None: add_parser = subparsers.add_parser("add", help="stage a file") add_parser.add_argument("path") @@ -387,3 +623,6 @@ def register_index_commands(subparsers) -> None: status_parser = subparsers.add_parser("status", help="show staged files") status_parser.set_defaults(handler=cmd_status) + + diff_parser = subparsers.add_parser("diff", help="show line changes since HEAD") + diff_parser.set_defaults(handler=cmd_diff) diff --git a/tests/test_index.py b/tests/test_index.py index d3d73e6..3361f0c 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,13 +1,24 @@ -"""Tests for minigit/index.py - WorkingTree, trees, diffing, and status.""" +"""Tests for minigit/index.py - WorkingTree, trees, diffing, status, checkout, and Myers diff.""" import os +import random +import sys +import types import pytest from minigit.commits import CommitManager from minigit.errors import MiniGitError -from minigit.index import WorkingTree, cmd_status -from minigit.objects import ObjectStore +from minigit.index import ( + LineEdit, + WorkingTree, + apply_edits, + cmd_diff, + cmd_status, + myers_diff, + split_lines, +) +from minigit.objects import ObjectStore, TreeEntry def test_build_tree_empty_index(tmp_path): @@ -408,3 +419,451 @@ def test_checkout_is_idempotent(tmp_path): assert first_index == second_index assert (tmp_path / "hello.txt").read_text() == "hi" + + +# --------------------------------------------------------------------------- +# Myers: pure function tests +# --------------------------------------------------------------------------- + + +def lcs_length(a, b): + table = [[0] * (len(b) + 1) for _ in range(len(a) + 1)] + for i in range(len(a) - 1, -1, -1): + for j in range(len(b) - 1, -1, -1): + if a[i] == b[j]: + table[i][j] = table[i + 1][j + 1] + 1 + else: + table[i][j] = max(table[i + 1][j], table[i][j + 1]) + return table[0][0] + + +def edit_cost(edits): + return sum(e.old_count + len(e.new_lines) for e in edits) + + +def test_identical_lists_have_no_edits(): + assert myers_diff(["a", "b", "c"], ["a", "b", "c"]) == [] + + +def test_both_empty_have_no_edits(): + assert myers_diff([], []) == [] + + +def test_pure_insert_uses_base_position(): + assert myers_diff(["a", "c"], ["a", "b", "c"]) == [LineEdit(1, 0, ["b"])] + + +def test_insert_at_start_and_end(): + assert myers_diff(["b"], ["a", "b"]) == [LineEdit(0, 0, ["a"])] + assert myers_diff(["a"], ["a", "b"]) == [LineEdit(1, 0, ["b"])] + + +def test_pure_delete(): + assert myers_diff(["a", "b", "c"], ["a", "c"]) == [LineEdit(1, 1, [])] + + +def test_replace(): + assert myers_diff(["a", "b", "c"], ["a", "x", "c"]) == [LineEdit(1, 1, ["x"])] + + +def test_from_empty_and_to_empty(): + assert myers_diff([], ["a", "b"]) == [LineEdit(0, 0, ["a", "b"])] + assert myers_diff(["a", "b"], []) == [LineEdit(0, 2, [])] + + +def test_repeated_lines(): + edits = myers_diff(["x", "x", "x"], ["x", "x"]) + assert edit_cost(edits) == 1 + assert apply_edits(["x", "x", "x"], edits) == ["x", "x"] + + +def test_two_separate_changes_stay_separate(): + old = ["a", "b", "c", "d", "e"] + new = ["a", "B", "c", "d", "E"] + assert myers_diff(old, new) == [LineEdit(1, 1, ["B"]), LineEdit(4, 1, ["E"])] + + +def test_missing_final_newline_is_a_change(): + old = split_lines("a\nb") + new = split_lines("a\nb\n") + assert myers_diff(old, new) == [LineEdit(1, 1, ["b\n"])] + + +def test_edits_are_sorted_and_do_not_overlap(): + random.seed(7) + for _ in range(200): + a = [random.choice("abc") for _ in range(random.randint(0, 8))] + b = [random.choice("abc") for _ in range(random.randint(0, 8))] + edits = myers_diff(a, b) + for first, second in zip(edits, edits[1:], strict=False): + assert first.start + first.old_count < second.start + + +def test_random_round_trip_and_minimal(): + random.seed(1) + for _ in range(500): + a = [random.choice("abc") for _ in range(random.randint(0, 9))] + b = [random.choice("abc") for _ in range(random.randint(0, 9))] + edits = myers_diff(a, b) + assert apply_edits(a, edits) == b + assert edit_cost(edits) == len(a) + len(b) - 2 * lcs_length(a, b) + + +def test_split_lines_keeps_newlines_and_only_splits_on_newline(): + assert split_lines("") == [] + assert split_lines("a\n") == ["a\n"] + assert split_lines("a\nb") == ["a\n", "b"] + assert split_lines("a\r\nb\n") == ["a\r\n", "b\n"] + assert split_lines("a\x0cb\u2028c\n") == ["a\x0cb\u2028c\n"] + assert split_lines("\n\n") == ["\n", "\n"] + + +# --------------------------------------------------------------------------- +# diff_working_tree_vs with line details +# --------------------------------------------------------------------------- + + +@pytest.fixture +def repo(tmp_path): + store = ObjectStore(str(tmp_path)) + return WorkingTree(str(tmp_path), store=store) + + +def write(repo, name, content, mode=None): + path = os.path.join(repo.root, name) + os.makedirs(os.path.dirname(path), exist_ok=True) + data = content if isinstance(content, bytes) else content.encode() + with open(path, "wb") as f: + f.write(data) + if mode is not None: + os.chmod(path, mode) + return path + + +def snapshot_tree(repo, files): + """Stage `files`, build the tree (this is our stand-in for HEAD), return its hash.""" + for name in files: + repo.stage_file(name) + return repo.build_tree_from_index() + + +def minigit_files(repo): + found = [] + for dirpath, _, filenames in os.walk(os.path.join(repo.root, ".minigit")): + for filename in filenames: + full = os.path.join(dirpath, filename) + found.append((os.path.relpath(full, repo.root), os.path.getmtime(full))) + return sorted(found) + + +def test_modified_file_has_line_edits_and_name_lists(repo): + write(repo, "a.txt", "one\ntwo\nthree\n") + tree = snapshot_tree(repo, ["a.txt"]) + write(repo, "a.txt", "one\nTWO\nthree\n") + + result = repo.diff_working_tree_vs(tree) + + assert result.modified == ["a.txt"] + assert result.added == [] + assert result.deleted == [] + assert result.line_diffs["a.txt"] == [LineEdit(1, 1, ["TWO\n"])] + + +def test_added_and_deleted_files_have_line_edits(repo): + write(repo, "gone.txt", "x\ny\n") + tree = snapshot_tree(repo, ["gone.txt"]) + os.remove(os.path.join(repo.root, "gone.txt")) + write(repo, "new.txt", "p\nq\n") + + result = repo.diff_working_tree_vs(tree) + + assert result.deleted == ["gone.txt"] + assert result.added == ["new.txt"] + assert result.line_diffs["gone.txt"] == [LineEdit(0, 2, [])] + assert result.line_diffs["new.txt"] == [LineEdit(0, 0, ["p\n", "q\n"])] + + +def test_unchanged_file_is_not_reported(repo): + write(repo, "same.txt", "hello\n") + tree = snapshot_tree(repo, ["same.txt"]) + + result = repo.diff_working_tree_vs(tree) + + assert result.added == result.deleted == result.modified == [] + assert result.line_diffs == {} + + +def test_unborn_head_none_is_an_empty_tree(repo): + write(repo, "a.txt", "hi\n") + + result = repo.diff_working_tree_vs(None) + + assert result.added == ["a.txt"] + assert result.line_diffs["a.txt"] == [LineEdit(0, 0, ["hi\n"])] + + +def test_binary_and_undecodable_files_have_no_line_diff(repo): + write(repo, "img.bin", b"\x00\x01\x02") + write(repo, "latin.txt", b"caf\xe9\n") + tree = snapshot_tree(repo, ["img.bin", "latin.txt"]) + write(repo, "img.bin", b"\x00\x01\x03") + write(repo, "latin.txt", b"caf\xe8\n") + + result = repo.diff_working_tree_vs(tree) + + assert result.modified == ["img.bin", "latin.txt"] + assert result.line_diffs["img.bin"] is None + assert result.line_diffs["latin.txt"] is None + + +def test_unicode_text_is_diffed_as_text(repo): + write(repo, "u.txt", "café\nnaïve\n") + tree = snapshot_tree(repo, ["u.txt"]) + write(repo, "u.txt", "café\nnaïve!\n") + + result = repo.diff_working_tree_vs(tree) + + assert result.line_diffs["u.txt"] == [LineEdit(1, 1, ["naïve!\n"])] + + +def test_mode_only_change_is_modified_with_no_line_edits(repo): + write(repo, "run.sh", "echo hi\n", mode=0o644) + tree = snapshot_tree(repo, ["run.sh"]) + os.chmod(os.path.join(repo.root, "run.sh"), 0o755) + + result = repo.diff_working_tree_vs(tree) + + assert result.modified == ["run.sh"] + assert result.line_diffs["run.sh"] == [] + + +def test_diff_ignores_minigit_directory(repo): + write(repo, "a.txt", "x\n") + tree = snapshot_tree(repo, ["a.txt"]) + + result = repo.diff_working_tree_vs(tree) + + assert not any(p.startswith(".minigit") for p in result.added) + + +def test_diff_writes_nothing_to_minigit(repo): + write(repo, "a.txt", "one\n") + tree = snapshot_tree(repo, ["a.txt"]) + write(repo, "a.txt", "two\n") + write(repo, "b.txt", "new\n") + before = minigit_files(repo) + + repo.diff_working_tree_vs(tree) + + assert minigit_files(repo) == before + + +# --------------------------------------------------------------------------- +# `minigit diff` and status (fake minigit.commits so these tests stand alone) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def cli_repo(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + repo = WorkingTree(".", store=ObjectStore(".")) + state = {"head_tree": None} + + class FakeCommitManager: + def __init__(self, repo_path=".", store=None, tree=None): + pass + + def get_head_tree(self): + return state["head_tree"] + + fake = types.ModuleType("minigit.commits") + fake.CommitManager = FakeCommitManager + monkeypatch.setitem(sys.modules, "minigit.commits", fake) + + def commit(*files): + state["head_tree"] = snapshot_tree(repo, files) + + return repo, commit + + +def run_diff(capsys): + assert cmd_diff(None) == 0 + return capsys.readouterr().out + + +def test_cli_clean_tree_prints_nothing(cli_repo, capsys): + repo, commit = cli_repo + write(repo, "a.txt", "hi\n") + commit("a.txt") + + assert run_diff(capsys) == "" + + +def test_cli_shows_headers_and_added_removed_lines(cli_repo, capsys): + repo, commit = cli_repo + write(repo, "a.txt", "one\ntwo\nthree\n") + commit("a.txt") + write(repo, "a.txt", "one\nTWO\nthree\n") + + out = run_diff(capsys).splitlines() + + assert "diff --minigit a/a.txt b/a.txt" in out + assert "--- a/a.txt" in out + assert "+++ b/a.txt" in out + assert "@@ -2,1 +2,1 @@" in out + assert "-two" in out + assert "+TWO" in out + assert " one" not in out + + +def test_cli_removed_lines_come_from_the_old_text(cli_repo, capsys): + repo, commit = cli_repo + write(repo, "a.txt", "keep\nold1\nold2\nkeep2\n") + commit("a.txt") + write(repo, "a.txt", "keep\nnew\nkeep2\n") + + out = run_diff(capsys).splitlines() + + assert out.index("-old1") < out.index("-old2") < out.index("+new") + + +def test_cli_second_hunk_line_numbers_account_for_earlier_edits(cli_repo, capsys): + repo, commit = cli_repo + write(repo, "a.txt", "a\nb\nc\nd\ne\nf\n") + commit("a.txt") + write(repo, "a.txt", "a\nX\nY\nb\nc\nd\ne\nF\n") + + out = run_diff(capsys).splitlines() + + assert "@@ -1,0 +2,2 @@" in out + assert "@@ -6,1 +8,1 @@" in out + + +def test_cli_new_and_deleted_files(cli_repo, capsys): + repo, commit = cli_repo + write(repo, "gone.txt", "bye\n") + commit("gone.txt") + os.remove("gone.txt") + write(repo, "new.txt", "hello\n") + + out = run_diff(capsys).splitlines() + + assert "deleted file mode 100644" in out + assert "-bye" in out + assert "+++ /dev/null" in out + assert "new file mode 100644" in out + assert "--- /dev/null" in out + assert "+hello" in out + + +def test_cli_unborn_head_shows_everything_as_new(cli_repo, capsys): + repo, _ = cli_repo + write(repo, "a.txt", "hi\n") + + out = run_diff(capsys).splitlines() + + assert "new file mode 100644" in out + assert "+hi" in out + + +def test_cli_binary_file_reports_binary_changed(cli_repo, capsys): + repo, commit = cli_repo + write(repo, "img.bin", b"\x00\x01") + commit("img.bin") + write(repo, "img.bin", b"\x00\x02") + + out = run_diff(capsys) + + assert "binary file changed" in out + assert "@@" not in out + + +def test_cli_mode_only_change(cli_repo, capsys): + repo, commit = cli_repo + write(repo, "run.sh", "echo hi\n", mode=0o644) + commit("run.sh") + os.chmod("run.sh", 0o755) + + out = run_diff(capsys).splitlines() + + assert "old mode 100644" in out + assert "new mode 100755" in out + assert not any(line.startswith("@@") for line in out) + + +def test_cli_missing_final_newline_is_marked(cli_repo, capsys): + repo, commit = cli_repo + write(repo, "a.txt", "a\nb") + commit("a.txt") + write(repo, "a.txt", "a\nb\n") + + out = run_diff(capsys).splitlines() + + assert "-b" in out + assert "\\ No newline at end of file" in out + assert "+b" in out + + +def test_cli_diff_and_status_are_read_only(cli_repo, capsys): + repo, commit = cli_repo + write(repo, "a.txt", "one\n") + commit("a.txt") + write(repo, "a.txt", "two\n") + write(repo, "b.txt", "new\n") + before_files = minigit_files(repo) + before_index = repo.read_index() + + cmd_diff(None) + cmd_status(None) + capsys.readouterr() + + assert minigit_files(repo) == before_files + assert repo.read_index() == before_index + with open("a.txt") as f: + assert f.read() == "two\n" + + +def test_status_sections_still_staged_not_staged_untracked(cli_repo, capsys): + repo, commit = cli_repo + write(repo, "a.txt", "one\n") + commit("a.txt") + write(repo, "a.txt", "two\n") + write(repo, "b.txt", "new\n") + repo.stage_file("b.txt") + write(repo, "c.txt", "loose\n") + + cmd_status(None) + out = capsys.readouterr().out.splitlines() + + assert out == ["staged:", " b.txt", "not staged:", " a.txt", "untracked:", " c.txt"] + + +# --------------------------------------------------------------------------- +# Merge integration: M3 hands us a merged tree (conflict markers are just blob text) +# --------------------------------------------------------------------------- + + +def test_checkout_applies_a_merge_tree_with_conflict_markers(repo): + write(repo, "a.txt", "base\n") + repo.stage_file("a.txt") + base_tree = repo.build_tree_from_index() + repo.checkout(base_tree) + + conflicted = b"<<<<<<< ours\nmine\n=======\ntheirs\n>>>>>>> theirs\n" + blob = repo.store.write_object(conflicted, "blob") + clean = repo.store.write_object(b"added by merge\n", "blob") + merge_tree = repo.store.write_tree( + [ + TreeEntry("100644", "blob", blob, "a.txt"), + TreeEntry("100644", "blob", clean, "b.txt"), + ] + ) + + repo.checkout(merge_tree) + + with open(os.path.join(repo.root, "a.txt"), "rb") as f: + assert f.read() == conflicted + with open(os.path.join(repo.root, "b.txt"), "rb") as f: + assert f.read() == b"added by merge\n" + assert [e.path for e in repo.read_index()] == ["a.txt", "b.txt"] + assert repo.diff_working_tree_vs(merge_tree).line_diffs == {}