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