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