OXYGENHUB

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