OXYGENHUB

Fetch reports how each bookmark stands against the remote's copy b35df36b7dc5

oxy fetch said 'up to date' when nothing new arrived, while the local
bookmark was still behind what an earlier fetch brought. It now
compares the two: behind (with the pull to run), ahead, diverged, or up
to date; --json carries ahead and behind. A change only in a remote
bookmark resolves by id or hash, and oxy show says where it is. With a
single remote not named origin, fetch, pull and push default to it, and
bookmark list and the prompt count against it (metalgear findings 22
and 29). Test.

closes #56
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 11:19 UTC
Signature
✔ signed by Andy McCall
Hash
b35df36b7dc57fcce2bf0fadde6a12c5d0e2fc4da06f0f4ebb6b46637dc01df1
Depends on
135212de5370 360aa74a6277 75167672ebcd 7b9809e67c3d 83e0677b07fe 8fd78eb52ad2 94084087a876 982864068a59 c07406bcc237 f8c695764c95 fce44ae9ad29 fddf64baa3d2
Depended on by
42ccf504aa50 5cde7882d767 bea00d4177c6 827281eed2ed
In bookmarks
agents-in-readme 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.3.0 v0.3.1 v0.4.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
23 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Fetch says when a bookmark is behind (#56).** `oxy fetch` reports each bookmark against the remote's copy ("main is 1 change behind: oxy pull to take it", ahead, diverged) and says "up to date" only when they match; `--json` gives `ahead` and `behind`. A change that was fetched but not pulled resolves by id or hash, and `oxy show` says it is only in `origin/main`. With one remote not called `origin`, fetch, pull and push use it, and `bookmark list` and the prompt count against it.
6 +
5 7
- **Blame twice as fast (#41).** `oxy blame` prints through a buffered writer and formats each change once, and the state index's tree view loads in one step: blame of the Linux tree's MAINTAINERS (30 000 lines) is 0.08 s, from 0.16 s; tuned Git 0.06 s. The faster tree view also takes 16 ms off diff, commit and checkout.
6 8
7 9
- **`oxy log` in milliseconds (#41).** Log no longer checks the files on disk: its working-copy row shows the working copy as last recorded, "(working copy)" or "(working copy, uncommitted changes)", as Git's log never looks at the working tree; `oxy status` checks the disk. `log -n 100` is 0.03 s on the Linux tree (from 0.17 s) and 0.02 s on a 10 000-commit history.
…
58 unchanged lines
modified crates/oxy-core/src/repo/bookmarks.rs +34 −3
…
10 unchanged lines
11 11
    pub heads: Vec<ChangeHash>,
12 12
    /// The workspace's current bookmark.
13 13
    pub current: bool,
14 -
    /// Changes ahead of / behind `origin`'s copy, when tracked.
14 +
    /// Changes ahead of / behind the default remote's copy, when tracked.
15 15
    pub ahead: Option<usize>,
16 16
    pub behind: Option<usize>,
17 17
}
…
62 unchanged lines
80 80
            .join("bookmark")
81 81
    }
82 82
83 -
    /// Every bookmark with its heads and remote-tracking difference.
83 +
    /// The remote a command uses when none is named: `origin`, else the
84 +
    /// only remote there is (finding 29 of the metalgear feedback: a
85 +
    /// checkout whose one remote is `earth`).
86 +
    pub fn default_remote(&self) -> String {
87 +
        match self.config.remotes.keys().collect::<Vec<_>>().as_slice() {
88 +
            [only] if *only != "origin" => (*only).clone(),
89 +
            _ => "origin".into(),
90 +
        }
91 +
    }
92 +
93 +
    /// How bookmark `name` stands against `remote`'s copy of it: the
94 +
    /// changes only here and only there. `None` when either side has no
95 +
    /// such bookmark.
96 +
    pub fn tracking(&self, remote: &str, name: &str) -> Result<Option<(usize, usize)>> {
97 +
        let view = self.view()?;
98 +
        let (Some(st), Some(rs)) = (
99 +
            view.bookmarks.get(name),
100 +
            view.remotes.get(remote).and_then(|b| b.get(name)),
101 +
        ) else {
102 +
            return Ok(None);
103 +
        };
104 +
        let heads = self.store.get_state(st)?.heads;
105 +
        let remote_heads = self.store.get_state(rs)?.heads;
106 +
        Ok(Some((
107 +
            self.negotiate(&remote_heads, &heads)?.len(),
108 +
            self.negotiate(&heads, &remote_heads)?.len(),
109 +
        )))
110 +
    }
111 +
112 +
    /// Every bookmark with its heads and its difference from the default
113 +
    /// remote's copy.
84 114
    pub fn bookmarks(&self) -> Result<Vec<BookmarkInfo>> {
85 115
        let view = self.view()?;
116 +
        let remote = self.default_remote();
86 117
        let mut out = Vec::new();
87 118
        for (name, st) in &view.bookmarks {
88 119
            let heads = self.store.get_state(st)?.heads;
89 -
            let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(name)) {
120 +
            let (ahead, behind) = match view.remotes.get(&remote).and_then(|b| b.get(name)) {
90 121
                Some(rs) => {
91 122
                    let remote_heads = self.store.get_state(rs)?.heads;
92 123
                    (
…
455 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +46 −1
…
1931 unchanged lines
1932 1932
        };
1933 1933
        match matches.len() {
1934 1934
            1 => Ok(matches[0].hash),
1935 -
            0 => fail(format!("no change matches {spec:?}")),
1935 +
            0 => match self.resolve_in_remotes(spec)? {
1936 +
                Some(h) => Ok(h),
1937 +
                None => fail(format!("no change matches {spec:?}")),
1938 +
            },
1936 1939
            _ => {
1937 1940
                let ids: BTreeSet<ChangeId> = matches.iter().map(|e| e.change.change_id).collect();
1938 1941
                if ids.len() == 1 {
…
3 unchanged lines
1942 1945
                    ))
1943 1946
                } else {
1944 1947
                    fail(format!("{spec:?} is ambiguous"))
1948 +
                }
1949 +
            }
1950 +
        }
1951 +
    }
1952 +
1953 +
    /// A change no local bookmark holds, found by id or `#hash` prefix in
1954 +
    /// the remote bookmarks' states: fetched but not yet pulled (oxygen#56).
1955 +
    /// `None` when none matches, or more than one.
1956 +
    fn resolve_in_remotes(&self, spec: &str) -> Result<Option<ChangeHash>> {
1957 +
        let view = self.view()?;
1958 +
        let mut found: BTreeSet<ChangeHash> = BTreeSet::new();
1959 +
        for bookmarks in view.remotes.values() {
1960 +
            for st in bookmarks.values() {
1961 +
                let heads = self.store.get_state(st)?.heads;
1962 +
                for e in self.log_state(&heads)? {
1963 +
                    let hit = match spec.strip_prefix('#') {
1964 +
                        Some(hp) => e.hash.starts_with_hex(hp),
1965 +
                        None => e.change.change_id.starts_with_hex(spec),
1966 +
                    };
1967 +
                    if hit {
1968 +
                        found.insert(e.hash);
1969 +
                    }
1970 +
                }
1971 +
            }
1972 +
        }
1973 +
        Ok((found.len() == 1).then(|| *found.first().unwrap()))
1974 +
    }
1975 +
1976 +
    /// Where a change is when no local bookmark or the working copy holds
1977 +
    /// it: the first remote bookmark that does (`origin/main`). `None` when
1978 +
    /// it is held locally, or nowhere.
1979 +
    pub fn remote_only_location(&self, hash: &ChangeHash) -> Result<Option<String>> {
1980 +
        if self.log()?.iter().any(|e| e.hash == *hash) {
1981 +
            return Ok(None);
1982 +
        }
1983 +
        let view = self.view()?;
1984 +
        for (remote, bookmarks) in &view.remotes {
1985 +
            for (name, st) in bookmarks {
1986 +
                let heads = self.store.get_state(st)?.heads;
1987 +
                if self.log_state(&heads)?.iter().any(|e| e.hash == *hash) {
1988 +
                    return Ok(Some(format!("{remote}/{name}")));
1945 1989
                }
1946 1990
            }
1947 1991
        }
1992 +
        Ok(None)
1948 1993
    }
1949 1994
1950 1995
    /// Human-readable description of what a change does, with line content.
…
149 unchanged lines
modified crates/oxy-core/src/repo/prompt.rs +5 −1
…
977 unchanged lines
978 978
        let manifest = self.read_manifest()?;
979 979
        let conflicted = manifest.as_ref().map(|(c, _, _)| *c).unwrap_or(false);
980 980
        let dirty = self.quick_dirty()?.unwrap_or(false);
981 -
        let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(&bookmark)) {
981 +
        let (ahead, behind) = match view
982 +
            .remotes
983 +
            .get(&self.default_remote())
984 +
            .and_then(|b| b.get(&bookmark))
985 +
        {
982 986
            Some(st) => {
983 987
                let remote_heads = self.store.get_state(st)?.heads;
984 988
                let ahead = self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok();
…
56 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +56 −0
…
6861 unchanged lines
6862 6862
        "nothing left to commit"
6863 6863
    );
6864 6864
}
6865 +
6866 +
/// oxygen#56: after a fetch, how a bookmark stands against its remote copy
6867 +
/// is known (fetch printed "up to date" while main was behind), a change
6868 +
/// only fetched resolves and says where it is, and a checkout whose only
6869 +
/// remote is not `origin` uses that one.
6870 +
#[test]
6871 +
fn a_fetched_change_shows_as_behind_and_resolves() {
6872 +
    hermetic();
6873 +
    let dir = tempfile::tempdir().unwrap();
6874 +
    let root = dir.path();
6875 +
    let origin = Repo::init(&root.join("origin")).unwrap();
6876 +
    drop(origin);
6877 +
    let url = root.join("origin").to_string_lossy().to_string();
6878 +
    let a = Repo::clone(&url, &root.join("a"))
6879 +
        .unwrap()
6880 +
        .with_home(root.join("home-a"));
6881 +
    write(&root.join("a"), "f.txt", b"one\n");
6882 +
    a.commit("one", &[]).unwrap();
6883 +
    a.push("origin", false).unwrap();
6884 +
    let b = Repo::clone(&url, &root.join("b"))
6885 +
        .unwrap()
6886 +
        .with_home(root.join("home-b"));
6887 +
    assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 0)));
6888 +
6889 +
    write(&root.join("a"), "f.txt", b"one\ntwo\n");
6890 +
    let h2 = a.commit("two", &[]).unwrap();
6891 +
    a.push("origin", false).unwrap();
6892 +
    b.fetch("origin").unwrap();
6893 +
    // Fetched, not pulled: one behind, the change found and placed.
6894 +
    assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 1)));
6895 +
    assert_eq!(b.tracking("origin", "nope").unwrap(), None);
6896 +
    assert_eq!(
6897 +
        b.resolve_change(&format!("#{}", &h2.to_hex()[..12]))
6898 +
            .unwrap(),
6899 +
        h2
6900 +
    );
6901 +
    assert_eq!(
6902 +
        b.remote_only_location(&h2).unwrap().as_deref(),
6903 +
        Some("origin/main")
6904 +
    );
6905 +
    // Fetching again changes nothing: still behind, which is what the
6906 +
    // CLI now says rather than "up to date".
6907 +
    b.fetch("origin").unwrap();
6908 +
    assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 1)));
6909 +
    b.pull("origin").unwrap();
6910 +
    assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 0)));
6911 +
    assert_eq!(b.remote_only_location(&h2).unwrap(), None);
6912 +
6913 +
    // One remote, not called origin: it is the default.
6914 +
    let mut c = Repo::init(&root.join("c")).unwrap();
6915 +
    assert_eq!(c.default_remote(), "origin");
6916 +
    c.config.set("remote.earth.url", &url).unwrap();
6917 +
    assert_eq!(c.default_remote(), "earth");
6918 +
    c.config.set("remote.origin.url", &url).unwrap();
6919 +
    assert_eq!(c.default_remote(), "origin");
6920 +
}
modified src/cli/sync.rs +50 −15
…
179 unchanged lines
180 180
    Ok(0)
181 181
}
182 182
183 -
fn remote_name(remote: Option<&str>) -> String {
184 -
    remote.unwrap_or("origin").to_string()
183 +
/// The remote named, else `origin`, else the only remote there is.
184 +
fn remote_name(repo: &oxy_core::repo::Repo, remote: Option<&str>) -> String {
185 +
    remote.map_or_else(|| repo.default_remote(), str::to_string)
185 186
}
186 187
187 188
pub fn fetch(remote: Option<&str>, as_json: bool) -> Result<i32> {
188 189
    let repo = open()?;
189 -
    let remote = remote_name(remote);
190 +
    let remote = remote_name(&repo, remote);
190 191
    let sp = ui::spinner(&format!("Fetching from {remote}…"));
191 192
    let report = repo.fetch(&remote)?;
192 193
    sp.clear();
…
4 unchanged lines
197 198
                ("remote", json::s(&remote)),
198 199
                (
199 200
                    "refs",
200 -
                    json::arr(report.refs.iter().map(|(name, heads, n)| json::obj([
201 -
                        ("name", json::s(name)),
202 -
                        ("heads", json::hashes(heads)),
203 -
                        ("new", json::n(*n))
204 -
                    ])))
201 +
                    json::arr(report.refs.iter().map(|(name, heads, n)| {
202 +
                        let t = repo.tracking(&remote, name).ok().flatten();
203 +
                        json::obj([
204 +
                            ("name", json::s(name)),
205 +
                            ("heads", json::hashes(heads)),
206 +
                            ("new", json::n(*n)),
207 +
                            (
208 +
                                "ahead",
209 +
                                t.map_or_else(|| "null".into(), |(a, _)| json::n(a)),
210 +
                            ),
211 +
                            (
212 +
                                "behind",
213 +
                                t.map_or_else(|| "null".into(), |(_, b)| json::n(b)),
214 +
                            ),
215 +
                        ])
216 +
                    }))
205 217
                ),
206 218
                ("new_tags", json::n(report.new_tags)),
207 219
                ("unverified", json::b(report.unverified)),
…
9 unchanged lines
217 229
        );
218 230
    }
219 231
    for (name, heads, n) in &report.refs {
220 -
        let what = if *n == 0 {
221 -
            ui::dim("up to date")
222 -
        } else {
223 -
            ui::ok(plural(*n, "new change"))
232 +
        // What was fetched, then how the local bookmark stands against it:
233 +
        // "up to date" only when the two are the same (oxygen#56).
234 +
        let fetched =
235 +
            (*n > 0).then(|| format!("{}{}", ui::ok(plural(*n, "new change")), ui::sep()));
236 +
        let them = |k: usize| if k == 1 { "it" } else { "them" };
237 +
        let standing = match repo.tracking(&remote, name).ok().flatten() {
238 +
            None => ui::dim(format!("no local bookmark {name}")),
239 +
            Some((0, 0)) => ui::dim(format!("{name} up to date")),
240 +
            Some((0, b)) if *name == repo.config.bookmark => ui::warn(format!(
241 +
                "{name} is {} behind: oxy pull to take {}",
242 +
                plural(b, "change"),
243 +
                them(b)
244 +
            )),
245 +
            Some((0, b)) => ui::warn(format!(
246 +
                "{name} is {} behind: oxy bookmark switch {name} then oxy pull to take {}",
247 +
                plural(b, "change"),
248 +
                them(b)
249 +
            )),
250 +
            Some((a, 0)) => ui::dim(format!(
251 +
                "{name} is {} ahead: oxy push to send {}",
252 +
                plural(a, "change"),
253 +
                them(a)
254 +
            )),
255 +
            Some((a, b)) => ui::warn(format!(
256 +
                "{name} and {remote}/{name} have diverged ({a} here, {b} there): oxy pull to merge them"
257 +
            )),
224 258
        };
259 +
        let what = format!("{}{standing}", fetched.unwrap_or_default());
225 260
        println!(
226 261
            "{} {}{}{}{}{what}",
227 262
            ui::done("Fetched"),
…
50 unchanged lines
278 313
279 314
pub fn pull(remote: Option<&str>, as_json: bool) -> Result<i32> {
280 315
    let repo = open()?;
281 -
    let remote = remote_name(remote);
316 +
    let remote = remote_name(&repo, remote);
282 317
    let sp = ui::spinner(&format!("Pulling from {remote}…"));
283 318
    let r = repo.pull(&remote)?;
284 319
    if as_json {
…
77 unchanged lines
362 397
/// `oxy push [REMOTE] --delete NAME`.
363 398
pub fn push_delete(remote: Option<&str>, name: &str, as_json: bool) -> Result<i32> {
364 399
    let repo = open()?;
365 -
    let remote = remote_name(remote);
400 +
    let remote = remote_name(&repo, remote);
366 401
    let sp = ui::spinner(&format!("Deleting {remote}/{name}…"));
367 402
    let results = repo.push_delete(&remote, name)?;
368 403
    sp.clear();
…
41 unchanged lines
410 445
    as_json: bool,
411 446
) -> Result<i32> {
412 447
    let repo = open()?;
413 -
    let remote = remote_name(remote);
448 +
    let remote = remote_name(&repo, remote);
414 449
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
415 450
    let results = match bookmark {
416 451
        Some(b) => repo.push_bookmark(&remote, b, force)?,
…
416 unchanged lines
modified src/cli/workspace.rs +12 −0
…
1065 unchanged lines
1066 1066
        ui::hash(format!("#{h}"))
1067 1067
    );
1068 1068
    println!("{}", ui::kv("kind", format!("{:?}", c.kind).to_lowercase()));
1069 +
    // Fetched but not pulled (oxygen#56): say where it is.
1070 +
    if let Some(at) = repo.remote_only_location(&h)? {
1071 +
        println!(
1072 +
            "{}",
1073 +
            ui::kv(
1074 +
                "only in",
1075 +
                ui::warn(format!(
1076 +
                    "{at}: fetched, not yet in a local bookmark (oxy pull)"
1077 +
                ))
1078 +
            )
1079 +
        );
1080 +
    }
1069 1081
    println!(
1070 1082
        "{}",
1071 1083
        ui::kv(
…
1321 unchanged lines