Several remotes and no origin: a default, and the right comparison 42ccf504aa50
A repository with remotes earth and gcs and none named origin got no ahead/behind in bookmark list, and pull said 'no remote named origin; add one'. bookmark list now compares each bookmark with a remote that has it (the default when it does, else the first by name, never one no longer configured) and names it when there are several. core.remote sets the default remote, and without one the error names the remotes and both ways forward. closes #83
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-08 21:56 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
42ccf504aa505f7aa691f396b185182832ed303149d4a9cf5512bb20b089c7e6- Depends on
- 14edbaaf5291 2b5ee371060e 6d3eefbce2f0 72484195899a 75167672ebcd 7b9809e67c3d 982864068a59 b35df36b7dc5 ba160f60219e bf3a67819b59 d069701a4997 ee06a6c557b7 fce44ae9ad29
- Depended on by
- d6e0d5b8b339 cbe8499a5a8c
- 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.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
- 15 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/oxy-core/src/repo/bookmarks.rs +33 −2
| … | 13 unchanged lines |
||
| 14 | 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 | + | /// The remote `ahead` and `behind` are measured against. |
|
| 18 | + | pub remote: Option<String>, |
|
| 17 | 19 | } |
|
| 18 | 20 | ||
| 19 | 21 | #[derive(Clone, Debug)] |
|
| … | 64 unchanged lines |
||
| 84 | 86 | /// only remote there is (finding 29 of the metalgear feedback: a |
|
| 85 | 87 | /// checkout whose one remote is `earth`). |
|
| 86 | 88 | pub fn default_remote(&self) -> String { |
|
| 89 | + | if let Some(r) = &self.config.default_remote { |
|
| 90 | + | return r.clone(); |
|
| 91 | + | } |
|
| 87 | 92 | match self.config.remotes.keys().collect::<Vec<_>>().as_slice() { |
|
| 88 | 93 | [only] if *only != "origin" => (*only).clone(), |
|
| 89 | 94 | _ => "origin".into(), |
|
| 90 | 95 | } |
|
| 91 | 96 | } |
|
| 92 | 97 | ||
| 98 | + | /// The remote a bookmark is compared with: the default when it has |
|
| 99 | + | /// the bookmark, else the first remote by name that does (a repository |
|
| 100 | + | /// with `earth` and `gcs` and no `origin`, oxygen#83). |
|
| 101 | + | fn tracking_remote(&self, view: &View, name: &str) -> Option<String> { |
|
| 102 | + | // Only remotes still configured: a removed one's copies linger. |
|
| 103 | + | let configured = |r: &str| self.config.remotes.contains_key(r); |
|
| 104 | + | let default = self.default_remote(); |
|
| 105 | + | if configured(&default) |
|
| 106 | + | && view |
|
| 107 | + | .remotes |
|
| 108 | + | .get(&default) |
|
| 109 | + | .is_some_and(|b| b.contains_key(name)) |
|
| 110 | + | { |
|
| 111 | + | return Some(default); |
|
| 112 | + | } |
|
| 113 | + | view.remotes |
|
| 114 | + | .iter() |
|
| 115 | + | .find(|(r, b)| configured(r) && b.contains_key(name)) |
|
| 116 | + | .map(|(r, _)| r.clone()) |
|
| 117 | + | } |
|
| 118 | + | ||
| 93 | 119 | /// How bookmark `name` stands against `remote`'s copy of it: the |
|
| 94 | 120 | /// changes only here and only there. `None` when either side has no |
|
| 95 | 121 | /// such bookmark. |
|
| … | 17 unchanged lines |
||
| 113 | 139 | /// remote's copy. |
|
| 114 | 140 | pub fn bookmarks(&self) -> Result<Vec<BookmarkInfo>> { |
|
| 115 | 141 | let view = self.view()?; |
|
| 116 | - | let remote = self.default_remote(); |
|
| 117 | 142 | let mut out = Vec::new(); |
|
| 118 | 143 | for (name, st) in &view.bookmarks { |
|
| 119 | 144 | let heads = self.store.get_state(st)?.heads; |
|
| 120 | - | let (ahead, behind) = match view.remotes.get(&remote).and_then(|b| b.get(name)) { |
|
| 145 | + | let remote = self.tracking_remote(&view, name); |
|
| 146 | + | let (ahead, behind) = match remote |
|
| 147 | + | .as_ref() |
|
| 148 | + | .and_then(|r| view.remotes.get(r)) |
|
| 149 | + | .and_then(|b| b.get(name)) |
|
| 150 | + | { |
|
| 121 | 151 | Some(rs) => { |
|
| 122 | 152 | let remote_heads = self.store.get_state(rs)?.heads; |
|
| 123 | 153 | ( |
|
| … | 9 unchanged lines |
||
| 133 | 163 | current: *name == self.config.bookmark, |
|
| 134 | 164 | ahead, |
|
| 135 | 165 | behind, |
|
| 166 | + | remote, |
|
| 136 | 167 | }); |
|
| 137 | 168 | } |
|
| 138 | 169 | Ok(out) |
|
| … | 507 unchanged lines |
modified crates/oxy-core/src/repo/config.rs +7 −0
| … | 27 unchanged lines |
||
| 28 | 28 | pub remote_tokens: BTreeMap<String, String>, |
|
| 29 | 29 | /// `remote.<name>.ca`: PEM file of extra roots to trust for that remote. |
|
| 30 | 30 | pub remote_cas: BTreeMap<String, String>, |
|
| 31 | + | /// `core.remote`: the remote `pull`, `fetch` and `push` use when none is |
|
| 32 | + | /// named, for a repository whose remotes include no `origin`. |
|
| 33 | + | pub default_remote: Option<String>, |
|
| 31 | 34 | /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21). |
|
| 32 | 35 | pub op_retention_days: u64, |
|
| 33 | 36 | /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21). |
|
| … | 34 unchanged lines |
||
| 68 | 71 | remotes: BTreeMap::new(), |
|
| 69 | 72 | remote_tokens: BTreeMap::new(), |
|
| 70 | 73 | remote_cas: BTreeMap::new(), |
|
| 74 | + | default_remote: None, |
|
| 71 | 75 | op_retention_days: 90, |
|
| 72 | 76 | gc_grace_days: 14, |
|
| 73 | 77 | checkpoint_interval: 256, |
|
| … | 84 unchanged lines |
||
| 158 | 162 | parse_size(value)? |
|
| 159 | 163 | } |
|
| 160 | 164 | } |
|
| 165 | + | "core.remote" => { |
|
| 166 | + | self.default_remote = (!value.is_empty()).then(|| value.to_string()); |
|
| 167 | + | } |
|
| 161 | 168 | "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"), |
|
| 162 | 169 | "filter.paths" => { |
|
| 163 | 170 | self.sparse_paths = value |
|
| … | 143 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +14 −3
| … | 879 unchanged lines |
||
| 880 | 880 | ||
| 881 | 881 | fn remote_url(&self, name: &str) -> Result<String> { |
|
| 882 | 882 | self.config.remotes.get(name).cloned().ok_or_else(|| { |
|
| 883 | - | RepoError::Msg(format!( |
|
| 884 | - | "no remote named {name:?}; add one with `oxy remote add`" |
|
| 885 | - | )) |
|
| 883 | + | let names: Vec<&str> = self.config.remotes.keys().map(String::as_str).collect(); |
|
| 884 | + | RepoError::Msg(match names.as_slice() { |
|
| 885 | + | [] => format!("no remote named {name:?}; add one with `oxy remote add`"), |
|
| 886 | + | _ if name == "origin" && names.len() > 1 && self.config.default_remote.is_none() => format!( |
|
| 887 | + | "this repository has no remote named origin, and several others ({}): name one (`oxy pull {}`), or make one the default with `oxy config core.remote {}`", |
|
| 888 | + | names.join(", "), |
|
| 889 | + | names[0], |
|
| 890 | + | names[0] |
|
| 891 | + | ), |
|
| 892 | + | _ => format!( |
|
| 893 | + | "no remote named {name:?}; this repository has {}", |
|
| 894 | + | names.join(", ") |
|
| 895 | + | ), |
|
| 896 | + | }) |
|
| 886 | 897 | }) |
|
| 887 | 898 | } |
|
| 888 | 899 | ||
| … | 1032 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +47 −0
| … | 7830 unchanged lines |
||
| 7831 | 7831 | one.fetch("origin").unwrap(); |
|
| 7832 | 7832 | assert_eq!(one.catch_up_bookmark("main").unwrap(), None); |
|
| 7833 | 7833 | } |
|
| 7834 | + | ||
| 7835 | + | #[test] |
|
| 7836 | + | fn a_bookmark_is_compared_with_a_remote_that_has_it_when_there_is_no_origin() { |
|
| 7837 | + | // oxygen#83: remotes earth and gcs, origin removed. |
|
| 7838 | + | hermetic(); |
|
| 7839 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 7840 | + | let root = dir.path(); |
|
| 7841 | + | let _up = Repo::init(&root.join("up")).unwrap(); |
|
| 7842 | + | let url = root.join("up").to_string_lossy().to_string(); |
|
| 7843 | + | let other = Repo::clone(&url, &root.join("other")) |
|
| 7844 | + | .unwrap() |
|
| 7845 | + | .with_home(root.join("home")); |
|
| 7846 | + | write(&root.join("other"), "a.txt", b"a\n"); |
|
| 7847 | + | other.commit("a", &[]).unwrap(); |
|
| 7848 | + | assert!(other.push("origin", false).unwrap()[0].ok); |
|
| 7849 | + | let mut one = Repo::clone(&url, &root.join("one")) |
|
| 7850 | + | .unwrap() |
|
| 7851 | + | .with_home(root.join("home")); |
|
| 7852 | + | one.config.set("remote.earth.url", &url).unwrap(); |
|
| 7853 | + | one.config.set("remote.gcs.url", &url).unwrap(); |
|
| 7854 | + | one.config.set("remote.origin.url", "").unwrap(); |
|
| 7855 | + | one.save_config().unwrap(); |
|
| 7856 | + | write(&root.join("other"), "b.txt", b"b\n"); |
|
| 7857 | + | other.commit("b", &[]).unwrap(); |
|
| 7858 | + | assert!(other.push("origin", false).unwrap()[0].ok); |
|
| 7859 | + | one.fetch("earth").unwrap(); |
|
| 7860 | + | let b = one |
|
| 7861 | + | .bookmarks() |
|
| 7862 | + | .unwrap() |
|
| 7863 | + | .into_iter() |
|
| 7864 | + | .find(|b| b.name == "main") |
|
| 7865 | + | .unwrap(); |
|
| 7866 | + | assert_eq!( |
|
| 7867 | + | (b.remote.as_deref(), b.behind), |
|
| 7868 | + | (Some("earth"), Some(1)), |
|
| 7869 | + | "a stale origin copy is not used" |
|
| 7870 | + | ); |
|
| 7871 | + | assert_eq!( |
|
| 7872 | + | one.default_remote(), |
|
| 7873 | + | "origin", |
|
| 7874 | + | "no default chosen among several" |
|
| 7875 | + | ); |
|
| 7876 | + | let e = one.pull(&one.default_remote()).unwrap_err().to_string(); |
|
| 7877 | + | assert!(e.contains("earth, gcs") && e.contains("core.remote"), "{e}"); |
|
| 7878 | + | one.config.set("core.remote", "earth").unwrap(); |
|
| 7879 | + | assert_eq!(one.default_remote(), "earth"); |
|
| 7880 | + | } |
modified docs/guide.md +4 −0
| … | 385 unchanged lines |
||
| 386 | 386 | moved since you last fetched, the push is refused: pull and push again. |
|
| 387 | 387 | `--force` overrides the compare-and-swap. |
|
| 388 | 388 | - `oxy remote add|remove|list` manages remotes; the clone source is `origin`. |
|
| 389 | + | Commands that take a remote use `origin`, or the only remote there is; with |
|
| 390 | + | several and none named `origin`, name one or set `oxy config core.remote |
|
| 391 | + | NAME`. `bookmark list` compares each bookmark with a remote that has it, |
|
| 392 | + | and names the remote when there is more than one. |
|
| 389 | 393 | ||
| 390 | 394 | A repository can also be cloned from a local path, which is handy for tests |
|
| 391 | 395 | and for a bare "hub" on a shared disk. |
|
| … | 263 unchanged lines |
modified src/cli/workspace.rs +7 −0
| … | 2411 unchanged lines |
||
| 2412 | 2412 | ("current", json::b(b.current)), |
|
| 2413 | 2413 | ("ahead", json::opt(b.ahead, json::n)), |
|
| 2414 | 2414 | ("behind", json::opt(b.behind, json::n)), |
|
| 2415 | + | ("remote", json::opt(b.remote.as_deref(), json::s)), |
|
| 2415 | 2416 | ]) |
|
| 2416 | 2417 | }) |
|
| 2417 | 2418 | .collect(); |
|
| … | 25 unchanged lines |
||
| 2443 | 2444 | if let Some(n) = b.behind.filter(|n| *n > 0) { |
|
| 2444 | 2445 | tail += &ui::warn(format!(" {}{n}", ui::glyph("↓", "-"))); |
|
| 2445 | 2446 | } |
|
| 2447 | + | // Which remote, when there is a choice (oxygen#83). |
|
| 2448 | + | if !tail.is_empty() && repo.config.remotes.len() > 1 { |
|
| 2449 | + | if let Some(r) = &b.remote { |
|
| 2450 | + | tail += &ui::dim(format!(" {r}")); |
|
| 2451 | + | } |
|
| 2452 | + | } |
|
| 2446 | 2453 | let shown = heads_newest_first(&repo, &b.heads); |
|
| 2447 | 2454 | // The newest head's subject, so a busy bookmark shows its latest work. |
|
| 2448 | 2455 | let head = match shown.first() { |
|
| … | 229 unchanged lines |