OXYGENHUB

oxy remote add takes a store's settings; oxy remote check 9aa399044d9d

remote add --profile, --region, --endpoint and --credentials go on the
URL of an s3://, gs:// or az:// remote as query parameters, which is
where the stores read them; they are refused for other remotes.

remote check NAME reaches the remote with the credentials in hand and
says how long that took, what it holds (this repository, another one,
or nothing yet), and, for a store, runs the conformance check under a
probe prefix and lists what passed, leaving nothing behind. A
directory store now removes the directories its deletions leave empty.

refs #63
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-03 22:50 UTC
Signature
✔ signed by Andy McCall
Hash
9aa399044d9d1415fb4d3b74cb0f2941094de3cba57dadbfc8d9ba3b69c2455f
Depends on
14edbaaf5291 75167672ebcd 827281eed2ed a5f76c79666c c6aa5bd9d1c8 d42369abf180 fce44ae9ad29
Depended on by
7505194116e7 d8d9367878a6
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.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
14 atoms, 1 tree operation, 4 files

Browse the tree at this change

added tests/cli_strata.rs +122 −0
1 +
//! Remotes kept on a store, through the command line: `oxy remote add`
2 +
//! with its store settings, `oxy remote check`, and push, clone and pull
3 +
//! through a `dir://` store (docs/stores.md).
4 +
5 +
use std::path::Path;
6 +
use std::process::Command;
7 +
8 +
fn oxy(dir: &Path, home: &Path, args: &[&str]) -> (i32, String, String) {
9 +
    let out = Command::new(env!("CARGO_BIN_EXE_oxy"))
10 +
        .arg("--plain")
11 +
        .args(args)
12 +
        .current_dir(dir)
13 +
        .env("OXY_HOME", home)
14 +
        .env("NO_COLOR", "1")
15 +
        .output()
16 +
        .unwrap();
17 +
    (
18 +
        out.status.code().unwrap_or(-1),
19 +
        String::from_utf8_lossy(&out.stdout).to_string(),
20 +
        String::from_utf8_lossy(&out.stderr).to_string(),
21 +
    )
22 +
}
23 +
24 +
fn ok(dir: &Path, home: &Path, args: &[&str]) -> String {
25 +
    let (code, out, err) = oxy(dir, home, args);
26 +
    assert_eq!(code, 0, "oxy {args:?}: {out}{err}");
27 +
    out
28 +
}
29 +
30 +
#[test]
31 +
fn store_remotes_through_the_cli() {
32 +
    let tmp = tempfile::tempdir().unwrap();
33 +
    let home = tmp.path().join("home");
34 +
    std::fs::create_dir_all(&home).unwrap();
35 +
    let a = tmp.path().join("a");
36 +
    ok(tmp.path(), &home, &["init", a.to_str().unwrap()]);
37 +
    std::fs::write(a.join("f.txt"), "hello\n").unwrap();
38 +
    ok(&a, &home, &["commit", "-m", "first"]);
39 +
    let bucket = tmp.path().join("bucket");
40 +
    let url = format!("dir://{}", bucket.display());
41 +
42 +
    // Store settings belong to bucket URLs only, and are kept on the URL.
43 +
    let (code, _, err) = oxy(
44 +
        &a,
45 +
        &home,
46 +
        &["remote", "add", "usb", &url, "--profile", "work"],
47 +
    );
48 +
    assert_ne!(code, 0);
49 +
    assert!(err.contains("--profile") && err.contains("s3://"), "{err}");
50 +
    ok(
51 +
        &a,
52 +
        &home,
53 +
        &[
54 +
            "remote",
55 +
            "add",
56 +
            "cloud",
57 +
            "s3://my-bucket/repos/a",
58 +
            "--profile",
59 +
            "work",
60 +
            "--region",
61 +
            "eu-west-2",
62 +
        ],
63 +
    );
64 +
    let listed = ok(&a, &home, &["remote", "list"]);
65 +
    assert!(
66 +
        listed.contains("s3://my-bucket/repos/a?profile=work&region=eu-west-2"),
67 +
        "{listed}"
68 +
    );
69 +
    ok(&a, &home, &["remote", "remove", "cloud"]);
70 +
71 +
    // A directory store: check before anything is there, push, check again.
72 +
    ok(&a, &home, &["remote", "add", "usb", &url]);
73 +
    let out = ok(&a, &home, &["remote", "check", "usb"]);
74 +
    assert!(
75 +
        out.contains("Reachable")
76 +
            && out.contains("holds no repository yet")
77 +
            && out.contains("Behaves"),
78 +
        "{out}"
79 +
    );
80 +
    assert_eq!(
81 +
        std::fs::read_dir(&bucket).unwrap().count(),
82 +
        0,
83 +
        "the check left nothing behind"
84 +
    );
85 +
    ok(&a, &home, &["push", "usb"]);
86 +
    let out = ok(&a, &home, &["remote", "check", "usb"]);
87 +
    assert!(
88 +
        out.contains("holds this repository")
89 +
            && out.contains("1 bookmark")
90 +
            && out.contains("1 bundle")
91 +
            && out.contains("Behaves"),
92 +
        "{out}"
93 +
    );
94 +
95 +
    // Clone from the store, push back, pull.
96 +
    let b = tmp.path().join("b");
97 +
    ok(tmp.path(), &home, &["clone", &url, b.to_str().unwrap()]);
98 +
    assert_eq!(std::fs::read_to_string(b.join("f.txt")).unwrap(), "hello\n");
99 +
    std::fs::write(b.join("f.txt"), "hello\nagain\n").unwrap();
100 +
    ok(&b, &home, &["commit", "-m", "second"]);
101 +
    ok(&b, &home, &["push"]);
102 +
    ok(&a, &home, &["pull", "usb"]);
103 +
    assert_eq!(
104 +
        std::fs::read_to_string(a.join("f.txt")).unwrap(),
105 +
        "hello\nagain\n"
106 +
    );
107 +
108 +
    // Another repository is told the store is taken.
109 +
    let c = tmp.path().join("c");
110 +
    ok(tmp.path(), &home, &["init", c.to_str().unwrap()]);
111 +
    std::fs::write(c.join("g.txt"), "other\n").unwrap();
112 +
    ok(&c, &home, &["commit", "-m", "mine"]);
113 +
    ok(&c, &home, &["remote", "add", "usb", &url]);
114 +
    let out = ok(&c, &home, &["remote", "check", "usb"]);
115 +
    assert!(
116 +
        out.contains("holds another repository") && out.contains("would be refused"),
117 +
        "{out}"
118 +
    );
119 +
    let (code, _, err) = oxy(&c, &home, &["push", "usb"]);
120 +
    assert_ne!(code, 0);
121 +
    assert!(err.contains("different repository"), "{err}");
122 +
}
modified crates/oxy-core/src/strata.rs +18 −3
…
339 unchanged lines
340 340
    fn delete(&self, key: &str) -> Result<()> {
341 341
        let path = self.path(key)?;
342 342
        match fs::remove_file(&path) {
343 -
            Ok(()) => Ok(()),
344 -
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
345 -
            Err(e) => Err(e.into()),
343 +
            Ok(()) => {}
344 +
            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
345 +
            Err(e) => return Err(e.into()),
346 +
        }
347 +
        // A directory left empty goes too, up to the root, so the store
348 +
        // holds nothing but its objects.
349 +
        let mut dir = path.parent();
350 +
        while let Some(d) = dir {
351 +
            if d == self.root || fs::remove_dir(d).is_err() {
352 +
                break;
353 +
            }
354 +
            dir = d.parent();
346 355
        }
356 +
        Ok(())
347 357
    }
348 358
349 359
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
…
297 unchanged lines
647 657
            .filter(|p| p.is_file())
648 658
            .collect();
649 659
        assert!(stray.is_empty(), "{stray:?}");
660 +
        assert_eq!(
661 +
            fs::read_dir(dir.path().join("bucket")).unwrap().count(),
662 +
            0,
663 +
            "no empty directories left"
664 +
        );
650 665
    }
651 666
652 667
    fn walkdir(dir: &Path) -> Vec<PathBuf> {
…
83 unchanged lines
modified src/cli/mod.rs +36 −2
…
737 unchanged lines
738 738
    Add {
739 739
        /// Name to refer to the remote by, such as origin
740 740
        name: String,
741 -
        /// Local path or http(s) URL of the repository
741 +
        /// A local path or the http(s) URL of a hub repository; or a store the repository is kept on: s3://BUCKET/PREFIX, gs://BUCKET/PREFIX, az://ACCOUNT/CONTAINER/PREFIX, dir://PATH
742 742
        url: String,
743 +
        /// For an s3:// store: the AWS profile to use (default: AWS_PROFILE, else "default")
744 +
        #[arg(long)]
745 +
        profile: Option<String>,
746 +
        /// For an s3:// store: its region (default: the environment, the profile, else asked of S3)
747 +
        #[arg(long)]
748 +
        region: Option<String>,
749 +
        /// For an s3://, gs:// or az:// store: another endpoint, such as an S3-compatible service
750 +
        #[arg(long)]
751 +
        endpoint: Option<String>,
752 +
        /// For a gs:// store: a service account key file, or application default credentials
753 +
        #[arg(long)]
754 +
        credentials: Option<String>,
743 755
    },
756 +
    /// Check a remote: that it is reachable with the credentials in hand, what it holds, and (for a store) that it behaves
757 +
    Check {
758 +
        /// The remote to check
759 +
        name: String,
760 +
    },
744 761
    /// Remove a remote
745 762
    Remove {
746 763
        /// The remote to remove
…
434 unchanged lines
1181 1198
            TagCmd::List { json } => workspace::tag_list(json),
1182 1199
        },
1183 1200
        Cmd::Remote { cmd } => match cmd {
1184 -
            RemoteCmd::Add { name, url } => sync::remote_add(&name, &url),
1201 +
            RemoteCmd::Add {
1202 +
                name,
1203 +
                url,
1204 +
                profile,
1205 +
                region,
1206 +
                endpoint,
1207 +
                credentials,
1208 +
            } => sync::remote_add(
1209 +
                &name,
1210 +
                &url,
1211 +
                &[
1212 +
                    ("profile", profile),
1213 +
                    ("region", region),
1214 +
                    ("endpoint", endpoint),
1215 +
                    ("credentials", credentials),
1216 +
                ],
1217 +
            ),
1218 +
            RemoteCmd::Check { name } => sync::remote_check(&name),
1185 1219
            RemoteCmd::Remove { name } => sync::remote_remove(&name),
1186 1220
            RemoteCmd::List { json } => sync::remote_list(json),
1187 1221
        },
…
130 unchanged lines
modified src/cli/sync.rs +184 −1
…
94 unchanged lines
95 95
    }
96 96
}
97 97
98 -
pub fn remote_add(name: &str, url: &str) -> Result<i32> {
98 +
/// Whether a URL names a store (a bucket or a directory in the store
99 +
/// layout) rather than a repository.
100 +
fn is_store_url(url: &str) -> bool {
101 +
    ["s3://", "gs://", "az://", "dir://"]
102 +
        .iter()
103 +
        .any(|p| url.starts_with(p))
104 +
}
105 +
106 +
/// Percent-encode what would break a query parameter.
107 +
fn encode_param(v: &str) -> String {
108 +
    let mut out = String::with_capacity(v.len());
109 +
    for b in v.bytes() {
110 +
        match b {
111 +
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => {
112 +
                out.push(b as char)
113 +
            }
114 +
            _ => out.push_str(&format!("%{b:02X}")),
115 +
        }
116 +
    }
117 +
    out
118 +
}
119 +
120 +
/// A store URL with settings added as query parameters, which is where a
121 +
/// store remote keeps them (`s3://b/p?profile=work&region=eu-west-2`).
122 +
fn url_with_settings(url: &str, settings: &[(&str, Option<String>)]) -> Result<String> {
123 +
    let given: Vec<(&str, &str)> = settings
124 +
        .iter()
125 +
        .filter_map(|(k, v)| v.as_deref().map(|v| (*k, v)))
126 +
        .collect();
127 +
    if given.is_empty() {
128 +
        return Ok(url.to_string());
129 +
    }
130 +
    if !is_store_url(url) || url.starts_with("dir://") {
131 +
        let names: Vec<String> = given.iter().map(|(k, _)| format!("--{k}")).collect();
132 +
        anyhow::bail!(
133 +
            "{} apply to a store remote (s3://, gs://, az://), not to {url}",
134 +
            names.join(", ")
135 +
        );
136 +
    }
137 +
    let mut out = url.to_string();
138 +
    for (k, v) in given {
139 +
        out.push(if out.contains('?') { '&' } else { '?' });
140 +
        out.push_str(k);
141 +
        out.push('=');
142 +
        out.push_str(&encode_param(v));
143 +
    }
144 +
    Ok(out)
145 +
}
146 +
147 +
pub fn remote_add(name: &str, url: &str, settings: &[(&str, Option<String>)]) -> Result<i32> {
99 148
    let mut repo = open()?;
149 +
    let url = url_with_settings(url, settings)?;
150 +
    let url = url.as_str();
100 151
    repo.config.set(
101 152
        &format!("remote.{name}.url"),
102 153
        &oxy_core::protocol::stored_url(url),
…
6 unchanged lines
109 160
        ui::dim(ui::glyph("→", "->"))
110 161
    );
111 162
    Ok(0)
163 +
}
164 +
165 +
/// `oxy remote check NAME`: reach the remote with the credentials in
166 +
/// hand, say what it holds, and, for a store, put it through the
167 +
/// conformance check under a probe prefix (left empty afterwards).
168 +
pub fn remote_check(name: &str) -> Result<i32> {
169 +
    let repo = open()?;
170 +
    let Some(url) = repo.config.remotes.get(name).cloned() else {
171 +
        anyhow::bail!("no remote {name:?}; oxy remote list shows the remotes there are");
172 +
    };
173 +
    let options = repo.options_for_url(&url);
174 +
    let mine = repo.id()?;
175 +
    let started = std::time::Instant::now();
176 +
    let sp = ui::spinner(&format!("Checking {name}…"));
177 +
    let Some(store) = oxy_core::protocol::open_store(&url, &options) else {
178 +
        // A hub or another repository: reach it and read its references.
179 +
        let (_, remote) = repo.open_remote_at(&url)?;
180 +
        let id = remote.repo_id()?;
181 +
        let refs = remote.refs()?;
182 +
        sp.done(&format!(
183 +
            "{} {} {}",
184 +
            ui::done("Reachable"),
185 +
            ui::bookmark(name),
186 +
            ui::dim(format!("({url}, {} ms)", started.elapsed().as_millis()))
187 +
        ));
188 +
        describe_holding(id, mine, refs.refs.len(), refs.tags.len(), None);
189 +
        return Ok(0);
190 +
    };
191 +
    let store = store.with_context(|| format!("opening the store of {name}"))?;
192 +
    // What it holds, in one listing and two reads.
193 +
    let marker = match store.get("oxygen.toml") {
194 +
        Ok(b) => Some(b),
195 +
        Err(oxy_core::strata::Error::NotFound(_)) => None,
196 +
        Err(e) => {
197 +
            sp.clear();
198 +
            return Err(anyhow::anyhow!("{e}"))
199 +
                .with_context(|| format!("reaching the store of {name}"));
200 +
        }
201 +
    };
202 +
    let reached = started.elapsed();
203 +
    let bundles = store.list("bundles/")?.len();
204 +
    let record = store.read_ref("refs")?;
205 +
    sp.done(&format!(
206 +
        "{} {} {}",
207 +
        ui::done("Reachable"),
208 +
        ui::bookmark(name),
209 +
        ui::dim(format!("({url}, {} ms)", reached.as_millis()))
210 +
    ));
211 +
    match marker {
212 +
        None => println!(
213 +
            "  {} {}",
214 +
            ui::note(),
215 +
            ui::dim("holds no repository yet: oxy push NAME binds it to this one")
216 +
        ),
217 +
        Some(_) => {
218 +
            let (id, bookmarks, tags) = match record {
219 +
                Some((bytes, _)) => {
220 +
                    let (id, resp) = oxy_core::protocol::wire::decode_refs(&bytes)?;
221 +
                    (id, resp.refs.len(), resp.tags.len())
222 +
                }
223 +
                None => (mine, 0, 0),
224 +
            };
225 +
            describe_holding(id, mine, bookmarks, tags, Some(bundles));
226 +
        }
227 +
    }
228 +
    // Then the behaviour a repository depends on, under a prefix of its own.
229 +
    let sp = ui::spinner("Checking that the store behaves…");
230 +
    let probe = format!("oxy-check-{}", std::process::id());
231 +
    let began = std::time::Instant::now();
232 +
    match oxy_core::strata::conformance(store.as_ref(), &probe) {
233 +
        Ok(passed) => {
234 +
            sp.clear();
235 +
            for p in &passed {
236 +
                println!("  {} {}", ui::ok(ui::glyph("✔", "*")), p);
237 +
            }
238 +
            println!(
239 +
                "{} {}",
240 +
                ui::done("Behaves"),
241 +
                ui::dim(format!(
242 +
                    "({} checks in {} ms; nothing left behind)",
243 +
                    passed.len(),
244 +
                    began.elapsed().as_millis()
245 +
                ))
246 +
            );
247 +
            Ok(0)
248 +
        }
249 +
        Err(e) => {
250 +
            sp.clear();
251 +
            // Leave nothing behind, even after a failed check.
252 +
            if let Ok(left) = store.list(&probe) {
253 +
                for entry in left {
254 +
                    let _ = store.delete(&entry.key);
255 +
                }
256 +
            }
257 +
            println!("{} {}", ui::err(ui::glyph("✘", "x")), e);
258 +
            println!(
259 +
                "  {} {}",
260 +
                ui::note(),
261 +
                ui::dim("a store that fails this cannot keep a repository safely; check the service and the credentials' permissions")
262 +
            );
263 +
            Ok(1)
264 +
        }
265 +
    }
266 +
}
267 +
268 +
fn describe_holding(
269 +
    id: [u8; 16],
270 +
    mine: [u8; 16],
271 +
    bookmarks: usize,
272 +
    tags: usize,
273 +
    bundles: Option<usize>,
274 +
) {
275 +
    let short: String = id[..6].iter().map(|b| format!("{b:02x}")).collect();
276 +
    let mut what = format!(
277 +
        "repository {short}: {} and {}",
278 +
        plural(bookmarks, "bookmark"),
279 +
        plural(tags, "tag")
280 +
    );
281 +
    if let Some(b) = bundles {
282 +
        what.push_str(&format!(" in {}", plural(b, "bundle")));
283 +
    }
284 +
    if id == mine {
285 +
        println!("  {} {}", ui::note(), ui::dim(format!("holds this {what}")));
286 +
    } else if id == oxy_core::protocol::UNBOUND_ID {
287 +
        println!("  {} {}", ui::note(), ui::dim("holds no repository yet"));
288 +
    } else {
289 +
        println!(
290 +
            "  {} {}",
291 +
            ui::caution(),
292 +
            ui::warn(format!("holds another {what}; a push would be refused"))
293 +
        );
294 +
    }
112 295
}
113 296
114 297
pub fn remote_remove(name: &str) -> Result<i32> {
…
756 unchanged lines