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 |