Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 29 additions & 25 deletions revup/amend.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,33 +190,37 @@ async def rebuild_stack_last_touched(
_, path = line.split("\t", 1)
staged_entries[path] = line

tmp_index = git_ctx.get_scratch_dir() + "/last_touched_index"

new_commit = GitCommitHash(stack[0].parents[0]) if stack[0].parents else GitCommitHash("")
for i, commit_obj in enumerate(stack):
if i in commit_to_files:
idx_env = {"GIT_INDEX_FILE": tmp_index}
await git_ctx.git("read-tree", commit_obj.tree, env=idx_env)
update_lines = [staged_entries[f] for f in commit_to_files[i]]
await git_ctx.git(
"update-index",
"--index-info",
env=idx_env,
input_str="\n".join(update_lines) + "\n",
)
new_tree = GitTreeHash(await git_ctx.git_stdout("write-tree", env=idx_env))
amended = CommitHeader(new_tree, [new_commit])
amended.author_name = commit_obj.author_name
amended.author_email = commit_obj.author_email
amended.author_date = commit_obj.author_date
amended.committer_name = commit_obj.committer_name
amended.committer_email = commit_obj.committer_email
# Refresh committer date, like git commit --amend.
amended.committer_date = ""
amended.commit_msg = commit_obj.commit_msg
new_commit = await git_ctx.commit_tree(amended)
else:
new_commit = await replay_cherry_pick(git_ctx, commit_obj, new_commit)
# Cherry-pick onto the rebuilt parent first, so amendments to earlier
# commits propagate forward through the rest of the stack.
rebuilt_parent = new_commit
new_commit = await replay_cherry_pick(git_ctx, commit_obj, new_commit)
if i not in commit_to_files:
continue

# Build a tree of just this commit's staged files and overlay it onto the
# cherry-picked commit. The merge base is the empty tree, so --theirs will
# always take the incoming version and will never conflict.
overlay = await git_ctx.make_tree_from_index_entries(
[staged_entries[f] for f in commit_to_files[i]]
)
amended = CommitHeader(GitTreeHash(""), [rebuilt_parent])
amended.author_name = commit_obj.author_name
amended.author_email = commit_obj.author_email
amended.author_date = commit_obj.author_date
amended.committer_name = commit_obj.committer_name
amended.committer_email = commit_obj.committer_email
# Refresh committer date, like git commit --amend.
amended.committer_date = ""
amended.commit_msg = commit_obj.commit_msg
new_commit = await git_ctx.merge_tree_commit(
new_commit,
GitCommitHash(overlay),
amended,
GitCommitHash(await git_ctx.empty_tree()),
"theirs",
)

return new_commit

Expand Down
39 changes: 39 additions & 0 deletions revup/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,45 @@ async def commit_tree(self, commit_info: CommitHeader) -> GitCommitHash:
ret = await self.git_stdout(*commit_tree_args, env=git_env)
return GitCommitHash(ret)

@lru_cache(maxsize=None)
async def empty_tree(self) -> GitTreeHash:
"""
Return the hash of the empty tree, computed so it works under any hash algorithm.
"""
return GitTreeHash(await self.git_stdout("mktree", input_str=""))

async def make_tree_from_index_entries(self, entries: List[str]) -> GitTreeHash:
"""
Build a tree from raw `ls-files --stage` formatted index entries
(`<mode> <blob> <stage>\\t<path>`), without touching any index.

`git mktree` only builds a single tree level and rejects paths with
slashes, so nested paths are grouped by directory and the trees are
built bottom-up, writing one subtree object per directory.
"""
# Nested dict: dir name -> subtree dict, plus "" -> list of blob lines
# for the files directly in that directory.
root: Dict[str, Any] = {}
for entry in entries:
meta, path = entry.split("\t", 1)
mode, blob, _stage = meta.split(" ")
*dirs, name = path.split("/")
node = root
for d in dirs:
node = node.setdefault(d, {})
node.setdefault("", []).append(f"{mode} blob {blob}\t{name}")

async def build(node: Dict[str, Any]) -> GitTreeHash:
lines = list(node.get("", []))
for name, child in node.items():
if name == "":
continue
subtree = await build(child)
lines.append(f"040000 tree {subtree}\t{name}")
return GitTreeHash(await self.git_stdout("mktree", input_str="\n".join(lines) + "\n"))

return await build(root)

@lru_cache(maxsize=None)
async def merge_tree(
self,
Expand Down
46 changes: 38 additions & 8 deletions tests/test_amend.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,19 +777,23 @@ async def test_amends_file_into_last_commit_that_touched_it(self):
async with GitTestEnvironment() as env:
await env.commit("root", {"root.txt": "r"})
await env.git_ctx.git("branch", "origin/main", "HEAD")
# a.txt is touched by both "first" and "second"; the amend must land
# in "second" (the last to touch it), not "first".
await env.commit("first\n\nTopic: alpha", {"a.txt": "v1"})
await env.commit("second\n\nTopic: beta", {"b.txt": "v1"})
await env.commit("second\n\nTopic: beta", {"a.txt": "v2", "b.txt": "v1"})
await env.commit("third\n\nTopic: gamma", {"c.txt": "v1"})

await env.stage_file("a.txt", "v2")
await env.stage_file("a.txt", "v3")
args = make_amend_args(last_touched=True, parse_topics=True)
await amend.main(args, env.git_ctx)

# a.txt should be amended into "first" (HEAD~1), not HEAD
content = await env.get_file_at_commit("a.txt", "HEAD~1")
assert content == "v2"
# a.txt should be amended into "second" (HEAD~1), not "first" (HEAD~2)
assert await env.get_file_at_commit("a.txt", "HEAD~2") == "v1"
assert await env.get_file_at_commit("a.txt", "HEAD~1") == "v3"
# ...and propagate forward to "third" (HEAD)
assert await env.get_file_at_commit("a.txt", "HEAD") == "v3"
# b.txt should be unchanged
content_b = await env.get_file_at_commit("b.txt", "HEAD")
assert content_b == "v1"
assert await env.get_file_at_commit("b.txt", "HEAD") == "v1"

@async_test
async def test_multiple_files_to_different_commits(self):
Expand All @@ -810,6 +814,26 @@ async def test_multiple_files_to_different_commits(self):
assert await env.get_file_at_commit("b.txt", "HEAD~1") == "b2"
assert await env.get_file_at_commit("c.txt", "HEAD") == "c2"

@async_test
async def test_amended_change_propagates_forward(self):
async with GitTestEnvironment() as env:
await env.commit("root", {"root.txt": "r"})
await env.git_ctx.git("branch", "origin/main", "HEAD")
# Nested path exercises the recursive mktree logic in
# make_tree_from_index_entries.
await env.commit("first\n\nTopic: alpha", {"sub/dir/a.txt": "v1"})
await env.commit("second\n\nTopic: beta", {"b.txt": "b1"})

# sub/dir/a.txt is amended into "first"; the new content must carry
# through to the rebuilt "second" as well, with no leftover staged change.
await env.stage_file("sub/dir/a.txt", "v2")
args = make_amend_args(last_touched=True, parse_topics=True)
await amend.main(args, env.git_ctx)

assert await env.get_file_at_commit("sub/dir/a.txt", "HEAD~1") == "v2"
assert await env.get_file_at_commit("sub/dir/a.txt", "HEAD") == "v2"
assert not await env.has_staged_changes()

@async_test
async def test_file_not_in_stack_remains_staged(self):
async with GitTestEnvironment() as env:
Expand Down Expand Up @@ -916,14 +940,20 @@ async def test_works_with_subdirectories(self):
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", {"src/lib/a.txt": "a1"})
# Two sibling files in the same subdirectory land in the same commit,
# exercising a mktree subtree built from multiple entries.
await env.commit(
"first\n\nTopic: alpha", {"src/lib/a.txt": "a1", "src/lib/c.txt": "c1"}
)
await env.commit("second\n\nTopic: beta", {"src/lib/b.txt": "b1"})

await env.stage_file("src/lib/a.txt", "a2")
await env.stage_file("src/lib/c.txt", "c2")
args = make_amend_args(last_touched=True, parse_topics=True)
await amend.main(args, env.git_ctx)

assert await env.get_file_at_commit("src/lib/a.txt", "HEAD~1") == "a2"
assert await env.get_file_at_commit("src/lib/c.txt", "HEAD~1") == "c2"
assert await env.get_file_at_commit("src/lib/b.txt", "HEAD") == "b1"

@async_test
Expand Down
Loading