OXYGENHUB

prompt: compare a subrepo by its pin, not its directory's time 80677b7a3513

Right after a commit, the quick dirty check took a subrepo's directory
for a racy file and the prompt showed * until a full status ran. The
fast paths now leave subrepo entries out of the stat and racy checks
and compare each one's pin instead.

closes #75
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 18:16 UTC
Signature
✔ signed by Andy McCall
Hash
80677b7a3513d273c0f0882bce963c630769560b657700b5ca2457f3b678ce7d
Depends on
135212de5370 1ec3f008e96a 3604179679eb 982864068a59 d069701a4997 d4dbab97e948 e96a430c78f3
Depended on by
7993d465675b
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, 2 files

Browse the tree at this change

modified crates/oxy-core/src/repo/prompt.rs +66 −3
…
710 unchanged lines
711 711
                        md.len() == e.size
712 712
                            && fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == e.hash)
713 713
                    }
714 +
                    // A subrepo: its pin is compared on its own (`subrepo_moved`),
715 +
                    // its directory's time says nothing (oxygen#75).
716 +
                    Ok(md) if md.is_dir() => {
717 +
                        path.join(OXY_DIR).exists() || path.join(".git").exists()
718 +
                    }
714 719
                    _ => false,
715 720
                };
716 721
                !same
…
117 unchanged lines
834 839
        self.disk_differs_listing(manifest, keep)
835 840
    }
836 841
842 +
    /// The manifest's entries that are subrepos (nested repositories it
843 +
    /// tracks): a directory, compared by its pin, not its stat.
844 +
    /// Known from the walk's nested repositories, and from `.oxymodules`:
845 +
    /// a commit's manifest does not list the nested repositories.
846 +
    fn subrepo_entries<'m>(&self, manifest: &'m Manifest) -> Vec<(&'m String, &'m ManifestEntry)> {
847 +
        let modules = self.modules_on_disk();
848 +
        let mut out: Vec<(&'m String, &'m ManifestEntry)> = manifest
849 +
            .nested
850 +
            .iter()
851 +
            .map(String::as_str)
852 +
            .chain(modules.entries.keys().map(String::as_str))
853 +
            .filter_map(|n| manifest.entries.get_key_value(n))
854 +
            .collect();
855 +
        out.sort_by(|a, b| a.0.cmp(b.0));
856 +
        out.dedup_by(|a, b| a.0 == b.0);
857 +
        out
858 +
    }
859 +
860 +
    /// Whether a subrepo is at another pin than the manifest recorded: its
861 +
    /// pin now (the nested repository's base, or Git's `HEAD`), encoded as
862 +
    /// a snapshot records it, against the recorded hash. Absent, it keeps
863 +
    /// its recorded pin, as a snapshot does; unreadable, it counts as moved.
864 +
    fn subrepo_moved(&self, rel: &str, me: &ManifestEntry) -> bool {
865 +
        let dir = self.root.join(rel);
866 +
        if !(dir.join(OXY_DIR).exists() || dir.join(".git").exists()) {
867 +
            return false;
868 +
        }
869 +
        let modules = self.modules_on_disk();
870 +
        let hint = modules.entries.get(rel).and_then(|m| m.bookmark.clone());
871 +
        match super::subrepos::read_pin(&dir, hint.as_deref()) {
872 +
            // A commit's manifest names the pin by its blob (its hash is of
873 +
            // no bytes); a snapshot's by the bytes' hash.
874 +
            Ok(pin) => match me.blob {
875 +
                Some(b) => Repo::blob_ref(&pin.encode()) != b,
876 +
                None => *blake3::hash(&pin.encode()).as_bytes() != me.hash,
877 +
            },
878 +
            Err(_) => true,
879 +
        }
880 +
    }
881 +
837 882
    /// [`Repo::disk_differs`] without listing the tree, when the manifest
838 883
    /// knows its directories: a directory's time moves when an entry is
839 884
    /// added, removed or renamed in it, so with every directory's time as
…
28 unchanged lines
868 913
        if !dirs_same {
869 914
            return None;
870 915
        }
871 -
        let rels: Vec<&String> = manifest.entries.keys().collect();
916 +
        // Subrepos are directories, compared by their pins below.
917 +
        let subrepos = self.subrepo_entries(manifest);
918 +
        let is_subrepo = |rel: &str| subrepos.iter().any(|(r, _)| r.as_str() == rel);
919 +
        let tracked: Vec<(&String, &ManifestEntry)> = manifest
920 +
            .entries
921 +
            .iter()
922 +
            .filter(|(rel, _)| !is_subrepo(rel))
923 +
            .collect();
924 +
        let rels: Vec<&String> = tracked.iter().map(|(r, _)| *r).collect();
872 925
        let now = stat_tracked(root, &rels);
873 926
        let differs_from = |me: &ManifestEntry, rel: &str, now: &Tracked| match now {
874 927
            Tracked::Link(t) => *blake3::hash(t).as_bytes() != me.hash,
…
11 unchanged lines
886 939
            return None;
887 940
        }
888 941
        // The entries in the order `rels` was taken in: no lookup by path.
889 -
        let entries: Vec<&ManifestEntry> = manifest.entries.values().collect();
942 +
        let entries: Vec<&ManifestEntry> = tracked.iter().map(|(_, me)| *me).collect();
890 943
        let differs = rels
891 944
            .par_iter()
892 945
            .zip(entries.par_iter())
893 946
            .zip(now.par_iter())
894 -
            .any(|((rel, me), n)| differs_from(me, rel, n.as_ref().unwrap()));
947 +
            .any(|((rel, me), n)| differs_from(me, rel, n.as_ref().unwrap()))
948 +
            || subrepos.iter().any(|(rel, me)| self.subrepo_moved(rel, me));
895 949
        if keep && (differs || racy) {
896 950
            let mut listing = super::walk::Listing {
897 951
                dirs: manifest.dirs.clone(),
…
88 unchanged lines
986 1040
                })
987 1041
                .collect();
988 1042
            let root = &self.root;
1043 +
            // Subrepos are not listed as files: compared by their pins.
1044 +
            let subrepos = self.subrepo_entries(manifest);
1045 +
            if subrepos.iter().any(|(rel, me)| self.subrepo_moved(rel, me)) {
1046 +
                return Ok(true);
1047 +
            }
1048 +
            let skipped: Vec<_> = skipped
1049 +
                .into_iter()
1050 +
                .filter(|(rel, _)| !subrepos.iter().any(|(r, _)| r == rel))
1051 +
                .collect();
989 1052
            Ok(skipped.par_iter().any(|(rel, me)| {
990 1053
                let path = root.join(rel.as_str());
991 1054
                let Ok(m) = fs::symlink_metadata(&path) else {
…
114 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +47 −0
…
7659 unchanged lines
7660 7660
        .to_string();
7661 7661
    assert!(err.contains("cannot open that kind of store"), "{err}");
7662 7662
}
7663 +
7664 +
/// oxygen#75: the prompt's quick check agrees with status in a repository
7665 +
/// with a subrepo: clean after a commit, dirty when the subrepo moves to
7666 +
/// another state, clean again once that pin is committed. (It compared the
7667 +
/// subrepo's directory as if it were a file, so it was always dirty.)
7668 +
#[test]
7669 +
fn the_quick_dirty_check_compares_a_subrepo_by_its_pin() {
7670 +
    hermetic();
7671 +
    let dir = tempfile::tempdir().unwrap();
7672 +
    let root = dir.path();
7673 +
    let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
7674 +
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
7675 +
    let lib = Repo::clone(&lib_url, &root.join("lib"))
7676 +
        .unwrap()
7677 +
        .with_home(root.join("home"));
7678 +
    write(&root.join("lib"), "lib.txt", b"v1\n");
7679 +
    lib.commit("lib v1", &[]).unwrap();
7680 +
    assert!(lib.push("origin", false).unwrap()[0].ok);
7681 +
    let app_dir = root.join("app");
7682 +
    let app = Repo::init(&app_dir).unwrap().with_home(root.join("home"));
7683 +
    write(&app_dir, "main.txt", b"app\n");
7684 +
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
7685 +
    app.commit("app with lib", &[]).unwrap();
7686 +
    // Out of the racy window, so the stat-only path decides.
7687 +
    std::thread::sleep(std::time::Duration::from_millis(2100));
7688 +
    let reopen = || Repo::open(&app_dir).unwrap();
7689 +
    assert_eq!(
7690 +
        reopen().quick_dirty().unwrap(),
7691 +
        Some(false),
7692 +
        "clean, as status says"
7693 +
    );
7694 +
    let nested = Repo::open(&app_dir.join("libs/lib")).unwrap();
7695 +
    write(&app_dir.join("libs/lib"), "lib.txt", b"v2\n");
7696 +
    nested.commit("lib v2", &[]).unwrap();
7697 +
    assert_eq!(
7698 +
        reopen().quick_dirty().unwrap(),
7699 +
        Some(true),
7700 +
        "the subrepo moved"
7701 +
    );
7702 +
    reopen().commit("pin lib v2", &[]).unwrap();
7703 +
    std::thread::sleep(std::time::Duration::from_millis(2100));
7704 +
    assert_eq!(
7705 +
        reopen().quick_dirty().unwrap(),
7706 +
        Some(false),
7707 +
        "the new pin is committed"
7708 +
    );
7709 +
}