Merge #b6704561: feat(storage): retire migration backups family by fami…

feat(storage): retire migration backups family by family

nostr:nevent1qgsx2lyl2e4zvfadwcvkd9fkrcwczj7mf858hy85mwqclwgut8wpg2spz3mhxue69uhhyetvv9ujumn8d96zuer9wcq3yamnwvaz7tm8d96xummnw3ezucm0d5q3kamnwvaz7tmwva5hgtnyv9hxxmmwwashjer9wchxxmmdqqstvuz9v8ewz6hfkpwssajjl9sjsxk50y39tlq30x75f9f8qy6wx8g94xt55

PR-Author: DanConwayDev's Agent
nostr:npub1v47f74n2ycn66asev62nv8sas99akj0g0wg0fkup37u3ckwuzs4q7cwtp0

CoverNote:

Version 3 automatically migrates complete per-owner and `/prs/` repositories into thin views backed by one local object family per repository identifier. This PR retires each legacy family backup as soon as that family is proven healthy, so migration overhead is bounded by the family in flight instead of retaining a second full copy of the Git data indefinitely.

## Retirement safety

- Every view must be correctly wired to its family and match the active migration journal snapshot.
- The family must contain every Git-readable backup object, all packs must be indexed and pass `git verify-pack`, and the normal steady-state family integrity inspection must be healthy. This last check verifies readable/hash-correct objects, refs, alternates, packs, and shallow state before retirement.
- A durable `retired` journal state is fsynced before deletion. Backup-directory removals and parent pruning are fsynced before the journal is removed, so restart recovery cannot lose track of a resurrected directory entry.
- Unindexed packs are quarantined by SHA-256 content beneath `.grasp/migration/unindexed-packs/`. Identical retries converge; different payloads cannot overwrite one another even when their original filenames match.
- Any active-migration gate error fails closed. A family that is structurally migrated but unhealthy keeps its backup, starts the service, logs an `ERROR`, and enters the ordinary asynchronous integrity-repair path.

## Shallow compatibility and repair

A server-side depth-one fallback existed only in untagged development revisions from 2026-01-05 (`623cae5`) through its 2026-01-12 fix (`f25eea8`); no tagged v1 or v2 release shipped it. It ran only when pending state or PR objects had not arrived through the normal push path and the server fetched them from another listed Git server.

V3 preserves any resulting `shallow` marker on the thin view, so shallow clones and the current tree remain as available as before migration, and keeps the legacy backup. The normal integrity worker automatically requests the full closure from accepted clone URLs. Successful repair removes the marker immediately and the next launch retires the backup; failed repair keeps both marker and backup and logs the affected identifier. No separate operator step is required for upgrade.

## Upgrade boundary

The v2-to-v3 Git-data migration is one-way. V2 does not coordinate writes through shared families and must not run against migrated thin views. Rollback means restoring the pre-upgrade Git and relay-data snapshot together, not only replacing the binary.

Installations already migrated by the earlier candidate are supported: manually removed verified backups leave completed journals that are compacted safely, while remaining backups are rechecked and retired family by family. Unverifiable stale backups are retained with a warning.

GC and unreachable-object pruning remain deliberately excluded to preserve delete-state rollback material. Optional S3 storage is also separate from this local-storage PR.

## Validation

Regressions cover corrupt loose family objects, repeated same-name unindexed packs with different content, durable interrupted retirement, active and stale journal states, manually removed backups, real depth-one migration, automatic closure recovery, marker retention on failed repair, and full client cloning after repair.

`cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, and `git diff --check` all pass on Rust 1.96.
This commit is contained in:
DanConwayDev
2026-08-19 09:23:58 +01:00
7 changed files with 1335 additions and 151 deletions
+26
View File
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking changes
- Version 3 performs an automatic, one-way migration of `NGIT_GIT_DATA_PATH`
from complete per-owner and `/prs/` repositories to thin views backed by
shared identifier families. Version 2 does not coordinate reads and writes
through this layout and must not be run against migrated Git data. Rollback
requires restoring the Git and relay-data snapshot taken before the v3
launch, not only downgrading the binary.
- Added `trusted_proxy_cidrs` to the public `Config` struct. Rust consumers
that construct `Config` with a struct literal must provide it.
- Added `base_path` to the public `Config` struct. Rust consumers that
@@ -72,6 +78,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Retire identifier-family migration backups family by family. Once every
view of a family is installed, each backup is verified (family contains
every Git-readable backup object, thin views match their journal snapshot,
family packs are indexed and valid), and the ordinary family integrity
report must be healthy before deletion. Unhealthy families retain their
backups for automatic repair. Healthy backups are deleted before the next
family migrates, bounding peak migration disk overhead to the family in
flight. Unindexed legacy packs are quarantined by content under
`.grasp/migration/unindexed-packs/`, unverifiable backups are retained with
a warning, backup-directory removals are fsynced before their journals are
removed, and completed installations whose backups were already removed
manually start unchanged.
- Reconcile server-side shallow repositories left by untagged development
builds between 2026-01-05 (`623cae5`) and 2026-01-12 (`f25eea8`). No tagged
v1 or v2 release shipped the depth-one fallback fetch. The affected
non-happy path fetched missing state or PR data from another listed Git
server when the objects had not arrived by push. V3 preserves existing
shallow clone behavior and the legacy backup, automatically requests the
complete closure from accepted clone sources, and logs any family that
remains shallow without making it less available.
- Scope bare log levels to ngit-grasp while keeping dependencies at warnings;
explicit tracing filter expressions remain unchanged.
- Keep per-event discovery and validation details at debug, retain aggregate
+87 -6
View File
@@ -249,6 +249,42 @@ view whose alternate is the identifier family. Therefore:
Mirroring a PR into an owner view becomes a ref update after an object
availability check. It no longer copies the object graph.
## V2 to v3 migration boundary
V3 changes the on-disk meaning of every served Git repository. Owner and
`/prs/` paths become thin views whose objects and write serialization belong to
the identifier family under `.grasp`. Migration happens automatically on the
first v3 launch, but the result is not safe for v2: v2 has no family lock,
retained-root, or shared-object write model. A software rollback therefore
requires restoring the Git and relay-data snapshot taken before v3; changing
only the binary is not supported.
One pre-release development interval can leave server-side shallow
repositories for v3 to reconcile. State Git-data fallback was introduced by
commit `623cae5` on 2026-01-05 with `git fetch --depth=1`, carried into the
rewritten purgatory sync path, and changed to a full fetch by commit `f25eea8`
on 2026-01-12. The first tagged release was v1.0.0 on 2026-02-26 and already
contained the fix, so no tagged v1 or v2 release shipped the shallow-fetch
behavior. Only operators who deployed an untagged source revision from that
seven-day interval can have repositories created by this bug.
The affected path was the non-happy-path Git-data fallback. It ran only when a
pending state or PR event named OIDs which had not arrived through an ordinary
push, then fetched those OIDs into an existing local repository from another
announcement or PR clone URL. A repository whose required data arrived through
the normal push path did not invoke this fallback.
V3 treats a legacy `shallow` marker as compatibility state rather than a reason
to delete or disable the repository. Migration preserves the marker on the thin
view so its shallow clones and current tree continue to work, and retains the
legacy backup. After the listener starts, the ordinary integrity worker
requests the complete closure from other accepted clone servers. On success it
removes the marker; the next v3 launch retires the now-redundant backup. On
failure the existing shallow clone and current tree remain available, the
backup remains, and an `ERROR` log identifies the family with a non-zero
`shallow_views` count. The operator does not need to take a separate action for
the v3 upgrade.
## Startup migration
Migration runs after configuration validation and before purgatory restoration,
@@ -275,10 +311,54 @@ For each legacy bare repository:
On restart, the journal determines whether to resume copying, finish a rename,
or restore the legacy directory. Every state transition is safe to repeat.
The original repository backup remains until the entire storage-version
migration has been verified. The first implementation does not delete those
backups automatically; an operator-visible later cleanup process can do so
after an appropriate rollback window.
Backups are retired family by family rather than accumulating until the whole
migration finishes. After the last view of a family is installed, each of its
backups must pass a retirement gate: the family contains every Git-readable
object of the backup, every affected view is a correctly wired thin view whose
refs and `HEAD` match its journal snapshot, and every family pack is indexed
and passes `git verify-pack`. The ordinary family integrity inspection must
also report the family and all of its views healthy. This final check catches
corrupt loose objects, missing history, broken ref targets, invalid alternates,
and shallow views. An unhealthy family keeps its backups while the non-blocking
integrity worker attempts repair after startup. Only a healthy family writes a
durable `retired` journal state, deletes the backup, and removes the journal
before the next family is migrated. Peak migration overhead is therefore
bounded by the family currently in flight except for the small set of families
still awaiting repair.
Backup deletion is itself durable: every directory entry removed while
deleting and pruning a backup is fsynced before its journal is removed. A
power loss therefore leaves either a discoverable `retired` journal that
finishes deletion or no backup entry to rediscover.
Packs without an index are Git-invisible, so the superset proof cannot vouch
for them; they are moved into content-addressed directories beneath
`.grasp/migration/unindexed-packs/` before their backup is deleted. Repeated
migrations preserve different payloads even when their original pack filenames
match, while an identical payload converges on the same quarantine path.
A server-side `shallow` marker is a compatibility boundary, not valid final
family storage. Migration copies it onto the installed thin view so the view
keeps serving exactly what the legacy repository served, and excludes its
backup from retirement until the family holds the complete reachable closure
of the backup's refs. Closure recovery is the ordinary integrity repair: the
family reports truncated parents as missing objects and marked views as
`shallow_views`, repair fetches the closure from accepted clone sources, and
once complete removes the marker. The backup retires on the next launch. An
unrecoverable closure retains both marker and backup and keeps the family
reported as unresolved; it never blocks startup because recovery depends on
remote servers. Client-requested shallow clones and fetches remain supported
throughout.
Retirement failures during an active migration are fail-closed and keep the
backup. Journals from a migration that already committed are handled
leniently on later launches: their ref snapshots are stale once the server
has served traffic, so the gate skips snapshot equality, and an unverifiable
backup (for example one whose migrated view was later deleted by repository
lifecycle) is retained with a warning as operator-managed rollback material.
Installations whose backups were verified and deleted manually simply have
their completed journals removed.
The global `storage-version` advances only after every eligible repository is
complete. A server must fail startup on an unrepairable mismatch rather than
@@ -324,8 +404,9 @@ on remote servers.
This is also the migration repair path. Migration remains a deterministic,
offline conversion that preserves every Git-readable object and the exact
legacy refs. Once those paths are thin family views, the ordinary family pass
can heal pre-existing missing objects. Unindexed legacy packs remain in the
migration backup; there is no separate legacy repair subsystem.
can heal pre-existing missing objects. Unindexed legacy packs are preserved
under `.grasp/migration/unindexed-packs/` when their backup is retired; there
is no separate legacy repair subsystem.
Operators can queue the same identifier-scoped check in the live process:
+8 -7
View File
@@ -24,16 +24,17 @@ How-to guides are **recipes** that show you how to solve specific problems or ac
## Available How-To Guides
### [Upgrade Git family storage](upgrade-git-family-storage.md)
**Problem:** Deduplicate existing repository objects during a server upgrade
### [Upgrade from v2 to v3 Git family storage](upgrade-git-family-storage.md)
**Problem:** Perform the one-way identifier-family storage migration safely
**Difficulty:** Advanced
**You'll learn:**
- Prepare capacity and a release rollback point
- Run the automatic crash-safe launch migration
- Verify owner and `/prs/` repository views
- Check or repair one identifier family on demand
- Recover safely from an interrupted launch
- Prepare capacity and a snapshot-based rollback point
- Run the automatic crash-safe v3 launch migration
- Interpret migration and integrity summary logs
- Restore v2 safely when a release rollback is required
---
+46 -88
View File
@@ -1,106 +1,64 @@
# Upgrade a server to identifier-family Git storage
# Upgrade from v2 to v3 Git family storage
This procedure upgrades existing owner repositories and `/prs/` repositories
to ref-only views backed by one local object family per repository identifier.
It requires no new configuration and keeps all durable Git storage local.
Version 3 replaces complete per-owner and `/prs/` bare repositories with thin
views backed by one local object family per repository identifier. The upgrade
requires no new configuration and runs automatically before the server accepts
traffic.
The upgrader runs automatically on every relay launch after configuration has
been validated and before purgatory restoration, background sync, or HTTP
request handling. A failed migration stops startup; restarting resumes from its
fsynced journal.
This is a one-way Git-data migration. A v2 binary does not understand the
family locking and write model and must not be run against a migrated
`NGIT_GIT_DATA_PATH`. Rolling back means restoring a complete pre-upgrade
snapshot, not only installing the older binary.
After runtime database initialization, a non-blocking integrity pass checks
the resulting identifier families and views. It attempts to fetch missing
objects from clone URLs in accepted repository announcements and emits an
`ERROR` log for any family that remains unhealthy. Network repair never holds
up the listening service or changes whether the structural migration commits.
## Before deploying
## Before upgrading
1. Stop writes to the relay and take a filesystem snapshot of both the Git and
relay data directories. An older binary cannot serve the new thin views, so
this external snapshot is the clean release-level rollback boundary.
2. Check free space on `NGIT_GIT_DATA_PATH`. During the first launch the server
retains every original repository and builds a verified family union. Plan
for at least the current owner and `/prs/` repository footprint again, plus
room for the largest identifier family. Deduplication reduces the final
family size but should not be assumed for preflight capacity planning.
3. Keep Git and relay data on durable storage.
4. Do not configure Git GC for this rollout. Unreachable objects and migration
backups intentionally preserve delete-state rollback material.
relay data directories. Keep it for the rollback window.
2. Check free space on `NGIT_GIT_DATA_PATH`. Plan for the current footprint
plus roughly one extra copy of the largest identifier family: every owner
and `/prs/` repository sharing one identifier. The migration retires each
healthy family's legacy repositories before processing the next family.
3. Keep Git and relay data on durable storage. Do not enable Git garbage
collection for this rollout; retained objects preserve recovery from
deletion-state mistakes.
## Perform the upgrade
## Upgrade
Deploy the new release with the existing configuration and start the relay.
For every identifier, launch migration inventories all objects from all
matching owner and `/prs/` paths, including unreachable objects, verifies the
union, then atomically replaces each repository with a thin view. Original
repositories move to:
Deploy v3 with the existing configuration and start the relay. The launch
migration is journaled, crash-safe, and safe to resume by restarting the same
v3 release. It:
```text
<git-data>/.grasp/migration/backups/
```
1. Builds and verifies one identifier family at a time, including unreachable
objects retained for rollback.
2. Replaces each owner and `/prs/` repository with a thin view that preserves
its refs, `HEAD`, configuration, and observable Git behavior.
3. Deletes a legacy backup only after its object superset, view wiring, packs,
and ordinary family integrity report all verify.
4. Keeps any unhealthy family's backup while the running server automatically
attempts repair from accepted clone URLs.
Progress and restart state live under `.grasp/migration/journal/`. The global
`.grasp/storage-version` marker is written only after every eligible view has
been reconciled and verified. Do not edit these files while the service is
running.
Progress is stored below `.grasp/migration/`; the completed layout is marked by
`.grasp/storage-version`. Do not edit these files while the service is running.
Confirm the service reaches its normal listening state and exercise a clone,
fetch, and push for both a normal repository and a `/prs/` route. Retain the
external snapshot and `.grasp/migration/backups/` for the rollback window. The
server never deletes those backups automatically.
Watch for the terminal startup-pass summary:
Confirm that the service reaches its normal listening state, then check the
terminal integrity summary:
```text
Git identifier-family integrity startup pass completed
```
An `unresolved` or `failed` count above zero is accompanied by an `ERROR` log
for each affected identifier. This reports pre-existing missing data without
putting startup into a network-dependent restart loop.
An `unresolved` or `failed` count above zero has a corresponding `ERROR` naming
the identifier. The server has already attempted automatic repair. A retained
backup protects an unhealthy family's legacy data, and a legacy shallow view
continues serving at its pre-upgrade level rather than being made less usable.
## Check or repair one identifier on demand
## Roll back
Queue a read-only check for every object format and owner/`/prs/` view sharing
an identifier:
Stop v3 and restore the pre-upgrade Git and relay-data snapshot together before
starting v2. Do not point v2 at thin family views, even if ordinary clones
appear to work: v2 does not coordinate writes through the shared family or
preserve its durability invariants.
```console
ngit-grasp integrity-check \
--git-data-path /var/lib/ngit-grasp/git \
--identifier example
```
Add `--repair` to repair alternate wiring and try accepted clone servers for
missing OIDs:
```console
ngit-grasp integrity-check \
--git-data-path /var/lib/ngit-grasp/git \
--identifier example \
--repair
```
The command queues a durable request for the running relay rather than opening
or mutating a family from a second process. The worker normally consumes it
within five seconds and writes the result to the service log. A request queued
while the relay is stopped is processed after its next startup integrity pass.
Repeated requests for the same identifier and mode safely coalesce.
## Failure and rollback
- A migration failure is fail-closed. Fix the reported filesystem or Git error
and restart; the journal resumes the safe transition.
- A post-migration integrity repair failure is fail-open because the damage
predates conversion or arose after it. Inspect the identifier's `ERROR` log,
repair or update its listed clone sources, and queue `integrity-check
--repair` again.
- To roll the software release back, stop the service and restore the complete
pre-upgrade Git and relay-data snapshot together. Do not point an older
binary at migrated thin views.
There is deliberately no automatic cleanup step. Backup retirement and
unreachable-object pruning remain deferred until rollback and delete-state
retention have an explicit policy. S3 adoption is a separate optional rollout
and is not required for this local storage model.
The storage model, automatic shallow-history compatibility repair, and detailed
retirement guarantees are described in
[Identifier-family Git object storage](../explanation/git-family-object-storage.md#v2-to-v3-migration-boundary).
+177
View File
@@ -6,6 +6,7 @@
//! checks use the same report.
use std::collections::BTreeSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
@@ -94,6 +95,10 @@ pub struct FamilyIntegrityReport {
pub missing_oids: BTreeSet<String>,
pub missing_ref_targets: Vec<MissingRefTarget>,
pub invalid_alternates: Vec<PathBuf>,
/// Views still carrying a server-side `shallow` marker. Shallow
/// repositories are not valid family storage; the marker is removed by
/// repair once the family holds the complete closure of the view's refs.
pub shallow_views: Vec<PathBuf>,
pub pack_errors: Vec<String>,
pub fsck_diagnostics: Vec<String>,
}
@@ -104,6 +109,7 @@ impl FamilyIntegrityReport {
self.missing_oids.is_empty()
&& self.missing_ref_targets.is_empty()
&& self.invalid_alternates.is_empty()
&& self.shallow_views.is_empty()
&& self.pack_errors.is_empty()
&& self.fsck_diagnostics.is_empty()
}
@@ -318,6 +324,7 @@ async fn run_one_family<S: FamilyRepairSource + ?Sized>(
missing_sample,
missing_ref_targets = outcome.final_report.missing_ref_targets.len(),
invalid_alternates = outcome.final_report.invalid_alternates.len(),
shallow_views = outcome.final_report.shallow_views.len(),
pack_errors = outcome.final_report.pack_errors.len(),
fsck_diagnostics = outcome.final_report.fsck_diagnostics.len(),
diagnostic,
@@ -425,6 +432,33 @@ pub async fn check_and_repair_family<S: FamilyRepairSource + ?Sized>(
storage.advertise_base_tip(key, &source_ref, &target.oid)?;
}
}
// A server-side shallow marker stops being a truncation boundary the
// moment the family holds the full closure of the view's refs. Until
// then it stays in place so the view keeps serving what it always did.
for view in &initial.shallow_views {
match family_contains_closure(&storage.family_repo_path(key), view) {
Ok(()) => {
let marker = view.join("shallow");
match std::fs::remove_file(&marker) {
Ok(()) => info!(
view = %view.display(),
"Removed server-side shallow marker after family closure recovery"
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
warn!(view = %view.display(), %error, "Remove shallow marker");
}
}
}
Err(error) => {
warn!(
view = %view.display(),
%error,
"Keeping server-side shallow marker until the family closure is complete"
);
}
}
}
let final_report = inspect_family(storage, key)?;
Ok(FamilyRepairOutcome {
initial,
@@ -515,12 +549,16 @@ pub fn inspect_family(storage: &LocalGitStorage, key: &FamilyKey) -> Result<Fami
let mut missing_oids = BTreeSet::new();
let mut missing_ref_targets = Vec::new();
let mut invalid_alternates = Vec::new();
let mut shallow_views = Vec::new();
let mut refs_checked = 0;
for view in &views {
if !storage.is_thin_view(key, view) {
invalid_alternates.push(view.clone());
}
if view.join("shallow").is_file() {
shallow_views.push(view.clone());
}
for (reference, oid) in list_refs(view)? {
refs_checked += 1;
if !oid_exists(&family, &oid)? {
@@ -545,11 +583,74 @@ pub fn inspect_family(storage: &LocalGitStorage, key: &FamilyKey) -> Result<Fami
missing_oids,
missing_ref_targets,
invalid_alternates,
shallow_views,
pack_errors,
fsck_diagnostics,
})
}
/// Verify the family contains the complete reachable closure of every ref in
/// `refs_source` whose target object the family already has.
///
/// Tips absent from the family altogether are pre-existing missing objects
/// and are reported separately; they cannot make a closure walk start.
pub(crate) fn family_contains_closure(family: &Path, refs_source: &Path) -> Result<()> {
let output = Command::new("git")
.args(["for-each-ref", "--format=%(objectname) %(objecttype)"])
.current_dir(refs_source)
.output()
.with_context(|| format!("list refs in {}", refs_source.display()))?;
if !output.status.success() {
return Err(anyhow!(
"list refs in {}: {}",
refs_source.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
let mut tips = BTreeSet::new();
for line in String::from_utf8(output.stdout)?.lines() {
let mut fields = line.split_whitespace();
let (Some(oid), Some(object_type)) = (fields.next(), fields.next()) else {
continue;
};
// A ref directly naming a blob has no further closure to walk.
if matches!(object_type, "commit" | "tag" | "tree") && oid_exists(family, oid)? {
tips.insert(oid.to_owned());
}
}
if tips.is_empty() {
return Ok(());
}
let mut child = Command::new("git")
.args(["rev-list", "--objects", "--stdin"])
.current_dir(family)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.with_context(|| format!("walk closure in {}", family.display()))?;
{
let stdin = child.stdin.as_mut().context("git rev-list stdin")?;
for tip in &tips {
writeln!(stdin, "{tip}")?;
}
}
let output = child.wait_with_output()?;
if !output.status.success() {
let diagnostic: String = String::from_utf8_lossy(&output.stderr)
.trim()
.chars()
.take(2048)
.collect();
return Err(anyhow!(
"family {} is missing part of the closure reachable from {}: {diagnostic}",
family.display(),
refs_source.display()
));
}
Ok(())
}
fn discover_views(storage: &LocalGitStorage, key: &FamilyKey) -> Result<Vec<PathBuf>> {
let mut views = Vec::new();
let entries = match std::fs::read_dir(storage.git_data_path()) {
@@ -886,6 +987,82 @@ mod tests {
assert!(!report.is_healthy());
}
#[test]
fn reports_a_view_with_a_shallow_marker() {
let (_temp, storage, key, view, commit) = fixture();
fs::write(view.join("shallow"), format!("{commit}\n")).unwrap();
let report = inspect_family(&storage, &key).unwrap();
assert_eq!(report.shallow_views, vec![view]);
assert!(!report.is_healthy());
}
#[tokio::test]
async fn repair_removes_shallow_marker_after_closure_recovery() {
let (temp, storage, key, view, commit) = fixture();
let family = storage.family_repo_path(&key);
let child = write_commit(&family, Some(&commit));
git(&view, &["update-ref", "refs/heads/main", &child]);
storage.retain_tip(&key, "child", &child).unwrap();
let source_repo = temp.path().join("source.git");
git(
temp.path(),
&[
"clone",
"--bare",
family.to_str().unwrap(),
source_repo.to_str().unwrap(),
],
);
git(
&source_repo,
&["update-ref", "refs/heads/recovery", &commit],
);
// Truncate the family below the child commit and mark the view as a
// legacy server-side shallow repository.
let object = family.join("objects").join(&commit[..2]).join(&commit[2..]);
fs::remove_file(object).unwrap();
fs::write(view.join("shallow"), format!("{child}\n")).unwrap();
let before = inspect_family(&storage, &key).unwrap();
assert!(!before.is_healthy());
assert_eq!(before.shallow_views, vec![view.clone()]);
let source = LocalRepairSource {
url: source_repo.to_string_lossy().into_owned(),
family_objects: storage.family_objects_path(&key),
};
let outcome = check_and_repair_family(&storage, &key, &source, true)
.await
.unwrap();
assert!(outcome.repaired(), "{outcome:#?}");
assert!(!view.join("shallow").exists());
assert!(oid_exists(&family, &commit).unwrap());
}
#[tokio::test]
async fn repair_keeps_shallow_marker_while_closure_is_missing() {
let (_temp, storage, key, view, commit) = fixture();
let family = storage.family_repo_path(&key);
let child = write_commit(&family, Some(&commit));
git(&view, &["update-ref", "refs/heads/main", &child]);
let object = family.join("objects").join(&commit[..2]).join(&commit[2..]);
fs::remove_file(object).unwrap();
fs::write(view.join("shallow"), format!("{child}\n")).unwrap();
let source = LocalRepairSource {
url: String::new(),
family_objects: storage.family_objects_path(&key),
};
let outcome = check_and_repair_family(&storage, &key, &source, true)
.await
.unwrap();
assert!(!outcome.final_report.is_healthy());
assert!(view.join("shallow").is_file());
}
#[test]
fn reports_an_unindexed_family_pack() {
let (_temp, storage, key, _view, _commit) = fixture();
+980 -47
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -126,17 +126,25 @@ impl RelayServer {
info!("Database backend: {}", config.database_backend);
// Upgrade Git storage before any runtime component can inspect or
// mutate repositories. Migration is resumable and retains original
// repositories as operator-managed rollback backups.
// mutate repositories. Migration is resumable; each identifier
// family's legacy backups are verified and retired before the next
// family converts, so peak disk overhead is bounded by the family
// currently in flight. Backups that cannot be verified are retained
// as operator-managed rollback material.
let git_storage = git::storage::LocalGitStorage::new(config.effective_git_data_path());
let migration = git::migration::migrate_on_startup(&git_storage)
.await
.context("upgrade Git repositories to identifier-family storage")?;
if !migration.already_current {
if !migration.already_current
|| migration.retired_backups > 0
|| migration.retained_backups > 0
{
info!(
migrated_views = migration.migrated_views,
recovered_views = migration.recovered_views,
families_built = migration.families_built,
retired_backups = migration.retired_backups,
retained_backups = migration.retained_backups,
"Git identifier-family storage migration completed"
);
}