OXYGENHUB

Coverage / src/cli/sync.rs 40.7%

432 of 1062 lines run, 41 of 174 functions · run #427 on main · 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, skipped) = Repo::clone_reporting(url, &dest, max_blob, sparse, depth)?;
305×
    let heads = repo.bookmark_heads()?;
315×
    sp.done(&format!(
325×
        "{} into {} {}",
335×
        ui::done("Cloned"),
345×
        ui::path(repo.root().display()),
355×
        ui::dim(format!(
365×
            "({url}, {} with {})",
375×
            ui::bookmark(&repo.config.bookmark),
385×
            plural(heads.len(), "head")
395×
        ))
405×
    ));
415×
    warn_skipped(&skipped);
425×
    let others = repo.remote_only_bookmarks()?.len();
435×
    if others > 0 {
440×
        println!(
450×
            "  {} {}",
460×
            ui::note(),
470×
            ui::dim(format!(
480×
                "origin has {} more: oxy bookmark list --all, then oxy bookmark switch NAME",
490×
                plural(others, "bookmark")
500×
            ))
510×
        );
525×
    }
535×
    let subrepos = repo.subrepos()?.len();
545×
    if subrepos == 0 {
555×
        return Ok(0);
560×
    }
570×
    if recursive {
580×
        let sp = ui::spinner("Cloning subrepos…");
590×
        let results = repo.submodule_update(true, &[])?;
600×
        sp.clear();
610×
        return Ok(print_subrepo_results(results));
620×
    }
630×
    println!(
64
        "  {} {}",
650×
        ui::note(),
660×
        ui::dim(format!(
67
            "{} not cloned: oxy submodule update --init (or clone with --recursive)",
680×
            plural(subrepos, "subrepo")
69
        ))
70
    );
710×
    Ok(0)
725×
}
73
74
/// After a switch or pull: say when subrepos are not at their pins.
755×
pub fn subrepos_behind(repo: &Repo) {
765×
    let Ok(subs) = repo.subrepos() else {
770×
        return;
78
    };
795×
    let stale = subs
805×
        .iter()
815×
        .filter(|s| s.committed && s.actual.as_ref() != Some(&s.pin))
825×
        .count();
835×
    if stale > 0 {
840×
        println!(
85
            "  {} {}",
860×
            ui::note(),
870×
            ui::dim(format!(
88
                "{} not at {} pin: oxy submodule update --init",
890×
                plural(stale, "subrepo"),
900×
                if stale == 1 { "its" } else { "their" }
91
            ))
92
        );
935×
    }
945×
}
95
96
/// Whether a URL names a store (a bucket or a directory in the store
97
/// layout) rather than a repository.
982×
fn is_store_url(url: &str) -> bool {
992×
    ["s3://", "gs://", "az://", "dir://"]
1002×
        .iter()
1015×
        .any(|p| url.starts_with(p))
1022×
}
103
104
/// Percent-encode what would break a query parameter.
1052×
fn encode_param(v: &str) -> String {
1062×
    let mut out = String::with_capacity(v.len());
10713×
    for b in v.bytes() {
10813×
        match b {
10910×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => {
11013×
                out.push(b as char)
111
            }
1120×
            _ => out.push_str(&format!("%{b:02X}")),
113
        }
114
    }
1152×
    out
1162×
}
117
118
/// A store URL with settings added as query parameters, which is where a
119
/// store remote keeps them (`s3://b/p?profile=work&region=eu-west-2`).
1204×
fn url_with_settings(url: &str, settings: &[(&str, Option<String>)]) -> Result<String> {
1214×
    let given: Vec<(&str, &str)> = settings
1224×
        .iter()
12316×
        .filter_map(|(k, v)| v.as_deref().map(|v| (*k, v)))
1244×
        .collect();
1254×
    if given.is_empty() {
1262×
        return Ok(url.to_string());
1272×
    }
1282×
    if !is_store_url(url) || url.starts_with("dir://") {
1291×
        let names: Vec<String> = given.iter().map(|(k, _)| format!("--{k}")).collect();
1301×
        anyhow::bail!(
131
            "{} apply to a store remote (s3://, gs://, az://), not to {url}",
1321×
            names.join(", ")
133
        );
1341×
    }
1351×
    let mut out = url.to_string();
1362×
    for (k, v) in given {
1372×
        out.push(if out.contains('?') { '&' } else { '?' });
1382×
        out.push_str(k);
1392×
        out.push('=');
1402×
        out.push_str(&encode_param(v));
141
    }
1421×
    Ok(out)
1434×
}
144
1454×
pub fn remote_add(name: &str, url: &str, settings: &[(&str, Option<String>)]) -> Result<i32> {
1464×
    let mut repo = open()?;
1474×
    let url = url_with_settings(url, settings)?;
1483×
    let url = url.as_str();
1493×
    repo.config.set(
1503×
        &format!("remote.{name}.url"),
1513×
        &oxy_core::protocol::stored_url(url),
1520×
    )?;
1533×
    repo.save_config()?;
1543×
    println!(
155
        "{} remote {} {} {url}",
1563×
        ui::done("Added"),
1573×
        ui::bookmark(name),
1583×
        ui::dim(ui::glyph("→", "->"))
159
    );
1603×
    Ok(0)
1614×
}
162
163
/// `oxy remote check NAME`: reach the remote with the credentials in
164
/// hand, say what it holds, and, for a store, put it through the
165
/// conformance check under a probe prefix (left empty afterwards).
1663×
pub fn remote_check(name: &str) -> Result<i32> {
1673×
    let repo = open()?;
1683×
    let Some(url) = repo.config.remotes.get(name).cloned() else {
1690×
        anyhow::bail!("no remote {name:?}; oxy remote list shows the remotes there are");
170
    };
1713×
    let options = repo.options_for_url(&url);
1723×
    let mine = repo.id()?;
1733×
    let started = std::time::Instant::now();
1743×
    let sp = ui::spinner(&format!("Checking {name}…"));
1753×
    let Some(store) = oxy_core::protocol::open_store(&url, &options) else {
176
        // A hub or another repository: reach it and read its references.
1770×
        let (_, remote) = repo.open_remote_at(&url)?;
1780×
        let id = remote.repo_id()?;
1790×
        let refs = remote.refs()?;
1800×
        sp.done(&format!(
1810×
            "{} {} {}",
1820×
            ui::done("Reachable"),
1830×
            ui::bookmark(name),
1840×
            ui::dim(format!("({url}, {} ms)", started.elapsed().as_millis()))
1850×
        ));
1860×
        describe_holding(id, mine, refs.refs.len(), refs.tags.len(), None);
1870×
        return Ok(0);
188
    };
1893×
    let store = store.with_context(|| format!("opening the store of {name}"))?;
190
    // What it holds, in one listing and two reads.
1913×
    let marker = match store.get("oxygen.toml") {
1922×
        Ok(b) => Some(b),
1931×
        Err(oxy_core::strata::Error::NotFound(_)) => None,
1940×
        Err(e) => {
1950×
            sp.clear();
1960×
            return Err(anyhow::anyhow!("{e}"))
1970×
                .with_context(|| format!("reaching the store of {name}"));
198
        }
199
    };
2003×
    let reached = started.elapsed();
2013×
    let bundles = store.list("bundles/")?.len();
2023×
    let record = store.read_ref("refs")?;
2033×
    sp.done(&format!(
2043×
        "{} {} {}",
2053×
        ui::done("Reachable"),
2063×
        ui::bookmark(name),
2073×
        ui::dim(format!("({url}, {} ms)", reached.as_millis()))
2083×
    ));
2093×
    match marker {
2101×
        None => println!(
211
            "  {} {}",
2121×
            ui::note(),
2131×
            ui::dim("holds no repository yet: oxy push NAME binds it to this one")
214
        ),
215
        Some(_) => {
2162×
            let (id, bookmarks, tags) = match record {
2172×
                Some((bytes, _)) => {
2182×
                    let (id, resp) = oxy_core::protocol::wire::decode_refs(&bytes)?;
2192×
                    (id, resp.refs.len(), resp.tags.len())
220
                }
2210×
                None => (mine, 0, 0),
222
            };
2232×
            describe_holding(id, mine, bookmarks, tags, Some(bundles));
224
        }
225
    }
226
    // Then the behaviour a repository depends on, under a prefix of its own.
2273×
    let sp = ui::spinner("Checking that the store behaves…");
2283×
    let probe = format!("oxy-check-{}", std::process::id());
2293×
    let began = std::time::Instant::now();
2303×
    match oxy_core::strata::conformance(store.as_ref(), &probe) {
2313×
        Ok(passed) => {
2323×
            sp.clear();
23324×
            for p in &passed {
23424×
                println!("  {} {}", ui::ok(ui::glyph("✔", "*")), p);
23524×
            }
2363×
            println!(
237
                "{} {}",
2383×
                ui::done("Behaves"),
2393×
                ui::dim(format!(
240
                    "({} checks in {} ms; nothing left behind)",
2413×
                    passed.len(),
2423×
                    began.elapsed().as_millis()
243
                ))
244
            );
2453×
            Ok(0)
246
        }
2470×
        Err(e) => {
2480×
            sp.clear();
249
            // Leave nothing behind, even after a failed check.
2500×
            if let Ok(left) = store.list(&probe) {
2510×
                for entry in left {
2520×
                    let _ = store.delete(&entry.key);
2530×
                }
2540×
            }
2550×
            println!("{} {}", ui::err(ui::glyph("✘", "x")), e);
2560×
            println!(
257
                "  {} {}",
2580×
                ui::note(),
2590×
                ui::dim("a store that fails this cannot keep a repository safely; check the service and the credentials' permissions")
260
            );
2610×
            Ok(1)
262
        }
263
    }
2643×
}
265
2662×
fn describe_holding(
2672×
    id: [u8; 16],
2682×
    mine: [u8; 16],
2692×
    bookmarks: usize,
2702×
    tags: usize,
2712×
    bundles: Option<usize>,
2722×
) {
27312×
    let short: String = id[..6].iter().map(|b| format!("{b:02x}")).collect();
2742×
    let mut what = format!(
275
        "repository {short}: {} and {}",
2762×
        plural(bookmarks, "bookmark"),
2772×
        plural(tags, "tag")
278
    );
2792×
    if let Some(b) = bundles {
2802×
        what.push_str(&format!(" in {}", plural(b, "bundle")));
2812×
    }
2822×
    if id == mine {
2831×
        println!("  {} {}", ui::note(), ui::dim(format!("holds this {what}")));
2841×
    } else if id == oxy_core::protocol::UNBOUND_ID {
2850×
        println!("  {} {}", ui::note(), ui::dim("holds no repository yet"));
2861×
    } else {
2871×
        println!(
2881×
            "  {} {}",
2891×
            ui::caution(),
2901×
            ui::warn(format!("holds another {what}; a push would be refused"))
2911×
        );
2921×
    }
2932×
}
294
2951×
pub fn remote_remove(name: &str) -> Result<i32> {
2961×
    let mut repo = open()?;
2971×
    repo.remove_remote(name)?;
2981×
    println!("{} remote {}", ui::done("Removed"), ui::bookmark(name));
2991×
    Ok(0)
3001×
}
301
3022×
pub fn remote_list(as_json: bool) -> Result<i32> {
3032×
    let repo = open()?;
3042×
    let view = repo.view()?;
3052×
    if as_json {
3061×
        let items = repo.config.remotes.iter().map(|(name, url)| {
3071×
            let bms = view.remotes.get(name).map(|b| {
3081×
                json::arr(b.iter().map(|(bm, st)| {
3091×
                    let heads = repo
3101×
                        .store()
3111×
                        .get_state(st)
3121×
                        .map(|s| s.heads)
3131×
                        .unwrap_or_default();
3141×
                    json::obj([("name", json::s(bm)), ("heads", json::hashes(&heads))])
3151×
                }))
3161×
            });
3171×
            json::obj([
3181×
                ("name", json::s(name)),
3191×
                ("url", json::s(url)),
320
                // The CA file `remote.NAME.ca` names, for tools that talk to
321
                // the same hub (an editor's forge view, `oh`).
322
                (
3231×
                    "ca",
3241×
                    json::opt(repo.config.remote_cas.get(name), |c| json::s(c)),
325
                ),
3261×
                ("bookmarks", bms.unwrap_or_else(|| "[]".into())),
327
            ])
3281×
        });
3291×
        println!("{}", json::arr(items));
3301×
        return Ok(0);
3311×
    }
3321×
    if repo.config.remotes.is_empty() {
3330×
        println!(
3340×
            "{}",
3350×
            ui::dim("No remotes; add one with oxy remote add <name> <url>.")
3360×
        );
3371×
    }
3381×
    for (name, url) in &repo.config.remotes {
3391×
        println!(
340
            "{} {}{}{}",
3411×
            ui::ok(ui::glyph("⇄", "<>")),
3421×
            ui::bookmark(name),
3431×
            ui::sep(),
3441×
            ui::dim(url)
345
        );
3461×
        if let Some(bms) = view.remotes.get(name) {
3470×
            for (bm, st) in bms {
3480×
                let heads = repo
3490×
                    .store()
3500×
                    .get_state(st)
3510×
                    .map(|s| s.heads.len())
3520×
                    .unwrap_or(0);
3530×
                println!(
354
                    "    {} {}{}{}",
3550×
                    ui::dim(ui::glyph("↳", "->")),
3560×
                    ui::id(format!("{name}/{bm}")),
3570×
                    ui::sep(),
3580×
                    ui::dim(plural(heads, "head"))
359
                );
360
            }
3611×
        }
362
    }
3631×
    Ok(0)
3642×
}
365
366
/// The remote named, else `origin`, else the only remote there is.
36712×
fn remote_name(repo: &oxy_core::repo::Repo, remote: Option<&str>) -> String {
36812×
    remote.map_or_else(|| repo.default_remote(), str::to_string)
36912×
}
370
3711×
pub fn fetch(remote: Option<&str>, as_json: bool) -> Result<i32> {
3721×
    let repo = open()?;
3731×
    let remote = remote_name(&repo, remote);
3741×
    let sp = ui::spinner(&format!("Fetching from {remote}…"));
3751×
    let report = noted(&repo, &remote, repo.fetch(&remote))?;
3761×
    sp.clear();
3771×
    if as_json {
3781×
        println!(
379
            "{}",
3801×
            json::obj([
3811×
                ("remote", json::s(&remote)),
382
                (
3831×
                    "refs",
3841×
                    json::arr(report.refs.iter().map(|(name, heads, n)| {
3851×
                        let t = repo.tracking(&remote, name).ok().flatten();
3861×
                        json::obj([
3871×
                            ("name", json::s(name)),
3881×
                            ("heads", json::hashes(heads)),
3891×
                            ("new", json::n(*n)),
390
                            (
3911×
                                "ahead",
3921×
                                t.map_or_else(|| "null".into(), |(a, _)| json::n(a)),
393
                            ),
394
                            (
3951×
                                "behind",
3961×
                                t.map_or_else(|| "null".into(), |(_, b)| json::n(b)),
397
                            ),
398
                        ])
3991×
                    }))
400
                ),
4011×
                ("new_tags", json::n(report.new_tags)),
402
                (
4031×
                    "withdrawn_tags",
4041×
                    json::arr(report.withdrawn_tags.iter().map(|n| json::s(n))),
405
                ),
4061×
                ("unverified", json::b(report.unverified)),
407
                (
4081×
                    "log_notes",
4091×
                    json::arr(report.log_notes.iter().map(|n| json::s(n))),
410
                ),
4111×
                ("skipped", skipped_json(&report.skipped)),
412
            ])
413
        );
4141×
        return Ok(0);
4150×
    }
4160×
    if report.refs.is_empty() {
4170×
        println!(
4180×
            "{} {}",
4190×
            ui::done("Fetched"),
4200×
            ui::dim(format!("nothing new from {remote}"))
4210×
        );
4220×
    }
4230×
    for (name, heads, n) in &report.refs {
424
        // What was fetched, then how the local bookmark stands against it:
425
        // "up to date" only when the two are the same (oxygen#56).
4260×
        let fetched =
4270×
            (*n > 0).then(|| format!("{}{}", ui::ok(plural(*n, "new change")), ui::sep()));
4280×
        let them = |k: usize| if k == 1 { "it" } else { "them" };
4290×
        let standing = match repo.tracking(&remote, name).ok().flatten() {
4300×
            None => ui::dim(format!("no local bookmark {name}")),
4310×
            Some((0, 0)) => ui::dim(format!("{name} up to date")),
4320×
            Some((0, b)) if *name == repo.config.bookmark => ui::warn(format!(
433
                "{name} is {} behind: oxy pull to take {}",
4340×
                plural(b, "change"),
4350×
                them(b)
436
            )),
4370×
            Some((0, b)) => ui::warn(format!(
438
                "{name} is {} behind: oxy bookmark switch {name} then oxy pull to take {}",
4390×
                plural(b, "change"),
4400×
                them(b)
441
            )),
4420×
            Some((a, 0)) => ui::dim(format!(
443
                "{name} is {} ahead: oxy push to send {}",
4440×
                plural(a, "change"),
4450×
                them(a)
446
            )),
4470×
            Some((a, b)) => ui::warn(format!(
448
                "{name} and {remote}/{name} have diverged ({a} here, {b} there): oxy pull to merge them"
449
            )),
450
        };
4510×
        let what = format!("{}{standing}", fetched.unwrap_or_default());
4520×
        println!(
453
            "{} {}{}{}{}{what}",
4540×
            ui::done("Fetched"),
4550×
            ui::id(format!("{remote}/{name}")),
4560×
            ui::sep(),
4570×
            ui::dim(plural(heads.len(), "head")),
4580×
            ui::sep()
459
        );
460
    }
4610×
    if report.new_tags > 0 {
4620×
        println!(
4630×
            "  {} {}",
4640×
            ui::note(),
4650×
            ui::dim(format!("{} adopted", plural(report.new_tags, "tag")))
4660×
        );
4670×
    }
4680×
    for t in &report.withdrawn_tags {
4690×
        println!(
4700×
            "  {} {}",
4710×
            ui::note(),
4720×
            ui::dim(format!("tag {t} was withdrawn on {remote}, and here"))
4730×
        );
4740×
    }
4750×
    if report.unverified {
4760×
        eprintln!(
4770×
            "{} {remote} has no trust policy; nothing fetched from it was verified",
4780×
            ui::caution()
4790×
        );
4800×
    }
4810×
    for n in &report.log_notes {
4820×
        eprintln!("{} {n}", ui::caution());
4830×
    }
4840×
    warn_skipped(&report.skipped);
4850×
    Ok(0)
4861×
}
487
488
/// Bookmarks a fetch left as they were, their attestation unverifiable
489
/// (oxygenhub#266): one warning each, naming it and why.
4908×
fn warn_skipped(skipped: &[(String, String)]) {
4918×
    for (name, why) in skipped {
4920×
        eprintln!(
4930×
            "{} {} {}",
4940×
            ui::caution(),
4950×
            ui::warn(format!("{name} not updated:")),
4960×
            ui::dim(why)
4970×
        );
4980×
    }
4998×
}
500
5012×
fn skipped_json(skipped: &[(String, String)]) -> String {
5022×
    json::arr(
5032×
        skipped
5042×
            .iter()
5052×
            .map(|(name, why)| json::obj([("name", json::s(name)), ("reason", json::s(why))])),
506
    )
5072×
}
508
509
/// Pull acts on the current bookmark only: name the others the fetch
510
/// found behind their remote, which is easy to take for the current one
511
/// (oxygen#29).
5123×
fn others_behind(repo: &oxy_core::repo::Repo, remote: &str) {
5133×
    let Ok(bookmarks) = repo.bookmarks() else {
5140×
        return;
515
    };
5164×
    for b in bookmarks {
5174×
        let Some(n) = b.behind.filter(|n| *n > 0) else {
5184×
            continue;
519
        };
5200×
        if b.current {
5210×
            continue;
5220×
        }
5230×
        println!(
524
            "  {} {}",
5250×
            ui::note(),
5260×
            ui::dim(format!(
527
                "{} is {} behind {remote}/{}: oxy bookmark switch {} then oxy pull to take {}",
528
                b.name,
5290×
                plural(n, "change"),
530
                b.name,
531
                b.name,
5320×
                if n == 1 { "it" } else { "them" }
533
            ))
534
        );
535
    }
5363×
}
537
5384×
pub fn pull(remote: Option<&str>, as_json: bool) -> Result<i32> {
5394×
    let repo = open()?;
5404×
    let remote = remote_name(&repo, remote);
5414×
    let sp = ui::spinner(&format!("Pulling from {remote}…"));
5424×
    let r = noted(&repo, &remote, repo.pull(&remote))?;
5434×
    if as_json {
5441×
        sp.clear();
5451×
        let conflicted = repo
5461×
            .snapshot_status("oxy pull")?
547
            .base_snapshot
5481×
            .is_conflicted();
5491×
        println!(
550
            "{}",
5511×
            json::obj([
5521×
                ("remote", json::s(&remote)),
5531×
                ("bookmark", json::s(&repo.config.bookmark)),
5541×
                ("heads", json::hashes(&r.heads)),
5551×
                ("changed", json::b(r.changed)),
5561×
                ("checked_out", json::b(r.checked_out)),
5571×
                ("conflicted", json::b(conflicted)),
5581×
                ("others", json::arr(r.others.iter().map(|o| json::s(o)))),
5591×
                ("skipped", skipped_json(&r.skipped))
560
            ])
561
        );
5621×
        return Ok(0);
5633×
    }
5643×
    if !r.changed {
5651×
        sp.done(&format!(
566
            "{} {}{}",
5671×
            ui::ok(ui::glyph("✔", "*")),
5681×
            ui::dim(format!(
569
                "{} is up to date with {remote}.",
570
                repo.config.bookmark
571
            )),
5721×
            if r.checked_out {
5730×
                ui::dim(" The working copy had fallen behind it and is updated.")
574
            } else {
5751×
                String::new()
576
            }
577
        ));
5781×
        others_pulled(&r.others);
5791×
        others_behind(&repo, &remote);
5801×
        warn_skipped(&r.skipped);
5811×
        if r.checked_out {
5820×
            super::workspace::where_now(&repo);
5831×
        }
5841×
        return Ok(0);
5852×
    }
5862×
    sp.done(&format!(
587
        "{} {} now has {}{}",
5882×
        ui::done("Pulled:"),
5892×
        ui::bookmark(&repo.config.bookmark),
5902×
        plural(r.heads.len(), "head"),
5912×
        ui::dim(if r.checked_out {
5922×
            "; working copy updated"
593
        } else {
5940×
            "; run oxy checkout to update the working copy"
595
        })
596
    ));
5972×
    let snap = repo.snapshot("oxy pull")?;
5982×
    if snap.base_snapshot.is_conflicted() {
5990×
        println!(
6000×
            "  {} {}",
6010×
            ui::caution(),
6020×
            ui::warn("the result has conflicts; see oxy status")
6030×
        );
6042×
    }
6052×
    others_pulled(&r.others);
6062×
    others_behind(&repo, &remote);
6072×
    warn_skipped(&r.skipped);
6082×
    subrepos_behind(&repo);
6092×
    super::workspace::where_now(&repo);
6102×
    Ok(0)
6114×
}
612
613
/// The other bookmarks a pull moved.
6143×
fn others_pulled(others: &[String]) {
6153×
    if others.is_empty() {
6162×
        return;
6171×
    }
6181×
    let shown: Vec<String> = others.iter().take(8).map(ui::bookmark).collect();
6191×
    let more = others.len().saturating_sub(8);
6201×
    println!(
621
        "  {} {}{}",
6221×
        ui::done("Also updated"),
6231×
        shown.join(", "),
6241×
        if more > 0 {
6250×
            ui::dim(format!(" and {more} more"))
626
        } else {
6271×
            String::new()
628
        }
629
    );
6303×
}
631
632
/// `oxy push [REMOTE] --delete NAME`.
6330×
pub fn push_delete(remote: Option<&str>, name: &str, as_json: bool) -> Result<i32> {
6340×
    let repo = open()?;
6350×
    let remote = remote_name(&repo, remote);
6360×
    let sp = ui::spinner(&format!("Deleting {remote}/{name}…"));
6370×
    let results = repo.push_delete(&remote, name)?;
6380×
    sp.clear();
6390×
    if as_json {
6400×
        println!(
641
            "{}",
6420×
            json::arr(results.iter().map(|r| {
6430×
                json::obj([
6440×
                    ("name", json::s(&r.name)),
6450×
                    ("ok", json::b(r.ok)),
6460×
                    ("reason", json::s(&r.reason)),
6470×
                    ("state", json::s("")),
6480×
                ])
6490×
            }))
650
        );
6510×
        return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 });
6520×
    }
6530×
    let mut code = 0;
6540×
    for r in results {
6550×
        if r.ok {
6560×
            let done = if r.name.starts_with("tags/") {
6570×
                "Withdrew"
658
            } else {
6590×
                "Deleted"
660
            };
6610×
            println!(
662
                "{} {}",
6630×
                ui::done(done),
6640×
                ui::id(format!("{remote}/{}", r.name))
665
            );
6660×
        } else {
6670×
            println!(
6680×
                "{} {} {}{}{}",
6690×
                ui::err(ui::glyph("✖", "x")),
6700×
                ui::err("Rejected"),
6710×
                ui::id(format!("{remote}/{}", r.name)),
6720×
                ui::sep(),
6730×
                r.reason
6740×
            );
6750×
            code = 1;
6760×
        }
677
    }
6780×
    Ok(code)
6790×
}
680
6817×
pub fn push(
6827×
    remote: Option<&str>,
6837×
    force: bool,
6847×
    bookmark: Option<&str>,
6857×
    as_json: bool,
6867×
) -> Result<i32> {
6877×
    let repo = open()?;
6887×
    let remote = remote_name(&repo, remote);
6897×
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
6907×
    let bookmark_name = bookmark.map_or_else(|| repo.config.bookmark.clone(), String::from);
691
    // A long push goes in parts: the spinner says which.
6927×
    let results = noted(
6937×
        &repo,
6947×
        &remote,
6957×
        repo.push_bookmark_with(&remote, &bookmark_name, force, &mut |m| {
6960×
            sp.set(&format!("Pushing to {remote}… {m}"))
6970×
        }),
6981×
    )?;
6996×
    sp.clear();
7006×
    if as_json {
701
        // The state each accepted bookmark now holds on the remote (its local
702
        // state object), so a caller can look up what the push started, such
703
        // as the checks a forge runs on it.
7041×
        let view = repo.view()?;
7051×
        println!(
706
            "{}",
7071×
            json::arr(results.iter().map(|r| {
7081×
                let state = view
7091×
                    .bookmarks
7101×
                    .get(&r.name)
7111×
                    .filter(|_| r.ok)
7121×
                    .map(|st| st.to_hex())
7131×
                    .unwrap_or_default();
7141×
                json::obj([
7151×
                    ("name", json::s(&r.name)),
7161×
                    ("ok", json::b(r.ok)),
7171×
                    ("reason", json::s(&r.reason)),
7181×
                    ("state", json::s(&state)),
7191×
                ])
7201×
            }))
721
        );
7221×
        return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 });
7235×
    }
7245×
    let mut code = 0;
7255×
    for r in results {
7265×
        if r.ok {
7275×
            println!(
728
                "{} {}{}{}",
7295×
                ui::done("Pushed"),
7305×
                ui::id(format!("{remote}/{}", r.name)),
7315×
                ui::sep(),
7325×
                ui::dim(if r.reason.is_empty() {
7335×
                    "updated"
734
                } else {
7350×
                    r.reason.as_str()
736
                })
737
            );
7380×
        } else {
7390×
            println!(
7400×
                "{} {} {}{}{}",
7410×
                ui::err(ui::glyph("✖", "x")),
7420×
                ui::err("Rejected"),
7430×
                ui::id(format!("{remote}/{}", r.name)),
7440×
                ui::sep(),
7450×
                r.reason
7460×
            );
7470×
            code = 1;
7480×
        }
749
    }
7505×
    Ok(code)
7517×
}
752
7530×
pub fn serve(
7540×
    root: PathBuf,
7550×
    bind: &str,
7560×
    tokens: Vec<String>,
7570×
    tls: Option<(PathBuf, PathBuf)>,
7580×
) -> Result<i32> {
7590×
    let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding {bind}"))?;
7600×
    let root = oxy_core::portable::canonical(&root)?;
7610×
    let tls = tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key });
7620×
    println!(
763
        "Serving repositories under {} on {}://{bind}/<repo>/oxy/v1/ ({})",
7640×
        root.display(),
7650×
        if tls.is_some() { "https" } else { "http" },
7660×
        if tokens.is_empty() {
7670×
            "open: no push tokens configured"
768
        } else {
7690×
            "push requires a token"
770
        }
771
    );
7720×
    if tls.is_none()
7730×
        && !bind.starts_with("127.")
7740×
        && !bind.starts_with("localhost")
7750×
        && !bind.starts_with("[::1]")
7760×
    {
7770×
        eprintln!("warning: serving plain HTTP on a non-loopback address; put a TLS terminator in front or pass --tls-cert/--tls-key");
7780×
    }
7790×
    oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens, tls })?;
7800×
    Ok(0)
7810×
}
782
783
/// `oxy git colocate [--off]`: keep a `.git` beside `.oxy` (spec §23).
7842×
pub fn git_colocate(off: bool) -> Result<i32> {
7852×
    let mut repo = open()?;
7862×
    if repo.is_bare() {
7870×
        anyhow::bail!("a bare repository has no working copy for a .git beside it");
7882×
    }
7892×
    repo.config
7902×
        .set("git.colocate", if off { "false" } else { "true" })?;
7912×
    repo.save_config()?;
7922×
    if off {
7931×
        println!(
794
            "{} the .git beside this checkout is no longer updated {}",
7951×
            ui::done("Stopped:"),
7961×
            ui::dim("(it is left as it is: delete .git yourself if you want it gone)")
797
        );
7981×
        return Ok(0);
7991×
    }
8001×
    let sp = ui::spinner("Exporting to .git…");
8011×
    repo.git_colocate_sync()?;
8021×
    sp.clear();
8031×
    println!(
804
        "{} .git beside .oxy holds {} {}",
8051×
        ui::done("Colocated:"),
8061×
        ui::bookmark(&repo.config.bookmark),
8071×
        ui::dim("(updated after every oxy command; Git's own commits are not read back: commit with oxy)")
808
    );
8091×
    Ok(0)
8102×
}
811
8120×
pub fn git_export(git_dir: &std::path::Path, branch: Option<&str>) -> Result<i32> {
8130×
    let repo = open()?;
8140×
    let sp = ui::spinner("Exporting to Git…");
8150×
    let r = match branch {
8160×
        Some(b) => repo.git_export_as(git_dir, b)?,
8170×
        None => repo.git_export(git_dir)?,
818
    };
8190×
    sp.clear();
8200×
    println!(
821
        "{} {} to {} as branch {} {}",
8220×
        ui::done("Exported"),
8230×
        plural(r.exported, "new state"),
8240×
        ui::path(git_dir.display()),
8250×
        ui::bookmark(&r.branch),
8260×
        ui::dim(format!(
827
            "(currently {}; {} already exported)",
8280×
            plural(r.heads, "head"),
829
            r.skipped
830
        ))
831
    );
8320×
    Ok(0)
8330×
}
834
8350×
pub fn git_recover() -> Result<i32> {
8360×
    let repo = open()?;
8370×
    let sp = ui::spinner("Reading the store…");
8380×
    let (changes, heads) = repo.recover_git_import(&|m| sp.set(m))?;
8390×
    sp.done(&format!(
8400×
        "{} {} from imported Git commits: {} now has {}",
8410×
        ui::done("Recovered"),
8420×
        plural(changes, "change"),
8430×
        ui::bookmark(&repo.config.bookmark),
8440×
        plural(heads, "head")
8450×
    ));
8460×
    println!(
847
        "  {} {}",
8480×
        ui::note(),
8490×
        ui::dim("carry on with oxy git fetch GIT_DIR --branch BRANCH")
850
    );
8510×
    Ok(0)
8520×
}
853
854
/// `--claim` (oxygen#97): record `git.claim`, so this import and every
855
/// later fetch author the identity's own commits with its key.
8560×
fn claim_commits(repo: &mut Repo) -> Result<()> {
8570×
    repo.config.set("git.claim", "true")?;
8580×
    repo.save_config()?;
8590×
    Ok(())
8600×
}
861
8620×
pub fn git_fetch(url: &str, branch: &str, claim: bool) -> Result<i32> {
8630×
    let mut repo = open()?;
8640×
    if claim {
8650×
        claim_commits(&mut repo)?;
8660×
    }
8670×
    let sp = ui::spinner(&format!("Fetching {url} {branch}…"));
8680×
    let r = repo.git_fetch(url, branch)?;
8690×
    sp.clear();
8700×
    if r.commits == 0 {
8710×
        println!(
8720×
            "{} {}",
8730×
            ui::done("Fetched"),
8740×
            ui::dim(format!("nothing new from {url} {branch}"))
8750×
        );
8760×
    } else {
8770×
        println!(
878
            "{} {} ({} merges) from {} {}{}{} now has {}",
8790×
            ui::done("Fetched"),
8800×
            plural(r.commits, "new commit"),
881
            r.merges,
8820×
            ui::path(url),
8830×
            ui::bookmark(branch),
8840×
            ui::sep(),
8850×
            ui::bookmark(&repo.config.bookmark),
8860×
            plural(r.heads, "head")
887
        );
8880×
        if !repo.snapshot("oxy git fetch")?.changed {
8890×
            println!("  {} {}", ui::note(), ui::dim("working copy updated"));
8900×
        } else {
8910×
            println!(
8920×
                "  {} {}",
8930×
                ui::note(),
8940×
                ui::dim("run oxy checkout to update the working copy")
8950×
            );
8960×
        }
897
    }
8980×
    if r.submodules > 0 {
8990×
        println!(
900
            "  {} {}",
9010×
            ui::note(),
9020×
            ui::dim(format!(
903
                "{} submodule {} imported as subrepos with Git pins",
904
                r.submodules,
9050×
                if r.submodules == 1 {
9060×
                    "entry"
907
                } else {
9080×
                    "entries"
909
                }
910
            ))
911
        );
9120×
    }
9130×
    Ok(0)
9140×
}
915
9160×
pub fn git_push(url: &str, branch: &str) -> Result<i32> {
9170×
    let repo = open()?;
9180×
    let sp = ui::spinner(&format!("Pushing to {url} {branch}…"));
9190×
    let r = repo.git_push(url, branch)?;
9200×
    sp.clear();
9210×
    println!(
922
        "{} {} to {} {} {}",
9230×
        ui::done("Pushed"),
9240×
        plural(r.exported, "new commit"),
9250×
        ui::path(url),
9260×
        ui::bookmark(&r.branch),
9270×
        ui::dim(format!("({} already there)", r.skipped))
928
    );
9290×
    Ok(0)
9300×
}
931
932
/// `oxy git verify`: exit 1 if any commit's tree is not reproduced.
9330×
pub fn git_verify(
9340×
    git_dir: &std::path::Path,
9350×
    branch: &str,
9360×
    last: Option<usize>,
9370×
    max: usize,
9380×
) -> Result<i32> {
9390×
    let repo = open()?;
9400×
    let started = std::time::Instant::now();
9410×
    let sp = ui::spinner(&format!(
9420×
        "Rebuilding every commit of {} {branch}…",
9430×
        git_dir.display()
9440×
    ));
9450×
    let r = repo.verify_git_import_last(git_dir, branch, last, max)?;
9460×
    sp.clear();
9470×
    for (_, what) in &r.mismatches {
9480×
        println!("{} {what}", ui::err(ui::glyph("✖", "x")));
9490×
    }
9500×
    if r.not_yet > 0 {
9510×
        println!(
9520×
            "  {} {}",
9530×
            ui::note(),
9540×
            ui::dim(format!(
9550×
                "{} not imported yet, after the last imported commit: not checked",
9560×
                plural(r.not_yet, "commit")
9570×
            ))
9580×
        );
9590×
    }
9600×
    if r.mismatches.is_empty() {
9610×
        println!(
962
            "{}",
9630×
            ui::done(if r.skipped > 0 {
9640×
                format!(
965
                    "The last {} commits reproduced ({} followed), {} files compared, in {:.1}s",
9660×
                    r.commits - r.skipped,
967
                    r.skipped,
968
                    r.files_compared,
9690×
                    started.elapsed().as_secs_f64()
970
                )
971
            } else {
9720×
                format!(
973
                    "Every commit reproduced: {} commits, {} files compared, in {:.1}s",
974
                    r.commits,
975
                    r.files_compared,
9760×
                    started.elapsed().as_secs_f64()
977
                )
978
            })
979
        );
9800×
        Ok(0)
981
    } else {
982
        // What differs, by kind, the commit taken off: one difference
983
        // carried through history reads as one line.
9840×
        let mut kinds: std::collections::BTreeMap<String, usize> = Default::default();
9850×
        for (_, what) in &r.mismatches {
9860×
            let kind = what.split_once(": ").map_or(what.as_str(), |(_, k)| k);
9870×
            *kinds.entry(kind.to_string()).or_default() += 1;
988
        }
9890×
        let mut kinds: Vec<_> = kinds.into_iter().collect();
9900×
        kinds.sort_by_key(|k| std::cmp::Reverse(k.1));
9910×
        for (kind, n) in kinds.iter().take(10) {
9920×
            println!("  {} {}", ui::dim(format!("{n:>6} ×")), kind);
9930×
        }
9940×
        println!(
995
            "{} differences in {} commits checked{}",
9960×
            r.mismatches.len(),
997
            r.commits,
9980×
            if max > 0 && r.mismatches.len() >= max {
9990×
                format!(" (stopped at {max}; --max-differences 0 checks every commit)")
1000
            } else {
10010×
                String::new()
1002
            }
1003
        );
10040×
        Ok(1)
1005
    }
10060×
}
1007
10080×
pub fn git_import(
10090×
    git_dir: &std::path::Path,
10100×
    dest: &std::path::Path,
10110×
    branch: &str,
10120×
    claim: bool,
10130×
) -> Result<i32> {
10140×
    let mut repo = Repo::init(dest)?;
10150×
    if claim {
10160×
        claim_commits(&mut repo)?;
10170×
    }
10180×
    let started = std::time::Instant::now();
10190×
    let sp = ui::spinner(&format!("Importing {} {branch}…", git_dir.display()));
10200×
    let r = repo.import_git_with(git_dir, branch, &mut |where_| sp.set(where_))?;
10210×
    sp.clear();
10220×
    println!(
1023
        "{} {} ({} merges) from {} {} into {} {}",
10240×
        ui::done("Imported"),
10250×
        plural(r.commits, "commit"),
1026
        r.merges,
10270×
        ui::path(git_dir.display()),
10280×
        ui::bookmark(branch),
10290×
        ui::path(repo.root().display()),
10300×
        ui::dim(format!(
1031
            "in {:.1?}; {} now has {}",
10320×
            started.elapsed(),
1033
            repo.config.bookmark,
10340×
            plural(r.heads, "head")
1035
        ))
1036
    );
10370×
    if r.submodules > 0 {
10380×
        println!(
1039
            "  {} {}",
10400×
            ui::note(),
10410×
            ui::dim(format!(
1042
                "{} submodule {} imported as subrepos with Git pins",
1043
                r.submodules,
10440×
                if r.submodules == 1 {
10450×
                    "entry"
1046
                } else {
10470×
                    "entries"
1048
                }
1049
            ))
1050
        );
10510×
    }
10520×
    Ok(0)
10530×
}
1054
10551×
pub fn submodule_add(url: &str, path: &str, bookmark: Option<&str>) -> Result<i32> {
10561×
    let repo = open()?;
10571×
    let sp = ui::spinner(&format!("Cloning {url} into {path}…"));
10581×
    let pin = repo.submodule_add(url, path, bookmark)?;
10591×
    sp.done(&format!(
10601×
        "{} subrepo {} {} {}",
10611×
        ui::done("Added"),
10621×
        ui::path(path),
10631×
        ui::dim(ui::glyph("→", "->")),
10641×
        ui::dim(pin.summary())
10651×
    ));
10661×
    println!(
1067
        "  {} {}",
10681×
        ui::note(),
10691×
        ui::dim("recorded in .oxymodules; commit to pin it")
1070
    );
10711×
    Ok(0)
10721×
}
1073
10740×
pub fn submodule_update(init: bool, paths: &[String]) -> Result<i32> {
10750×
    let repo = open()?;
10760×
    let sp = ui::spinner("Updating subrepos…");
10770×
    let results = repo.submodule_update(init, paths)?;
10780×
    let unpinned: Vec<_> = repo
10790×
        .unpinned_modules()?
10800×
        .into_iter()
10810×
        .filter(|(p, _)| paths.is_empty() || paths.iter().any(|q| q.trim_end_matches('/') == p))
10820×
        .collect();
10830×
    sp.clear();
10840×
    if results.is_empty() && unpinned.is_empty() {
10850×
        println!("{}", ui::dim("No subrepos."));
10860×
    }
10870×
    let code = print_subrepo_results(results);
10880×
    print_unpinned(&unpinned);
10890×
    Ok(if unpinned.is_empty() { code } else { 1 })
10900×
}
1091
1092
/// `.oxymodules` entries never added: nothing to clone, so say how to add
1093
/// them (oxygen#72).
10940×
fn print_unpinned(unpinned: &[(String, oxy_core::subrepo::Module)]) {
10950×
    for (path, m) in unpinned {
10960×
        let url = if m.url.is_empty() { "<url>" } else { &m.url };
10970×
        println!(
1098
            "{} {}",
10990×
            ui::warn(format!(
1100
                "{} in .oxymodules but never added, so no pin",
11010×
                ui::glyph("⚠", "!")
1102
            )),
11030×
            ui::path(path)
1104
        );
11050×
        println!(
1106
            "  {}",
11070×
            ui::dim(format!("add it with: oxy submodule add {url} {path}"))
1108
        );
1109
    }
11100×
}
1111
11120×
fn print_subrepo_results(results: Vec<(String, oxy_core::repo::SubrepoSync)>) -> i32 {
11130×
    let mut code = 0;
11140×
    for (path, r) in results {
1115
        use oxy_core::repo::SubrepoSync::*;
11160×
        let line = match r {
11170×
            Updated => ui::done("Updated"),
11180×
            UpToDate => ui::ok(format!("{} up to date", ui::glyph("✔", "*"))),
1119
            Missing => {
11200×
                code = 1;
11210×
                ui::warn(format!(
1122
                    "{} missing (pass --init to clone)",
11230×
                    ui::glyph("⚠", "!")
1124
                ))
1125
            }
1126
            Dirty => {
11270×
                code = 1;
11280×
                ui::warn(format!(
1129
                    "{} has uncommitted changes; left as is",
11300×
                    ui::glyph("⚠", "!")
1131
                ))
1132
            }
11330×
            KeptBookmark { bookmark, newer } => ui::warn(format!(
1134
                "{} checked out at the pin; its bookmark {bookmark} keeps {} the pin lacks",
11350×
                ui::glyph("⚠", "!"),
11360×
                plural(newer, "newer change")
1137
            )),
11380×
            Failed(e) => {
11390×
                code = 1;
11400×
                ui::err(format!("{} {e}", ui::glyph("✖", "x")))
1141
            }
1142
        };
11430×
        println!("{line} {}", ui::path(path));
1144
    }
11450×
    code
11460×
}
1147
11480×
fn pin_json(p: &oxy_core::subrepo::Pin) -> String {
11490×
    match p {
1150
        oxy_core::subrepo::Pin::Oxy {
11510×
            repo,
11520×
            heads,
11530×
            bookmark,
11540×
        } => json::obj([
11550×
            ("kind", json::s("oxy")),
1156
            (
11570×
                "repo",
11580×
                json::s(&repo.iter().map(|b| format!("{b:02x}")).collect::<String>()),
1159
            ),
11600×
            ("heads", json::hashes(heads)),
11610×
            ("bookmark", json::s(bookmark)),
1162
        ]),
11630×
        oxy_core::subrepo::Pin::Git { sha, branch } => json::obj([
11640×
            ("kind", json::s("git")),
11650×
            ("sha", json::s(sha)),
11660×
            ("branch", json::s(branch)),
11670×
        ]),
1168
    }
11690×
}
1170
11711×
pub fn submodule_status(as_json: bool) -> Result<i32> {
11721×
    let repo = open()?;
11731×
    let subs = repo.subrepos()?;
11741×
    let unpinned = repo.unpinned_modules()?;
11751×
    if as_json {
11761×
        let pinned = subs.iter().map(|s| {
11770×
            json::obj([
11780×
                ("path", json::s(&s.path)),
11790×
                ("pin", pin_json(&s.pin)),
11800×
                ("actual", json::opt(s.actual.as_ref(), pin_json)),
11810×
                ("at_pin", json::b(s.actual.as_ref() == Some(&s.pin))),
11820×
                ("dirty", json::b(s.dirty)),
11830×
                ("committed", json::b(s.committed)),
11840×
                ("url", json::opt(s.url.as_deref(), json::s)),
11850×
            ])
11860×
        });
1187
        // Listed in .oxymodules but never added: no pin at all.
11881×
        let unpinned_json = unpinned.iter().map(|(path, m)| {
11890×
            json::obj([
11900×
                ("path", json::s(path)),
11910×
                ("pin", "null".to_string()),
11920×
                ("actual", "null".to_string()),
11930×
                ("at_pin", json::b(false)),
11940×
                ("dirty", json::b(false)),
11950×
                ("committed", json::b(false)),
11960×
                ("url", json::s(&m.url)),
11970×
            ])
11980×
        });
11991×
        println!("{}", json::arr(pinned.chain(unpinned_json)));
12001×
        return Ok(0);
12010×
    }
12020×
    if subs.is_empty() && unpinned.is_empty() {
12030×
        println!("{}", ui::dim("No subrepos."));
12040×
    }
12050×
    for s in subs {
12060×
        let state = match (&s.actual, s.dirty) {
12070×
            _ if !s.committed => {
12080×
                ui::warn(format!("{} added, not committed yet", ui::glyph("+", "+")))
1209
            }
12100×
            (None, _) => ui::warn(format!("{} missing", ui::glyph("⚠", "!"))),
12110×
            (Some(a), dirty) if *a == s.pin => {
12120×
                if dirty {
12130×
                    ui::warn(format!(
1214
                        "{} at pin, uncommitted changes",
12150×
                        ui::glyph("✎", "~")
1216
                    ))
1217
                } else {
12180×
                    ui::ok(format!("{} at pin", ui::glyph("✔", "*")))
1219
                }
1220
            }
12210×
            (Some(a), _) => ui::warn(format!("{} at {}", ui::glyph("↕", "<>"), a.summary())),
1222
        };
12230×
        println!(
1224
            "{} {}{}{}{}{state}",
12250×
            ui::ok(ui::glyph("⧉", "#")),
12260×
            ui::path(&s.path),
12270×
            ui::sep(),
12280×
            ui::dim(s.pin.summary()),
12290×
            ui::sep()
1230
        );
12310×
        if let Some(u) = s.url {
12320×
            println!("    {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(u));
12330×
        }
1234
    }
12350×
    print_unpinned(&unpinned);
12360×
    Ok(0)
12371×
}
1238
12390×
pub fn submodule_remove(path: &str, delete: bool, force: bool) -> Result<i32> {
12400×
    let repo = open()?;
12410×
    let dir = repo.root().join(path.trim_end_matches('/'));
1242
    // Look before unregistering, so a refused delete changes nothing.
12430×
    if delete && dir.exists() && !force {
12440×
        if let Ok(sub) = oxy_core::repo::Repo::open(&dir) {
12450×
            let snap = sub.snapshot("oxy submodule remove --delete")?;
12460×
            if !snap.statuses.is_empty() {
12470×
                anyhow::bail!(
1248
                    "{path} has {} with uncommitted edits; commit them in the subrepo (and push them if you want them kept), or pass --force to delete them",
12490×
                    plural(snap.statuses.len(), "file")
1250
                );
12510×
            }
12520×
        }
12530×
    }
12540×
    repo.submodule_remove(path)?;
12550×
    if !delete {
12560×
        println!(
1257
            "{} subrepo {} {}",
12580×
            ui::done("Removed"),
12590×
            ui::path(path),
12600×
            ui::dim("(directory left in place; commit to record the removal)")
1261
        );
12620×
        println!(
1263
            "  {} {}",
12640×
            ui::note(),
12650×
            ui::dim(format!(
1266
                "to delete the directory too: oxy submodule remove {path} --delete"
1267
            ))
1268
        );
12690×
        return Ok(0);
12700×
    }
12710×
    if dir.exists() {
12720×
        delete_tree(&dir)?;
12730×
    }
12740×
    println!(
1275
        "{} subrepo {} {}",
12760×
        ui::done("Removed"),
12770×
        ui::path(path),
12780×
        ui::dim("and its directory (commit to record the removal)")
1279
    );
12800×
    Ok(0)
12810×
}
1282
1283
/// Delete a directory tree that holds a repository: its object files are
1284
/// read-only, so each is made writable first (#164).
12850×
fn delete_tree(dir: &std::path::Path) -> Result<()> {
12860×
    fn writable(p: &std::path::Path) -> std::io::Result<()> {
12870×
        let meta = std::fs::symlink_metadata(p)?;
12880×
        if meta.file_type().is_symlink() {
12890×
            return Ok(());
12900×
        }
12910×
        let mut perms = meta.permissions();
12920×
        if perms.readonly() {
1293
            #[allow(clippy::permissions_set_readonly_false)]
12940×
            perms.set_readonly(false);
12950×
            std::fs::set_permissions(p, perms)?;
12960×
        }
12970×
        if meta.is_dir() {
12980×
            for e in std::fs::read_dir(p)? {
12990×
                writable(&e?.path())?;
1300
            }
13010×
        }
13020×
        Ok(())
13030×
    }
13040×
    writable(dir).map_err(|e| anyhow::anyhow!("{}: {e}", dir.display()))?;
13050×
    std::fs::remove_dir_all(dir).map_err(|e| anyhow::anyhow!("{}: {e}", dir.display()))?;
13060×
    Ok(())
13070×
}
1308
13090×
pub fn deepen(remote: &str, depth: u32) -> Result<i32> {
13100×
    let repo = open()?;
13110×
    if !repo.is_shallow() {
13120×
        println!(
1313
            "{}",
13140×
            ui::dim("This repository is not shallow; nothing to deepen.")
1315
        );
13160×
        return Ok(0);
13170×
    }
13180×
    let sp = ui::spinner(&format!("Fetching {depth} more levels from {remote}…"));
13190×
    let n = repo.deepen(remote, depth)?;
13200×
    sp.done(&format!(
1321
        "{} {}; {}",
13220×
        ui::done("Deepened by"),
13230×
        plural(n, "change"),
13240×
        if repo.is_shallow() {
13250×
            "still shallow".to_string()
1326
        } else {
13270×
            ui::ok("history is now complete")
1328
        }
1329
    ));
13300×
    Ok(0)
13310×
}
1332
1333
/// `r`, with a note when the remote's own token was refused while another
1334
/// sign-in for its hub would do (oxygen#155).
133512×
fn noted<T>(repo: &oxy_core::repo::Repo, remote: &str, r: oxy_core::repo::Result<T>) -> Result<T> {
133612×
    r.map_err(|e| {
13371×
        let m = e.to_string();
13381×
        match repo.stale_token_note(remote, &m) {
13390×
            Some(note) => anyhow::anyhow!("{m}\n  note: {note}"),
13401×
            None => e.into(),
1341
        }
13421×
    })
134312×
}