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®ion=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®ion=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 |