go-git RepackObjects can remove the only copies of reachable objects when pack finalization fails
Bug Description
Affected component
- Project: [go-git](https://github.com/go-git/go-git)
- Upstream checkout: https://github.com/go-git/go-git.git
- Tested revision: 8372f313bbadedba17bb789ec702e6931466be68
- git describe: v6.0.0-alpha.5-111-g8372f313
- Commit date: 2026-09-09T11:00:41+02:00
- Commit subject: Merge pull request #2362 from go-git/feat/http-per-origin-credentials
- Go module: github.com/go-git/go-git/v6
- Affected range: not determined; the behavior is confirmed at this exact revision.
The issue is in Repository.RepackObjects, specifically the loose-object cleanup in createNewObjectPack.
Suggested triage: high-impact repository-integrity/data-loss issue. It should be handled as a security issue when an application runs repacking on untrusted repositories and an attacker can influence the conditions that make pack finalization fail. The reproduction below does not demonstrate a remote-only trigger for the finalization failure.
Summary
RepackObjects deletes reachable loose objects immediately after the pack encoder returns, but the PackfileWriter is closed only by a deferred call. Closing the writer is part of pack finalization and publication. If that close fails, RepackObjects returns an error after the original loose objects have already been deleted. When those loose objects were the only durable copies, the repository is left with a reference to an object that can no longer be read and no replacement pack.
This is a destructive failure-ordering bug: the operation reports failure, but the failed operation has already removed the source data it was supposed to preserve.
Technical details
At the tested revision, the operation proceeds in this order:
- createNewObjectPack obtains a pack writer and registers a deferred PackfileWriter.Close() call in [repository.go at lines 2160-2164](https://github.com/go-git/go-git/blob/8372f313bbadedba17bb789ec702e6931466be68/repository.go#L2160-L2164).
- The encoder writes the objects and returns a pack hash at [repository.go lines 2169-2172](https://github.com/go-git/go-git/blob/8372f313bbadedba17bb789ec702e6931466be68/repository.go#L2169-L2172).
- The function deletes every loose object reached by the object walk at [repository.go lines 2175-2189](https://github.com/go-git/go-git/blob/8372f313bbadedba17bb789ec702e6931466be68/repository.go#L2175-L2189).
- Only when the function returns does the deferred close run. The helper [CheckClose](https://github.com/go-git/go-git/blob/8372f313bbadedba17bb789ec702e6931466be68/utils/ioutil/common.go#L132-L138) propagates a close error through the named return value.
For the filesystem backend, the replacement pack is not made visible until the writer's save path completes. PackWriter.Close() calls save() only after all file descriptors have closed ([writers.go lines 105-137](https://github.com/go-git/go-git/blob/8372f313bbadedba17bb789ec702e6931466be68/storage/filesystem/dotgit/writers.go#L105-L137)); the final pack rename is in [writers.go lines 239-247](https://github.com/go-git/go-git/blob/8372f313bbadedba17bb789ec702e6931466be68/storage/filesystem/dotgit/writers.go#L239-L247). Therefore a close or publication failure is a real state in which the new pack is not available.
Reproduction result
The standalone reproducer creates a repository with one commit and no pack files. The commit, tree, and blob are present only as three loose objects. It then wraps the billy filesystem used by go-git's filesystem storage so that the actual temporary pack file accepts all bytes but returns injected pack close failure from Close(). This exercises the filesystem PackWriter rather than replacing the PackfileWriter interface.
The expected result is an error with the original three loose objects still present, or a successfully published replacement pack. The observed result is:
OBSERVED repack_error=injected pack close failure loose_before=3 loose_after=0 packs_before=[] packs_after=[]
OBSERVED failed_repack_head_lookup_error=object not foundThe final lookup uses a fresh storage/cache handle. This rules out a cached object masking the on-disk deletion. The test is repeated three times by the wrapper and passes each time because it asserts the current destructive behavior.
Impact
If the failed repack removes the only copies of reachable objects:
- refs such as HEAD can point to missing commit, tree, or blob objects;
- subsequent reads, history operations, and repository maintenance can fail;
- locally stored history or content can be permanently lost if no remote, backup, reflog copy, or other object pack is available.
The demonstrated impact is repository integrity and availability, with direct data loss. The issue does not by itself provide code execution, privilege escalation, or disclosure of repository contents. If another durable copy of an object exists in a pack, the same cleanup may not make that particular object unreadable; the proof uses the stronger only-copy case.
Attack and abuse scenarios
The repository contents alone do not cause the injected Close() failure, so this should not be described as an unconditional remote exploit. Relevant deployment scenarios include:
- A service imports repositories from untrusted users and automatically calls RepackObjects during ingestion, quota enforcement, or maintenance. A repository that contributes to resource pressure, combined with a disk, descriptor, filesystem, or rename failure, can turn the failed maintenance operation into repository loss.
- A local tool or server-side maintenance worker repacks while the underlying storage is full, becomes read-only, loses access to the object directory, or otherwise fails during writer close/publication. The same ordering loses the loose source objects before the error is returned.
- A process or host interruption in the interval between loose-object cleanup and pack publication is a plausible equivalent failure mode, but it is not directly injected by this reproducer and should be verified separately by the maintainer.
The security severity therefore depends on whether an attacker can submit a repository and influence a repack invocation or its failure conditions. In other deployments this remains a serious reliability and data-loss bug.
Reproduction instructions
The package contains a self-contained Go test source in [reproduce_test.go](reproduce_test.go). Run the wrapper from the repository root:
findings/gc/go-git-repack-loose-delete-before-publish/reproduce.sh \
findings/gc/go-git-repack-loose-delete-before-publish/evidence/20260909By default the wrapper uses ./go-git. A different checkout can be selected with GO_GIT_DIR=/path/to/go-git. The wrapper temporarily copies the test into the module root, runs it three times, records the environment and commit metadata, and removes the temporary source file on exit. It does not modify go-git's production sources.
The complete captured output is in [evidence/20260909/run.log](evidence/20260909/run.log), with the checks and environment details summarized in [evidence.md](evidence.md).
go-git Version
v6.0.0-alpha.5-111-g8372f313
Steps to Reproduce
The package contains a self-contained Go test source in [reproduce_test.go](reproduce_test.go). Run the wrapper from the repository root:
findings/gc/go-git-repack-loose-delete-before-publish/reproduce.sh \
findings/gc/go-git-repack-loose-delete-before-publish/evidence/20260909By default the wrapper uses ./go-git. A different checkout can be selected with GO_GIT_DIR=/path/to/go-git. The wrapper temporarily copies the test into the module root, runs it three times, records the environment and commit metadata, and removes the temporary source file on exit. It does not modify go-git's production sources.
The complete captured output is in [evidence/20260909/run.log](evidence/20260909/run.log), with the checks and environment details summarized in [evidence.md](evidence.md).
Additional Information
Attachments:
Source: go-git/go-git