OXYGENHUB
# 79b5a90ffd43 oxygen / src / cli / sync.rs
34.5 KiB · Rust · last changed pull: bring a working copy that fell behind up to its bookmark 27 days ago source blame history raw
1
//! Commands that talk to remotes, and the reference server.
2
3
use super::format::plural;
4
use super::json;
5
use super::open;
6
use super::ui;
7
use anyhow::{Context, Result};
8
use oxy_core::repo::Repo;
9
use std::path::PathBuf;
10
11
pub fn clone(
12
    url: &str,
13
    dest: Option<PathBuf>,
14
    max_blob: Option<&str>,
15
    sparse: &[String],
16
    depth: Option<u32>,
17
    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
/// Whether a URL names a store (a bucket or a directory in the store
99
/// layout) rather than a repository.
100
fn is_store_url(url: &str) -> bool {
101
    ["s3://", "gs://", "az://", "dir://"]
102
        .iter()
103
        .any(|p| url.starts_with(p))
104
}
105
106
/// Percent-encode what would break a query parameter.
107
fn encode_param(v: &str) -> String {
108
    let mut out = String::with_capacity(v.len());
109
    for b in v.bytes() {
110
        match b {
111
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => {
112
                out.push(b as char)
113
            }
114
            _ => out.push_str(&format!("%{b:02X}")),
115
        }
116
    }
117
    out
118
}
119
120
/// A store URL with settings added as query parameters, which is where a
121
/// store remote keeps them (`s3://b/p?profile=work&region=eu-west-2`).
122
fn url_with_settings(url: &str, settings: &[(&str, Option<String>)]) -> Result<String> {
123
    let given: Vec<(&str, &str)> = settings
124
        .iter()
125
        .filter_map(|(k, v)| v.as_deref().map(|v| (*k, v)))
126
        .collect();
127
    if given.is_empty() {
128
        return Ok(url.to_string());
129
    }
130
    if !is_store_url(url) || url.starts_with("dir://") {
131
        let names: Vec<String> = given.iter().map(|(k, _)| format!("--{k}")).collect();
132
        anyhow::bail!(
133
            "{} apply to a store remote (s3://, gs://, az://), not to {url}",
134
            names.join(", ")
135
        );
136
    }
137
    let mut out = url.to_string();
138
    for (k, v) in given {
139
        out.push(if out.contains('?') { '&' } else { '?' });
140
        out.push_str(k);
141
        out.push('=');
142
        out.push_str(&encode_param(v));
143
    }
144
    Ok(out)
145
}
146
147
pub fn remote_add(name: &str, url: &str, settings: &[(&str, Option<String>)]) -> Result<i32> {
148
    let mut repo = open()?;
149
    let url = url_with_settings(url, settings)?;
150
    let url = url.as_str();
151
    repo.config.set(
152
        &format!("remote.{name}.url"),
153
        &oxy_core::protocol::stored_url(url),
154
    )?;
155
    repo.save_config()?;
156
    println!(
157
        "{} remote {} {} {url}",
158
        ui::done("Added"),
159
        ui::bookmark(name),
160
        ui::dim(ui::glyph("→", "->"))
161
    );
162
    Ok(0)
163
}
164
165
/// `oxy remote check NAME`: reach the remote with the credentials in
166
/// hand, say what it holds, and, for a store, put it through the
167
/// conformance check under a probe prefix (left empty afterwards).
168
pub fn remote_check(name: &str) -> Result<i32> {
169
    let repo = open()?;
170
    let Some(url) = repo.config.remotes.get(name).cloned() else {
171
        anyhow::bail!("no remote {name:?}; oxy remote list shows the remotes there are");
172
    };
173
    let options = repo.options_for_url(&url);
174
    let mine = repo.id()?;
175
    let started = std::time::Instant::now();
176
    let sp = ui::spinner(&format!("Checking {name}…"));
177
    let Some(store) = oxy_core::protocol::open_store(&url, &options) else {
178
        // A hub or another repository: reach it and read its references.
179
        let (_, remote) = repo.open_remote_at(&url)?;
180
        let id = remote.repo_id()?;
181
        let refs = remote.refs()?;
182
        sp.done(&format!(
183
            "{} {} {}",
184
            ui::done("Reachable"),
185
            ui::bookmark(name),
186
            ui::dim(format!("({url}, {} ms)", started.elapsed().as_millis()))
187
        ));
188
        describe_holding(id, mine, refs.refs.len(), refs.tags.len(), None);
189
        return Ok(0);
190
    };
191
    let store = store.with_context(|| format!("opening the store of {name}"))?;
192
    // What it holds, in one listing and two reads.
193
    let marker = match store.get("oxygen.toml") {
194
        Ok(b) => Some(b),
195
        Err(oxy_core::strata::Error::NotFound(_)) => None,
196
        Err(e) => {
197
            sp.clear();
198
            return Err(anyhow::anyhow!("{e}"))
199
                .with_context(|| format!("reaching the store of {name}"));
200
        }
201
    };
202
    let reached = started.elapsed();
203
    let bundles = store.list("bundles/")?.len();
204
    let record = store.read_ref("refs")?;
205
    sp.done(&format!(
206
        "{} {} {}",
207
        ui::done("Reachable"),
208
        ui::bookmark(name),
209
        ui::dim(format!("({url}, {} ms)", reached.as_millis()))
210
    ));
211
    match marker {
212
        None => println!(
213
            "  {} {}",
214
            ui::note(),
215
            ui::dim("holds no repository yet: oxy push NAME binds it to this one")
216
        ),
217
        Some(_) => {
218
            let (id, bookmarks, tags) = match record {
219
                Some((bytes, _)) => {
220
                    let (id, resp) = oxy_core::protocol::wire::decode_refs(&bytes)?;
221
                    (id, resp.refs.len(), resp.tags.len())
222
                }
223
                None => (mine, 0, 0),
224
            };
225
            describe_holding(id, mine, bookmarks, tags, Some(bundles));
226
        }
227
    }
228
    // Then the behaviour a repository depends on, under a prefix of its own.
229
    let sp = ui::spinner("Checking that the store behaves…");
230
    let probe = format!("oxy-check-{}", std::process::id());
231
    let began = std::time::Instant::now();
232
    match oxy_core::strata::conformance(store.as_ref(), &probe) {
233
        Ok(passed) => {
234
            sp.clear();
235
            for p in &passed {
236
                println!("  {} {}", ui::ok(ui::glyph("✔", "*")), p);
237
            }
238
            println!(
239
                "{} {}",
240
                ui::done("Behaves"),
241
                ui::dim(format!(
242
                    "({} checks in {} ms; nothing left behind)",
243
                    passed.len(),
244
                    began.elapsed().as_millis()
245
                ))
246
            );
247
            Ok(0)
248
        }
249
        Err(e) => {
250
            sp.clear();
251
            // Leave nothing behind, even after a failed check.
252
            if let Ok(left) = store.list(&probe) {
253
                for entry in left {
254
                    let _ = store.delete(&entry.key);
255
                }
256
            }
257
            println!("{} {}", ui::err(ui::glyph("✘", "x")), e);
258
            println!(
259
                "  {} {}",
260
                ui::note(),
261
                ui::dim("a store that fails this cannot keep a repository safely; check the service and the credentials' permissions")
262
            );
263
            Ok(1)
264
        }
265
    }
266
}
267
268
fn describe_holding(
269
    id: [u8; 16],
270
    mine: [u8; 16],
271
    bookmarks: usize,
272
    tags: usize,
273
    bundles: Option<usize>,
274
) {
275
    let short: String = id[..6].iter().map(|b| format!("{b:02x}")).collect();
276
    let mut what = format!(
277
        "repository {short}: {} and {}",
278
        plural(bookmarks, "bookmark"),
279
        plural(tags, "tag")
280
    );
281
    if let Some(b) = bundles {
282
        what.push_str(&format!(" in {}", plural(b, "bundle")));
283
    }
284
    if id == mine {
285
        println!("  {} {}", ui::note(), ui::dim(format!("holds this {what}")));
286
    } else if id == oxy_core::protocol::UNBOUND_ID {
287
        println!("  {} {}", ui::note(), ui::dim("holds no repository yet"));
288
    } else {
289
        println!(
290
            "  {} {}",
291
            ui::caution(),
292
            ui::warn(format!("holds another {what}; a push would be refused"))
293
        );
294
    }
295
}
296
297
pub fn remote_remove(name: &str) -> Result<i32> {
298
    let mut repo = open()?;
299
    repo.config.set(&format!("remote.{name}.url"), "")?;
300
    repo.save_config()?;
301
    println!("{} remote {}", ui::done("Removed"), ui::bookmark(name));
302
    Ok(0)
303
}
304
305
pub fn remote_list(as_json: bool) -> Result<i32> {
306
    let repo = open()?;
307
    let view = repo.view()?;
308
    if as_json {
309
        let items = repo.config.remotes.iter().map(|(name, url)| {
310
            let bms = view.remotes.get(name).map(|b| {
311
                json::arr(b.iter().map(|(bm, st)| {
312
                    let heads = repo
313
                        .store()
314
                        .get_state(st)
315
                        .map(|s| s.heads)
316
                        .unwrap_or_default();
317
                    json::obj([("name", json::s(bm)), ("heads", json::hashes(&heads))])
318
                }))
319
            });
320
            json::obj([
321
                ("name", json::s(name)),
322
                ("url", json::s(url)),
323
                // The CA file `remote.NAME.ca` names, for tools that talk to
324
                // the same hub (an editor's forge view, `oh`).
325
                (
326
                    "ca",
327
                    json::opt(repo.config.remote_cas.get(name), |c| json::s(c)),
328
                ),
329
                ("bookmarks", bms.unwrap_or_else(|| "[]".into())),
330
            ])
331
        });
332
        println!("{}", json::arr(items));
333
        return Ok(0);
334
    }
335
    if repo.config.remotes.is_empty() {
336
        println!(
337
            "{}",
338
            ui::dim("No remotes; add one with oxy remote add <name> <url>.")
339
        );
340
    }
341
    for (name, url) in &repo.config.remotes {
342
        println!(
343
            "{} {}{}{}",
344
            ui::ok(ui::glyph("⇄", "<>")),
345
            ui::bookmark(name),
346
            ui::sep(),
347
            ui::dim(url)
348
        );
349
        if let Some(bms) = view.remotes.get(name) {
350
            for (bm, st) in bms {
351
                let heads = repo
352
                    .store()
353
                    .get_state(st)
354
                    .map(|s| s.heads.len())
355
                    .unwrap_or(0);
356
                println!(
357
                    "    {} {}{}{}",
358
                    ui::dim(ui::glyph("↳", "->")),
359
                    ui::id(format!("{name}/{bm}")),
360
                    ui::sep(),
361
                    ui::dim(plural(heads, "head"))
362
                );
363
            }
364
        }
365
    }
366
    Ok(0)
367
}
368
369
/// The remote named, else `origin`, else the only remote there is.
370
fn remote_name(repo: &oxy_core::repo::Repo, remote: Option<&str>) -> String {
371
    remote.map_or_else(|| repo.default_remote(), str::to_string)
372
}
373
374
pub fn fetch(remote: Option<&str>, as_json: bool) -> Result<i32> {
375
    let repo = open()?;
376
    let remote = remote_name(&repo, remote);
377
    let sp = ui::spinner(&format!("Fetching from {remote}…"));
378
    let report = repo.fetch(&remote)?;
379
    sp.clear();
380
    if as_json {
381
        println!(
382
            "{}",
383
            json::obj([
384
                ("remote", json::s(&remote)),
385
                (
386
                    "refs",
387
                    json::arr(report.refs.iter().map(|(name, heads, n)| {
388
                        let t = repo.tracking(&remote, name).ok().flatten();
389
                        json::obj([
390
                            ("name", json::s(name)),
391
                            ("heads", json::hashes(heads)),
392
                            ("new", json::n(*n)),
393
                            (
394
                                "ahead",
395
                                t.map_or_else(|| "null".into(), |(a, _)| json::n(a)),
396
                            ),
397
                            (
398
                                "behind",
399
                                t.map_or_else(|| "null".into(), |(_, b)| json::n(b)),
400
                            ),
401
                        ])
402
                    }))
403
                ),
404
                ("new_tags", json::n(report.new_tags)),
405
                ("unverified", json::b(report.unverified)),
406
            ])
407
        );
408
        return Ok(0);
409
    }
410
    if report.refs.is_empty() {
411
        println!(
412
            "{} {}",
413
            ui::done("Fetched"),
414
            ui::dim(format!("nothing new from {remote}"))
415
        );
416
    }
417
    for (name, heads, n) in &report.refs {
418
        // What was fetched, then how the local bookmark stands against it:
419
        // "up to date" only when the two are the same (oxygen#56).
420
        let fetched =
421
            (*n > 0).then(|| format!("{}{}", ui::ok(plural(*n, "new change")), ui::sep()));
422
        let them = |k: usize| if k == 1 { "it" } else { "them" };
423
        let standing = match repo.tracking(&remote, name).ok().flatten() {
424
            None => ui::dim(format!("no local bookmark {name}")),
425
            Some((0, 0)) => ui::dim(format!("{name} up to date")),
426
            Some((0, b)) if *name == repo.config.bookmark => ui::warn(format!(
427
                "{name} is {} behind: oxy pull to take {}",
428
                plural(b, "change"),
429
                them(b)
430
            )),
431
            Some((0, b)) => ui::warn(format!(
432
                "{name} is {} behind: oxy bookmark switch {name} then oxy pull to take {}",
433
                plural(b, "change"),
434
                them(b)
435
            )),
436
            Some((a, 0)) => ui::dim(format!(
437
                "{name} is {} ahead: oxy push to send {}",
438
                plural(a, "change"),
439
                them(a)
440
            )),
441
            Some((a, b)) => ui::warn(format!(
442
                "{name} and {remote}/{name} have diverged ({a} here, {b} there): oxy pull to merge them"
443
            )),
444
        };
445
        let what = format!("{}{standing}", fetched.unwrap_or_default());
446
        println!(
447
            "{} {}{}{}{}{what}",
448
            ui::done("Fetched"),
449
            ui::id(format!("{remote}/{name}")),
450
            ui::sep(),
451
            ui::dim(plural(heads.len(), "head")),
452
            ui::sep()
453
        );
454
    }
455
    if report.new_tags > 0 {
456
        println!(
457
            "  {} {}",
458
            ui::note(),
459
            ui::dim(format!("{} adopted", plural(report.new_tags, "tag")))
460
        );
461
    }
462
    if report.unverified {
463
        eprintln!(
464
            "{} {remote} has no trust policy; nothing fetched from it was verified",
465
            ui::caution()
466
        );
467
    }
468
    Ok(0)
469
}
470
471
/// Pull acts on the current bookmark only: name the others the fetch
472
/// found behind their remote, which is easy to take for the current one
473
/// (oxygen#29).
474
fn others_behind(repo: &oxy_core::repo::Repo, remote: &str) {
475
    let Ok(bookmarks) = repo.bookmarks() else {
476
        return;
477
    };
478
    for b in bookmarks {
479
        let Some(n) = b.behind.filter(|n| *n > 0) else {
480
            continue;
481
        };
482
        if b.current {
483
            continue;
484
        }
485
        println!(
486
            "  {} {}",
487
            ui::note(),
488
            ui::dim(format!(
489
                "{} is {} behind {remote}/{}: oxy bookmark switch {} then oxy pull to take {}",
490
                b.name,
491
                plural(n, "change"),
492
                b.name,
493
                b.name,
494
                if n == 1 { "it" } else { "them" }
495
            ))
496
        );
497
    }
498
}
499
500
pub fn pull(remote: Option<&str>, as_json: bool) -> Result<i32> {
501
    let repo = open()?;
502
    let remote = remote_name(&repo, remote);
503
    let sp = ui::spinner(&format!("Pulling from {remote}…"));
504
    let r = repo.pull(&remote)?;
505
    if as_json {
506
        sp.clear();
507
        let conflicted = repo
508
            .snapshot_status("oxy pull")?
509
            .base_snapshot
510
            .is_conflicted();
511
        println!(
512
            "{}",
513
            json::obj([
514
                ("remote", json::s(&remote)),
515
                ("bookmark", json::s(&repo.config.bookmark)),
516
                ("heads", json::hashes(&r.heads)),
517
                ("changed", json::b(r.changed)),
518
                ("checked_out", json::b(r.checked_out)),
519
                ("conflicted", json::b(conflicted)),
520
                ("others", json::arr(r.others.iter().map(|o| json::s(o))))
521
            ])
522
        );
523
        return Ok(0);
524
    }
525
    if !r.changed {
526
        sp.done(&format!(
527
            "{} {}{}",
528
            ui::ok(ui::glyph("✔", "*")),
529
            ui::dim(format!(
530
                "{} is up to date with {remote}.",
531
                repo.config.bookmark
532
            )),
533
            if r.checked_out {
534
                ui::dim(" The working copy had fallen behind it and is updated.")
535
            } else {
536
                String::new()
537
            }
538
        ));
539
        others_pulled(&r.others);
540
        others_behind(&repo, &remote);
541
        if r.checked_out {
542
            super::workspace::where_now(&repo);
543
        }
544
        return Ok(0);
545
    }
546
    sp.done(&format!(
547
        "{} {} now has {}{}",
548
        ui::done("Pulled:"),
549
        ui::bookmark(&repo.config.bookmark),
550
        plural(r.heads.len(), "head"),
551
        ui::dim(if r.checked_out {
552
            "; working copy updated"
553
        } else {
554
            "; run oxy checkout to update the working copy"
555
        })
556
    ));
557
    let snap = repo.snapshot("oxy pull")?;
558
    if snap.base_snapshot.is_conflicted() {
559
        println!(
560
            "  {} {}",
561
            ui::caution(),
562
            ui::warn("the result has conflicts; see oxy status")
563
        );
564
    }
565
    others_pulled(&r.others);
566
    others_behind(&repo, &remote);
567
    subrepos_behind(&repo);
568
    super::workspace::where_now(&repo);
569
    Ok(0)
570
}
571
572
/// The other bookmarks a pull moved.
573
fn others_pulled(others: &[String]) {
574
    if others.is_empty() {
575
        return;
576
    }
577
    let shown: Vec<String> = others.iter().take(8).map(ui::bookmark).collect();
578
    let more = others.len().saturating_sub(8);
579
    println!(
580
        "  {} {}{}",
581
        ui::done("Also updated"),
582
        shown.join(", "),
583
        if more > 0 {
584
            ui::dim(format!(" and {more} more"))
585
        } else {
586
            String::new()
587
        }
588
    );
589
}
590
591
/// `oxy push [REMOTE] --delete NAME`.
592
pub fn push_delete(remote: Option<&str>, name: &str, as_json: bool) -> Result<i32> {
593
    let repo = open()?;
594
    let remote = remote_name(&repo, remote);
595
    let sp = ui::spinner(&format!("Deleting {remote}/{name}…"));
596
    let results = repo.push_delete(&remote, name)?;
597
    sp.clear();
598
    if as_json {
599
        println!(
600
            "{}",
601
            json::arr(results.iter().map(|r| {
602
                json::obj([
603
                    ("name", json::s(&r.name)),
604
                    ("ok", json::b(r.ok)),
605
                    ("reason", json::s(&r.reason)),
606
                    ("state", json::s("")),
607
                ])
608
            }))
609
        );
610
        return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 });
611
    }
612
    let mut code = 0;
613
    for r in results {
614
        if r.ok {
615
            println!(
616
                "{} {}",
617
                ui::done("Deleted"),
618
                ui::id(format!("{remote}/{}", r.name))
619
            );
620
        } else {
621
            println!(
622
                "{} {} {}{}{}",
623
                ui::err(ui::glyph("✖", "x")),
624
                ui::err("Rejected"),
625
                ui::id(format!("{remote}/{}", r.name)),
626
                ui::sep(),
627
                r.reason
628
            );
629
            code = 1;
630
        }
631
    }
632
    Ok(code)
633
}
634
635
pub fn push(
636
    remote: Option<&str>,
637
    force: bool,
638
    bookmark: Option<&str>,
639
    as_json: bool,
640
) -> Result<i32> {
641
    let repo = open()?;
642
    let remote = remote_name(&repo, remote);
643
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
644
    let results = match bookmark {
645
        Some(b) => repo.push_bookmark(&remote, b, force)?,
646
        None => repo.push(&remote, force)?,
647
    };
648
    sp.clear();
649
    if as_json {
650
        println!(
651
            "{}",
652
            json::arr(results.iter().map(|r| json::obj([
653
                ("name", json::s(&r.name)),
654
                ("ok", json::b(r.ok)),
655
                ("reason", json::s(&r.reason))
656
            ])))
657
        );
658
        return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 });
659
    }
660
    let mut code = 0;
661
    for r in results {
662
        if r.ok {
663
            println!(
664
                "{} {}{}{}",
665
                ui::done("Pushed"),
666
                ui::id(format!("{remote}/{}", r.name)),
667
                ui::sep(),
668
                ui::dim(if r.reason.is_empty() {
669
                    "updated"
670
                } else {
671
                    r.reason.as_str()
672
                })
673
            );
674
        } else {
675
            println!(
676
                "{} {} {}{}{}",
677
                ui::err(ui::glyph("✖", "x")),
678
                ui::err("Rejected"),
679
                ui::id(format!("{remote}/{}", r.name)),
680
                ui::sep(),
681
                r.reason
682
            );
683
            code = 1;
684
        }
685
    }
686
    Ok(code)
687
}
688
689
pub fn serve(
690
    root: PathBuf,
691
    bind: &str,
692
    tokens: Vec<String>,
693
    tls: Option<(PathBuf, PathBuf)>,
694
) -> Result<i32> {
695
    let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding {bind}"))?;
696
    let root = root.canonicalize()?;
697
    let tls = tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key });
698
    println!(
699
        "Serving repositories under {} on {}://{bind}/<repo>/oxy/v1/ ({})",
700
        root.display(),
701
        if tls.is_some() { "https" } else { "http" },
702
        if tokens.is_empty() {
703
            "open: no push tokens configured"
704
        } else {
705
            "push requires a token"
706
        }
707
    );
708
    if tls.is_none()
709
        && !bind.starts_with("127.")
710
        && !bind.starts_with("localhost")
711
        && !bind.starts_with("[::1]")
712
    {
713
        eprintln!("warning: serving plain HTTP on a non-loopback address; put a TLS terminator in front or pass --tls-cert/--tls-key");
714
    }
715
    oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens, tls })?;
716
    Ok(0)
717
}
718
719
pub fn git_export(git_dir: &std::path::Path, branch: Option<&str>) -> Result<i32> {
720
    let repo = open()?;
721
    let sp = ui::spinner("Exporting to Git…");
722
    let r = match branch {
723
        Some(b) => repo.git_export_as(git_dir, b)?,
724
        None => repo.git_export(git_dir)?,
725
    };
726
    sp.clear();
727
    println!(
728
        "{} {} to {} as branch {} {}",
729
        ui::done("Exported"),
730
        plural(r.exported, "new state"),
731
        ui::path(git_dir.display()),
732
        ui::bookmark(&r.branch),
733
        ui::dim(format!(
734
            "(currently {}; {} already exported)",
735
            plural(r.heads, "head"),
736
            r.skipped
737
        ))
738
    );
739
    Ok(0)
740
}
741
742
pub fn git_fetch(url: &str, branch: &str) -> Result<i32> {
743
    let repo = open()?;
744
    let sp = ui::spinner(&format!("Fetching {url} {branch}…"));
745
    let r = repo.git_fetch(url, branch)?;
746
    sp.clear();
747
    if r.commits == 0 {
748
        println!(
749
            "{} {}",
750
            ui::done("Fetched"),
751
            ui::dim(format!("nothing new from {url} {branch}"))
752
        );
753
    } else {
754
        println!(
755
            "{} {} ({} merges) from {} {}{}{} now has {}",
756
            ui::done("Fetched"),
757
            plural(r.commits, "new commit"),
758
            r.merges,
759
            ui::path(url),
760
            ui::bookmark(branch),
761
            ui::sep(),
762
            ui::bookmark(&repo.config.bookmark),
763
            plural(r.heads, "head")
764
        );
765
        if !repo.snapshot("oxy git fetch")?.changed {
766
            println!("  {} {}", ui::note(), ui::dim("working copy updated"));
767
        } else {
768
            println!(
769
                "  {} {}",
770
                ui::note(),
771
                ui::dim("run oxy checkout to update the working copy")
772
            );
773
        }
774
    }
775
    if r.submodules > 0 {
776
        println!(
777
            "  {} {}",
778
            ui::note(),
779
            ui::dim(format!(
780
                "{} submodule {} imported as subrepos with Git pins",
781
                r.submodules,
782
                if r.submodules == 1 {
783
                    "entry"
784
                } else {
785
                    "entries"
786
                }
787
            ))
788
        );
789
    }
790
    Ok(0)
791
}
792
793
pub fn git_push(url: &str, branch: &str) -> Result<i32> {
794
    let repo = open()?;
795
    let sp = ui::spinner(&format!("Pushing to {url} {branch}…"));
796
    let r = repo.git_push(url, branch)?;
797
    sp.clear();
798
    println!(
799
        "{} {} to {} {} {}",
800
        ui::done("Pushed"),
801
        plural(r.exported, "new commit"),
802
        ui::path(url),
803
        ui::bookmark(&r.branch),
804
        ui::dim(format!("({} already there)", r.skipped))
805
    );
806
    Ok(0)
807
}
808
809
/// `oxy git verify`: exit 1 if any commit's tree is not reproduced.
810
pub fn git_verify(git_dir: &std::path::Path, branch: &str) -> Result<i32> {
811
    let repo = open()?;
812
    let started = std::time::Instant::now();
813
    let sp = ui::spinner(&format!(
814
        "Rebuilding every commit of {} {branch}…",
815
        git_dir.display()
816
    ));
817
    let r = repo.verify_git_import(git_dir, branch)?;
818
    sp.clear();
819
    for (_, what) in &r.mismatches {
820
        println!("{} {what}", ui::err(ui::glyph("✖", "x")));
821
    }
822
    if r.mismatches.is_empty() {
823
        println!(
824
            "{}",
825
            ui::done(format!(
826
                "Every commit reproduced: {} commits, {} files compared, in {:.1}s",
827
                r.commits,
828
                r.files_compared,
829
                started.elapsed().as_secs_f64()
830
            ))
831
        );
832
        Ok(0)
833
    } else {
834
        println!(
835
            "{} differences in {} commits checked",
836
            r.mismatches.len(),
837
            r.commits
838
        );
839
        Ok(1)
840
    }
841
}
842
843
pub fn git_import(git_dir: &std::path::Path, dest: &std::path::Path, branch: &str) -> Result<i32> {
844
    let repo = Repo::init(dest)?;
845
    let started = std::time::Instant::now();
846
    let sp = ui::spinner(&format!("Importing {} {branch}…", git_dir.display()));
847
    let r = repo.import_git_with(git_dir, branch, &mut |where_| sp.set(where_))?;
848
    sp.clear();
849
    println!(
850
        "{} {} ({} merges) from {} {} into {} {}",
851
        ui::done("Imported"),
852
        plural(r.commits, "commit"),
853
        r.merges,
854
        ui::path(git_dir.display()),
855
        ui::bookmark(branch),
856
        ui::path(repo.root().display()),
857
        ui::dim(format!(
858
            "in {:.1?}; {} now has {}",
859
            started.elapsed(),
860
            repo.config.bookmark,
861
            plural(r.heads, "head")
862
        ))
863
    );
864
    if r.submodules > 0 {
865
        println!(
866
            "  {} {}",
867
            ui::note(),
868
            ui::dim(format!(
869
                "{} submodule {} imported as subrepos with Git pins",
870
                r.submodules,
871
                if r.submodules == 1 {
872
                    "entry"
873
                } else {
874
                    "entries"
875
                }
876
            ))
877
        );
878
    }
879
    Ok(0)
880
}
881
882
pub fn submodule_add(url: &str, path: &str, bookmark: Option<&str>) -> Result<i32> {
883
    let repo = open()?;
884
    let sp = ui::spinner(&format!("Cloning {url} into {path}…"));
885
    let pin = repo.submodule_add(url, path, bookmark)?;
886
    sp.done(&format!(
887
        "{} subrepo {} {} {}",
888
        ui::done("Added"),
889
        ui::path(path),
890
        ui::dim(ui::glyph("→", "->")),
891
        ui::dim(pin.summary())
892
    ));
893
    println!(
894
        "  {} {}",
895
        ui::note(),
896
        ui::dim("recorded in .oxymodules; commit to pin it")
897
    );
898
    Ok(0)
899
}
900
901
pub fn submodule_update(init: bool, paths: &[String]) -> Result<i32> {
902
    let repo = open()?;
903
    let sp = ui::spinner("Updating subrepos…");
904
    let results = repo.submodule_update(init, paths)?;
905
    let unpinned: Vec<_> = repo
906
        .unpinned_modules()?
907
        .into_iter()
908
        .filter(|(p, _)| paths.is_empty() || paths.iter().any(|q| q.trim_end_matches('/') == p))
909
        .collect();
910
    sp.clear();
911
    if results.is_empty() && unpinned.is_empty() {
912
        println!("{}", ui::dim("No subrepos."));
913
    }
914
    let code = print_subrepo_results(results);
915
    print_unpinned(&unpinned);
916
    Ok(if unpinned.is_empty() { code } else { 1 })
917
}
918
919
/// `.oxymodules` entries never added: nothing to clone, so say how to add
920
/// them (oxygen#72).
921
fn print_unpinned(unpinned: &[(String, oxy_core::subrepo::Module)]) {
922
    for (path, m) in unpinned {
923
        let url = if m.url.is_empty() { "<url>" } else { &m.url };
924
        println!(
925
            "{} {}",
926
            ui::warn(format!(
927
                "{} in .oxymodules but never added, so no pin",
928
                ui::glyph("⚠", "!")
929
            )),
930
            ui::path(path)
931
        );
932
        println!(
933
            "  {}",
934
            ui::dim(format!("add it with: oxy submodule add {url} {path}"))
935
        );
936
    }
937
}
938
939
fn print_subrepo_results(results: Vec<(String, oxy_core::repo::SubrepoSync)>) -> i32 {
940
    let mut code = 0;
941
    for (path, r) in results {
942
        use oxy_core::repo::SubrepoSync::*;
943
        let line = match r {
944
            Updated => ui::done("Updated"),
945
            UpToDate => ui::ok(format!("{} up to date", ui::glyph("✔", "*"))),
946
            Missing => {
947
                code = 1;
948
                ui::warn(format!(
949
                    "{} missing (pass --init to clone)",
950
                    ui::glyph("⚠", "!")
951
                ))
952
            }
953
            Dirty => {
954
                code = 1;
955
                ui::warn(format!(
956
                    "{} has uncommitted changes; left as is",
957
                    ui::glyph("⚠", "!")
958
                ))
959
            }
960
            KeptBookmark { bookmark, newer } => ui::warn(format!(
961
                "{} checked out at the pin; its bookmark {bookmark} keeps {} the pin lacks",
962
                ui::glyph("⚠", "!"),
963
                plural(newer, "newer change")
964
            )),
965
            Failed(e) => {
966
                code = 1;
967
                ui::err(format!("{} {e}", ui::glyph("✖", "x")))
968
            }
969
        };
970
        println!("{line} {}", ui::path(path));
971
    }
972
    code
973
}
974
975
fn pin_json(p: &oxy_core::subrepo::Pin) -> String {
976
    match p {
977
        oxy_core::subrepo::Pin::Oxy {
978
            repo,
979
            heads,
980
            bookmark,
981
        } => json::obj([
982
            ("kind", json::s("oxy")),
983
            (
984
                "repo",
985
                json::s(&repo.iter().map(|b| format!("{b:02x}")).collect::<String>()),
986
            ),
987
            ("heads", json::hashes(heads)),
988
            ("bookmark", json::s(bookmark)),
989
        ]),
990
        oxy_core::subrepo::Pin::Git { sha, branch } => json::obj([
991
            ("kind", json::s("git")),
992
            ("sha", json::s(sha)),
993
            ("branch", json::s(branch)),
994
        ]),
995
    }
996
}
997
998
pub fn submodule_status(as_json: bool) -> Result<i32> {
999
    let repo = open()?;
1000
    let subs = repo.subrepos()?;
1001
    let unpinned = repo.unpinned_modules()?;
1002
    if as_json {
1003
        let pinned = subs.iter().map(|s| {
1004
            json::obj([
1005
                ("path", json::s(&s.path)),
1006
                ("pin", pin_json(&s.pin)),
1007
                ("actual", json::opt(s.actual.as_ref(), pin_json)),
1008
                ("at_pin", json::b(s.actual.as_ref() == Some(&s.pin))),
1009
                ("dirty", json::b(s.dirty)),
1010
                ("committed", json::b(s.committed)),
1011
                ("url", json::opt(s.url.as_deref(), json::s)),
1012
            ])
1013
        });
1014
        // Listed in .oxymodules but never added: no pin at all.
1015
        let unpinned_json = unpinned.iter().map(|(path, m)| {
1016
            json::obj([
1017
                ("path", json::s(path)),
1018
                ("pin", "null".to_string()),
1019
                ("actual", "null".to_string()),
1020
                ("at_pin", json::b(false)),
1021
                ("dirty", json::b(false)),
1022
                ("committed", json::b(false)),
1023
                ("url", json::s(&m.url)),
1024
            ])
1025
        });
1026
        println!("{}", json::arr(pinned.chain(unpinned_json)));
1027
        return Ok(0);
1028
    }
1029
    if subs.is_empty() && unpinned.is_empty() {
1030
        println!("{}", ui::dim("No subrepos."));
1031
    }
1032
    for s in subs {
1033
        let state = match (&s.actual, s.dirty) {
1034
            _ if !s.committed => {
1035
                ui::warn(format!("{} added, not committed yet", ui::glyph("+", "+")))
1036
            }
1037
            (None, _) => ui::warn(format!("{} missing", ui::glyph("⚠", "!"))),
1038
            (Some(a), dirty) if *a == s.pin => {
1039
                if dirty {
1040
                    ui::warn(format!(
1041
                        "{} at pin, uncommitted changes",
1042
                        ui::glyph("✎", "~")
1043
                    ))
1044
                } else {
1045
                    ui::ok(format!("{} at pin", ui::glyph("✔", "*")))
1046
                }
1047
            }
1048
            (Some(a), _) => ui::warn(format!("{} at {}", ui::glyph("↕", "<>"), a.summary())),
1049
        };
1050
        println!(
1051
            "{} {}{}{}{}{state}",
1052
            ui::ok(ui::glyph("⧉", "#")),
1053
            ui::path(&s.path),
1054
            ui::sep(),
1055
            ui::dim(s.pin.summary()),
1056
            ui::sep()
1057
        );
1058
        if let Some(u) = s.url {
1059
            println!("    {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(u));
1060
        }
1061
    }
1062
    print_unpinned(&unpinned);
1063
    Ok(0)
1064
}
1065
1066
pub fn submodule_remove(path: &str) -> Result<i32> {
1067
    let repo = open()?;
1068
    repo.submodule_remove(path)?;
1069
    println!(
1070
        "{} subrepo {} {}",
1071
        ui::done("Removed"),
1072
        ui::path(path),
1073
        ui::dim("(directory left in place; commit to record the removal)")
1074
    );
1075
    Ok(0)
1076
}
1077
1078
pub fn deepen(remote: &str, depth: u32) -> Result<i32> {
1079
    let repo = open()?;
1080
    if !repo.is_shallow() {
1081
        println!(
1082
            "{}",
1083
            ui::dim("This repository is not shallow; nothing to deepen.")
1084
        );
1085
        return Ok(0);
1086
    }
1087
    let sp = ui::spinner(&format!("Fetching {depth} more levels from {remote}…"));
1088
    let n = repo.deepen(remote, depth)?;
1089
    sp.done(&format!(
1090
        "{} {}; {}",
1091
        ui::done("Deepened by"),
1092
        plural(n, "change"),
1093
        if repo.is_shallow() {
1094
            "still shallow".to_string()
1095
        } else {
1096
            ui::ok("history is now complete")
1097
        }
1098
    ));
1099
    Ok(0)
1100
}