diff --git a/revup/amend.py b/revup/amend.py index 7128a92..944f8a8 100644 --- a/revup/amend.py +++ b/revup/amend.py @@ -158,7 +158,9 @@ async def rebuild_stack_last_touched( Rebuild the stack, amending each staged file into the most recent commit that touched it. Returns the new HEAD commit. """ - # Map each staged file to its most recent commit in the stack (ordered oldest-first) + # Map each staged file to its most recent commit in the stack (ordered oldest-first). + # -z gives NUL-terminated, unquoted paths so names with spaces, quotes, or unicode + # match staged_files (also -z) exactly rather than git's default C-quoted form. file_to_commit: Dict[str, int] = {} for i, commit_obj in enumerate(stack): touched = await git_ctx.git_stdout( @@ -167,9 +169,10 @@ async def rebuild_stack_last_touched( "--name-only", "-r", "--no-renames", + "-z", commit_obj.commit_id, ) - for f in touched.split("\n"): + for f in touched.split("\0"): if f and f in staged_files: file_to_commit[f] = i @@ -185,8 +188,8 @@ async def rebuild_stack_last_touched( # A staged deletion has no entry here; its path stays out of staged_entries so # the overlay omits it, which the pre-image merge base below turns into a removal. staged_entries: Dict[str, str] = {} - ls_output = await git_ctx.git_stdout("ls-files", "--stage", "--", *files_to_amend) - for line in ls_output.split("\n"): + ls_output = await git_ctx.git_stdout("ls-files", "--stage", "-z", "--", *files_to_amend) + for line in ls_output.split("\0"): if not line: continue _, path = line.split("\t", 1) @@ -286,7 +289,7 @@ async def get_has_unstaged() -> bool: if args.last_touched: staged_files_output = await git_ctx.git_stdout( - "diff-index", "--cached", "-r", "--name-only", "--no-renames", "HEAD" + "diff-index", "--cached", "-r", "--name-only", "--no-renames", "-z", "HEAD" ) if not staged_files_output: return 0 @@ -304,7 +307,7 @@ async def get_has_unstaged() -> bool: if not stack: return 0 - staged_files = set(staged_files_output.split("\n")) + staged_files = set(f for f in staged_files_output.split("\0") if f) new_commit = await rebuild_stack_last_touched(git_ctx, stack, staged_files) if not new_commit: return 0 diff --git a/revup/git.py b/revup/git.py index 05b954b..a2f6f02 100644 --- a/revup/git.py +++ b/revup/git.py @@ -630,9 +630,11 @@ async def make_tree_from_paths(self, tree: GitTreeHash, paths: List[str]) -> Git """ if not paths: return await self.empty_tree() - ls_output = await self.git_stdout("ls-tree", "-r", tree, "--", *paths) + # -z gives NUL-terminated, unquoted paths so names with special chars or + # unicode aren't C-quoted, matching make_tree_from_index_entries's format. + ls_output = await self.git_stdout("ls-tree", "-r", "-z", tree, "--", *paths) entries = [] - for line in ls_output.split("\n"): + for line in ls_output.split("\0"): if not line: continue meta, path = line.split("\t", 1) diff --git a/tests/test_amend.py b/tests/test_amend.py index d9f0572..7706600 100644 --- a/tests/test_amend.py +++ b/tests/test_amend.py @@ -996,3 +996,27 @@ async def test_staged_deletion_amended_into_last_commit(self): assert await env.get_file_at_commit("keep.txt", "HEAD~1") == "k" assert await env.get_file_at_commit("b.txt", "HEAD") == "b1" assert not await env.has_staged_changes() + + @async_test + async def test_paths_with_special_chars_and_unicode(self): + # git C-quotes paths with spaces/unicode unless -z is used; exercise both + # a modification and a deletion of such paths in one run. + async with GitTestEnvironment() as env: + await env.commit("root", {"root.txt": "r"}) + await env.git_ctx.git("branch", "origin/main", "HEAD") + await env.commit( + "first\n\nTopic: alpha", + {"süb dir/fïle a.txt": "v1", "dél été.txt": "d1"}, + ) + await env.commit("second\n\nTopic: beta", {"b.txt": "b1"}) + + # Modify the unicode/space path and delete another such path. + await env.stage_file("süb dir/fïle a.txt", "v2") + await env.git_ctx.git("rm", "dél été.txt") + args = make_amend_args(last_touched=True, parse_topics=True) + await amend.main(args, env.git_ctx) + + assert await env.get_file_at_commit("süb dir/fïle a.txt", "HEAD~1") == "v2" + assert await env.git_ctx.git_return_code("cat-file", "-e", "HEAD:dél été.txt") != 0 + assert await env.get_file_at_commit("b.txt", "HEAD") == "b1" + assert not await env.has_staged_changes()