//! Commands that talk to remotes, and the reference server. use super::format::plural; use super::json; use super::open; use super::ui; use anyhow::{Context, Result}; use oxy_core::repo::Repo; use std::path::PathBuf; pub fn clone( url: &str, dest: Option, max_blob: Option<&str>, sparse: &[String], depth: Option, recursive: bool, ) -> Result { let dest = dest.unwrap_or_else(|| { let base = url .trim_end_matches('/') .rsplit('/') .next() .unwrap_or("repo"); PathBuf::from(base) }); let max_blob = max_blob.map(oxy_core::repo::parse_size).transpose()?; let sp = ui::spinner(&format!("Cloning {url}…")); let repo = match depth { Some(d) => Repo::clone_shallow(url, &dest, d, max_blob, sparse)?, None => Repo::clone_with(url, &dest, max_blob, sparse)?, }; let heads = repo.bookmark_heads()?; sp.done(&format!( "{} into {} {}", ui::done("Cloned"), ui::path(repo.root().display()), ui::dim(format!( "({url}, {} with {})", ui::bookmark(&repo.config.bookmark), plural(heads.len(), "head") )) )); let others = repo.remote_only_bookmarks()?.len(); if others > 0 { println!( " {} {}", ui::note(), ui::dim(format!( "origin has {} more: oxy bookmark list --all, then oxy bookmark switch NAME", plural(others, "bookmark") )) ); } let subrepos = repo.subrepos()?.len(); if subrepos == 0 { return Ok(0); } if recursive { let sp = ui::spinner("Cloning subrepos…"); let results = repo.submodule_update(true, &[])?; sp.clear(); return Ok(print_subrepo_results(results)); } println!( " {} {}", ui::note(), ui::dim(format!( "{} not cloned: oxy submodule update --init (or clone with --recursive)", plural(subrepos, "subrepo") )) ); Ok(0) } /// After a switch or pull: say when subrepos are not at their pins. pub fn subrepos_behind(repo: &Repo) { let Ok(subs) = repo.subrepos() else { return; }; let stale = subs .iter() .filter(|s| s.committed && s.actual.as_ref() != Some(&s.pin)) .count(); if stale > 0 { println!( " {} {}", ui::note(), ui::dim(format!( "{} not at {} pin: oxy submodule update --init", plural(stale, "subrepo"), if stale == 1 { "its" } else { "their" } )) ); } } /// Whether a URL names a store (a bucket or a directory in the store /// layout) rather than a repository. fn is_store_url(url: &str) -> bool { ["s3://", "gs://", "az://", "dir://"] .iter() .any(|p| url.starts_with(p)) } /// Percent-encode what would break a query parameter. fn encode_param(v: &str) -> String { let mut out = String::with_capacity(v.len()); for b in v.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => { out.push(b as char) } _ => out.push_str(&format!("%{b:02X}")), } } out } /// A store URL with settings added as query parameters, which is where a /// store remote keeps them (`s3://b/p?profile=work®ion=eu-west-2`). fn url_with_settings(url: &str, settings: &[(&str, Option)]) -> Result { let given: Vec<(&str, &str)> = settings .iter() .filter_map(|(k, v)| v.as_deref().map(|v| (*k, v))) .collect(); if given.is_empty() { return Ok(url.to_string()); } if !is_store_url(url) || url.starts_with("dir://") { let names: Vec = given.iter().map(|(k, _)| format!("--{k}")).collect(); anyhow::bail!( "{} apply to a store remote (s3://, gs://, az://), not to {url}", names.join(", ") ); } let mut out = url.to_string(); for (k, v) in given { out.push(if out.contains('?') { '&' } else { '?' }); out.push_str(k); out.push('='); out.push_str(&encode_param(v)); } Ok(out) } pub fn remote_add(name: &str, url: &str, settings: &[(&str, Option)]) -> Result { let mut repo = open()?; let url = url_with_settings(url, settings)?; let url = url.as_str(); repo.config.set( &format!("remote.{name}.url"), &oxy_core::protocol::stored_url(url), )?; repo.save_config()?; println!( "{} remote {} {} {url}", ui::done("Added"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")) ); Ok(0) } /// `oxy remote check NAME`: reach the remote with the credentials in /// hand, say what it holds, and, for a store, put it through the /// conformance check under a probe prefix (left empty afterwards). pub fn remote_check(name: &str) -> Result { let repo = open()?; let Some(url) = repo.config.remotes.get(name).cloned() else { anyhow::bail!("no remote {name:?}; oxy remote list shows the remotes there are"); }; let options = repo.options_for_url(&url); let mine = repo.id()?; let started = std::time::Instant::now(); let sp = ui::spinner(&format!("Checking {name}…")); let Some(store) = oxy_core::protocol::open_store(&url, &options) else { // A hub or another repository: reach it and read its references. let (_, remote) = repo.open_remote_at(&url)?; let id = remote.repo_id()?; let refs = remote.refs()?; sp.done(&format!( "{} {} {}", ui::done("Reachable"), ui::bookmark(name), ui::dim(format!("({url}, {} ms)", started.elapsed().as_millis())) )); describe_holding(id, mine, refs.refs.len(), refs.tags.len(), None); return Ok(0); }; let store = store.with_context(|| format!("opening the store of {name}"))?; // What it holds, in one listing and two reads. let marker = match store.get("oxygen.toml") { Ok(b) => Some(b), Err(oxy_core::strata::Error::NotFound(_)) => None, Err(e) => { sp.clear(); return Err(anyhow::anyhow!("{e}")) .with_context(|| format!("reaching the store of {name}")); } }; let reached = started.elapsed(); let bundles = store.list("bundles/")?.len(); let record = store.read_ref("refs")?; sp.done(&format!( "{} {} {}", ui::done("Reachable"), ui::bookmark(name), ui::dim(format!("({url}, {} ms)", reached.as_millis())) )); match marker { None => println!( " {} {}", ui::note(), ui::dim("holds no repository yet: oxy push NAME binds it to this one") ), Some(_) => { let (id, bookmarks, tags) = match record { Some((bytes, _)) => { let (id, resp) = oxy_core::protocol::wire::decode_refs(&bytes)?; (id, resp.refs.len(), resp.tags.len()) } None => (mine, 0, 0), }; describe_holding(id, mine, bookmarks, tags, Some(bundles)); } } // Then the behaviour a repository depends on, under a prefix of its own. let sp = ui::spinner("Checking that the store behaves…"); let probe = format!("oxy-check-{}", std::process::id()); let began = std::time::Instant::now(); match oxy_core::strata::conformance(store.as_ref(), &probe) { Ok(passed) => { sp.clear(); for p in &passed { println!(" {} {}", ui::ok(ui::glyph("✔", "*")), p); } println!( "{} {}", ui::done("Behaves"), ui::dim(format!( "({} checks in {} ms; nothing left behind)", passed.len(), began.elapsed().as_millis() )) ); Ok(0) } Err(e) => { sp.clear(); // Leave nothing behind, even after a failed check. if let Ok(left) = store.list(&probe) { for entry in left { let _ = store.delete(&entry.key); } } println!("{} {}", ui::err(ui::glyph("✘", "x")), e); println!( " {} {}", ui::note(), ui::dim("a store that fails this cannot keep a repository safely; check the service and the credentials' permissions") ); Ok(1) } } } fn describe_holding( id: [u8; 16], mine: [u8; 16], bookmarks: usize, tags: usize, bundles: Option, ) { let short: String = id[..6].iter().map(|b| format!("{b:02x}")).collect(); let mut what = format!( "repository {short}: {} and {}", plural(bookmarks, "bookmark"), plural(tags, "tag") ); if let Some(b) = bundles { what.push_str(&format!(" in {}", plural(b, "bundle"))); } if id == mine { println!(" {} {}", ui::note(), ui::dim(format!("holds this {what}"))); } else if id == oxy_core::protocol::UNBOUND_ID { println!(" {} {}", ui::note(), ui::dim("holds no repository yet")); } else { println!( " {} {}", ui::caution(), ui::warn(format!("holds another {what}; a push would be refused")) ); } } pub fn remote_remove(name: &str) -> Result { let mut repo = open()?; repo.remove_remote(name)?; println!("{} remote {}", ui::done("Removed"), ui::bookmark(name)); Ok(0) } pub fn remote_list(as_json: bool) -> Result { let repo = open()?; let view = repo.view()?; if as_json { let items = repo.config.remotes.iter().map(|(name, url)| { let bms = view.remotes.get(name).map(|b| { json::arr(b.iter().map(|(bm, st)| { let heads = repo .store() .get_state(st) .map(|s| s.heads) .unwrap_or_default(); json::obj([("name", json::s(bm)), ("heads", json::hashes(&heads))]) })) }); json::obj([ ("name", json::s(name)), ("url", json::s(url)), // The CA file `remote.NAME.ca` names, for tools that talk to // the same hub (an editor's forge view, `oh`). ( "ca", json::opt(repo.config.remote_cas.get(name), |c| json::s(c)), ), ("bookmarks", bms.unwrap_or_else(|| "[]".into())), ]) }); println!("{}", json::arr(items)); return Ok(0); } if repo.config.remotes.is_empty() { println!( "{}", ui::dim("No remotes; add one with oxy remote add .") ); } for (name, url) in &repo.config.remotes { println!( "{} {}{}{}", ui::ok(ui::glyph("⇄", "<>")), ui::bookmark(name), ui::sep(), ui::dim(url) ); if let Some(bms) = view.remotes.get(name) { for (bm, st) in bms { let heads = repo .store() .get_state(st) .map(|s| s.heads.len()) .unwrap_or(0); println!( " {} {}{}{}", ui::dim(ui::glyph("↳", "->")), ui::id(format!("{name}/{bm}")), ui::sep(), ui::dim(plural(heads, "head")) ); } } } Ok(0) } /// The remote named, else `origin`, else the only remote there is. fn remote_name(repo: &oxy_core::repo::Repo, remote: Option<&str>) -> String { remote.map_or_else(|| repo.default_remote(), str::to_string) } pub fn fetch(remote: Option<&str>, as_json: bool) -> Result { let repo = open()?; let remote = remote_name(&repo, remote); let sp = ui::spinner(&format!("Fetching from {remote}…")); let report = repo.fetch(&remote)?; sp.clear(); if as_json { println!( "{}", json::obj([ ("remote", json::s(&remote)), ( "refs", json::arr(report.refs.iter().map(|(name, heads, n)| { let t = repo.tracking(&remote, name).ok().flatten(); json::obj([ ("name", json::s(name)), ("heads", json::hashes(heads)), ("new", json::n(*n)), ( "ahead", t.map_or_else(|| "null".into(), |(a, _)| json::n(a)), ), ( "behind", t.map_or_else(|| "null".into(), |(_, b)| json::n(b)), ), ]) })) ), ("new_tags", json::n(report.new_tags)), ("unverified", json::b(report.unverified)), ( "log_notes", json::arr(report.log_notes.iter().map(|n| json::s(n))), ), ]) ); return Ok(0); } if report.refs.is_empty() { println!( "{} {}", ui::done("Fetched"), ui::dim(format!("nothing new from {remote}")) ); } for (name, heads, n) in &report.refs { // What was fetched, then how the local bookmark stands against it: // "up to date" only when the two are the same (oxygen#56). let fetched = (*n > 0).then(|| format!("{}{}", ui::ok(plural(*n, "new change")), ui::sep())); let them = |k: usize| if k == 1 { "it" } else { "them" }; let standing = match repo.tracking(&remote, name).ok().flatten() { None => ui::dim(format!("no local bookmark {name}")), Some((0, 0)) => ui::dim(format!("{name} up to date")), Some((0, b)) if *name == repo.config.bookmark => ui::warn(format!( "{name} is {} behind: oxy pull to take {}", plural(b, "change"), them(b) )), Some((0, b)) => ui::warn(format!( "{name} is {} behind: oxy bookmark switch {name} then oxy pull to take {}", plural(b, "change"), them(b) )), Some((a, 0)) => ui::dim(format!( "{name} is {} ahead: oxy push to send {}", plural(a, "change"), them(a) )), Some((a, b)) => ui::warn(format!( "{name} and {remote}/{name} have diverged ({a} here, {b} there): oxy pull to merge them" )), }; let what = format!("{}{standing}", fetched.unwrap_or_default()); println!( "{} {}{}{}{}{what}", ui::done("Fetched"), ui::id(format!("{remote}/{name}")), ui::sep(), ui::dim(plural(heads.len(), "head")), ui::sep() ); } if report.new_tags > 0 { println!( " {} {}", ui::note(), ui::dim(format!("{} adopted", plural(report.new_tags, "tag"))) ); } if report.unverified { eprintln!( "{} {remote} has no trust policy; nothing fetched from it was verified", ui::caution() ); } for n in &report.log_notes { eprintln!("{} {n}", ui::caution()); } Ok(0) } /// Pull acts on the current bookmark only: name the others the fetch /// found behind their remote, which is easy to take for the current one /// (oxygen#29). fn others_behind(repo: &oxy_core::repo::Repo, remote: &str) { let Ok(bookmarks) = repo.bookmarks() else { return; }; for b in bookmarks { let Some(n) = b.behind.filter(|n| *n > 0) else { continue; }; if b.current { continue; } println!( " {} {}", ui::note(), ui::dim(format!( "{} is {} behind {remote}/{}: oxy bookmark switch {} then oxy pull to take {}", b.name, plural(n, "change"), b.name, b.name, if n == 1 { "it" } else { "them" } )) ); } } pub fn pull(remote: Option<&str>, as_json: bool) -> Result { let repo = open()?; let remote = remote_name(&repo, remote); let sp = ui::spinner(&format!("Pulling from {remote}…")); let r = repo.pull(&remote)?; if as_json { sp.clear(); let conflicted = repo .snapshot_status("oxy pull")? .base_snapshot .is_conflicted(); println!( "{}", json::obj([ ("remote", json::s(&remote)), ("bookmark", json::s(&repo.config.bookmark)), ("heads", json::hashes(&r.heads)), ("changed", json::b(r.changed)), ("checked_out", json::b(r.checked_out)), ("conflicted", json::b(conflicted)), ("others", json::arr(r.others.iter().map(|o| json::s(o)))) ]) ); return Ok(0); } if !r.changed { sp.done(&format!( "{} {}{}", ui::ok(ui::glyph("✔", "*")), ui::dim(format!( "{} is up to date with {remote}.", repo.config.bookmark )), if r.checked_out { ui::dim(" The working copy had fallen behind it and is updated.") } else { String::new() } )); others_pulled(&r.others); others_behind(&repo, &remote); if r.checked_out { super::workspace::where_now(&repo); } return Ok(0); } sp.done(&format!( "{} {} now has {}{}", ui::done("Pulled:"), ui::bookmark(&repo.config.bookmark), plural(r.heads.len(), "head"), ui::dim(if r.checked_out { "; working copy updated" } else { "; run oxy checkout to update the working copy" }) )); let snap = repo.snapshot("oxy pull")?; if snap.base_snapshot.is_conflicted() { println!( " {} {}", ui::caution(), ui::warn("the result has conflicts; see oxy status") ); } others_pulled(&r.others); others_behind(&repo, &remote); subrepos_behind(&repo); super::workspace::where_now(&repo); Ok(0) } /// The other bookmarks a pull moved. fn others_pulled(others: &[String]) { if others.is_empty() { return; } let shown: Vec = others.iter().take(8).map(ui::bookmark).collect(); let more = others.len().saturating_sub(8); println!( " {} {}{}", ui::done("Also updated"), shown.join(", "), if more > 0 { ui::dim(format!(" and {more} more")) } else { String::new() } ); } /// `oxy push [REMOTE] --delete NAME`. pub fn push_delete(remote: Option<&str>, name: &str, as_json: bool) -> Result { let repo = open()?; let remote = remote_name(&repo, remote); let sp = ui::spinner(&format!("Deleting {remote}/{name}…")); let results = repo.push_delete(&remote, name)?; sp.clear(); if as_json { println!( "{}", json::arr(results.iter().map(|r| { json::obj([ ("name", json::s(&r.name)), ("ok", json::b(r.ok)), ("reason", json::s(&r.reason)), ("state", json::s("")), ]) })) ); return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 }); } let mut code = 0; for r in results { if r.ok { println!( "{} {}", ui::done("Deleted"), ui::id(format!("{remote}/{}", r.name)) ); } else { println!( "{} {} {}{}{}", ui::err(ui::glyph("✖", "x")), ui::err("Rejected"), ui::id(format!("{remote}/{}", r.name)), ui::sep(), r.reason ); code = 1; } } Ok(code) } pub fn push( remote: Option<&str>, force: bool, bookmark: Option<&str>, as_json: bool, ) -> Result { let repo = open()?; let remote = remote_name(&repo, remote); let sp = ui::spinner(&format!("Pushing to {remote}…")); let bookmark_name = bookmark.map_or_else(|| repo.config.bookmark.clone(), String::from); // A long push goes in parts: the spinner says which. let results = repo.push_bookmark_with(&remote, &bookmark_name, force, &mut |m| { sp.set(&format!("Pushing to {remote}… {m}")) })?; sp.clear(); if as_json { println!( "{}", json::arr(results.iter().map(|r| json::obj([ ("name", json::s(&r.name)), ("ok", json::b(r.ok)), ("reason", json::s(&r.reason)) ]))) ); return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 }); } let mut code = 0; for r in results { if r.ok { println!( "{} {}{}{}", ui::done("Pushed"), ui::id(format!("{remote}/{}", r.name)), ui::sep(), ui::dim(if r.reason.is_empty() { "updated" } else { r.reason.as_str() }) ); } else { println!( "{} {} {}{}{}", ui::err(ui::glyph("✖", "x")), ui::err("Rejected"), ui::id(format!("{remote}/{}", r.name)), ui::sep(), r.reason ); code = 1; } } Ok(code) } pub fn serve( root: PathBuf, bind: &str, tokens: Vec, tls: Option<(PathBuf, PathBuf)>, ) -> Result { let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding {bind}"))?; let root = root.canonicalize()?; let tls = tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key }); println!( "Serving repositories under {} on {}://{bind}//oxy/v1/ ({})", root.display(), if tls.is_some() { "https" } else { "http" }, if tokens.is_empty() { "open: no push tokens configured" } else { "push requires a token" } ); if tls.is_none() && !bind.starts_with("127.") && !bind.starts_with("localhost") && !bind.starts_with("[::1]") { eprintln!("warning: serving plain HTTP on a non-loopback address; put a TLS terminator in front or pass --tls-cert/--tls-key"); } oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens, tls })?; Ok(0) } pub fn git_export(git_dir: &std::path::Path, branch: Option<&str>) -> Result { let repo = open()?; let sp = ui::spinner("Exporting to Git…"); let r = match branch { Some(b) => repo.git_export_as(git_dir, b)?, None => repo.git_export(git_dir)?, }; sp.clear(); println!( "{} {} to {} as branch {} {}", ui::done("Exported"), plural(r.exported, "new state"), ui::path(git_dir.display()), ui::bookmark(&r.branch), ui::dim(format!( "(currently {}; {} already exported)", plural(r.heads, "head"), r.skipped )) ); Ok(0) } pub fn git_recover() -> Result { let repo = open()?; let sp = ui::spinner("Reading the store…"); let (changes, heads) = repo.recover_git_import(&|m| sp.set(m))?; sp.done(&format!( "{} {} from imported Git commits: {} now has {}", ui::done("Recovered"), plural(changes, "change"), ui::bookmark(&repo.config.bookmark), plural(heads, "head") )); println!( " {} {}", ui::note(), ui::dim("carry on with oxy git fetch GIT_DIR --branch BRANCH") ); Ok(0) } /// `--claim` (oxygen#97): record `git.claim`, so this import and every /// later fetch author the identity's own commits with its key. fn claim_commits(repo: &mut Repo) -> Result<()> { repo.config.set("git.claim", "true")?; repo.save_config()?; Ok(()) } pub fn git_fetch(url: &str, branch: &str, claim: bool) -> Result { let mut repo = open()?; if claim { claim_commits(&mut repo)?; } let sp = ui::spinner(&format!("Fetching {url} {branch}…")); let r = repo.git_fetch(url, branch)?; sp.clear(); if r.commits == 0 { println!( "{} {}", ui::done("Fetched"), ui::dim(format!("nothing new from {url} {branch}")) ); } else { println!( "{} {} ({} merges) from {} {}{}{} now has {}", ui::done("Fetched"), plural(r.commits, "new commit"), r.merges, ui::path(url), ui::bookmark(branch), ui::sep(), ui::bookmark(&repo.config.bookmark), plural(r.heads, "head") ); if !repo.snapshot("oxy git fetch")?.changed { println!(" {} {}", ui::note(), ui::dim("working copy updated")); } else { println!( " {} {}", ui::note(), ui::dim("run oxy checkout to update the working copy") ); } } if r.submodules > 0 { println!( " {} {}", ui::note(), ui::dim(format!( "{} submodule {} imported as subrepos with Git pins", r.submodules, if r.submodules == 1 { "entry" } else { "entries" } )) ); } Ok(0) } pub fn git_push(url: &str, branch: &str) -> Result { let repo = open()?; let sp = ui::spinner(&format!("Pushing to {url} {branch}…")); let r = repo.git_push(url, branch)?; sp.clear(); println!( "{} {} to {} {} {}", ui::done("Pushed"), plural(r.exported, "new commit"), ui::path(url), ui::bookmark(&r.branch), ui::dim(format!("({} already there)", r.skipped)) ); Ok(0) } /// `oxy git verify`: exit 1 if any commit's tree is not reproduced. pub fn git_verify(git_dir: &std::path::Path, branch: &str) -> Result { let repo = open()?; let started = std::time::Instant::now(); let sp = ui::spinner(&format!( "Rebuilding every commit of {} {branch}…", git_dir.display() )); let r = repo.verify_git_import(git_dir, branch)?; sp.clear(); for (_, what) in &r.mismatches { println!("{} {what}", ui::err(ui::glyph("✖", "x"))); } if r.mismatches.is_empty() { println!( "{}", ui::done(format!( "Every commit reproduced: {} commits, {} files compared, in {:.1}s", r.commits, r.files_compared, started.elapsed().as_secs_f64() )) ); Ok(0) } else { println!( "{} differences in {} commits checked", r.mismatches.len(), r.commits ); Ok(1) } } pub fn git_import( git_dir: &std::path::Path, dest: &std::path::Path, branch: &str, claim: bool, ) -> Result { let mut repo = Repo::init(dest)?; if claim { claim_commits(&mut repo)?; } let started = std::time::Instant::now(); let sp = ui::spinner(&format!("Importing {} {branch}…", git_dir.display())); let r = repo.import_git_with(git_dir, branch, &mut |where_| sp.set(where_))?; sp.clear(); println!( "{} {} ({} merges) from {} {} into {} {}", ui::done("Imported"), plural(r.commits, "commit"), r.merges, ui::path(git_dir.display()), ui::bookmark(branch), ui::path(repo.root().display()), ui::dim(format!( "in {:.1?}; {} now has {}", started.elapsed(), repo.config.bookmark, plural(r.heads, "head") )) ); if r.submodules > 0 { println!( " {} {}", ui::note(), ui::dim(format!( "{} submodule {} imported as subrepos with Git pins", r.submodules, if r.submodules == 1 { "entry" } else { "entries" } )) ); } Ok(0) } pub fn submodule_add(url: &str, path: &str, bookmark: Option<&str>) -> Result { let repo = open()?; let sp = ui::spinner(&format!("Cloning {url} into {path}…")); let pin = repo.submodule_add(url, path, bookmark)?; sp.done(&format!( "{} subrepo {} {} {}", ui::done("Added"), ui::path(path), ui::dim(ui::glyph("→", "->")), ui::dim(pin.summary()) )); println!( " {} {}", ui::note(), ui::dim("recorded in .oxymodules; commit to pin it") ); Ok(0) } pub fn submodule_update(init: bool, paths: &[String]) -> Result { let repo = open()?; let sp = ui::spinner("Updating subrepos…"); let results = repo.submodule_update(init, paths)?; let unpinned: Vec<_> = repo .unpinned_modules()? .into_iter() .filter(|(p, _)| paths.is_empty() || paths.iter().any(|q| q.trim_end_matches('/') == p)) .collect(); sp.clear(); if results.is_empty() && unpinned.is_empty() { println!("{}", ui::dim("No subrepos.")); } let code = print_subrepo_results(results); print_unpinned(&unpinned); Ok(if unpinned.is_empty() { code } else { 1 }) } /// `.oxymodules` entries never added: nothing to clone, so say how to add /// them (oxygen#72). fn print_unpinned(unpinned: &[(String, oxy_core::subrepo::Module)]) { for (path, m) in unpinned { let url = if m.url.is_empty() { "" } else { &m.url }; println!( "{} {}", ui::warn(format!( "{} in .oxymodules but never added, so no pin", ui::glyph("⚠", "!") )), ui::path(path) ); println!( " {}", ui::dim(format!("add it with: oxy submodule add {url} {path}")) ); } } fn print_subrepo_results(results: Vec<(String, oxy_core::repo::SubrepoSync)>) -> i32 { let mut code = 0; for (path, r) in results { use oxy_core::repo::SubrepoSync::*; let line = match r { Updated => ui::done("Updated"), UpToDate => ui::ok(format!("{} up to date", ui::glyph("✔", "*"))), Missing => { code = 1; ui::warn(format!( "{} missing (pass --init to clone)", ui::glyph("⚠", "!") )) } Dirty => { code = 1; ui::warn(format!( "{} has uncommitted changes; left as is", ui::glyph("⚠", "!") )) } KeptBookmark { bookmark, newer } => ui::warn(format!( "{} checked out at the pin; its bookmark {bookmark} keeps {} the pin lacks", ui::glyph("⚠", "!"), plural(newer, "newer change") )), Failed(e) => { code = 1; ui::err(format!("{} {e}", ui::glyph("✖", "x"))) } }; println!("{line} {}", ui::path(path)); } code } fn pin_json(p: &oxy_core::subrepo::Pin) -> String { match p { oxy_core::subrepo::Pin::Oxy { repo, heads, bookmark, } => json::obj([ ("kind", json::s("oxy")), ( "repo", json::s(&repo.iter().map(|b| format!("{b:02x}")).collect::()), ), ("heads", json::hashes(heads)), ("bookmark", json::s(bookmark)), ]), oxy_core::subrepo::Pin::Git { sha, branch } => json::obj([ ("kind", json::s("git")), ("sha", json::s(sha)), ("branch", json::s(branch)), ]), } } pub fn submodule_status(as_json: bool) -> Result { let repo = open()?; let subs = repo.subrepos()?; let unpinned = repo.unpinned_modules()?; if as_json { let pinned = subs.iter().map(|s| { json::obj([ ("path", json::s(&s.path)), ("pin", pin_json(&s.pin)), ("actual", json::opt(s.actual.as_ref(), pin_json)), ("at_pin", json::b(s.actual.as_ref() == Some(&s.pin))), ("dirty", json::b(s.dirty)), ("committed", json::b(s.committed)), ("url", json::opt(s.url.as_deref(), json::s)), ]) }); // Listed in .oxymodules but never added: no pin at all. let unpinned_json = unpinned.iter().map(|(path, m)| { json::obj([ ("path", json::s(path)), ("pin", "null".to_string()), ("actual", "null".to_string()), ("at_pin", json::b(false)), ("dirty", json::b(false)), ("committed", json::b(false)), ("url", json::s(&m.url)), ]) }); println!("{}", json::arr(pinned.chain(unpinned_json))); return Ok(0); } if subs.is_empty() && unpinned.is_empty() { println!("{}", ui::dim("No subrepos.")); } for s in subs { let state = match (&s.actual, s.dirty) { _ if !s.committed => { ui::warn(format!("{} added, not committed yet", ui::glyph("+", "+"))) } (None, _) => ui::warn(format!("{} missing", ui::glyph("⚠", "!"))), (Some(a), dirty) if *a == s.pin => { if dirty { ui::warn(format!( "{} at pin, uncommitted changes", ui::glyph("✎", "~") )) } else { ui::ok(format!("{} at pin", ui::glyph("✔", "*"))) } } (Some(a), _) => ui::warn(format!("{} at {}", ui::glyph("↕", "<>"), a.summary())), }; println!( "{} {}{}{}{}{state}", ui::ok(ui::glyph("⧉", "#")), ui::path(&s.path), ui::sep(), ui::dim(s.pin.summary()), ui::sep() ); if let Some(u) = s.url { println!(" {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(u)); } } print_unpinned(&unpinned); Ok(0) } pub fn submodule_remove(path: &str) -> Result { let repo = open()?; repo.submodule_remove(path)?; println!( "{} subrepo {} {}", ui::done("Removed"), ui::path(path), ui::dim("(directory left in place; commit to record the removal)") ); Ok(0) } pub fn deepen(remote: &str, depth: u32) -> Result { let repo = open()?; if !repo.is_shallow() { println!( "{}", ui::dim("This repository is not shallow; nothing to deepen.") ); return Ok(0); } let sp = ui::spinner(&format!("Fetching {depth} more levels from {remote}…")); let n = repo.deepen(remote, depth)?; sp.done(&format!( "{} {}; {}", ui::done("Deepened by"), plural(n, "change"), if repo.is_shallow() { "still shallow".to_string() } else { ui::ok("history is now complete") } )); Ok(0) }