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