-
Notifications
You must be signed in to change notification settings - Fork 4.7k
fix: keep the file when apply_patch does a case-only rename #4890
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2bf1a31
5b3650c
bcd1d9f
0b7e082
2575358
339f79a
428794c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,10 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import contextlib | ||
| import io | ||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, runtime_checkable | ||
| from uuid import uuid4 | ||
|
|
||
| from ..apply_diff import ApplyDiffMode, apply_diff | ||
| from ..editor import ApplyPatchOperation, ApplyPatchOperationType, ApplyPatchResult | ||
|
|
@@ -111,9 +113,11 @@ async def apply_operation( | |
|
|
||
| moved_relative_path, moved_display_path = self._resolve_path(operation.move_to) | ||
| moved_destination = self._session.normalize_path(moved_relative_path) | ||
| await self._write_text(moved_destination, updated_text) | ||
| if moved_destination != destination: | ||
| await self._session.rm(destination, user=self._user) | ||
| await self._move_updated_text( | ||
| source=destination, | ||
| moved_destination=moved_destination, | ||
| text=updated_text, | ||
| ) | ||
| return ApplyPatchResult( | ||
| output=f"Updated {display_path}\nMoved {display_path} to {moved_display_path}" | ||
| ) | ||
|
|
@@ -209,6 +213,72 @@ async def _read_text(self, destination: Path, *, op_path: str, decode_path: Path | |
| path=op_path, | ||
| ) | ||
|
|
||
| async def _move_updated_text( | ||
| self, | ||
| *, | ||
| source: Path, | ||
| moved_destination: Path, | ||
| text: str, | ||
| ) -> None: | ||
| """Apply an update that renames the file, without a window in which it does not exist. | ||
|
|
||
| Writing the destination and then removing the source destroys the file whenever the two | ||
| paths are one file on disk, which is what a case-only rename is on a filesystem that | ||
| folds case. Removing the source first destroys it whenever the replacement write fails. | ||
|
|
||
| So neither path is written or removed until the new content is committed somewhere else: | ||
| the text goes to a staging file, a single `mv` puts it at the destination, and only then | ||
| is the source removed when the filesystem says it is a different entry. When both names | ||
| are one entry and the leaf spellings differ, a second move changes the stored spelling. | ||
| Before the first move the original is untouched; after it the new content exists. There | ||
| is no moment where the only copy is in memory, and nothing is restored after the fact. | ||
|
|
||
| The identity answer can still go stale. On the different-entry branch, a writer that | ||
| replaces the source before the removal loses its file. On the same-entry branch, a writer | ||
| that replaces the source before the second move has its content moved onto the destination | ||
| and reported as the patched file. Closing either race needs an operation tied to the entry | ||
| whose identity was checked, which no backend here offers. | ||
|
|
||
| The staging file is a new inode, so a rename the filesystem folds onto the source path | ||
| replaces the original's mode and extended attributes. Carrying those across would mean | ||
| reading and reapplying them per backend; committing the content in a single `mv` is | ||
| worth more than the mode bits. | ||
|
|
||
| The staging name is a fixed length rather than a decoration of the destination name, | ||
| because a destination basename near the filesystem's 255-byte limit would make the | ||
| decorated name exceed it and the write would fail with ENAMETOOLONG. | ||
| """ | ||
| if source.as_posix() == moved_destination.as_posix(): | ||
| # Not a rename, so nothing needs committing elsewhere. Writing in place is what an | ||
| # update without `move_to` does, and it keeps the inode, the mode and the xattrs. | ||
| # | ||
| # The comparison is on the spelling rather than on `Path` equality, which folds case | ||
| # on a Windows host. Whether two sandbox paths are one file is the sandbox's answer, | ||
| # not the host's: a Windows host talking to a case-sensitive sandbox would otherwise | ||
| # take this branch for a case-only rename and never create the new name. Paths that | ||
| # differ only in case go down the staging path, where `same_file` asks the sandbox. | ||
| await self._write_text(source, text) | ||
| return | ||
|
|
||
| staging = moved_destination.with_name(f".apply_patch-{uuid4().hex}.tmp") | ||
| try: | ||
| await self._write_text(staging, text) | ||
| await self._session.mv(staging, moved_destination, user=self._user) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When AGENTS.md reference: AGENTS.md:L98-L98 Useful? React with 👍 / 👎. |
||
| except BaseException: | ||
| with contextlib.suppress(Exception): | ||
| await self._session.rm(staging, user=self._user) | ||
| raise | ||
| same_entry = await self._session.same_file( | ||
| source, moved_destination, follow_symlinks=False, user=self._user | ||
| ) | ||
| if same_entry and source.name != moved_destination.name: | ||
| # On case-folding APFS, replacing an existing entry through a case-variant path | ||
| # updates its content but keeps its old spelling. Moving that same entry performs | ||
| # the requested case-only rename without touching the committed content. | ||
| await self._session.mv(source, moved_destination, user=self._user) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a remote workspace contains an in-root directory symlink (for example, Useful? React with 👍 / 👎. |
||
| elif not same_entry: | ||
| await self._session.rm(source, user=self._user) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If another process replaces or recreates AGENTS.md reference: AGENTS.md:L104-L104 Useful? React with 👍 / 👎. |
||
|
|
||
| async def _write_text(self, destination: Path, text: str) -> None: | ||
| await self._session.mkdir(destination.parent, parents=True, user=self._user) | ||
| await self._session.write( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1146,6 +1146,99 @@ async def rm( | |
| if not result.ok(): | ||
| raise ExecNonZeroError(result, command=cmd) | ||
|
|
||
| async def mv( | ||
| self, | ||
| source: Path | str, | ||
| destination: Path | str, | ||
| *, | ||
| user: str | User | None = None, | ||
| ) -> None: | ||
| """Rename a path, replacing the destination if it exists. | ||
|
|
||
| This is a rename, not `mv`'s other behavior. Given an existing directory as the | ||
| destination, `mv` puts the source inside it and reports success, which for a caller | ||
| that then removes the source is a way to delete a file while believing it moved. The | ||
| destination is checked in the same shell invocation as the move, which keeps the | ||
| check and the move in one round trip. It does not make them one syscall. | ||
|
|
||
| `mv -T` would say this directly and is GNU-only, so it is unavailable on the BSD | ||
| userland this also has to run against. | ||
|
|
||
| :param source: Path to move. | ||
| :param destination: Path to move it to. | ||
| :param user: Optional sandbox user to move as. | ||
| :raises ExecNonZeroError: If the destination is an existing directory, or the move | ||
| fails. | ||
| """ | ||
| source = await self._validate_path_access(source, for_write=True) | ||
| destination = await self._validate_path_access(destination, for_write=True) | ||
|
|
||
| source_arg = sandbox_path_str(source) | ||
| destination_arg = sandbox_path_str(destination) | ||
| cmd = ( | ||
| "sh", | ||
| "-lc", | ||
| 'if [ -d "$2" ]; then exit 3; fi\nmv -f -- "$1" "$2"', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If another process creates AGENTS.md reference: AGENTS.md:L104-L104 Useful? React with 👍 / 👎. |
||
| "sh", | ||
| source_arg, | ||
| destination_arg, | ||
| ) | ||
| result = await self.exec(*cmd, shell=False, user=user) | ||
| if not result.ok(): | ||
| raise ExecNonZeroError( | ||
| result, command=("sh", "-lc", "<mv>", source_arg, destination_arg) | ||
| ) | ||
|
|
||
| async def same_file( | ||
| self, | ||
| left: Path | str, | ||
| right: Path | str, | ||
| *, | ||
| follow_symlinks: bool = True, | ||
| user: str | User | None = None, | ||
| ) -> bool: | ||
| """Return whether two paths name the same file on the sandbox filesystem. | ||
|
|
||
| This asks the filesystem, through `test -ef`, which compares device and inode. Two | ||
| paths that differ as strings can be one file: a filesystem that folds case stores | ||
| `notes.txt` and `Notes.txt` as a single entry, and APFS folds Unicode normalization | ||
| as well, so the NFC and NFD spellings of one accented name are also a single entry. | ||
| No string comparison can answer this, and neither can the host that is driving the | ||
| session, which may not be the kind of system the sandbox is running on. | ||
|
|
||
| `test -ef` resolves symlinks, so a symlink and the file it points at are the same | ||
| file by this test while being two directory entries. Pass ``follow_symlinks=False`` | ||
| when the caller needs to distinguish those entries. | ||
|
|
||
| :param left: First path to compare. | ||
| :param right: Second path to compare. | ||
| :param follow_symlinks: If false, a symlink on either side is not the same file as | ||
| its target when the backend preserves the requested leaf path. | ||
| :param user: Optional sandbox user to compare as. | ||
| :returns: True when both paths resolve to the same file. | ||
| """ | ||
| left = await self._validate_path_access(left) | ||
| right = await self._validate_path_access(right) | ||
|
|
||
| left_arg = sandbox_path_str(left) | ||
| right_arg = sandbox_path_str(right) | ||
| test = '[ "$1" -ef "$2" ]' | ||
| if not follow_symlinks: | ||
| test = '[ ! -L "$1" ] && [ ! -L "$2" ] && ' + test | ||
| cmd = ("sh", "-lc", test, "sh", left_arg, right_arg) | ||
| result = await self.exec(*cmd, shell=False, user=user) | ||
| if result.exit_code == 0: | ||
| return True | ||
| # `[` answers "different file" with 1 and reports its own failures with 2, and a | ||
| # missing shell exits 127. Only 1 is an answer; anything else is the session | ||
| # failing to tell us, and a caller about to delete a file on the strength of this | ||
| # must not read that as "different". | ||
| if result.exit_code == 1: | ||
| return False | ||
| raise ExecNonZeroError( | ||
| result, command=("sh", "-lc", "<same_file_check>", left_arg, right_arg) | ||
| ) | ||
|
|
||
| async def mkdir( | ||
| self, | ||
| path: Path | str, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
move_tois exactly the source path—a supported case already covered bytest_editor_move_to_same_path_does_not_remove_the_file—moving the newly created staging file over it replaces the inode instead of updating it in place. OnUnixLocalSandboxSession, this changes previously preserved metadata to the staging file's defaults, so a0600file can become0644, an executable can lose its execute bit, and ACLs or extended attributes disappear. Bypass staging for the exact-same-path case or copy the existing metadata onto the staged replacement before committing it.Useful? React with 👍 / 👎.