fix(repack): --drop-filtered --dry-run writes packs and deletes old packs with -d
## Summary
On Git 2.56.0, `git repack -a --filter=blob:limit=1m --drop-filtered --dry-run` prints the candidate blob but also writes a new promisor pack and its sidecar files. Adding explicit `-d` also removes the old redundant packs and their sidecar files. Both commands exit successfully despite the documented promise to list candidates without rebuilding packs or deleting anything.
This makes the advertised preview mutate repository storage. The tested blob remains available after both commands; this report does not demonstrate loss of object content.
## Steps to reproduce
The following Python 3 script was executed with Git 2.56.0 on macOS. It requires `git` on `PATH`, creates only disposable local repositories, disables ambient Git configuration and hooks, and uses a synthetic commit identity. It creates a 2,200,000-byte historical blob absent from the current index, makes a filtered clone through a local promisor remote, fetches that blob into a promisor pack, and snapshots the pack directory before each preview. Each variant uses its own fresh partial clone. The script removes its fixture after a successful run.
```python import hashlib import os from pathlib import Path import subprocess import shutil import tempfile
work = Path(tempfile.mkdtemp(prefix='agent-work.', dir='/tmp'))
env = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')}
env.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=os.devnull,
LC_ALL='C', GIT_TERMINAL_PROMPT='0')def git(repo, *args):
command = ['git', '-c', 'gc.auto=0', '-c', 'core.hooksPath=' + os.devnull,
'-c', 'user.name=Fixture', '-c',
'user.email=fixture@example.invalid',
'-C', str(repo), *args]
return subprocess.run(command, env=env, check=True, text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE).stdout.strip()def inventory(repo):
packdir = repo / '.git' / 'objects' / 'pack'
return {p.name: hashlib.sha256(p.read_bytes()).hexdigest()
for p in packdir.iterdir() if p.is_file()}print(git(work, '--version')) source = work / 'source' source.mkdir() git(source, 'init', '--template=', '-b', 'main') (source / 'large.bin').write_bytes(b'LARGE BLOB\n' * 200000) git(source, 'add', 'large.bin') git(source, 'commit', '-m', 'Add historical blob') blob = git(source, 'rev-parse', 'HEAD:large.bin') git(source, 'rm', 'large.bin') (source / 'tip.txt').write_text('tip\n') git(source, 'add', 'tip.txt') git(source, 'commit', '-m', 'Remove historical blob') remote = work / 'remote.git' git(work, 'clone', '--bare', str(source), str(remote)) git(remote, 'config', 'uploadpack.allowFilter', 'true')
for label, extra in [('dry-run', []), ('dry-run -d', ['-d'])]:
repo = work / ('clone-delete' if extra else 'clone-preview')
git(work, '-c', 'protocol.file.allow=always', 'clone', '--filter=blob:none',
remote.as_uri(), str(repo))
assert git(repo, 'cat-file', '-s', blob) == '2200000'
before = inventory(repo)
output = git(repo, 'repack', '-a', *extra, '--filter=blob:limit=1m',
'--drop-filtered', '--dry-run')
after = inventory(repo)
added = sorted(after.keys() - before.keys())
removed = sorted(before.keys() - after.keys())
changed = sorted(k for k in before.keys() & after.keys() if
before[k] != after[k])
print(label + ': exit 0')
print('candidate=' + output)
print('pack directory: before=%d after=%d added=%d removed=%d changed=%d' %
(len(before), len(after), len(added), len(removed), len(changed)))
print('added extensions=' + ','.join(sorted(Path(k).suffix for k in added)))
print('removed extensions=' + ','.join(sorted(Path(k).suffix for k
in removed)))
print('candidate size after=' + git(repo, 'cat-file', '-s', blob))
shutil.rmtree(work)
```## Expected behavior
The Git 2.56.0 `git-repack` manual says `--dry-run` should "List the objects that would be dropped, one object ID per line, without rebuilding any pack or deleting anything."
For both commands, Git should print the candidate blob and leave the existing pack files and their contents unchanged. Explicit `-d` should not cause deletion during this dry run. The fixture satisfies the documented prerequisites: `-a`, a supported `blob:limit` filter, a configured promisor remote, no operation in progress, and a candidate absent from the current index.
## Actual behavior
The executed script produced this output. The counts include regular files in `objects/pack`, and `changed` compares SHA-256 content hashes for names present before and after.
```text git version 2.56.0 dry-run: exit 0 candidate=faed553c77de798540bd1edc898e174bf4436b28 pack directory: before=12 after=16 added=4 removed=0 changed=0 added extensions=.idx,.pack,.promisor,.rev removed extensions= candidate size after=2200000 dry-run -d: exit 0 candidate=faed553c77de798540bd1edc898e174bf4436b28 pack directory: before=12 after=4 added=4 removed=12 changed=0 added extensions=.idx,.pack,.promisor,.rev removed extensions=.idx,.idx,.idx,.pack,.pack,.pack,.promisor,.promisor,.promisor,.rev,.rev,.rev candidate size after=2200000 ```
The ordinary preview added four files, one each with `.pack`, `.idx`, `.promisor`, and `.rev` extensions. The preview with explicit `-d` added the same four types and deleted the 12 previous pack and sidecar files. Both invocations printed the expected candidate object ID and exited 0. A subsequent `cat-file -s` still reported the blob's original size in both clones.
This report records one successful invocation of each variant in the final script execution. A preceding fixture execution reproduced the same counts. These observations establish the Git 2.56.0 behavior; current development Git was not built or executed for this report.
## Evidence
- Expected source: Git 2.56.0 [Documentation/git-repack.adoc, the `--dry-run` option](https://github.com/git/git/blob/v2.56.0/Documentation/git-repack.adoc#L217-L220), which promises no pack rebuilding or deletion. - Failure source: The inline script and its captured output in Actual behavior. The before/after pack inventories show four added files for both variants and 12 deleted files when explicit `-d` is present. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete
The primary violation evidence is execution of the inline fixture with Git 2.56.0. Both commands exited 0 with the pack-directory changes shown above. The manual and source links provide the contract and supporting static evidence.
The release source at [builtin/repack.c, lines 356-394](https://github.com/git/git/blob/v2.56.0/builtin/repack.c#L356-L394) guards the implicit `delete_redundant` setting with `!dry_run`, then prints candidates when `dry_run` is set. Execution continues beyond that block. This is a source breadcrumb consistent with the observed results, rather than a required implementation change.
The original implementation discussion also describes dry run as leaving the repository unchanged: [v5 1/6](https://lore.kernel.org/git/20260813200830.84348-2-r.siddharth.shrimali@gmail.com/) and [v5 5/6](https://lore.kernel.org/git/20260813200830.84348-6-r.siddharth.shrimali@gmail.com/).
## Restoration check
Run the same fixture after a fix. Both preview variants should still print the candidate object ID and exit successfully, with `added=0 removed=0 changed=0` and equal before/after pack-directory counts. Retaining the candidate output distinguishes a working preview from simply skipping the operation. The cached blob should remain available. Use the ordinary command and the explicit `-d` variant as separate checks of the same no-write contract.