OXYGENHUB

git recover: rescue an import that stopped before it finished 54e2e6309aa3

An import writes every object into one batch pack indexed only at the
end, and moves its bookmark only at the end, so a stopped import left
everything unreadable. Store::adopt_unfinished_packs reads such a pack
through (untouched for five minutes), checks each entry against its
reference, cuts a torn last one off and indexes it; oxy git recover
then points the bookmark at every change carrying a Git-Commit trailer,
and oxy git fetch carries on.

Checked: an import killed after 2474 of 3000 commits, recovered, then
fetched: 3000 commits verified.

closes #79
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-06 18:50 UTC
Signature
✔ signed by Andy McCall
Hash
54e2e6309aa32691f0029cc15aa75136d011d0f614cf128f60d1431e360712e7
Depends on
1983686fb7a7 30ea9c1e70fa 62794ecc7f74 75167672ebcd 79ab8528ff62 9935d490ddfb a9f686edf60b c3069537d7c5 d02e545dc265 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
d6e0d5b8b339
In bookmarks
bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.10.0 v0.11.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
8 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/oxy-core/src/repo/git.rs +60 −0
…
1383 unchanged lines
1384 1384
        Ok(())
1385 1385
    }
1386 1386
1387 +
    /// Recover an import that stopped before it moved the bookmark: its
1388 +
    /// unfinished pack indexed (`Store::adopt_unfinished_packs`), then every
1389 +
    /// change in the store that carries a `Git-Commit:` trailer, its heads
1390 +
    /// (those no other such change depends on) made the bookmark's heads,
1391 +
    /// so `oxy git fetch` carries on from there. Reads every object once.
1392 +
    /// Returns how many imported changes it found and the heads.
1393 +
    pub fn recover_git_import(&self, progress: &dyn Fn(&str)) -> Result<(usize, usize)> {
1394 +
        // A stopped import's objects are in a pack it never indexed.
1395 +
        // OXY_RECOVER_MIN_AGE (seconds) overrides the five minutes it must
1396 +
        // have been left alone, for testing.
1397 +
        let min_age = std::env::var("OXY_RECOVER_MIN_AGE")
1398 +
            .ok()
1399 +
            .and_then(|v| v.parse().ok())
1400 +
            .unwrap_or(300);
1401 +
        self.store
1402 +
            .adopt_unfinished_packs(std::time::Duration::from_secs(min_age), progress)?;
1403 +
        let all = self
1404 +
            .store
1405 +
            .list()
1406 +
            .map_err(|e| RepoError::Msg(format!("listing the store: {e}")))?;
1407 +
        let total = all.len();
1408 +
        let mut found: HashMap<ChangeHash, Vec<ChangeHash>> = HashMap::new();
1409 +
        for (i, r) in all.iter().enumerate() {
1410 +
            if i.is_multiple_of(100_000) {
1411 +
                progress(&format!(
1412 +
                    "{i} of {total} objects read, {} imported changes",
1413 +
                    found.len()
1414 +
                ));
1415 +
            }
1416 +
            let Ok(meta) = self.store.get_change_meta(r) else {
1417 +
                continue;
1418 +
            };
1419 +
            if meta.message.lines().any(|l| l.starts_with("Git-Commit: ")) {
1420 +
                found.insert(*r, meta.deps.clone());
1421 +
            }
1422 +
        }
1423 +
        if found.is_empty() {
1424 +
            return fail("no imported Git commit's change in this store");
1425 +
        }
1426 +
        let depended: HashSet<ChangeHash> = found.values().flatten().copied().collect();
1427 +
        let mut heads: Vec<ChangeHash> = found
1428 +
            .keys()
1429 +
            .filter(|h| !depended.contains(h))
1430 +
            .copied()
1431 +
            .collect();
1432 +
        // The bookmark's present heads stay: a recovery only adds.
1433 +
        heads.extend(self.bookmark_heads().unwrap_or_default());
1434 +
        let st = self.load_state_history(&heads)?;
1435 +
        let mut heads = st.maximal(&heads);
1436 +
        heads.sort();
1437 +
        heads.dedup();
1438 +
        let (op_ref, op) = self.head_op()?;
1439 +
        let mut view = op.view.clone();
1440 +
        let state_ref = self.store.put_state(&StateObject::new(heads.clone()))?;
1441 +
        view.bookmarks
1442 +
            .insert(self.config.bookmark.clone(), state_ref);
1443 +
        self.record_op(&[op_ref], "oxy git recover", view)?;
1444 +
        Ok((found.len(), heads.len()))
1445 +
    }
1446 +
1387 1447
    /// Point the bookmark at an import's heads so far (see
1388 1448
    /// `CHECKPOINT_COMMITS`); the working copy is left alone until the end.
1389 1449
    fn checkpoint_import(&self, heads: &BTreeSet<ChangeHash>, done: usize) -> Result<()> {
…
419 unchanged lines
modified crates/oxy-core/src/store.rs +154 −0
…
1418 unchanged lines
1419 1419
        Ok(())
1420 1420
    }
1421 1421
1422 +
    /// Finish packs a writer left unfinished: a batch writes its objects to
1423 +
    /// `.tmp-ID.pack` and only indexes it at the end, so a process that
1424 +
    /// stopped part way (an import killed after hours) leaves every object
1425 +
    /// in a pack nothing can read. Each such pack untouched for `min_age`
1426 +
    /// is read through: every whole entry checked against its reference,
1427 +
    /// a torn last one cut off, then indexed as a pack of the store.
1428 +
    /// Returns the packs and objects adopted.
1429 +
    pub fn adopt_unfinished_packs(
1430 +
        &self,
1431 +
        min_age: std::time::Duration,
1432 +
        progress: &dyn Fn(&str),
1433 +
    ) -> Result<(usize, usize)> {
1434 +
        use std::io::{BufReader, Read};
1435 +
        let mut packs = 0;
1436 +
        let mut objects = 0;
1437 +
        let Ok(dir) = fs::read_dir(&self.scratch) else {
1438 +
            return Ok((0, 0));
1439 +
        };
1440 +
        for entry in dir.flatten() {
1441 +
            let name = entry.file_name().to_string_lossy().to_string();
1442 +
            let Some(id) = name
1443 +
                .strip_prefix(".tmp-")
1444 +
                .and_then(|n| n.strip_suffix(".pack"))
1445 +
            else {
1446 +
                continue;
1447 +
            };
1448 +
            let path = entry.path();
1449 +
            let meta = fs::metadata(&path)?;
1450 +
            let age = meta
1451 +
                .modified()
1452 +
                .ok()
1453 +
                .and_then(|m| m.elapsed().ok())
1454 +
                .unwrap_or_default();
1455 +
            if age < min_age {
1456 +
                progress(&format!(
1457 +
                    "{name}: written to {}s ago, left alone",
1458 +
                    age.as_secs()
1459 +
                ));
1460 +
                continue;
1461 +
            }
1462 +
            let mut f = BufReader::with_capacity(1 << 20, fs::File::open(&path)?);
1463 +
            let mut magic = [0u8; 8];
1464 +
            if f.read_exact(&mut magic).is_err() || &magic != PACK_MAGIC {
1465 +
                progress(&format!("{name}: not a pack, left alone"));
1466 +
                continue;
1467 +
            }
1468 +
            let mut offset = PACK_MAGIC.len() as u64;
1469 +
            let mut index: Vec<(ObjectRef, PackEntry)> = Vec::new();
1470 +
            loop {
1471 +
                let mut head = [0u8; 42];
1472 +
                if f.read_exact(&mut head).is_err() {
1473 +
                    break;
1474 +
                }
1475 +
                let Some(ty) = ObjectType::ALL.get(head[0] as usize).copied() else {
1476 +
                    break;
1477 +
                };
1478 +
                let flags = head[1];
1479 +
                let stored_len = u32::from_le_bytes(head[2..6].try_into().unwrap());
1480 +
                let raw_len = u32::from_le_bytes(head[6..10].try_into().unwrap());
1481 +
                let r = ObjectRef(head[10..42].try_into().unwrap());
1482 +
                let mut stored = vec![0u8; stored_len as usize];
1483 +
                if flags & FLAG_DICT != 0 || f.read_exact(&mut stored).is_err() {
1484 +
                    break;
1485 +
                }
1486 +
                let e = PackEntry {
1487 +
                    offset: offset + 42,
1488 +
                    stored_len,
1489 +
                    raw_len,
1490 +
                    ty,
1491 +
                    flags,
1492 +
                };
1493 +
                if decode_entry(&r, e, stored, None).is_err() {
1494 +
                    break;
1495 +
                }
1496 +
                index.push((r, e));
1497 +
                offset += 42 + stored_len as u64;
1498 +
                if index.len().is_multiple_of(200_000) {
1499 +
                    progress(&format!(
1500 +
                        "{name}: {} objects checked, {} MiB",
1501 +
                        index.len(),
1502 +
                        offset >> 20
1503 +
                    ));
1504 +
                }
1505 +
            }
1506 +
            if index.is_empty() {
1507 +
                progress(&format!("{name}: no whole object, left alone"));
1508 +
                continue;
1509 +
            }
1510 +
            if offset < meta.len() {
1511 +
                progress(&format!(
1512 +
                    "{name}: {} torn bytes at the end cut off",
1513 +
                    meta.len() - offset
1514 +
                ));
1515 +
                fs::OpenOptions::new()
1516 +
                    .write(true)
1517 +
                    .open(&path)?
1518 +
                    .set_len(offset)?;
1519 +
            }
1520 +
            index.sort_by_key(|(r, _)| *r);
1521 +
            index.dedup_by_key(|(r, _)| *r);
1522 +
            objects += index.len();
1523 +
            packs += 1;
1524 +
            progress(&format!("{name}: {} objects indexed", index.len()));
1525 +
            self.finish_pack_files(id, &path, &index)?;
1526 +
        }
1527 +
        Ok((packs, objects))
1528 +
    }
1529 +
1422 1530
    /// Every object reference in the store, loose and packed (slow; for gc and tests).
1423 1531
    pub fn list(&self) -> io::Result<Vec<ObjectRef>> {
1424 1532
        let mut out: Vec<ObjectRef> = self.loose_objects()?.into_iter().map(|(r, _)| r).collect();
…
493 unchanged lines
1918 2026
        let again = Store::open(dir.path()).unwrap();
1919 2027
        for r in &refs {
1920 2028
            again
2029 +
                .get(r)
2030 +
                .unwrap_or_else(|e| panic!("{}: {e}", r.short(12)));
2031 +
        }
2032 +
    }
2033 +
2034 +
    #[test]
2035 +
    fn a_pack_a_stopped_batch_left_is_adopted_and_its_torn_end_cut() {
2036 +
        // oxygen#79: a batch never finished (a process killed part way)
2037 +
        // leaves its objects in an unindexed .tmp pack.
2038 +
        let dir = tempfile::tempdir().unwrap();
2039 +
        let mut refs = Vec::new();
2040 +
        {
2041 +
            let store = Store::open(dir.path()).unwrap();
2042 +
            let batch = store.begin_batch().unwrap();
2043 +
            for i in 0..300u32 {
2044 +
                let body = format!("object {i} {}", "y".repeat(20_000 + i as usize)).into_bytes();
2045 +
                refs.push(store.put(ObjectType::Blob, &body).unwrap());
2046 +
            }
2047 +
            std::mem::forget(batch);
2048 +
        }
2049 +
        let tmp: Vec<_> = fs::read_dir(dir.path())
2050 +
            .unwrap()
2051 +
            .flatten()
2052 +
            .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp-"))
2053 +
            .map(|e| e.path())
2054 +
            .collect();
2055 +
        assert_eq!(tmp.len(), 1, "one unfinished pack");
2056 +
        // A torn last entry: a header and part of its body.
2057 +
        let mut f = fs::OpenOptions::new().append(true).open(&tmp[0]).unwrap();
2058 +
        f.write_all(&[0, 1, 0xff, 0, 0, 0, 9, 9, 0, 0]).unwrap();
2059 +
        f.write_all(&[7; 40]).unwrap();
2060 +
        drop(f);
2061 +
        let store = Store::open(dir.path()).unwrap();
2062 +
        assert!(store.get(&refs[0]).is_err(), "unreadable before");
2063 +
        let young = store
2064 +
            .adopt_unfinished_packs(std::time::Duration::from_secs(3600), &|_| {})
2065 +
            .unwrap();
2066 +
        assert_eq!(young, (0, 0), "a pack written to lately is left alone");
2067 +
        let (packs, objects) = store
2068 +
            .adopt_unfinished_packs(std::time::Duration::ZERO, &|_| {})
2069 +
            .unwrap();
2070 +
        assert_eq!((packs, objects), (1, 300));
2071 +
        for r in &refs {
2072 +
            store
1921 2073
                .get(r)
1922 2074
                .unwrap_or_else(|e| panic!("{}: {e}", r.short(12)));
1923 2075
        }
2076 +
        let again = Store::open(dir.path()).unwrap();
2077 +
        assert!(again.get(&refs[299]).is_ok(), "indexed for good");
1924 2078
    }
1925 2079
}
1926 2080
…
120 unchanged lines
modified docs/guide.md +6 −0
…
540 unchanged lines
541 541
has been fetched, so it always fast-forwards. Exported commits carry
542 542
`Change-Id` and `Oxy-Hash` trailers.
543 543
544 +
A long import moves its bookmark to the commits done so far every 2000
545 +
commits, so if it stops, `oxy git fetch GIT_DIR --branch BRANCH` in the new
546 +
repository carries on from there. One stopped before its first checkpoint
547 +
(or by an older oxy) is recovered first with `oxy git recover`, which
548 +
indexes the objects it had written and points the bookmark at them.
549 +
544 550
## 13. Automate: hooks
545 551
546 552
Executables in `.oxy/hooks/<event>` (never cloned) receive one JSON object
…
100 unchanged lines
modified src/cli/mod.rs +5 −0
…
900 unchanged lines
901 901
        #[arg(long, default_value = "main")]
902 902
        branch: String,
903 903
    },
904 +
    /// Recover an import that stopped before it moved the bookmark: point the bookmark at every imported commit's change in the store, so oxy git fetch carries on from there
905 +
    Recover,
904 906
    /// Check this repository's import of a Git branch: every commit's tree must be reproduced exactly
905 907
    Verify {
906 908
        /// The Git repository the import came from
…
380 unchanged lines
1287 1289
            cmd: GitCmd::Push { url, branch },
1288 1290
        } => sync::git_push(&url, &branch),
1289 1291
        Cmd::Git {
1292 +
            cmd: GitCmd::Recover,
1293 +
        } => sync::git_recover(),
1294 +
        Cmd::Git {
1290 1295
            cmd:
1291 1296
                GitCmd::Import {
1292 1297
                    git_dir,
…
86 unchanged lines
modified src/cli/sync.rs +19 −0
…
547 unchanged lines
548 548
    Ok(0)
549 549
}
550 550
551 +
pub fn git_recover() -> Result<i32> {
552 +
    let repo = open()?;
553 +
    let sp = ui::spinner("Reading the store…");
554 +
    let (changes, heads) = repo.recover_git_import(&|m| sp.set(m))?;
555 +
    sp.done(&format!(
556 +
        "{} {} from imported Git commits: {} now has {}",
557 +
        ui::done("Recovered"),
558 +
        plural(changes, "change"),
559 +
        ui::bookmark(&repo.config.bookmark),
560 +
        plural(heads, "head")
561 +
    ));
562 +
    println!(
563 +
        "  {} {}",
564 +
        ui::note(),
565 +
        ui::dim("carry on with oxy git fetch GIT_DIR --branch BRANCH")
566 +
    );
567 +
    Ok(0)
568 +
}
569 +
551 570
pub fn git_fetch(url: &str, branch: &str) -> Result<i32> {
552 571
    let repo = open()?;
553 572
    let sp = ui::spinner(&format!("Fetching {url} {branch}…"));
…
317 unchanged lines