OXYGENHUB
# 8b7986dd3cb0 oxygen / src / cli / sync.rs
23.4 KiB · Rust · last changed git verify: --max-differences, and what differs by kind 27 days ago source blame history raw
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
11
pub fn clone(
12
    url: &str,
13
    dest: Option<PathBuf>,
14
    max_blob: Option<&str>,
15
    sparse: &[String],
16
    depth: Option<u32>,
17
) -> Result<i32> {
18
    let dest = dest.unwrap_or_else(|| {
19
        let base = url
20
            .trim_end_matches('/')
21
            .rsplit('/')
22
            .next()
23
            .unwrap_or("repo");
24
        PathBuf::from(base)
25
    });
26
    let max_blob = max_blob.map(oxy_core::repo::parse_size).transpose()?;
27
    let sp = ui::spinner(&format!("Cloning {url}…"));
28
    let repo = match depth {
29
        Some(d) => Repo::clone_shallow(url, &dest, d, max_blob, sparse)?,
30
        None => Repo::clone_with(url, &dest, max_blob, sparse)?,
31
    };
32
    let heads = repo.bookmark_heads()?;
33
    sp.done(&format!(
34
        "{} into {} {}",
35
        ui::done("Cloned"),
36
        ui::path(repo.root().display()),
37
        ui::dim(format!(
38
            "({url}, {} with {})",
39
            ui::bookmark(&repo.config.bookmark),
40
            plural(heads.len(), "head")
41
        ))
42
    ));
43
    Ok(0)
44
}
45
46
pub fn remote_add(name: &str, url: &str) -> Result<i32> {
47
    let mut repo = open()?;
48
    repo.config.set(&format!("remote.{name}.url"), url)?;
49
    repo.save_config()?;
50
    println!(
51
        "{} remote {} {} {url}",
52
        ui::done("Added"),
53
        ui::bookmark(name),
54
        ui::dim(ui::glyph("→", "->"))
55
    );
56
    Ok(0)
57
}
58
59
pub fn remote_remove(name: &str) -> Result<i32> {
60
    let mut repo = open()?;
61
    repo.config.set(&format!("remote.{name}.url"), "")?;
62
    repo.save_config()?;
63
    println!("{} remote {}", ui::done("Removed"), ui::bookmark(name));
64
    Ok(0)
65
}
66
67
pub fn remote_list(as_json: bool) -> Result<i32> {
68
    let repo = open()?;
69
    let view = repo.view()?;
70
    if as_json {
71
        let items = repo.config.remotes.iter().map(|(name, url)| {
72
            let bms = view.remotes.get(name).map(|b| {
73
                json::arr(b.iter().map(|(bm, st)| {
74
                    let heads = repo
75
                        .store()
76
                        .get_state(st)
77
                        .map(|s| s.heads)
78
                        .unwrap_or_default();
79
                    json::obj([("name", json::s(bm)), ("heads", json::hashes(&heads))])
80
                }))
81
            });
82
            json::obj([
83
                ("name", json::s(name)),
84
                ("url", json::s(url)),
85
                // The CA file `remote.NAME.ca` names, for tools that talk to
86
                // the same hub (an editor's forge view, `oh`).
87
                (
88
                    "ca",
89
                    json::opt(repo.config.remote_cas.get(name), |c| json::s(c)),
90
                ),
91
                ("bookmarks", bms.unwrap_or_else(|| "[]".into())),
92
            ])
93
        });
94
        println!("{}", json::arr(items));
95
        return Ok(0);
96
    }
97
    if repo.config.remotes.is_empty() {
98
        println!(
99
            "{}",
100
            ui::dim("No remotes; add one with oxy remote add <name> <url>.")
101
        );
102
    }
103
    for (name, url) in &repo.config.remotes {
104
        println!(
105
            "{} {}{}{}",
106
            ui::ok(ui::glyph("⇄", "<>")),
107
            ui::bookmark(name),
108
            ui::sep(),
109
            ui::dim(url)
110
        );
111
        if let Some(bms) = view.remotes.get(name) {
112
            for (bm, st) in bms {
113
                let heads = repo
114
                    .store()
115
                    .get_state(st)
116
                    .map(|s| s.heads.len())
117
                    .unwrap_or(0);
118
                println!(
119
                    "    {} {}{}{}",
120
                    ui::dim(ui::glyph("↳", "->")),
121
                    ui::id(format!("{name}/{bm}")),
122
                    ui::sep(),
123
                    ui::dim(plural(heads, "head"))
124
                );
125
            }
126
        }
127
    }
128
    Ok(0)
129
}
130
131
fn remote_name(remote: Option<&str>) -> String {
132
    remote.unwrap_or("origin").to_string()
133
}
134
135
pub fn fetch(remote: Option<&str>, as_json: bool) -> Result<i32> {
136
    let repo = open()?;
137
    let remote = remote_name(remote);
138
    let sp = ui::spinner(&format!("Fetching from {remote}…"));
139
    let report = repo.fetch(&remote)?;
140
    sp.clear();
141
    if as_json {
142
        println!(
143
            "{}",
144
            json::obj([
145
                ("remote", json::s(&remote)),
146
                (
147
                    "refs",
148
                    json::arr(report.refs.iter().map(|(name, heads, n)| json::obj([
149
                        ("name", json::s(name)),
150
                        ("heads", json::hashes(heads)),
151
                        ("new", json::n(*n))
152
                    ])))
153
                ),
154
                ("new_tags", json::n(report.new_tags)),
155
                ("unverified", json::b(report.unverified)),
156
            ])
157
        );
158
        return Ok(0);
159
    }
160
    if report.refs.is_empty() {
161
        println!(
162
            "{} {}",
163
            ui::done("Fetched"),
164
            ui::dim(format!("nothing new from {remote}"))
165
        );
166
    }
167
    for (name, heads, n) in &report.refs {
168
        let what = if *n == 0 {
169
            ui::dim("up to date")
170
        } else {
171
            ui::ok(plural(*n, "new change"))
172
        };
173
        println!(
174
            "{} {}{}{}{}{what}",
175
            ui::done("Fetched"),
176
            ui::id(format!("{remote}/{name}")),
177
            ui::sep(),
178
            ui::dim(plural(heads.len(), "head")),
179
            ui::sep()
180
        );
181
    }
182
    if report.new_tags > 0 {
183
        println!(
184
            "  {} {}",
185
            ui::note(),
186
            ui::dim(format!("{} adopted", plural(report.new_tags, "tag")))
187
        );
188
    }
189
    if report.unverified {
190
        eprintln!(
191
            "{} {remote} has no trust policy; nothing fetched from it was verified",
192
            ui::caution()
193
        );
194
    }
195
    Ok(0)
196
}
197
198
/// Pull acts on the current bookmark only: name the others the fetch
199
/// found behind their remote, which is easy to take for the current one
200
/// (oxygen#29).
201
fn others_behind(repo: &oxy_core::repo::Repo, remote: &str) {
202
    let Ok(bookmarks) = repo.bookmarks() else {
203
        return;
204
    };
205
    for b in bookmarks {
206
        let Some(n) = b.behind.filter(|n| *n > 0) else {
207
            continue;
208
        };
209
        if b.current {
210
            continue;
211
        }
212
        println!(
213
            "  {} {}",
214
            ui::note(),
215
            ui::dim(format!(
216
                "{} is {} behind {remote}/{}: oxy bookmark switch {} then oxy pull to take {}",
217
                b.name,
218
                plural(n, "change"),
219
                b.name,
220
                b.name,
221
                if n == 1 { "it" } else { "them" }
222
            ))
223
        );
224
    }
225
}
226
227
pub fn pull(remote: Option<&str>, as_json: bool) -> Result<i32> {
228
    let repo = open()?;
229
    let remote = remote_name(remote);
230
    let sp = ui::spinner(&format!("Pulling from {remote}…"));
231
    let r = repo.pull(&remote)?;
232
    if as_json {
233
        sp.clear();
234
        let conflicted = repo
235
            .snapshot_status("oxy pull")?
236
            .base_snapshot
237
            .is_conflicted();
238
        println!(
239
            "{}",
240
            json::obj([
241
                ("remote", json::s(&remote)),
242
                ("bookmark", json::s(&repo.config.bookmark)),
243
                ("heads", json::hashes(&r.heads)),
244
                ("changed", json::b(r.changed)),
245
                ("checked_out", json::b(r.checked_out)),
246
                ("conflicted", json::b(conflicted))
247
            ])
248
        );
249
        return Ok(0);
250
    }
251
    if !r.changed {
252
        sp.done(&format!(
253
            "{} {}",
254
            ui::ok(ui::glyph("✔", "*")),
255
            ui::dim(format!(
256
                "{} is up to date with {remote}.",
257
                repo.config.bookmark
258
            ))
259
        ));
260
        others_behind(&repo, &remote);
261
        return Ok(0);
262
    }
263
    sp.done(&format!(
264
        "{} {} now has {}{}",
265
        ui::done("Pulled:"),
266
        ui::bookmark(&repo.config.bookmark),
267
        plural(r.heads.len(), "head"),
268
        ui::dim(if r.checked_out {
269
            "; working copy updated"
270
        } else {
271
            "; run oxy checkout to update the working copy"
272
        })
273
    ));
274
    let snap = repo.snapshot("oxy pull")?;
275
    if snap.base_snapshot.is_conflicted() {
276
        println!(
277
            "  {} {}",
278
            ui::caution(),
279
            ui::warn("the result has conflicts; see oxy status")
280
        );
281
    }
282
    others_behind(&repo, &remote);
283
    Ok(0)
284
}
285
286
/// `oxy push [REMOTE] --delete NAME`.
287
pub fn push_delete(remote: Option<&str>, name: &str, as_json: bool) -> Result<i32> {
288
    let repo = open()?;
289
    let remote = remote_name(remote);
290
    let sp = ui::spinner(&format!("Deleting {remote}/{name}…"));
291
    let results = repo.push_delete(&remote, name)?;
292
    sp.clear();
293
    if as_json {
294
        println!(
295
            "{}",
296
            json::arr(results.iter().map(|r| {
297
                json::obj([
298
                    ("name", json::s(&r.name)),
299
                    ("ok", json::b(r.ok)),
300
                    ("reason", json::s(&r.reason)),
301
                    ("state", json::s("")),
302
                ])
303
            }))
304
        );
305
        return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 });
306
    }
307
    let mut code = 0;
308
    for r in results {
309
        if r.ok {
310
            println!(
311
                "{} {}",
312
                ui::done("Deleted"),
313
                ui::id(format!("{remote}/{}", r.name))
314
            );
315
        } else {
316
            println!(
317
                "{} {} {}{}{}",
318
                ui::err(ui::glyph("✖", "x")),
319
                ui::err("Rejected"),
320
                ui::id(format!("{remote}/{}", r.name)),
321
                ui::sep(),
322
                r.reason
323
            );
324
            code = 1;
325
        }
326
    }
327
    Ok(code)
328
}
329
330
pub fn push(
331
    remote: Option<&str>,
332
    force: bool,
333
    bookmark: Option<&str>,
334
    as_json: bool,
335
) -> Result<i32> {
336
    let repo = open()?;
337
    let remote = remote_name(remote);
338
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
339
    let results = match bookmark {
340
        Some(b) => repo.push_bookmark(&remote, b, force)?,
341
        None => repo.push(&remote, force)?,
342
    };
343
    sp.clear();
344
    if as_json {
345
        println!(
346
            "{}",
347
            json::arr(results.iter().map(|r| json::obj([
348
                ("name", json::s(&r.name)),
349
                ("ok", json::b(r.ok)),
350
                ("reason", json::s(&r.reason))
351
            ])))
352
        );
353
        return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 });
354
    }
355
    let mut code = 0;
356
    for r in results {
357
        if r.ok {
358
            println!(
359
                "{} {}{}{}",
360
                ui::done("Pushed"),
361
                ui::id(format!("{remote}/{}", r.name)),
362
                ui::sep(),
363
                ui::dim(if r.reason.is_empty() {
364
                    "updated"
365
                } else {
366
                    r.reason.as_str()
367
                })
368
            );
369
        } else {
370
            println!(
371
                "{} {} {}{}{}",
372
                ui::err(ui::glyph("✖", "x")),
373
                ui::err("Rejected"),
374
                ui::id(format!("{remote}/{}", r.name)),
375
                ui::sep(),
376
                r.reason
377
            );
378
            code = 1;
379
        }
380
    }
381
    Ok(code)
382
}
383
384
pub fn serve(
385
    root: PathBuf,
386
    bind: &str,
387
    tokens: Vec<String>,
388
    tls: Option<(PathBuf, PathBuf)>,
389
) -> Result<i32> {
390
    let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding {bind}"))?;
391
    let root = root.canonicalize()?;
392
    let tls = tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key });
393
    println!(
394
        "Serving repositories under {} on {}://{bind}/<repo>/oxy/v1/ ({})",
395
        root.display(),
396
        if tls.is_some() { "https" } else { "http" },
397
        if tokens.is_empty() {
398
            "open: no push tokens configured"
399
        } else {
400
            "push requires a token"
401
        }
402
    );
403
    if tls.is_none()
404
        && !bind.starts_with("127.")
405
        && !bind.starts_with("localhost")
406
        && !bind.starts_with("[::1]")
407
    {
408
        eprintln!("warning: serving plain HTTP on a non-loopback address; put a TLS terminator in front or pass --tls-cert/--tls-key");
409
    }
410
    oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens, tls })?;
411
    Ok(0)
412
}
413
414
pub fn git_export(git_dir: &std::path::Path, branch: Option<&str>) -> Result<i32> {
415
    let repo = open()?;
416
    let sp = ui::spinner("Exporting to Git…");
417
    let r = match branch {
418
        Some(b) => repo.git_export_as(git_dir, b)?,
419
        None => repo.git_export(git_dir)?,
420
    };
421
    sp.clear();
422
    println!(
423
        "{} {} to {} as branch {} {}",
424
        ui::done("Exported"),
425
        plural(r.exported, "new state"),
426
        ui::path(git_dir.display()),
427
        ui::bookmark(&r.branch),
428
        ui::dim(format!(
429
            "(currently {}; {} already exported)",
430
            plural(r.heads, "head"),
431
            r.skipped
432
        ))
433
    );
434
    Ok(0)
435
}
436
437
pub fn git_fetch(url: &str, branch: &str) -> Result<i32> {
438
    let repo = open()?;
439
    let sp = ui::spinner(&format!("Fetching {url} {branch}…"));
440
    let r = repo.git_fetch(url, branch)?;
441
    sp.clear();
442
    if r.commits == 0 {
443
        println!(
444
            "{} {}",
445
            ui::done("Fetched"),
446
            ui::dim(format!("nothing new from {url} {branch}"))
447
        );
448
    } else {
449
        println!(
450
            "{} {} ({} merges) from {} {}{}{} now has {}",
451
            ui::done("Fetched"),
452
            plural(r.commits, "new commit"),
453
            r.merges,
454
            ui::path(url),
455
            ui::bookmark(branch),
456
            ui::sep(),
457
            ui::bookmark(&repo.config.bookmark),
458
            plural(r.heads, "head")
459
        );
460
        if !repo.snapshot("oxy git fetch")?.changed {
461
            println!("  {} {}", ui::note(), ui::dim("working copy updated"));
462
        } else {
463
            println!(
464
                "  {} {}",
465
                ui::note(),
466
                ui::dim("run oxy checkout to update the working copy")
467
            );
468
        }
469
    }
470
    if r.submodules > 0 {
471
        println!(
472
            "  {} {}",
473
            ui::note(),
474
            ui::dim(format!(
475
                "{} submodule {} imported as subrepos with Git pins",
476
                r.submodules,
477
                if r.submodules == 1 {
478
                    "entry"
479
                } else {
480
                    "entries"
481
                }
482
            ))
483
        );
484
    }
485
    Ok(0)
486
}
487
488
pub fn git_push(url: &str, branch: &str) -> Result<i32> {
489
    let repo = open()?;
490
    let sp = ui::spinner(&format!("Pushing to {url} {branch}…"));
491
    let r = repo.git_push(url, branch)?;
492
    sp.clear();
493
    println!(
494
        "{} {} to {} {} {}",
495
        ui::done("Pushed"),
496
        plural(r.exported, "new commit"),
497
        ui::path(url),
498
        ui::bookmark(&r.branch),
499
        ui::dim(format!("({} already there)", r.skipped))
500
    );
501
    Ok(0)
502
}
503
504
/// `oxy git verify`: exit 1 if any commit's tree is not reproduced.
505
pub fn git_verify(
506
    git_dir: &std::path::Path,
507
    branch: &str,
508
    last: Option<usize>,
509
    max: usize,
510
) -> Result<i32> {
511
    let repo = open()?;
512
    let started = std::time::Instant::now();
513
    let sp = ui::spinner(&format!(
514
        "Rebuilding every commit of {} {branch}…",
515
        git_dir.display()
516
    ));
517
    let r = repo.verify_git_import_last(git_dir, branch, last, max)?;
518
    sp.clear();
519
    for (_, what) in &r.mismatches {
520
        println!("{} {what}", ui::err(ui::glyph("✖", "x")));
521
    }
522
    if r.mismatches.is_empty() {
523
        println!(
524
            "{}",
525
            ui::done(if r.skipped > 0 {
526
                format!(
527
                    "The last {} commits reproduced ({} followed), {} files compared, in {:.1}s",
528
                    r.commits - r.skipped,
529
                    r.skipped,
530
                    r.files_compared,
531
                    started.elapsed().as_secs_f64()
532
                )
533
            } else {
534
                format!(
535
                    "Every commit reproduced: {} commits, {} files compared, in {:.1}s",
536
                    r.commits,
537
                    r.files_compared,
538
                    started.elapsed().as_secs_f64()
539
                )
540
            })
541
        );
542
        Ok(0)
543
    } else {
544
        // What differs, by kind, the commit taken off: one difference
545
        // carried through history reads as one line.
546
        let mut kinds: std::collections::BTreeMap<String, usize> = Default::default();
547
        for (_, what) in &r.mismatches {
548
            let kind = what.split_once(": ").map_or(what.as_str(), |(_, k)| k);
549
            *kinds.entry(kind.to_string()).or_default() += 1;
550
        }
551
        let mut kinds: Vec<_> = kinds.into_iter().collect();
552
        kinds.sort_by_key(|k| std::cmp::Reverse(k.1));
553
        for (kind, n) in kinds.iter().take(10) {
554
            println!("  {} {}", ui::dim(format!("{n:>6} ×")), kind);
555
        }
556
        println!(
557
            "{} differences in {} commits checked{}",
558
            r.mismatches.len(),
559
            r.commits,
560
            if max > 0 && r.mismatches.len() >= max {
561
                format!(" (stopped at {max}; --max-differences 0 checks every commit)")
562
            } else {
563
                String::new()
564
            }
565
        );
566
        Ok(1)
567
    }
568
}
569
570
pub fn git_import(git_dir: &std::path::Path, dest: &std::path::Path, branch: &str) -> Result<i32> {
571
    let repo = Repo::init(dest)?;
572
    let started = std::time::Instant::now();
573
    let sp = ui::spinner(&format!("Importing {} {branch}…", git_dir.display()));
574
    let r = repo.import_git(git_dir, branch)?;
575
    sp.clear();
576
    println!(
577
        "{} {} ({} merges) from {} {} into {} {}",
578
        ui::done("Imported"),
579
        plural(r.commits, "commit"),
580
        r.merges,
581
        ui::path(git_dir.display()),
582
        ui::bookmark(branch),
583
        ui::path(repo.root().display()),
584
        ui::dim(format!(
585
            "in {:.1?}; {} now has {}",
586
            started.elapsed(),
587
            repo.config.bookmark,
588
            plural(r.heads, "head")
589
        ))
590
    );
591
    if r.submodules > 0 {
592
        println!(
593
            "  {} {}",
594
            ui::note(),
595
            ui::dim(format!(
596
                "{} submodule {} imported as subrepos with Git pins",
597
                r.submodules,
598
                if r.submodules == 1 {
599
                    "entry"
600
                } else {
601
                    "entries"
602
                }
603
            ))
604
        );
605
    }
606
    Ok(0)
607
}
608
609
pub fn submodule_add(url: &str, path: &str, bookmark: Option<&str>) -> Result<i32> {
610
    let repo = open()?;
611
    let sp = ui::spinner(&format!("Cloning {url} into {path}…"));
612
    let pin = repo.submodule_add(url, path, bookmark)?;
613
    sp.done(&format!(
614
        "{} subrepo {} {} {}",
615
        ui::done("Added"),
616
        ui::path(path),
617
        ui::dim(ui::glyph("→", "->")),
618
        ui::dim(pin.summary())
619
    ));
620
    println!(
621
        "  {} {}",
622
        ui::note(),
623
        ui::dim("recorded in .oxymodules; commit to pin it")
624
    );
625
    Ok(0)
626
}
627
628
pub fn submodule_update(init: bool, paths: &[String]) -> Result<i32> {
629
    let repo = open()?;
630
    let sp = ui::spinner("Updating subrepos…");
631
    let results = repo.submodule_update(init, paths)?;
632
    sp.clear();
633
    if results.is_empty() {
634
        println!("{}", ui::dim("No subrepos."));
635
    }
636
    let mut code = 0;
637
    for (path, r) in results {
638
        use oxy_core::repo::SubrepoSync::*;
639
        let line = match r {
640
            Updated => ui::done("Updated"),
641
            UpToDate => ui::ok(format!("{} up to date", ui::glyph("✔", "*"))),
642
            Missing => {
643
                code = 1;
644
                ui::warn(format!(
645
                    "{} missing (pass --init to clone)",
646
                    ui::glyph("⚠", "!")
647
                ))
648
            }
649
            Dirty => {
650
                code = 1;
651
                ui::warn(format!(
652
                    "{} has uncommitted changes; left as is",
653
                    ui::glyph("⚠", "!")
654
                ))
655
            }
656
            KeptBookmark { bookmark, newer } => ui::warn(format!(
657
                "{} checked out at the pin; its bookmark {bookmark} keeps {} the pin lacks",
658
                ui::glyph("⚠", "!"),
659
                plural(newer, "newer change")
660
            )),
661
            Failed(e) => {
662
                code = 1;
663
                ui::err(format!("{} {e}", ui::glyph("✖", "x")))
664
            }
665
        };
666
        println!("{line} {}", ui::path(path));
667
    }
668
    Ok(code)
669
}
670
671
fn pin_json(p: &oxy_core::subrepo::Pin) -> String {
672
    match p {
673
        oxy_core::subrepo::Pin::Oxy {
674
            repo,
675
            heads,
676
            bookmark,
677
        } => json::obj([
678
            ("kind", json::s("oxy")),
679
            (
680
                "repo",
681
                json::s(&repo.iter().map(|b| format!("{b:02x}")).collect::<String>()),
682
            ),
683
            ("heads", json::hashes(heads)),
684
            ("bookmark", json::s(bookmark)),
685
        ]),
686
        oxy_core::subrepo::Pin::Git { sha, branch } => json::obj([
687
            ("kind", json::s("git")),
688
            ("sha", json::s(sha)),
689
            ("branch", json::s(branch)),
690
        ]),
691
    }
692
}
693
694
pub fn submodule_status(as_json: bool) -> Result<i32> {
695
    let repo = open()?;
696
    let subs = repo.subrepos()?;
697
    if as_json {
698
        println!(
699
            "{}",
700
            json::arr(subs.iter().map(|s| {
701
                json::obj([
702
                    ("path", json::s(&s.path)),
703
                    ("pin", pin_json(&s.pin)),
704
                    ("actual", json::opt(s.actual.as_ref(), pin_json)),
705
                    ("at_pin", json::b(s.actual.as_ref() == Some(&s.pin))),
706
                    ("dirty", json::b(s.dirty)),
707
                    ("committed", json::b(s.committed)),
708
                    ("url", json::opt(s.url.as_deref(), json::s)),
709
                ])
710
            }))
711
        );
712
        return Ok(0);
713
    }
714
    if subs.is_empty() {
715
        println!("{}", ui::dim("No subrepos."));
716
    }
717
    for s in subs {
718
        let state = match (&s.actual, s.dirty) {
719
            _ if !s.committed => {
720
                ui::warn(format!("{} added, not committed yet", ui::glyph("+", "+")))
721
            }
722
            (None, _) => ui::warn(format!("{} missing", ui::glyph("⚠", "!"))),
723
            (Some(a), dirty) if *a == s.pin => {
724
                if dirty {
725
                    ui::warn(format!(
726
                        "{} at pin, uncommitted changes",
727
                        ui::glyph("✎", "~")
728
                    ))
729
                } else {
730
                    ui::ok(format!("{} at pin", ui::glyph("✔", "*")))
731
                }
732
            }
733
            (Some(a), _) => ui::warn(format!("{} at {}", ui::glyph("↕", "<>"), a.summary())),
734
        };
735
        println!(
736
            "{} {}{}{}{}{state}",
737
            ui::ok(ui::glyph("⧉", "#")),
738
            ui::path(&s.path),
739
            ui::sep(),
740
            ui::dim(s.pin.summary()),
741
            ui::sep()
742
        );
743
        if let Some(u) = s.url {
744
            println!("    {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(u));
745
        }
746
    }
747
    Ok(0)
748
}
749
750
pub fn submodule_remove(path: &str) -> Result<i32> {
751
    let repo = open()?;
752
    repo.submodule_remove(path)?;
753
    println!(
754
        "{} subrepo {} {}",
755
        ui::done("Removed"),
756
        ui::path(path),
757
        ui::dim("(directory left in place; commit to record the removal)")
758
    );
759
    Ok(0)
760
}
761
762
pub fn deepen(remote: &str, depth: u32) -> Result<i32> {
763
    let repo = open()?;
764
    if !repo.is_shallow() {
765
        println!(
766
            "{}",
767
            ui::dim("This repository is not shallow; nothing to deepen.")
768
        );
769
        return Ok(0);
770
    }
771
    let sp = ui::spinner(&format!("Fetching {depth} more levels from {remote}…"));
772
    let n = repo.deepen(remote, depth)?;
773
    sp.done(&format!(
774
        "{} {}; {}",
775
        ui::ok("Deepened by"),
776
        plural(n, "change"),
777
        if repo.is_shallow() {
778
            "still shallow".to_string()
779
        } else {
780
            ui::ok("history is now complete")
781
        }
782
    ));
783
    Ok(0)
784
}