Skip to content

Commit beae7cd

Browse files
committed
fix(reconciliation): enhance reconciliation sweep configuration and behavior
1 parent 88e49b5 commit beae7cd

6 files changed

Lines changed: 381 additions & 108 deletions

File tree

‎crates/gitlawb-node/src/api/ipfs.rs‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,16 +213,33 @@ pub async fn get_by_cid(
213213

214214
/// GET /api/v1/ipfs/pins
215215
///
216-
/// Returns all CIDs that have been pinned to the local IPFS node from git
217-
/// objects received via push. Each entry includes the git SHA-256 hex, the
218-
/// CIDv1 string, and the timestamp when it was pinned.
216+
/// Returns all CIDs that have been pinned from git objects received via push.
217+
/// Each entry includes the git SHA-256 hex, a CIDv1 string, and the timestamp
218+
/// when it was pinned. For Pinata-only rows (no local IPFS pin), the `cid`
219+
/// field carries `pinata_cid` so CLI consumers see a usable value. The raw
220+
/// `pinata_cid` is also surfaced.
219221
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
220222
let pins = state
221223
.db
222224
.list_pinned_cids()
223225
.await
224226
.map_err(AppError::Internal)?;
225227

228+
let pins: Vec<serde_json::Value> = pins
229+
.into_iter()
230+
.map(|p| {
231+
// Synthesize a usable CID from pinata_cid when this is a
232+
// Pinata-only row (no local IPFS pin).
233+
let display_cid = p.cid.clone().or_else(|| p.pinata_cid.clone());
234+
serde_json::json!({
235+
"sha256_hex": p.sha256_hex,
236+
"cid": display_cid,
237+
"pinned_at": p.pinned_at,
238+
"pinata_cid": p.pinata_cid,
239+
})
240+
})
241+
.collect();
242+
226243
Ok(Json(serde_json::json!({
227244
"pins": pins,
228245
"count": pins.len(),

‎crates/gitlawb-node/src/config.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ pub struct Config {
8686
#[arg(long, env = "GITLAWB_AUTO_SYNC", default_value_t = false)]
8787
pub auto_sync: bool,
8888

89+
/// Enable the periodic reconciliation sweep that re-derives pin/seal sets
90+
/// and fills durability gaps. Defaults to true; set to false to disable
91+
/// the sweep even when a pin backend (IPFS/Pinata) is configured.
92+
#[arg(
93+
long,
94+
env = "GITLAWB_RECONCILIATION_SWEEP",
95+
default_value_t = true,
96+
action = clap::ArgAction::Set
97+
)]
98+
pub reconciliation_sweep: bool,
99+
89100
/// Irys URL for Arweave permanent anchoring.
90101
/// Leave empty to disable. Use https://devnet.irys.xyz for free devnet.
91102
#[arg(long, env = "GITLAWB_IRYS_URL", default_value = "")]

‎crates/gitlawb-node/src/db/mod.rs‎

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2331,7 +2331,7 @@ impl Db {
23312331
.into_iter()
23322332
.map(|r| PinnedCidRecord {
23332333
sha256_hex: r.get("sha256_hex"),
2334-
cid: r.get("cid"),
2334+
cid: r.try_get("cid").ok(),
23352335
pinned_at: r.get("pinned_at"),
23362336
pinata_cid: r.get("pinata_cid"),
23372337
})
@@ -3765,6 +3765,149 @@ mod migration_tests {
37653765
// (d) Re-run: idempotent — ADD COLUMN IF NOT EXISTS must not error.
37663766
db.migrate().await.unwrap();
37673767
}
3768+
3769+
/// Migration v12 makes pinned_cids.cid nullable so record_pinata_cid can
3770+
/// create Pinata-only rows without a local IPFS CID. This test seeds a
3771+
/// pre-v12 schema (cid NOT NULL, pinata_cid column exists but no
3772+
/// nullability change yet) with rows in each of the three states the
3773+
/// has_ipfs_cid / filter_ipfs_pinned_oids predicates must classify:
3774+
///
3775+
/// (1) cid IS NOT NULL, pinata_cid IS NULL → has_ipfs = true
3776+
/// (2) cid IS NOT NULL, cid != pinata_cid → has_ipfs = true
3777+
/// (3) cid IS NOT NULL, cid = pinata_cid (legacy) → has_ipfs = false
3778+
///
3779+
/// After the migration we also test that a Pinata-only INSERT (cid = NULL)
3780+
/// works and produces has_ipfs = false, has_pinata = true.
3781+
#[sqlx::test]
3782+
async fn migration_v12_makes_cid_nullable_and_preserves_classification(pool: sqlx::PgPool) {
3783+
let db = super::Db::for_testing(pool);
3784+
3785+
// Create all tables, then drop the NOT NULL constraint on cid
3786+
// and drop schema_migrations records to simulate a pre-v12 node.
3787+
db.migrate().await.unwrap();
3788+
sqlx::query("ALTER TABLE pinned_cids ALTER COLUMN cid SET NOT NULL")
3789+
.execute(&db.pool)
3790+
.await
3791+
.unwrap();
3792+
3793+
sqlx::query("DELETE FROM schema_migrations")
3794+
.execute(&db.pool)
3795+
.await
3796+
.unwrap();
3797+
for m in MIGRATIONS.iter().take_while(|m| m.version < 12) {
3798+
sqlx::query(
3799+
"INSERT INTO schema_migrations (version, name, applied_at)
3800+
VALUES ($1, $2, $3)",
3801+
)
3802+
.bind(m.version)
3803+
.bind(m.name)
3804+
.bind("2026-07-01T00:00:00Z")
3805+
.execute(&db.pool)
3806+
.await
3807+
.unwrap();
3808+
}
3809+
3810+
// ── Seed legacy rows ───────────────────────────────────────────
3811+
let now = "2026-07-01T12:00:00Z";
3812+
3813+
// (1) Real local IPFS pin, no Pinata.
3814+
sqlx::query(
3815+
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, pinata_cid)
3816+
VALUES ($1, $2, $3, $4)",
3817+
)
3818+
.bind("sha_real_only")
3819+
.bind("QmRealLocalCid")
3820+
.bind(now)
3821+
.bind(Option::<&str>::None)
3822+
.execute(&db.pool)
3823+
.await
3824+
.unwrap();
3825+
3826+
// (2) Both CIDs present and distinct.
3827+
sqlx::query(
3828+
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, pinata_cid)
3829+
VALUES ($1, $2, $3, $4)",
3830+
)
3831+
.bind("sha_both_distinct")
3832+
.bind("QmLocalForThisBlob")
3833+
.bind(now)
3834+
.bind("QmPinataForThisBlob")
3835+
.execute(&db.pool)
3836+
.await
3837+
.unwrap();
3838+
3839+
// (3) Legacy row where cid was set to pinata_cid as fallback.
3840+
sqlx::query(
3841+
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, pinata_cid)
3842+
VALUES ($1, $2, $3, $4)",
3843+
)
3844+
.bind("sha_legacy_fallback")
3845+
.bind("QmLegacyEqual")
3846+
.bind(now)
3847+
.bind("QmLegacyEqual")
3848+
.execute(&db.pool)
3849+
.await
3850+
.unwrap();
3851+
3852+
// ── Apply migration v12 ────────────────────────────────────────
3853+
db.migrate().await.unwrap();
3854+
3855+
// ── Assertions ─────────────────────────────────────────────────
3856+
3857+
// Column is now nullable.
3858+
let nullable: String = sqlx::query_scalar(
3859+
"SELECT is_nullable FROM information_schema.columns
3860+
WHERE table_name = 'pinned_cids' AND column_name = 'cid'",
3861+
)
3862+
.fetch_one(&db.pool)
3863+
.await
3864+
.unwrap();
3865+
assert_eq!(nullable, "YES", "cid must be nullable after v12");
3866+
3867+
// Classification: has_ipfs_cid.
3868+
assert!(
3869+
db.has_ipfs_cid("sha_real_only").await.unwrap(),
3870+
"real local IPFS CID must be classified as pinned"
3871+
);
3872+
assert!(
3873+
db.has_ipfs_cid("sha_both_distinct").await.unwrap(),
3874+
"distinct local CID must be classified as pinned"
3875+
);
3876+
assert!(
3877+
!db.has_ipfs_cid("sha_legacy_fallback").await.unwrap(),
3878+
"legacy equal-cid row must NOT be classified as having an IPFS CID"
3879+
);
3880+
3881+
// has_pinata_cid.
3882+
assert!(
3883+
!db.has_pinata_cid("sha_real_only").await.unwrap(),
3884+
"no pinata_cid means has_pinata = false"
3885+
);
3886+
assert!(
3887+
db.has_pinata_cid("sha_both_distinct").await.unwrap(),
3888+
"non-null pinata_cid means has_pinata = true"
3889+
);
3890+
assert!(
3891+
db.has_pinata_cid("sha_legacy_fallback").await.unwrap(),
3892+
"non-null pinata_cid means has_pinata = true (legacy row)"
3893+
);
3894+
3895+
// ── Pinata-only INSERT (new post-v12 row) ──────────────────────
3896+
db.record_pinata_cid("sha_pinata_only", "QmPinataOnly")
3897+
.await
3898+
.unwrap();
3899+
assert!(
3900+
!db.has_ipfs_cid("sha_pinata_only").await.unwrap(),
3901+
"Pinata-only row must NOT be classified as having a local IPFS CID"
3902+
);
3903+
assert!(
3904+
db.has_pinata_cid("sha_pinata_only").await.unwrap(),
3905+
"Pinata-only row must have has_pinata = true"
3906+
);
3907+
3908+
// ── Idempotent re-run ──────────────────────────────────────────
3909+
db.migrate().await.unwrap();
3910+
}
37683911
}
37693912

37703913
#[cfg(test)]

‎crates/gitlawb-node/src/git/mod.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ impl GitCommand {
191191
// is set while we hold the lock, the sweep cannot drain the
192192
// registry until we release it.
193193
if let Some(ref ctx) = ctx {
194-
let mut registry = ctx.registry.lock().unwrap();
194+
let mut registry = ctx.registry.lock().unwrap_or_else(|e| e.into_inner());
195195
if ctx.canceled.load(Ordering::SeqCst) {
196196
// Canceled after spawn: kill the whole process group (not
197197
// just the immediate child) and wait to avoid zombies.
@@ -226,7 +226,11 @@ struct PgidGuard {
226226
impl Drop for PgidGuard {
227227
fn drop(&mut self) {
228228
if let (Some(pgid), Some(ref ctx)) = (self.pgid, &self.ctx) {
229-
ctx.registry.lock().unwrap().remove(&pgid);
229+
let _ = ctx
230+
.registry
231+
.lock()
232+
.unwrap_or_else(|e| e.into_inner())
233+
.remove(&pgid);
230234
}
231235
}
232236
}

‎crates/gitlawb-node/src/git/store.rs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,8 @@ pub fn list_refs(repo_path: &Path) -> Result<Vec<(String, String)>> {
6666
/// Read the current HEAD commit hash of a repository.
6767
/// Returns None if the repo is empty (no commits yet).
6868
pub fn head_commit(repo_path: &Path) -> Result<Option<String>> {
69-
let output = Command::new("git")
69+
let output = crate::git::GitCommand::new(repo_path)
7070
.args(["rev-parse", "--verify", "HEAD"])
71-
.current_dir(repo_path)
7271
.output()
7372
.context("failed to run git rev-parse")?;
7473

0 commit comments

Comments
 (0)