|
6 | 6 | from uuid import uuid4 |
7 | 7 |
|
8 | 8 | from app.models.schemas.sandbox import ( |
| 9 | + ChangedFile, |
9 | 10 | GitBranchesResponse, |
10 | 11 | GitCheckoutResponse, |
11 | 12 | GitCommandResponse, |
@@ -75,6 +76,37 @@ class Checkpoint(NamedTuple): |
75 | 76 | "git worktree add '$worktree_dir' -b '$branch_name' 2>&1" |
76 | 77 | ) |
77 | 78 |
|
| 79 | +# Build two trees and diff them so the result reflects only the assistant's |
| 80 | +# turn. The base tree is base_head + pre_run_diff applied via a temp index |
| 81 | +# (otherwise pre-existing dirty changes captured by the checkpoint would be |
| 82 | +# attributed to the assistant). The current tree is the working tree captured |
| 83 | +# by copying the real index then `git add -A` into a temp index — this folds |
| 84 | +# untracked files into the comparison, so pre-existing untracked files stay |
| 85 | +# silent and assistant-created files surface as additions. |
| 86 | +# `--no-renames` collapses renames to add+delete so the parser stays simple. |
| 87 | +GIT_CHANGED_FILES_TEMPLATE = Template( |
| 88 | + "{ base_tree='$base'; " |
| 89 | + 'if [ -n "$patch_file" ]; then ' |
| 90 | + "tmp_b=$$(mktemp); " |
| 91 | + "if GIT_INDEX_FILE=\"$$tmp_b\" git read-tree '$base' 2>/dev/null " |
| 92 | + '&& GIT_INDEX_FILE="$$tmp_b" git apply --cached --whitespace=nowarn ' |
| 93 | + "$patch_file 2>/dev/null; then " |
| 94 | + 'base_tree=$$(GIT_INDEX_FILE="$$tmp_b" git write-tree); ' |
| 95 | + "fi; " |
| 96 | + 'rm -f "$$tmp_b" $patch_file; ' |
| 97 | + "fi; " |
| 98 | + "tmp_c=$$(mktemp); " |
| 99 | + 'cp "$$(git rev-parse --git-path index)" "$$tmp_c" 2>/dev/null ' |
| 100 | + '|| GIT_INDEX_FILE="$$tmp_c" git read-tree HEAD 2>/dev/null; ' |
| 101 | + 'GIT_INDEX_FILE="$$tmp_c" git add -A 2>/dev/null; ' |
| 102 | + 'cur_tree=$$(GIT_INDEX_FILE="$$tmp_c" git write-tree 2>/dev/null); ' |
| 103 | + 'rm -f "$$tmp_c"; ' |
| 104 | + 'git diff --numstat --no-renames "$$base_tree" "$$cur_tree" 2>/dev/null; ' |
| 105 | + "printf '__STATUS__\\n'; " |
| 106 | + 'git diff --name-status --no-renames "$$base_tree" "$$cur_tree" 2>/dev/null; ' |
| 107 | + "}" |
| 108 | +) |
| 109 | + |
78 | 110 | GIT_DIFF_STAGED_TEMPLATE = Template("git diff$ctx --cached 2>/dev/null") |
79 | 111 | GIT_DIFF_UNSTAGED_TEMPLATE = Template("git diff$ctx 2>/dev/null;$untracked") |
80 | 112 | # "all" mode: try `git diff HEAD` first (combined staged+unstaged in one pass); |
@@ -428,6 +460,78 @@ async def restore_checkpoint_all( |
428 | 460 | ) |
429 | 461 | return await self.run_command(sandbox_id, cmd, cwd) |
430 | 462 |
|
| 463 | + async def get_changed_files( |
| 464 | + self, |
| 465 | + sandbox_id: str, |
| 466 | + base_head: str, |
| 467 | + pre_run_diff: str = "", |
| 468 | + cwd: str | None = None, |
| 469 | + ) -> list[ChangedFile]: |
| 470 | + if not re.fullmatch(r"[0-9a-fA-F]{40}", base_head): |
| 471 | + raise ValueError("Invalid checkpoint commit") |
| 472 | + patch_file = "" |
| 473 | + if pre_run_diff: |
| 474 | + name = f".agentrove-changed-{uuid4().hex}.patch" |
| 475 | + patch_path = posixpath.join(cwd, name) if cwd else name |
| 476 | + await self.sandbox_service.provider.write_file( |
| 477 | + sandbox_id, patch_path, pre_run_diff |
| 478 | + ) |
| 479 | + patch_file = shlex.quote( |
| 480 | + self.sandbox_service.provider.resolve_workspace_path(patch_path) |
| 481 | + ) |
| 482 | + cd_prefix = git_cd_prefix(cwd) |
| 483 | + cmd = GIT_CHANGED_FILES_TEMPLATE.substitute( |
| 484 | + base=base_head, patch_file=patch_file |
| 485 | + ) |
| 486 | + result = await self.sandbox_service.execute_command( |
| 487 | + sandbox_id, f"{cd_prefix}{cmd}" |
| 488 | + ) |
| 489 | + if result.exit_code != 0: |
| 490 | + return [] |
| 491 | + return self._parse_changed_files(result.stdout) |
| 492 | + |
| 493 | + @staticmethod |
| 494 | + def _parse_changed_files(output: str) -> list[ChangedFile]: |
| 495 | + numstat_section, _, status_section = output.partition("__STATUS__\n") |
| 496 | + |
| 497 | + # Binary files render as `-\t-\t<path>` in numstat — keep the path with |
| 498 | + # zeroed counts so the panel still lists the file. |
| 499 | + stats: dict[str, tuple[int, int]] = {} |
| 500 | + for line in numstat_section.splitlines(): |
| 501 | + parts = line.split("\t") |
| 502 | + if len(parts) < 3: |
| 503 | + continue |
| 504 | + add_str, del_str, path = parts[0], parts[1], parts[2] |
| 505 | + additions = int(add_str) if add_str.isdigit() else 0 |
| 506 | + deletions = int(del_str) if del_str.isdigit() else 0 |
| 507 | + stats[path] = (additions, deletions) |
| 508 | + |
| 509 | + statuses: dict[str, Literal["M", "A", "D"]] = {} |
| 510 | + for line in status_section.splitlines(): |
| 511 | + parts = line.split("\t") |
| 512 | + if len(parts) < 2: |
| 513 | + continue |
| 514 | + code, path = parts[0], parts[1] |
| 515 | + letter = code[:1] |
| 516 | + if letter == "M": |
| 517 | + statuses[path] = "M" |
| 518 | + elif letter == "A": |
| 519 | + statuses[path] = "A" |
| 520 | + elif letter == "D": |
| 521 | + statuses[path] = "D" |
| 522 | + |
| 523 | + files = [ |
| 524 | + ChangedFile( |
| 525 | + path=path, |
| 526 | + status=statuses.get(path, "M"), |
| 527 | + additions=additions, |
| 528 | + deletions=deletions, |
| 529 | + ) |
| 530 | + for path, (additions, deletions) in stats.items() |
| 531 | + ] |
| 532 | + files.sort(key=lambda f: f.path) |
| 533 | + return files |
| 534 | + |
431 | 535 | async def create_branch( |
432 | 536 | self, |
433 | 537 | sandbox_id: str, |
|
0 commit comments