From 576d6f643071127a54b61f78ee2fadee5f7814db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Villarreal=20Ortega?= Date: Sat, 7 Nov 2026 20:15:16 +0100 Subject: [PATCH] fix(ssh-restore): reset the local index to HEAD after importing remote git history (#16644) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents can run on a remote SSH environment. After the run, Paperclip restores the workspace and imports the remote git history. > - The import moves the checked-out branch with `git update-ref`. `update-ref` does not change the index. > - The index then still matches the old tip. `git status` in the workspace shows the new files of the run as staged deletions. > - An operator who commits in that workspace can remove the work of the agent by mistake. > - The sandbox restore already resets the index after the import (#8595). The SSH restore does not. > - This pull request calls the same `resetLocalGitIndexToHead` after the SSH restore. > - The benefit is a clean `git status` after each SSH run, the same as after a sandbox run. ## Linked Issues or Issue Description Refs #8595 (the same fix for the sandbox restore). Refs #15662, Refs #16440 (other open changes to the same SSH restore code). **What happened?** After a remote SSH run imported a commit, `git status` in the local workspace showed every new file of that commit as a staged deletion, and every changed file as a staged modification. The files on disk were correct. **Expected behavior** `git status` is clean after the import, because the working tree and HEAD are the commit of the run. **Steps to reproduce** 1. Use an opencode_local agent on an SSH environment with a git_worktree project workspace. 2. Let the agent add a file and commit on the remote copy. 3. After the run, run `git status` in the local worktree. The new file shows as `D` in the index. **Paperclip version or commit** paperclipai 2026.916.1 (also present on master 272316808) ## What Changed - `packages/adapter-utils/src/ssh.ts`: the SSH restore merge gets an `afterApply` that calls `resetLocalGitIndexToHead`, when remote git history was imported. The sandbox restore does the same since #8595. - `packages/adapter-utils/src/ssh-fixture.test.ts`: "round-trips remote git commits through the managed runtime restore path" now also checks that nothing is staged after the restore. ## Verification - I ran the SSH fixture tests in a container with `sshd` (they skip on a host without it): `ssh-fixture.test.ts` and `git-workspace-sync.test.ts`, 87 tests pass. - Without the change, the managed runtime restore test leaves `M tracked.txt` staged (`git diff --cached`) and `git status` shows `MM tracked.txt`. With the change, nothing is staged and `git status` shows ` M tracked.txt`, which is the uncommitted remote edit. - `npx tsc --noEmit -p packages/adapter-utils` passes. - I found the bug on a real SSH environment (opencode_local in a runner container, paperclipai 2026.916.1). ## Risks Low risk. It reuses the index reset that the sandbox restore already uses. It runs only when remote git history was imported. Work staged on the host before the run is reset to HEAD, the same as in the sandbox path. ## Model Used Claude Opus 5.5 (claude-opus-5-5), Anthropic, used through Claude Code with tool use (shell, file edits). Human-reviewed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --- packages/adapter-utils/src/ssh-fixture.test.ts | 2 ++ packages/adapter-utils/src/ssh.ts | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/adapter-utils/src/ssh-fixture.test.ts b/packages/adapter-utils/src/ssh-fixture.test.ts index 39d3c52515e..76168e31346 100644 --- a/packages/adapter-utils/src/ssh-fixture.test.ts +++ b/packages/adapter-utils/src/ssh-fixture.test.ts @@ -1153,6 +1153,8 @@ describe("ssh env-lab fixture", () => { expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote update"); await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n"); + // The index follows the imported HEAD: only the remote's uncommitted edit shows, unstaged. + expect(await git(localRepo, ["diff", "--cached", "--name-status"])).toBe(""); }, SSH_FIXTURE_TEST_TIMEOUT_MS); it("propagates remote commits to the local worktree with no git remote configured (no-remote-git contract)", async () => { diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 37472e8293d..798d73ef6ee 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -13,6 +13,7 @@ import { PROJECT_REPOSITORIES_DIR, PROJECT_REPOSITORY_STAGING_PATTERN, readSanitizedOriginRemoteUrl, + resetLocalGitIndexToHead, } from "./git-workspace-sync.js"; import type { RunProcessResult } from "./server-utils.js"; import type { DirectorySnapshot } from "./workspace-restore-merge.js"; @@ -1800,6 +1801,13 @@ async function restoreWorkspaceRootFromSsh(input: { }); } : undefined, + // integrateImportedGitHead moves the branch with update-ref, which leaves the index + // on the old tip. Reset it to HEAD as the sandbox restore does (#8595). + afterApply: importedHead + ? async () => { + await resetLocalGitIndexToHead({ localDir: input.localDir }); + } + : undefined, }); } finally { if (importedRef) {