fix: a killed move that only changed an entry's type is restored, not marked done
A regular file holding `original` and a symlink to `original` are one Git blob
id, so the interrupted-pull classifier's equal-blob branch (which compared only
the executable bit, and skipped current links) never attributed a file<->symlink
change to the killed git. Recovery returned False, deleted its marker, and left a
staged `T` entry with HEAD still on `pre` (review N07 residual).
The equal-blob branch now asks `_modes_git_wrote`: the target keeps the pre blob
under another mode and the entry already has that mode. The worktree shows it
via lstat (a link) and the executable bit (not on Windows); under
core.symlinks=false a link is checked out as the very same plain file, so the
index's staged mode is read too (git writes it after the tree). Only paths whose
target has a same-blob mode change pay the one config + ls-files call.
Test: test_an_equal_blob_type_change_git_wrote_is_restored, both directions x
{tree written, tree+index written, core.symlinks=false}; 6/6 fail on base.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user