diff --git a/hermes_cli/_early_recovery.py b/hermes_cli/_early_recovery.py index 9fc84975b3..903ed5b9f9 100644 --- a/hermes_cli/_early_recovery.py +++ b/hermes_cli/_early_recovery.py @@ -381,6 +381,42 @@ def _hash_worktree(git, paths: list[str]) -> dict[str, str]: return blobs +def _modes_git_wrote(git, root: Path, entries: dict, links: set[str]) -> set[str]: + """Paths whose new entry keeps the ``pre`` blob under another mode, already in that mode. + + Equal bytes cannot tell a regular file holding ``original`` from a symlink to ``original`` (one + blob id), nor 644 from 755: only the entry's mode can (review N07). The worktree shows a link + (lstat), except under ``core.symlinks=false`` where git checks a link out as a plain file holding + its target, and the executable bit, except on Windows. The index shows the mode git staged where + the worktree cannot; git writes it after the files, so it covers a git killed after a whole tree. + """ + wanted = {path: {m for m, b in new if b == old_blob and m != old_mode} + for path, (old_mode, old_blob, new) in entries.items() if old_blob is not None} + wanted = {path: modes for path, modes in wanted.items() if modes} + if not wanted: + return set() + symlinks = git("config", "--type=bool", "core.symlinks").stdout.strip() != "false" + staged = git("ls-files", "-s", "-z", "--", *wanted) + if staged.returncode != 0: + raise subprocess.SubprocessError(staged.stderr.strip()) + index = {path: meta.split()[0] for meta, _tab, path in + (record.partition("\t") for record in staged.stdout.split("\0")) if path} + written = set() + for path, modes in wanted.items(): + if path in links: + shown = {"120000"} + elif not (root / path).is_file(): + shown = set() # missing: the caller judges it by absence + else: + shown = set(_REGULAR_FILE_MODES) if sys.platform == "win32" else \ + {"100755" if (root / path).stat().st_mode & 0o100 else "100644"} + if not symlinks: + shown.add("120000") + if (entries[path][0] not in shown and shown & modes) or index.get(path) in modes: + written.add(path) + return written + + def _paths_git_wrote(git, root: Path, pre: str, target: str) -> tuple[list[str], list[str], set[str], set[str]] | None: """Paths the killed git already touched on the way to ``target``: (restore from HEAD, remove as added, directories git may have created for its added files, the added ones to keep aside, not delete). @@ -412,15 +448,14 @@ def _paths_git_wrote(git, root: Path, pre: str, target: str) -> tuple[list[str], links = {path for path in entries if os.path.islink(root / path)} worktree_blob = _hash_worktree(git, [path for path in entries if path not in links and (root / path).is_file()]) worktree_blob.update({path: blob_id(os.fsencode(os.readlink(root / path)), pre) for path in links}) + retyped = _modes_git_wrote(git, root, entries, links) restore, added, kept = [], [], set() for path, (old_mode, old_blob, new) in entries.items(): file, blobs = root / path, {blob for _mode, blob in new if blob} if path not in worktree_blob: written = old_blob is not None # unlinked (or deleted), not yet recreated - elif worktree_blob[path] == old_blob: # only a mode change tells whether git got here - written = (sys.platform != "win32" and path not in links - and any(b == old_blob and m != old_mode for m, b in new) - and bool(file.stat().st_mode & 0o100) != (old_mode == "100755")) + elif worktree_blob[path] == old_blob: # only the entry's mode tells whether git got here + written = path in retyped elif worktree_blob[path] in blobs or path in unknown: written = True elif path in links: # git creates a symlink whole: any other target is the user's diff --git a/tests/hermes_cli/test_update_interrupted_pull.py b/tests/hermes_cli/test_update_interrupted_pull.py index 2f716ef71d..eba56ea7ac 100644 --- a/tests/hermes_cli/test_update_interrupted_pull.py +++ b/tests/hermes_cli/test_update_interrupted_pull.py @@ -823,6 +823,38 @@ def test_a_move_killed_after_git_wrote_a_symlink_change_is_restored(tmp_path, ba assert _what_is_at(root / "L") == before +def _regular_naming_core(root: Path) -> None: + """A regular file holding ``core.py``: the same blob as a symlink to ``core.py``.""" + (root / "L").unlink(missing_ok=True) + (root / "L").write_bytes(b"core.py") + + +@pytest.mark.platforms("posix") # creating symlinks needs Developer Mode/admin on Windows +@pytest.mark.parametrize("kill", ["tree-written", "tree-and-index-written", "core-symlinks-false"]) +@pytest.mark.parametrize(("base", "change"), [(_regular_naming_core, _link), (_link, _regular_naming_core)], + ids=["regular-to-symlink", "symlink-to-regular"]) +def test_an_equal_blob_type_change_git_wrote_is_restored(tmp_path, base, change, kill): + """A regular file holding ``core.py`` and a symlink to ``core.py`` are one blob id: only the entry's + mode tells that git wrote it. Killed with the tree (and index) written but HEAD still ``pre``, the + move is put back, never left as a type change with its marker spent (review N07). Under + ``core.symlinks=false`` git checks the link out as that very file: only the index shows the change.""" + root, pre, target = _killed_move(tmp_path, change, base) + if kill == "core-symlinks-false": + _git(root, "config", "core.symlinks", "false") + (root / "L").unlink() + _git(root, "checkout", "--", "L") # the link as git checks it out here: a plain file + before = _what_is_at(root / "L") + if kill == "tree-written": + change(root) + else: # git's index/tree update, before its HEAD update + _git(root, "read-tree", "-u", "-m", pre, target) + assert _git(root, "status", "--porcelain") == "T L" + assert er.restore_interrupted_pull(root) is True + assert _git(root, "rev-parse", "HEAD") == pre and not er.interrupted_pull_marker(root).exists() + assert _git(root, "status", "--porcelain", "--untracked-files=all") == "" + assert _what_is_at(root / "L") == before + + def test_the_tree_move_marker_is_durable_before_it_appears_under_its_name(checkout, commit_point, monkeypatch): """The marker is the restore's only record: it is written to a temp file, fsynced, then renamed over its name. An in-place write could leave a power cut with an empty or half marker over a