mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Each issue's agent works in a git worktree that Paperclip keeps on
the instance's disk
> - Git cache expiration removes a closed issue's worktree only when the
remote can reproduce it exactly: every commit pushed, nothing
uncommitted
> - Most closed worktrees fail that test, so they are kept forever and
disks fill up
> - A restore point that saves only the part the remote cannot reproduce
would let those worktrees be removed without losing work
> - This pull request adds the library to capture, verify, encrypt and
restore such restore points, with no callers yet
> - The benefit is a small, reviewable foundation for removing unpushed
and uncommitted worktrees safely in follow-up changes
## Linked Issues or Issue Description
Bug report described in-PR (bug report template).
**What happened?**
Closed issues' worktrees accumulate without bound. With
`PAPERCLIP_GIT_CACHE_EXPIRATION_ENABLED=true` and dry run off, one
instance's last 111 expiration checks expired nothing. The skip reasons
were:
| Skip reason | Checks |
|---|---|
| `dirty_or_untracked_files` | 39 |
| `unpushed_managed_checkout` | 32 |
| `live_or_unresolved_consumer` | 21 |
| `unique_ignored_files` | 9 |
| commit not on the remote | 10 |
Without expiration, the terminal workspace reaper only archives merged,
clean worktrees. One local instance archived 37 workspaces across about
6,200 sweeps.
**Expected behavior**
A closed worktree can be removed once anything the remote cannot
reproduce is saved, and restored exactly if the work resumes.
**Steps to reproduce**
1. Close an issue whose worktree has an unpushed commit or an untracked
file.
2. Enable git cache expiration with dry run off.
3. The worktree is never removed; expiration reports
`dirty_or_untracked_files` or `upstream_commit_unverified`.
**Paperclip version or commit:** `master` at def4726bea.
**Deployment mode:** any; observed on a server instance and a local
instance.
**Agent adapter(s) involved:** not adapter-specific (core bug).
## What Changed
New `server/src/services/git-restore-point.ts`, a library that nothing
calls yet:
- **`captureRestorePoint`**
- Snapshots the index and the working tree as deterministic commits in a
temporary object store. The store borrows the repository's objects
through alternates and writes none of its own into the repository.
- Bundles those snapshot commits, unpushed commits, and commits
reachable only from the reflog or `ORIG_HEAD`, against the remote
commits as prerequisites.
- Returns `null` when the remote can already reproduce everything.
Refuses to capture when more than 50 history-only commits exist, rather
than dropping any.
- **Leaves out:** ignored files (such as `node_modules`), and untracked
`.env` and `.env.*` files. Provisioning recreates environment files with
current secrets. Edits to tracked environment files, such as
`.env.example`, are captured.
- **Skips** nested untracked repositories, paths with a filter
attribute, gitlinks and changes over size limits rather than capturing
them partially. Special files are ignored, because git never tracks
them.
- **`currentRestorePointSnapshot`** recomputes the snapshot ids, so a
caller can prove nothing changed between capture and deletion.
- **`importRestorePoint`** verifies the bundle's prerequisites and refs
against the manifest, then imports the objects without leaving refs
behind.
- **`applyRestorePoint`**
- Requires `HEAD` to match.
- Restores the working tree (`git apply`), the index (`read-tree`) and
non-default file modes.
- Then recomputes the snapshot and requires it to equal the captured
one.
- **`sealRestorePoint` / `openRestorePoint`**
- Encrypt with a fresh AES-256-GCM key per restore point, with the
restore point's identity as additional authenticated data.
- The data key is wrapped by the instance secrets provider
(`localEncryptedProvider`), the same pattern already used for saved
Claude settings.
- Opening checks the ciphertext hash, the key's bound identity, the GCM
tag, and the plaintext hash and size.
- **Isolation:** every git command runs with system and global config
disabled, hooks disabled, and line-ending conversion off.
- New `server/src/__tests__/git-restore-point.test.ts`.
- No code comments were added.
## Verification
- `pnpm exec vitest run src/__tests__/git-restore-point.test.ts` (in
`server/`): 18 passed, using real repositories and a bare remote. The
cases:
- **Full round trip** into a fresh clone that lacks the unpushed
commits, covering:
- two unpushed commits;
- staged and unstaged edits of the same file;
- a deletion, an untracked file, a symlink, `0755` and `0600` modes;
- `node_modules` and `.env` files left out.
- **No-op:** capture returns `null` for a reproducible workspace, and
creates no objects in the repository (`fsck --unreachable` stays empty).
- **Snapshot ids** are deterministic and change when contents change.
- **History-only commits** are preserved and leave no refs after import.
- **Skips:** nested repositories and size limits. Special files are
ignored.
- **Rejections:** missing prerequisites and `HEAD` mismatches.
- **Encryption:** tampering, truncation, a wrong identity and a
different master key are all rejected.
- **Review regressions:** edits to a tracked `.env.example` are
captured; staged files whose contents match the index get limits and
modes; permission-only changes change the snapshot; a configured `diff`
textconv does not alter restored bytes; more than 50 history-only
commits refuses capture.
- **Mutation checks:** removing the `.env` exclusion or the index
restore fails the round trip, and removing each review fix fails its
regression test.
- **Stability:** 12 consecutive runs passed. An earlier version copied
the index for the working-tree snapshot and was racy when a file changed
within the same second. It now starts from `HEAD`'s tree instead.
- `pnpm run typecheck` (in `server/`): pass.
## Risks
- **No behavior change:** nothing calls the library yet.
- **Follow-ups:** wiring it into expiration (storage, metadata
versioning, restore on reopen and on the next run, and retention) comes
in separate PRs behind configuration that defaults to off.
- **Key dependence:** a restore point is only as recoverable as the
instance's secrets master key. The follow-up wiring will refuse to
create restore points when that key is not durable.
- **Rollback:** revert.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, tests, read-only measurements on live
instances).
## 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
- [x] 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
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>