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