OXYGENHUB

Coverage / src/cli/sync.rs 38.8%

368 of 949 lines run, 33 of 148 functions · run #348 on paths-for-resolutions-and-amend · run missed

1
//! Commands that talk to remotes, and the reference server.
2
3
use super::format::plural;
4
use super::json;
5
use super::open;
6
use super::ui;
7
use anyhow::{Context, Result};
8
use oxy_core::repo::Repo;
9
use std::path::PathBuf;
10
115×
pub fn clone(
125×
    url: &str,
135×
    dest: Option<PathBuf>,
145×
    max_blob: Option<&str>,
155×
    sparse: &[String],
165×
    depth: Option<u32>,
175×
    recursive: bool,
185×
) -> Result<i32> {
195×
    let dest = dest.unwrap_or_else(|| {
200×
        let base = url
210×
            .trim_end_matches('/')
220×
            .rsplit('/')
230×
            .next()
240×
            .unwrap_or("repo");
250×
        PathBuf::from(base)
260×
    });
275×
    let max_blob = max_blob.map(oxy_core::repo::parse_size).transpose()?;
285×
    let sp = ui::spinner(&format!("Cloning {url}…"));
295×
    let repo = match depth {
300×
        Some(d) => Repo::clone_shallow(url, &dest, d, max_blob, sparse)?,
315×
        None => Repo::clone_with(url, &dest, max_blob, sparse)?,
32
    };
335×
    let heads = repo.bookmark_heads()?;
345×
    sp.done(&format!(
355×
        "{} into {} {}",
365×
        ui::done("Cloned"),
375×
        ui::path(repo.root().display()),
385×
        ui::dim(format!(
395×
            "({url}, {} with {})",
405×
            ui::bookmark(&repo.config.bookmark),
415×
            plural(heads.len(), "head")
425×
        ))
435×
    ));
445×
    let others = repo.remote_only_bookmarks()?.len();
455×
    if others > 0 {
460×
        println!(
470×
            "  {} {}",
480×
            ui::note(),
490×
            ui::dim(format!(
500×
                "origin has {} more: oxy bookmark list --all, then oxy bookmark switch NAME",
510×
                plural(others, "bookmark")
520×
            ))
530×
        );
545×
    }
555×
    let subrepos = repo.subrepos()?.len();
565×
    if subrepos == 0 {
575×
        return Ok(0);
580×
    }
590×
    if recursive {
600×
        let sp = ui::spinner("Cloning subrepos…");
610×
        let results = repo.submodule_update(true, &[])?;
620×
        sp.clear();
630×
        return Ok(print_subrepo_results(results));
640×
    }
650×
    println!(
66
        "  {} {}",
670×
        ui::note(),
680×
        ui::dim(format!(
69
            "{} not cloned: oxy submodule update --init (or clone with --recursive)",
700×
            plural(subrepos, "subrepo")
71
        ))
72
    );
730×
    Ok(0)
745×
}
75
76
/// After a switch or pull: say when subrepos are not at their pins.
775×
pub fn subrepos_behind(repo: &Repo) {
785×
    let Ok(subs) = repo.subrepos() else {
790×
        return;
80
    };
815×
    let stale = subs
825×
        .iter()
835×
        .filter(|s| s.committed && s.actual.as_ref() != Some(&s.pin))
845×
        .count();
855×
    if stale > 0 {
860×
        println!(
87
            "  {} {}",
880×
            ui::note(),
890×
            ui::dim(format!(
90
                "{} not at {} pin: oxy submodule update --init",
910×
                plural(stale, "subrepo"),
920×
                if stale == 1 { "its" } else { "their" }
93
            ))
94
        );
955×
    }
965×
}
97
98
/// Whether a URL names a store (a bucket or a directory in the store
99
/// layout) rather than a repository.
1002×
fn is_store_url(url: &str) -> bool {
1012×
    ["s3://", "gs://", "az://", "dir://"]
1022×
        .iter()
1035×
        .any(|p| url.starts_with(p))
1042×
}
105
106
/// Percent-encode what would break a query parameter.
1072×
fn encode_param(v: &str) -> String {
1082×
    let mut out = String::with_capacity(v.len());
10913×
    for b in v.bytes() {
11013×
        match b {
11110×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => {
11213×
                out.push(b as char)
113
            }
1140×
            _ => out.push_str(&format!("%{b:02X}")),
115
        }
116
    }
1172×
    out
1182×
}
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`).
1224×
fn url_with_settings(url: &str, settings: &[(&str, Option<String>)]) -> Result<String> {
1234×
    let given: Vec<(&str, &str)> = settings
1244×
        .iter()
12516×
        .filter_map(|(k, v)| v.as_deref().map(|v| (*k, v)))
1264×
        .collect();
1274×
    if given.is_empty() {
1282×
        return Ok(url.to_string());
1292×
    }
1302×
    if !is_store_url(url) || url.starts_with("dir://") {
1311×
        let names: Vec<String> = given.iter().map(|(k, _)| format!("--{k}")).collect();
1321×
        anyhow::bail!(
133
            "{} apply to a store remote (s3://, gs://, az://), not to {url}",
1341×
            names.join(", ")
135
        );
1361×
    }
1371×
    let mut out = url.to_string();
1382×
    for (k, v) in given {
1392×
        out.push(if out.contains('?') { '&' } else { '?' });
1402×
        out.push_str(k);
1412×
        out.push('=');
1422×
        out.push_str(&encode_param(v));
143
    }
1441×
    Ok(out)
1454×
}
146
1474×
pub fn remote_add(name: &str, url: &str, settings: &[(&str, Option<String>)]) -> Result<i32> {
1484×
    let mut repo = open()?;
1494×
    let url = url_with_settings(url, settings)?;
1503×
    let url = url.as_str();
1513×
    repo.config.set(
1523×
        &format!("remote.{name}.url"),
1533×
        &oxy_core::protocol::stored_url(url),
1540×
    )?;
1553×
    repo.save_config()?;
1563×
    println!(
157
        "{} remote {} {} {url}",
1583×
        ui::done("Added"),
1593×
        ui::bookmark(name),
1603×
        ui::dim(ui::glyph("→", "->"))
161
    );
1623×
    Ok(0)
1634×
}
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).
1683×
pub fn remote_check(name: &str) -> Result<i32> {
1693×
    let repo = open()?;
1703×
    let Some(url) = repo.config.remotes.get(name).cloned() else {
1710×
        anyhow::bail!("no remote {name:?}; oxy remote list shows the remotes there are");
172
    };
1733×
    let options = repo.options_for_url(&url);
1743×
    let mine = repo.id()?;
1753×
    let started = std::time::Instant::now();
1763×
    let sp = ui::spinner(&format!("Checking {name}…"));
1773×
    let Some(store) = oxy_core::protocol::open_store(&url, &options) else {
178
        // A hub or another repository: reach it and read its references.
1790×
        let (_, remote) = repo.open_remote_at(&url)?;
1800×
        let id = remote.repo_id()?;
1810×
        let refs = remote.refs()?;
1820×
        sp.done(&format!(
1830×
            "{} {} {}",
1840×
            ui::done("Reachable"),
1850×
            ui::bookmark(name),
1860×
            ui::dim(format!("({url}, {} ms)", started.elapsed().as_millis()))
1870×
        ));
1880×
        describe_holding(id, mine, refs.refs.len(), refs.tags.len(), None);
1890×
        return Ok(0);
190
    };
1913×
    let store = store.with_context(|| format!("opening the store of {name}"))?;
192
    // What it holds, in one listing and two reads.
1933×
    let marker = match store.get("oxygen.toml") {
1942×
        Ok(b) => Some(b),
1951×
        Err(oxy_core::strata::Error::NotFound(_)) => None,
1960×
        Err(e) => {
1970×
            sp.clear();
1980×
            return Err(anyhow::anyhow!("{e}"))
1990×
                .with_context(|| format!("reaching the store of {name}"));
200
        }
201
    };
2023×
    let reached = started.elapsed();
2033×
    let bundles = store.list("bundles/")?.len();
2043×
    let record = store.read_ref("refs")?;
2053×
    sp.done(&format!(
2063×
        "{} {} {}",
2073×
        ui::done("Reachable"),
2083×
        ui::bookmark(name),
2093×
        ui::dim(format!("({url}, {} ms)", reached.as_millis()))
2103×
    ));
2113×
    match marker {
2121×
        None => println!(
213
            "  {} {}",
2141×
            ui::note(),
2151×
            ui::dim("holds no repository yet: oxy push NAME binds it to this one")
216
        ),
217
        Some(_) => {
2182×
            let (id, bookmarks, tags) = match record {
2192×
                Some((bytes, _)) => {
2202×
                    let (id, resp) = oxy_core::protocol::wire::decode_refs(&bytes)?;
2212×
                    (id, resp.refs.len(), resp.tags.len())
222
                }
2230×
                None => (mine, 0, 0),
224
            };
2252×
            describe_holding(id, mine, bookmarks, tags, Some(bundles));
226
        }
227
    }
228
    // Then the behaviour a repository depends on, under a prefix of its own.
2293×
    let sp = ui::spinner("Checking that the store behaves…");
2303×
    let probe = format!("oxy-check-{}", std::process::id());
2313×
    let began = std::time::Instant::now();
2323×
    match oxy_core::strata::conformance(store.as_ref(), &probe) {
2333×
        Ok(passed) => {
2343×
            sp.clear();
23524×
            for p in &passed {
23624×
                println!("  {} {}", ui::ok(ui::glyph("✔", "*")), p);
23724×
            }
2383×
            println!(
239
                "{} {}",
2403×
                ui::done("Behaves"),
2413×
                ui::dim(format!(
242
                    "({} checks in {} ms; nothing left behind)",
2433×
                    passed.len(),
2443×
                    began.elapsed().as_millis()
245
                ))
246
            );
2473×
            Ok(0)
248
        }
2490×
        Err(e) => {
2500×
            sp.clear();
251
            // Leave nothing behind, even after a failed check.
2520×
            if let Ok(left) = store.list(&probe) {
2530×
                for entry in left {
2540×
                    let _ = store.delete(&entry.key);
2550×
                }
2560×
            }
2570×
            println!("{} {}", ui::err(ui::glyph("✘", "x")), e);
2580×
            println!(
259
                "  {} {}",
2600×
                ui::note(),
2610×
                ui::dim("a store that fails this cannot keep a repository safely; check the service and the credentials' permissions")
262
            );
2630×
            Ok(1)
264
        }
265
    }
2663×
}
267
2682×
fn describe_holding(
2692×
    id: [u8; 16],
2702×
    mine: [u8; 16],
2712×
    bookmarks: usize,
2722×
    tags: usize,
2732×
    bundles: Option<usize>,
2742×
) {
27512×
    let short: String = id[..6].iter().map(|b| format!("{b:02x}")).collect();
2762×
    let mut what = format!(
277
        "repository {short}: {} and {}",
2782×
        plural(bookmarks, "bookmark"),
2792×
        plural(tags, "tag")
280
    );
2812×
    if let Some(b) = bundles {
2822×
        what.push_str(&format!(" in {}", plural(b, "bundle")));
2832×
    }
2842×
    if id == mine {
2851×
        println!("  {} {}", ui::note(), ui::dim(format!("holds this {what}")));
2861×
    } else if id == oxy_core::protocol::UNBOUND_ID {
2870×
        println!("  {} {}", ui::note(), ui::dim("holds no repository yet"));
2881×
    } else {
2891×
        println!(
2901×
            "  {} {}",
2911×
            ui::caution(),
2921×
            ui::warn(format!("holds another {what}; a push would be refused"))
2931×
        );
2941×
    }
2952×
}
296
2971×
pub fn remote_remove(name: &str) -> Result<i32> {
2981×
    let mut repo = open()?;
2991×
    repo.remove_remote(name)?;
3001×
    println!("{} remote {}", ui::done("Removed"), ui::bookmark(name));
3011×
    Ok(0)
3021×
}
303
3042×
pub fn remote_list(as_json: bool) -> Result<i32> {
3052×
    let repo = open()?;
3062×
    let view = repo.view()?;
3072×
    if as_json {
3081×
        let items = repo.config.remotes.iter().map(|(name, url)| {
3091×
            let bms = view.remotes.get(name).map(|b| {
3101×
                json::arr(b.iter().map(|(bm, st)| {
3111×
                    let heads = repo
3121×
                        .store()
3131×
                        .get_state(st)
3141×
                        .map(|s| s.heads)
3151×
                        .unwrap_or_default();
3161×
                    json::obj([("name", json::s(bm)), ("heads", json::hashes(&heads))])
3171×
                }))
3181×
            });
3191×
            json::obj([
3201×
                ("name", json::s(name)),
3211×
                ("url", json::s(url)),
322
                // The CA file `remote.NAME.ca` names, for tools that talk to
323
                // the same hub (an editor's forge view, `oh`).
324
                (
3251×
                    "ca",
3261×
                    json::opt(repo.config.remote_cas.get(name), |c| json::s(c)),
327
                ),
3281×
                ("bookmarks", bms.unwrap_or_else(|| "[]".into())),
329
            ])
3301×
        });
3311×
        println!("{}", json::arr(items));
3321×
        return Ok(0);
3331×
    }
3341×
    if repo.config.remotes.is_empty() {
3350×
        println!(
3360×
            "{}",
3370×
            ui::dim("No remotes; add one with oxy remote add <name> <url>.")
3380×
        );
3391×
    }
3401×
    for (name, url) in &repo.config.remotes {
3411×
        println!(
342
            "{} {}{}{}",
3431×
            ui::ok(ui::glyph("⇄", "<>")),
3441×
            ui::bookmark(name),
3451×
            ui::sep(),
3461×
            ui::dim(url)
347
        );
3481×
        if let Some(bms) = view.remotes.get(name) {
3490×
            for (bm, st) in bms {
3500×
                let heads = repo
3510×
                    .store()
3520×
                    .get_state(st)
3530×
                    .map(|s| s.heads.len())
3540×
                    .unwrap_or(0);
3550×
                println!(
356
                    "    {} {}{}{}",
3570×
                    ui::dim(ui::glyph("↳", "->")),
3580×
                    ui::id(format!("{name}/{bm}")),
3590×
                    ui::sep(),
3600×
                    ui::dim(plural(heads, "head"))
361
                );
362
            }
3631×
        }
364
    }
3651×
    Ok(0)
3662×
}
367
368
/// The remote named, else `origin`, else the only remote there is.
36912×
fn remote_name(repo: &oxy_core::repo::Repo, remote: Option<&str>) -> String {
37012×
    remote.map_or_else(|| repo.default_remote(), str::to_string)
37112×
}
372
3731×
pub fn fetch(remote: Option<&str>, as_json: bool) -> Result<i32> {
3741×
    let repo = open()?;
3751×
    let remote = remote_name(&repo, remote);
3761×
    let sp = ui::spinner(&format!("Fetching from {remote}…"));
3771×
    let report = repo.fetch(&remote)?;
3781×
    sp.clear();
3791×
    if as_json {
3801×
        println!(
381
            "{}",
3821×
            json::obj([
3831×
                ("remote", json::s(&remote)),
384
                (
3851×
                    "refs",
3861×
                    json::arr(report.refs.iter().map(|(name, heads, n)| {
3871×
                        let t = repo.tracking(&remote, name).ok().flatten();
3881×
                        json::obj([
3891×
                            ("name", json::s(name)),
3901×
                            ("heads", json::hashes(heads)),
3911×
                            ("new", json::n(*n)),
392
                            (
3931×
                                "ahead",
3941×
                                t.map_or_else(|| "null".into(), |(a, _)| json::n(a)),
395
                            ),
396
                            (
3971×
                                "behind",
3981×
                                t.map_or_else(|| "null".into(), |(_, b)| json::n(b)),
399
                            ),
400
                        ])
4011×
                    }))
402
                ),
4031×
                ("new_tags", json::n(report.new_tags)),
4041×
                ("unverified", json::b(report.unverified)),
405
                (
4061×
                    "log_notes",
4071×
                    json::arr(report.log_notes.iter().map(|n| json::s(n))),
408
                ),
409
            ])
410
        );
4111×
        return Ok(0);
4120×
    }
4130×
    if report.refs.is_empty() {
4140×
        println!(
4150×
            "{} {}",
4160×
            ui::done("Fetched"),
4170×
            ui::dim(format!("nothing new from {remote}"))
4180×
        );
4190×
    }
4200×
    for (name, heads, n) in &report.refs {
421
        // What was fetched, then how the local bookmark stands against it:
422
        // "up to date" only when the two are the same (oxygen#56).
4230×
        let fetched =
4240×
            (*n > 0).then(|| format!("{}{}", ui::ok(plural(*n, "new change")), ui::sep()));
4250×
        let them = |k: usize| if k == 1 { "it" } else { "them" };
4260×
        let standing = match repo.tracking(&remote, name).ok().flatten() {
4270×
            None => ui::dim(format!("no local bookmark {name}")),
4280×
            Some((0, 0)) => ui::dim(format!("{name} up to date")),
4290×
            Some((0, b)) if *name == repo.config.bookmark => ui::warn(format!(
430
                "{name} is {} behind: oxy pull to take {}",
4310×
                plural(b, "change"),
4320×
                them(b)
433
            )),
4340×
            Some((0, b)) => ui::warn(format!(
435
                "{name} is {} behind: oxy bookmark switch {name} then oxy pull to take {}",
4360×
                plural(b, "change"),
4370×
                them(b)
438
            )),
4390×
            Some((a, 0)) => ui::dim(format!(
440
                "{name} is {} ahead: oxy push to send {}",
4410×
                plural(a, "change"),
4420×
                them(a)
443
            )),
4440×
            Some((a, b)) => ui::warn(format!(
445
                "{name} and {remote}/{name} have diverged ({a} here, {b} there): oxy pull to merge them"
446
            )),
447
        };
4480×
        let what = format!("{}{standing}", fetched.unwrap_or_default());
4490×
        println!(
450
            "{} {}{}{}{}{what}",
4510×
            ui::done("Fetched"),
4520×
            ui::id(format!("{remote}/{name}")),
4530×
            ui::sep(),
4540×
            ui::dim(plural(heads.len(), "head")),
4550×
            ui::sep()
456
        );
457
    }
4580×
    if report.new_tags > 0 {
4590×
        println!(
4600×
            "  {} {}",
4610×
            ui::note(),
4620×
            ui::dim(format!("{} adopted", plural(report.new_tags, "tag")))
4630×
        );
4640×
    }
4650×
    if report.unverified {
4660×
        eprintln!(
4670×
            "{} {remote} has no trust policy; nothing fetched from it was verified",
4680×
            ui::caution()
4690×
        );
4700×
    }
4710×
    for n in &report.log_notes {
4720×
        eprintln!("{} {n}", ui::caution());
4730×
    }
4740×
    Ok(0)
4751×
}
476
477
/// Pull acts on the current bookmark only: name the others the fetch
478
/// found behind their remote, which is easy to take for the current one
479
/// (oxygen#29).
4803×
fn others_behind(repo: &oxy_core::repo::Repo, remote: &str) {
4813×
    let Ok(bookmarks) = repo.bookmarks() else {
4820×
        return;
483
    };
4844×
    for b in bookmarks {
4854×
        let Some(n) = b.behind.filter(|n| *n > 0) else {
4864×
            continue;
487
        };
4880×
        if b.current {
4890×
            continue;
4900×
        }
4910×
        println!(
492
            "  {} {}",
4930×
            ui::note(),
4940×
            ui::dim(format!(
495
                "{} is {} behind {remote}/{}: oxy bookmark switch {} then oxy pull to take {}",
496
                b.name,
4970×
                plural(n, "change"),
498
                b.name,
499
                b.name,
5000×
                if n == 1 { "it" } else { "them" }
501
            ))
502
        );
503
    }
5043×
}
505
5064×
pub fn pull(remote: Option<&str>, as_json: bool) -> Result<i32> {
5074×
    let repo = open()?;
5084×
    let remote = remote_name(&repo, remote);
5094×
    let sp = ui::spinner(&format!("Pulling from {remote}…"));
5104×
    let r = repo.pull(&remote)?;
5114×
    if as_json {
5121×
        sp.clear();
5131×
        let conflicted = repo
5141×
            .snapshot_status("oxy pull")?
515
            .base_snapshot
5161×
            .is_conflicted();
5171×
        println!(
518
            "{}",
5191×
            json::obj([
5201×
                ("remote", json::s(&remote)),
5211×
                ("bookmark", json::s(&repo.config.bookmark)),
5221×
                ("heads", json::hashes(&r.heads)),
5231×
                ("changed", json::b(r.changed)),
5241×
                ("checked_out", json::b(r.checked_out)),
5251×
                ("conflicted", json::b(conflicted)),
5261×
                ("others", json::arr(r.others.iter().map(|o| json::s(o))))
527
            ])
528
        );
5291×
        return Ok(0);
5303×
    }
5313×
    if !r.changed {
5321×
        sp.done(&format!(
533
            "{} {}{}",
5341×
            ui::ok(ui::glyph("✔", "*")),
5351×
            ui::dim(format!(
536
                "{} is up to date with {remote}.",
537
                repo.config.bookmark
538
            )),
5391×
            if r.checked_out {
5400×
                ui::dim(" The working copy had fallen behind it and is updated.")
541
            } else {
5421×
                String::new()
543
            }
544
        ));
5451×
        others_pulled(&r.others);
5461×
        others_behind(&repo, &remote);
5471×
        if r.checked_out {
5480×
            super::workspace::where_now(&repo);
5491×
        }
5501×
        return Ok(0);
5512×
    }
5522×
    sp.done(&format!(
553
        "{} {} now has {}{}",
5542×
        ui::done("Pulled:"),
5552×
        ui::bookmark(&repo.config.bookmark),
5562×
        plural(r.heads.len(), "head"),
5572×
        ui::dim(if r.checked_out {
5582×
            "; working copy updated"
559
        } else {
5600×
            "; run oxy checkout to update the working copy"
561
        })
562
    ));
5632×
    let snap = repo.snapshot("oxy pull")?;
5642×
    if snap.base_snapshot.is_conflicted() {
5650×
        println!(
5660×
            "  {} {}",
5670×
            ui::caution(),
5680×
            ui::warn("the result has conflicts; see oxy status")
5690×
        );
5702×
    }
5712×
    others_pulled(&r.others);
5722×
    others_behind(&repo, &remote);
5732×
    subrepos_behind(&repo);
5742×
    super::workspace::where_now(&repo);
5752×
    Ok(0)
5764×
}
577
578
/// The other bookmarks a pull moved.
5793×
fn others_pulled(others: &[String]) {
5803×
    if others.is_empty() {
5812×
        return;
5821×
    }
5831×
    let shown: Vec<String> = others.iter().take(8).map(ui::bookmark).collect();
5841×
    let more = others.len().saturating_sub(8);
5851×
    println!(
586
        "  {} {}{}",
5871×
        ui::done("Also updated"),
5881×
        shown.join(", "),
5891×
        if more > 0 {
5900×
            ui::dim(format!(" and {more} more"))
591
        } else {
5921×
            String::new()
593
        }
594
    );
5953×
}
596
597
/// `oxy push [REMOTE] --delete NAME`.
5980×
pub fn push_delete(remote: Option<&str>, name: &str, as_json: bool) -> Result<i32> {
5990×
    let repo = open()?;
6000×
    let remote = remote_name(&repo, remote);
6010×
    let sp = ui::spinner(&format!("Deleting {remote}/{name}…"));
6020×
    let results = repo.push_delete(&remote, name)?;
6030×
    sp.clear();
6040×
    if as_json {
6050×
        println!(
606
            "{}",
6070×
            json::arr(results.iter().map(|r| {
6080×
                json::obj([
6090×
                    ("name", json::s(&r.name)),
6100×
                    ("ok", json::b(r.ok)),
6110×
                    ("reason", json::s(&r.reason)),
6120×
                    ("state", json::s("")),
6130×
                ])
6140×
            }))
615
        );
6160×
        return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 });
6170×
    }
6180×
    let mut code = 0;
6190×
    for r in results {
6200×
        if r.ok {
6210×
            println!(
6220×
                "{} {}",
6230×
                ui::done("Deleted"),
6240×
                ui::id(format!("{remote}/{}", r.name))
6250×
            );
6260×
        } else {
6270×
            println!(
6280×
                "{} {} {}{}{}",
6290×
                ui::err(ui::glyph("✖", "x")),
6300×
                ui::err("Rejected"),
6310×
                ui::id(format!("{remote}/{}", r.name)),
6320×
                ui::sep(),
6330×
                r.reason
6340×
            );
6350×
            code = 1;
6360×
        }
637
    }
6380×
    Ok(code)
6390×
}
640
6417×
pub fn push(
6427×
    remote: Option<&str>,
6437×
    force: bool,
6447×
    bookmark: Option<&str>,
6457×
    as_json: bool,
6467×
) -> Result<i32> {
6477×
    let repo = open()?;
6487×
    let remote = remote_name(&repo, remote);
6497×
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
6507×
    let bookmark_name = bookmark.map_or_else(|| repo.config.bookmark.clone(), String::from);
651
    // A long push goes in parts: the spinner says which.
6527×
    let results = repo.push_bookmark_with(&remote, &bookmark_name, force, &mut |m| {
6530×
        sp.set(&format!("Pushing to {remote}… {m}"))
6541×
    })?;
6556×
    sp.clear();
6566×
    if as_json {
657
        // The state each accepted bookmark now holds on the remote (its local
658
        // state object), so a caller can look up what the push started, such
659
        // as the checks a forge runs on it.
6601×
        let view = repo.view()?;
6611×
        println!(
662
            "{}",
6631×
            json::arr(results.iter().map(|r| {
6641×
                let state = view
6651×
                    .bookmarks
6661×
                    .get(&r.name)
6671×
                    .filter(|_| r.ok)
6681×
                    .map(|st| st.to_hex())
6691×
                    .unwrap_or_default();
6701×
                json::obj([
6711×
                    ("name", json::s(&r.name)),
6721×
                    ("ok", json::b(r.ok)),
6731×
                    ("reason", json::s(&r.reason)),
6741×
                    ("state", json::s(&state)),
6751×
                ])
6761×
            }))
677
        );
6781×
        return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 });
6795×
    }
6805×
    let mut code = 0;
6815×
    for r in results {
6825×
        if r.ok {
6835×
            println!(
684
                "{} {}{}{}",
6855×
                ui::done("Pushed"),
6865×
                ui::id(format!("{remote}/{}", r.name)),
6875×
                ui::sep(),
6885×
                ui::dim(if r.reason.is_empty() {
6895×
                    "updated"
690
                } else {
6910×
                    r.reason.as_str()
692
                })
693
            );
6940×
        } else {
6950×
            println!(
6960×
                "{} {} {}{}{}",
6970×
                ui::err(ui::glyph("✖", "x")),
6980×
                ui::err("Rejected"),
6990×
                ui::id(format!("{remote}/{}", r.name)),
7000×
                ui::sep(),
7010×
                r.reason
7020×
            );
7030×
            code = 1;
7040×
        }
705
    }
7065×
    Ok(code)
7077×
}
708
7090×
pub fn serve(
7100×
    root: PathBuf,
7110×
    bind: &str,
7120×
    tokens: Vec<String>,
7130×
    tls: Option<(PathBuf, PathBuf)>,
7140×
) -> Result<i32> {
7150×
    let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding {bind}"))?;
7160×
    let root = root.canonicalize()?;
7170×
    let tls = tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key });
7180×
    println!(
719
        "Serving repositories under {} on {}://{bind}/<repo>/oxy/v1/ ({})",
7200×
        root.display(),
7210×
        if tls.is_some() { "https" } else { "http" },
7220×
        if tokens.is_empty() {
7230×
            "open: no push tokens configured"
724
        } else {
7250×
            "push requires a token"
726
        }
727
    );
7280×
    if tls.is_none()
7290×
        && !bind.starts_with("127.")
7300×
        && !bind.starts_with("localhost")
7310×
        && !bind.starts_with("[::1]")
7320×
    {
7330×
        eprintln!("warning: serving plain HTTP on a non-loopback address; put a TLS terminator in front or pass --tls-cert/--tls-key");
7340×
    }
7350×
    oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens, tls })?;
7360×
    Ok(0)
7370×
}
738
7390×
pub fn git_export(git_dir: &std::path::Path, branch: Option<&str>) -> Result<i32> {
7400×
    let repo = open()?;
7410×
    let sp = ui::spinner("Exporting to Git…");
7420×
    let r = match branch {
7430×
        Some(b) => repo.git_export_as(git_dir, b)?,
7440×
        None => repo.git_export(git_dir)?,
745
    };
7460×
    sp.clear();
7470×
    println!(
748
        "{} {} to {} as branch {} {}",
7490×
        ui::done("Exported"),
7500×
        plural(r.exported, "new state"),
7510×
        ui::path(git_dir.display()),
7520×
        ui::bookmark(&r.branch),
7530×
        ui::dim(format!(
754
            "(currently {}; {} already exported)",
7550×
            plural(r.heads, "head"),
756
            r.skipped
757
        ))
758
    );
7590×
    Ok(0)
7600×
}
761
7620×
pub fn git_recover() -> Result<i32> {
7630×
    let repo = open()?;
7640×
    let sp = ui::spinner("Reading the store…");
7650×
    let (changes, heads) = repo.recover_git_import(&|m| sp.set(m))?;
7660×
    sp.done(&format!(
7670×
        "{} {} from imported Git commits: {} now has {}",
7680×
        ui::done("Recovered"),
7690×
        plural(changes, "change"),
7700×
        ui::bookmark(&repo.config.bookmark),
7710×
        plural(heads, "head")
7720×
    ));
7730×
    println!(
774
        "  {} {}",
7750×
        ui::note(),
7760×
        ui::dim("carry on with oxy git fetch GIT_DIR --branch BRANCH")
777
    );
7780×
    Ok(0)
7790×
}
780
781
/// `--claim` (oxygen#97): record `git.claim`, so this import and every
782
/// later fetch author the identity's own commits with its key.
7830×
fn claim_commits(repo: &mut Repo) -> Result<()> {
7840×
    repo.config.set("git.claim", "true")?;
7850×
    repo.save_config()?;
7860×
    Ok(())
7870×
}
788
7890×
pub fn git_fetch(url: &str, branch: &str, claim: bool) -> Result<i32> {
7900×
    let mut repo = open()?;
7910×
    if claim {
7920×
        claim_commits(&mut repo)?;
7930×
    }
7940×
    let sp = ui::spinner(&format!("Fetching {url} {branch}…"));
7950×
    let r = repo.git_fetch(url, branch)?;
7960×
    sp.clear();
7970×
    if r.commits == 0 {
7980×
        println!(
7990×
            "{} {}",
8000×
            ui::done("Fetched"),
8010×
            ui::dim(format!("nothing new from {url} {branch}"))
8020×
        );
8030×
    } else {
8040×
        println!(
805
            "{} {} ({} merges) from {} {}{}{} now has {}",
8060×
            ui::done("Fetched"),
8070×
            plural(r.commits, "new commit"),
808
            r.merges,
8090×
            ui::path(url),
8100×
            ui::bookmark(branch),
8110×
            ui::sep(),
8120×
            ui::bookmark(&repo.config.bookmark),
8130×
            plural(r.heads, "head")
814
        );
8150×
        if !repo.snapshot("oxy git fetch")?.changed {
8160×
            println!("  {} {}", ui::note(), ui::dim("working copy updated"));
8170×
        } else {
8180×
            println!(
8190×
                "  {} {}",
8200×
                ui::note(),
8210×
                ui::dim("run oxy checkout to update the working copy")
8220×
            );
8230×
        }
824
    }
8250×
    if r.submodules > 0 {
8260×
        println!(
827
            "  {} {}",
8280×
            ui::note(),
8290×
            ui::dim(format!(
830
                "{} submodule {} imported as subrepos with Git pins",
831
                r.submodules,
8320×
                if r.submodules == 1 {
8330×
                    "entry"
834
                } else {
8350×
                    "entries"
836
                }
837
            ))
838
        );
8390×
    }
8400×
    Ok(0)
8410×
}
842
8430×
pub fn git_push(url: &str, branch: &str) -> Result<i32> {
8440×
    let repo = open()?;
8450×
    let sp = ui::spinner(&format!("Pushing to {url} {branch}…"));
8460×
    let r = repo.git_push(url, branch)?;
8470×
    sp.clear();
8480×
    println!(
849
        "{} {} to {} {} {}",
8500×
        ui::done("Pushed"),
8510×
        plural(r.exported, "new commit"),
8520×
        ui::path(url),
8530×
        ui::bookmark(&r.branch),
8540×
        ui::dim(format!("({} already there)", r.skipped))
855
    );
8560×
    Ok(0)
8570×
}
858
859
/// `oxy git verify`: exit 1 if any commit's tree is not reproduced.
8600×
pub fn git_verify(
8610×
    git_dir: &std::path::Path,
8620×
    branch: &str,
8630×
    last: Option<usize>,
8640×
    max: usize,
8650×
) -> Result<i32> {
8660×
    let repo = open()?;
8670×
    let started = std::time::Instant::now();
8680×
    let sp = ui::spinner(&format!(
8690×
        "Rebuilding every commit of {} {branch}…",
8700×
        git_dir.display()
8710×
    ));
8720×
    let r = repo.verify_git_import_last(git_dir, branch, last, max)?;
8730×
    sp.clear();
8740×
    for (_, what) in &r.mismatches {
8750×
        println!("{} {what}", ui::err(ui::glyph("✖", "x")));
8760×
    }
8770×
    if r.not_yet > 0 {
8780×
        println!(
8790×
            "  {} {}",
8800×
            ui::note(),
8810×
            ui::dim(format!(
8820×
                "{} not imported yet, after the last imported commit: not checked",
8830×
                plural(r.not_yet, "commit")
8840×
            ))
8850×
        );
8860×
    }
8870×
    if r.mismatches.is_empty() {
8880×
        println!(
889
            "{}",
8900×
            ui::done(if r.skipped > 0 {
8910×
                format!(
892
                    "The last {} commits reproduced ({} followed), {} files compared, in {:.1}s",
8930×
                    r.commits - r.skipped,
894
                    r.skipped,
895
                    r.files_compared,
8960×
                    started.elapsed().as_secs_f64()
897
                )
898
            } else {
8990×
                format!(
900
                    "Every commit reproduced: {} commits, {} files compared, in {:.1}s",
901
                    r.commits,
902
                    r.files_compared,
9030×
                    started.elapsed().as_secs_f64()
904
                )
905
            })
906
        );
9070×
        Ok(0)
908
    } else {
909
        // What differs, by kind, the commit taken off: one difference
910
        // carried through history reads as one line.
9110×
        let mut kinds: std::collections::BTreeMap<String, usize> = Default::default();
9120×
        for (_, what) in &r.mismatches {
9130×
            let kind = what.split_once(": ").map_or(what.as_str(), |(_, k)| k);
9140×
            *kinds.entry(kind.to_string()).or_default() += 1;
915
        }
9160×
        let mut kinds: Vec<_> = kinds.into_iter().collect();
9170×
        kinds.sort_by_key(|k| std::cmp::Reverse(k.1));
9180×
        for (kind, n) in kinds.iter().take(10) {
9190×
            println!("  {} {}", ui::dim(format!("{n:>6} ×")), kind);
9200×
        }
9210×
        println!(
922
            "{} differences in {} commits checked{}",
9230×
            r.mismatches.len(),
924
            r.commits,
9250×
            if max > 0 && r.mismatches.len() >= max {
9260×
                format!(" (stopped at {max}; --max-differences 0 checks every commit)")
927
            } else {
9280×
                String::new()
929
            }
930
        );
9310×
        Ok(1)
932
    }
9330×
}
934
9350×
pub fn git_import(
9360×
    git_dir: &std::path::Path,
9370×
    dest: &std::path::Path,
9380×
    branch: &str,
9390×
    claim: bool,
9400×
) -> Result<i32> {
9410×
    let mut repo = Repo::init(dest)?;
9420×
    if claim {
9430×
        claim_commits(&mut repo)?;
9440×
    }
9450×
    let started = std::time::Instant::now();
9460×
    let sp = ui::spinner(&format!("Importing {} {branch}…", git_dir.display()));
9470×
    let r = repo.import_git_with(git_dir, branch, &mut |where_| sp.set(where_))?;
9480×
    sp.clear();
9490×
    println!(
950
        "{} {} ({} merges) from {} {} into {} {}",
9510×
        ui::done("Imported"),
9520×
        plural(r.commits, "commit"),
953
        r.merges,
9540×
        ui::path(git_dir.display()),
9550×
        ui::bookmark(branch),
9560×
        ui::path(repo.root().display()),
9570×
        ui::dim(format!(
958
            "in {:.1?}; {} now has {}",
9590×
            started.elapsed(),
960
            repo.config.bookmark,
9610×
            plural(r.heads, "head")
962
        ))
963
    );
9640×
    if r.submodules > 0 {
9650×
        println!(
966
            "  {} {}",
9670×
            ui::note(),
9680×
            ui::dim(format!(
969
                "{} submodule {} imported as subrepos with Git pins",
970
                r.submodules,
9710×
                if r.submodules == 1 {
9720×
                    "entry"
973
                } else {
9740×
                    "entries"
975
                }
976
            ))
977
        );
9780×
    }
9790×
    Ok(0)
9800×
}
981
9820×
pub fn submodule_add(url: &str, path: &str, bookmark: Option<&str>) -> Result<i32> {
9830×
    let repo = open()?;
9840×
    let sp = ui::spinner(&format!("Cloning {url} into {path}…"));
9850×
    let pin = repo.submodule_add(url, path, bookmark)?;
9860×
    sp.done(&format!(
9870×
        "{} subrepo {} {} {}",
9880×
        ui::done("Added"),
9890×
        ui::path(path),
9900×
        ui::dim(ui::glyph("→", "->")),
9910×
        ui::dim(pin.summary())
9920×
    ));
9930×
    println!(
994
        "  {} {}",
9950×
        ui::note(),
9960×
        ui::dim("recorded in .oxymodules; commit to pin it")
997
    );
9980×
    Ok(0)
9990×
}
1000
10010×
pub fn submodule_update(init: bool, paths: &[String]) -> Result<i32> {
10020×
    let repo = open()?;
10030×
    let sp = ui::spinner("Updating subrepos…");
10040×
    let results = repo.submodule_update(init, paths)?;
10050×
    let unpinned: Vec<_> = repo
10060×
        .unpinned_modules()?
10070×
        .into_iter()
10080×
        .filter(|(p, _)| paths.is_empty() || paths.iter().any(|q| q.trim_end_matches('/') == p))
10090×
        .collect();
10100×
    sp.clear();
10110×
    if results.is_empty() && unpinned.is_empty() {
10120×
        println!("{}", ui::dim("No subrepos."));
10130×
    }
10140×
    let code = print_subrepo_results(results);
10150×
    print_unpinned(&unpinned);
10160×
    Ok(if unpinned.is_empty() { code } else { 1 })
10170×
}
1018
1019
/// `.oxymodules` entries never added: nothing to clone, so say how to add
1020
/// them (oxygen#72).
10210×
fn print_unpinned(unpinned: &[(String, oxy_core::subrepo::Module)]) {
10220×
    for (path, m) in unpinned {
10230×
        let url = if m.url.is_empty() { "<url>" } else { &m.url };
10240×
        println!(
1025
            "{} {}",
10260×
            ui::warn(format!(
1027
                "{} in .oxymodules but never added, so no pin",
10280×
                ui::glyph("⚠", "!")
1029
            )),
10300×
            ui::path(path)
1031
        );
10320×
        println!(
1033
            "  {}",
10340×
            ui::dim(format!("add it with: oxy submodule add {url} {path}"))
1035
        );
1036
    }
10370×
}
1038
10390×
fn print_subrepo_results(results: Vec<(String, oxy_core::repo::SubrepoSync)>) -> i32 {
10400×
    let mut code = 0;
10410×
    for (path, r) in results {
1042
        use oxy_core::repo::SubrepoSync::*;
10430×
        let line = match r {
10440×
            Updated => ui::done("Updated"),
10450×
            UpToDate => ui::ok(format!("{} up to date", ui::glyph("✔", "*"))),
1046
            Missing => {
10470×
                code = 1;
10480×
                ui::warn(format!(
1049
                    "{} missing (pass --init to clone)",
10500×
                    ui::glyph("⚠", "!")
1051
                ))
1052
            }
1053
            Dirty => {
10540×
                code = 1;
10550×
                ui::warn(format!(
1056
                    "{} has uncommitted changes; left as is",
10570×
                    ui::glyph("⚠", "!")
1058
                ))
1059
            }
10600×
            KeptBookmark { bookmark, newer } => ui::warn(format!(
1061
                "{} checked out at the pin; its bookmark {bookmark} keeps {} the pin lacks",
10620×
                ui::glyph("⚠", "!"),
10630×
                plural(newer, "newer change")
1064
            )),
10650×
            Failed(e) => {
10660×
                code = 1;
10670×
                ui::err(format!("{} {e}", ui::glyph("✖", "x")))
1068
            }
1069
        };
10700×
        println!("{line} {}", ui::path(path));
1071
    }
10720×
    code
10730×
}
1074
10750×
fn pin_json(p: &oxy_core::subrepo::Pin) -> String {
10760×
    match p {
1077
        oxy_core::subrepo::Pin::Oxy {
10780×
            repo,
10790×
            heads,
10800×
            bookmark,
10810×
        } => json::obj([
10820×
            ("kind", json::s("oxy")),
1083
            (
10840×
                "repo",
10850×
                json::s(&repo.iter().map(|b| format!("{b:02x}")).collect::<String>()),
1086
            ),
10870×
            ("heads", json::hashes(heads)),
10880×
            ("bookmark", json::s(bookmark)),
1089
        ]),
10900×
        oxy_core::subrepo::Pin::Git { sha, branch } => json::obj([
10910×
            ("kind", json::s("git")),
10920×
            ("sha", json::s(sha)),
10930×
            ("branch", json::s(branch)),
10940×
        ]),
1095
    }
10960×
}
1097
10981×
pub fn submodule_status(as_json: bool) -> Result<i32> {
10991×
    let repo = open()?;
11001×
    let subs = repo.subrepos()?;
11011×
    let unpinned = repo.unpinned_modules()?;
11021×
    if as_json {
11031×
        let pinned = subs.iter().map(|s| {
11040×
            json::obj([
11050×
                ("path", json::s(&s.path)),
11060×
                ("pin", pin_json(&s.pin)),
11070×
                ("actual", json::opt(s.actual.as_ref(), pin_json)),
11080×
                ("at_pin", json::b(s.actual.as_ref() == Some(&s.pin))),
11090×
                ("dirty", json::b(s.dirty)),
11100×
                ("committed", json::b(s.committed)),
11110×
                ("url", json::opt(s.url.as_deref(), json::s)),
11120×
            ])
11130×
        });
1114
        // Listed in .oxymodules but never added: no pin at all.
11151×
        let unpinned_json = unpinned.iter().map(|(path, m)| {
11160×
            json::obj([
11170×
                ("path", json::s(path)),
11180×
                ("pin", "null".to_string()),
11190×
                ("actual", "null".to_string()),
11200×
                ("at_pin", json::b(false)),
11210×
                ("dirty", json::b(false)),
11220×
                ("committed", json::b(false)),
11230×
                ("url", json::s(&m.url)),
11240×
            ])
11250×
        });
11261×
        println!("{}", json::arr(pinned.chain(unpinned_json)));
11271×
        return Ok(0);
11280×
    }
11290×
    if subs.is_empty() && unpinned.is_empty() {
11300×
        println!("{}", ui::dim("No subrepos."));
11310×
    }
11320×
    for s in subs {
11330×
        let state = match (&s.actual, s.dirty) {
11340×
            _ if !s.committed => {
11350×
                ui::warn(format!("{} added, not committed yet", ui::glyph("+", "+")))
1136
            }
11370×
            (None, _) => ui::warn(format!("{} missing", ui::glyph("⚠", "!"))),
11380×
            (Some(a), dirty) if *a == s.pin => {
11390×
                if dirty {
11400×
                    ui::warn(format!(
1141
                        "{} at pin, uncommitted changes",
11420×
                        ui::glyph("✎", "~")
1143
                    ))
1144
                } else {
11450×
                    ui::ok(format!("{} at pin", ui::glyph("✔", "*")))
1146
                }
1147
            }
11480×
            (Some(a), _) => ui::warn(format!("{} at {}", ui::glyph("↕", "<>"), a.summary())),
1149
        };
11500×
        println!(
1151
            "{} {}{}{}{}{state}",
11520×
            ui::ok(ui::glyph("⧉", "#")),
11530×
            ui::path(&s.path),
11540×
            ui::sep(),
11550×
            ui::dim(s.pin.summary()),
11560×
            ui::sep()
1157
        );
11580×
        if let Some(u) = s.url {
11590×
            println!("    {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(u));
11600×
        }
1161
    }
11620×
    print_unpinned(&unpinned);
11630×
    Ok(0)
11641×
}
1165
11660×
pub fn submodule_remove(path: &str) -> Result<i32> {
11670×
    let repo = open()?;
11680×
    repo.submodule_remove(path)?;
11690×
    println!(
1170
        "{} subrepo {} {}",
11710×
        ui::done("Removed"),
11720×
        ui::path(path),
11730×
        ui::dim("(directory left in place; commit to record the removal)")
1174
    );
11750×
    Ok(0)
11760×
}
1177
11780×
pub fn deepen(remote: &str, depth: u32) -> Result<i32> {
11790×
    let repo = open()?;
11800×
    if !repo.is_shallow() {
11810×
        println!(
1182
            "{}",
11830×
            ui::dim("This repository is not shallow; nothing to deepen.")
1184
        );
11850×
        return Ok(0);
11860×
    }
11870×
    let sp = ui::spinner(&format!("Fetching {depth} more levels from {remote}…"));
11880×
    let n = repo.deepen(remote, depth)?;
11890×
    sp.done(&format!(
1190
        "{} {}; {}",
11910×
        ui::done("Deepened by"),
11920×
        plural(n, "change"),
11930×
        if repo.is_shallow() {
11940×
            "still shallow".to_string()
1195
        } else {
11960×
            ui::ok("history is now complete")
1197
        }
1198
    ));
11990×
    Ok(0)
12000×
}