Skip to content

Add prune_local.py to drop old local history - #6

Merged
PEEKPerformer merged 6 commits into
mainfrom
feat/prune-local-history
Sep 17, 2026
Merged

PEEKPerformer merged 6 commits into
mainfrom
feat/prune-local-history

Conversation

@PEEKPerformer

Copy link
Copy Markdown
Owner

Stacked on #5. This PR's diff is only its own commit.

What this PR does

Adds scripts/prune_local.py, which makes the local archive clone shallow. It keeps commits from the last --keep-days days (default 7) and deletes older objects from disk. The remote keeps the full history. Without --yes it only prints the plan, and it is never run automatically.

Why

Every local clone keeps every superseded transcript version, and encrypted zstd blobs get no deltas. On the maintainer's machine .git was 13 GiB, next to 5.2 GiB of working tree and a 2.6 GiB index. The history is already on the remote. The maintainer shrank the clone by hand on 2026-09-02; this makes that safe and repeatable.

Deletion only happens if every check passes:

  • an origin remote exists with the current branch on it;
  • a fresh fetch shows every local commit on origin;
  • no other local branch or stash holds unpushed commits;
  • no ingest is running (it holds the ingest lock for the whole run).

The steps are fetch --shallow-since (falling back to --depth=1 when the window holds no commits), reflog expire, repack -a -d with pack.window=0, prune --expire=now, and fsck --connectivity-only. pack.window=0 skips a delta search that can't find anything in encrypted, compressed blobs. Every git step has a one-hour timeout.

Measured on an APFS clone of the real archive (850 local commits over 15 days, 13.2 GiB of objects):

--keep-days Objects after Time Peak RSS
14 12.5 GiB 209 s 1.9 GiB
2 (from the 14-day result) 8.1 GiB 56 s 2.6 GiB

With 14 days the saving was small because almost all local history was younger than that. The default is 7.

Type of change

  • Bug fix (non-breaking change that fixes a documented or observed defect)
  • New feature (non-breaking change that adds capability)
  • Breaking change (alters existing behavior; requires major-version bump and CHANGELOG entry)
  • Documentation only
  • Performance improvement (no functional change)
  • Internal refactor (no user-visible change)
  • CI / tooling

Non-negotiables check

  • Forensic completeness (no filtering of transcript content)
  • Provenance via hash chain (SHA-256 per session, verifiable with stock Unix tools)
  • Git is the archive substrate
  • Environment capture (host CLI version, OS, holotype version, project-repo git state)
  • Append-only by design
  • No silent network behavior

The remote is never rewritten. Local history is dropped only when the remote already holds it. The fetch is explicit: the user runs the script.

Selftest

  • python scripts/selftest.py passes locally
  • If this PR adds a feature or fixes a bug, a new selftest assertion exercises the change
  • CI is green (GitHub Actions workflow on this PR)

New step, using the existing bare-repo remote:

  • prune refuses while a commit is unpushed and leaves the clone unchanged;
  • without --yes it only plans;
  • with --yes it removes a blob reachable only from 30- and 40-day-old commits;
  • the working tree is unchanged, the remote still has the old blob, and the shallow clone commits and pushes afterwards.

Manifest / archive-format impact

  • No archive-format change
  • manifest_version bumped from N to N+1
  • index_schema_version bumped from N to N+1
  • On-disk archive layout changed (describe migration path)

Documentation

  • CHANGELOG.md updated (2.5.0)
  • README.md updated if user-facing behavior changed
  • SKILL.md updated (operations table)
  • docs/ updated as appropriate

Cross-platform impact

  • Verified on macOS
  • Verified on Linux (CI)
  • Verified on Windows (not applicable: uses fcntl, like ingest.py)
  • CI passes on all configured runners

AI assistance

Design agreed with the maintainer. Implementation, measurements, and this draft by Claude Code. Selftest run locally.

Anything else reviewers should know

A local-only archive (no remote) is refused, because its history is the only copy. The working tree and the search index are out of scope: cite, verify, and search depend on them.

git runs `git maintenance run --auto --detach` after every commit.
Once the loose-object count passes gc.auto, each commit starts its own
detached full repack, and nothing stops a second one from starting
while the first is still running. An archive's pack is mostly zstd or
git-crypt blobs, so each repack holds gigabytes. One ingest tick makes
one commit per changed session, which stacked repacks until a 16 GB
Mac with a 7.3 GiB archive ran out of memory and had to be
force-restarted.

Set maintenance.auto=false and gc.auto=0 in the archive's local git
config. init.py sets them after `git init`. ingest.py sets them under
the ingest lock before the first commit, which migrates existing
archives on their next tick. Loose objects now wait for a manual
`git repack -d`.

The selftest checks both the init path and the migration path.
On a real archive 1,832 compressed deposits failed the sweep fsck,
yet every one decompressed to its canonical sha256. The v2.3.0 pure
migration copied sha256_compressed from the prior manifest instead of
hashing the file, so stale values survived, and the fsck compares only
the compressed hash. About 40 false failures per sweep slice buried
any real one.

Hash the file during a pure migration. In the fsck, fall back to the
canonical hash when the compressed one disagrees and count a match as
stale, not failed. Add scripts/repair_compressed_hashes.py to rewrite
the stale values in one commit, touching only that field and only
after the canonical hash matches.
The preferred Claude Code source on the maintainer's machine is a
backup mirror in ~/Documents, which iCloud syncs. iCloud had evicted
3,556 of its 11,311 transcripts to the cloud. The launchd tick may not
download an evicted file, so every read failed with EDEADLK and each
sweep logged dozens of "Resource deadlock avoided" worker failures.

Dedup now prefers a later path whose copy is on disk over an evicted
first path. A deposit whose source is still evicted returns
skipped-offline and leaves the prior deposit in place.
git push ran with no timeout. A push that stalls rather than fails
holds the ingest lock, so every later tick exits early and nothing is
deposited until someone kills it by hand.

Run pushes in their own process group with a one-hour limit and kill
the whole group on timeout, so ssh and pack-objects exit too. A timed
out push is reported like any other push failure.
A session that is still being written changes on almost every tick,
and each update stores the whole transcript again: an encrypted, zstd
blob gets no delta against its last version. On a real archive one
26 MB session was stored 129 times in two weeks, and history grew by
4.7 GB in 15 days.

Defer a content update to an existing deposit until its source has
been quiet for deposit.settle_hours (default 6), capped at 24 h after
the last deposit. New sessions and pure migrations never wait.
--settle-hours overrides the config; 0 deposits every change now.

Replaying the archive's last 15 days, a 6 h window cuts history growth
from 4.7 GB to about 0.9 GB.
The remote holds the archive's full history, but every local clone
keeps every superseded transcript version too, and encrypted zstd
blobs get no deltas. On the maintainer's machine .git was 13 GiB.

prune_local.py makes the local clone shallow, keeping the last
--keep-days days (default 7), then expires reflogs, repacks without
a delta search, and prunes. It deletes nothing unless a fresh fetch
shows every local commit on origin, no other branch or stash holds
unpushed work, and no ingest is running. Without --yes it only prints
the plan. It is never run automatically.

On an APFS clone of the real archive, keeping 2 days took 13.2 GiB
of objects down to 8.1 GiB in 56 s, peaking at 2.6 GiB resident.
@PEEKPerformer
PEEKPerformer changed the base branch from feat/settle-window to main September 17, 2026 12:42
@PEEKPerformer PEEKPerformer reopened this Sep 17, 2026
@PEEKPerformer
PEEKPerformer merged commit c253b7f into main Sep 17, 2026
4 checks passed
@PEEKPerformer
PEEKPerformer deleted the feat/prune-local-history branch September 17, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant