OXYGENHUB
# c6aa5bd9d1c8 oxygen / src / cli / sync.rs
15.0 KiB · Rust · last changed Machine interface for editors (spec 0.8b): oxy root, cat, version, hunks, discard by path or hunk; --json on log (with --path), show, diff, resolve, op log, bookmark list, remote list, fetch, pull, push, submodule status, bisect status, verify, policy show; commit --hunks; integration test over every JSON command 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::open;
5
use super::json;
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(url: &str, dest: Option<PathBuf>, max_blob: Option<&str>, sparse: &[String], depth: Option<u32>) -> Result<i32> {
12
    let dest = dest.unwrap_or_else(|| {
13
        let base = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
14
        PathBuf::from(base)
15
    });
16
    let max_blob = max_blob.map(oxy_core::repo::parse_size).transpose()?;
17
    let sp = ui::spinner(&format!("Cloning {url}…"));
18
    let repo = match depth {
19
        Some(d) => Repo::clone_shallow(url, &dest, d, max_blob, sparse)?,
20
        None => Repo::clone_with(url, &dest, max_blob, sparse)?,
21
    };
22
    let heads = repo.bookmark_heads()?;
23
    sp.done(&format!("{} into {} {}", ui::done("Cloned"), ui::path(repo.root().display()), ui::dim(format!("({url}, {} with {})", ui::bookmark(&repo.config.bookmark), plural(heads.len(), "head")))));
24
    Ok(0)
25
}
26
27
pub fn remote_add(name: &str, url: &str) -> Result<i32> {
28
    let mut repo = open()?;
29
    repo.config.set(&format!("remote.{name}.url"), url)?;
30
    repo.save_config()?;
31
    println!("{} remote {} {} {url}", ui::done("Added"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")));
32
    Ok(0)
33
}
34
35
pub fn remote_remove(name: &str) -> Result<i32> {
36
    let mut repo = open()?;
37
    repo.config.set(&format!("remote.{name}.url"), "")?;
38
    repo.save_config()?;
39
    println!("{} remote {}", ui::done("Removed"), ui::bookmark(name));
40
    Ok(0)
41
}
42
43
pub fn remote_list(as_json: bool) -> Result<i32> {
44
    let repo = open()?;
45
    let view = repo.view()?;
46
    if as_json {
47
        let items = repo.config.remotes.iter().map(|(name, url)| {
48
            let bms = view.remotes.get(name).map(|b| {
49
                json::arr(b.iter().map(|(bm, st)| {
50
                    let heads = repo.store().get_state(st).map(|s| s.heads).unwrap_or_default();
51
                    json::obj([("name", json::s(bm)), ("heads", json::hashes(&heads))])
52
                }))
53
            });
54
            json::obj([("name", json::s(name)), ("url", json::s(url)), ("bookmarks", bms.unwrap_or_else(|| "[]".into()))])
55
        });
56
        println!("{}", json::arr(items));
57
        return Ok(0);
58
    }
59
    if repo.config.remotes.is_empty() {
60
        println!("{}", ui::dim("No remotes; add one with oxy remote add <name> <url>."));
61
    }
62
    for (name, url) in &repo.config.remotes {
63
        println!("{} {}{}{}", ui::ok(ui::glyph("⇄", "<>")), ui::bookmark(name), ui::sep(), ui::dim(url));
64
        if let Some(bms) = view.remotes.get(name) {
65
            for (bm, st) in bms {
66
                let heads = repo.store().get_state(st).map(|s| s.heads.len()).unwrap_or(0);
67
                println!("    {} {}{}{}", ui::dim(ui::glyph("↳", "->")), ui::id(format!("{name}/{bm}")), ui::sep(), ui::dim(plural(heads, "head")));
68
            }
69
        }
70
    }
71
    Ok(0)
72
}
73
74
fn remote_name(remote: Option<&str>) -> String {
75
    remote.unwrap_or("origin").to_string()
76
}
77
78
pub fn fetch(remote: Option<&str>, as_json: bool) -> Result<i32> {
79
    let repo = open()?;
80
    let remote = remote_name(remote);
81
    let sp = ui::spinner(&format!("Fetching from {remote}…"));
82
    let report = repo.fetch(&remote)?;
83
    sp.clear();
84
    if as_json {
85
        println!(
86
            "{}",
87
            json::obj([
88
                ("remote", json::s(&remote)),
89
                ("refs", json::arr(report.refs.iter().map(|(name, heads, n)| json::obj([("name", json::s(name)), ("heads", json::hashes(heads)), ("new", json::n(*n))])))),
90
                ("new_tags", json::n(report.new_tags)),
91
                ("unverified", json::b(report.unverified)),
92
            ])
93
        );
94
        return Ok(0);
95
    }
96
    if report.refs.is_empty() {
97
        println!("{} {}", ui::done("Fetched"), ui::dim(format!("nothing new from {remote}")));
98
    }
99
    for (name, heads, n) in &report.refs {
100
        let what = if *n == 0 { ui::dim("up to date") } else { ui::ok(plural(*n, "new change")) };
101
        println!("{} {}{}{}{}{what}", ui::done("Fetched"), ui::id(format!("{remote}/{name}")), ui::sep(), ui::dim(plural(heads.len(), "head")), ui::sep());
102
    }
103
    if report.new_tags > 0 {
104
        println!("  {} {}", ui::note(), ui::dim(format!("{} adopted", plural(report.new_tags, "tag"))));
105
    }
106
    if report.unverified {
107
        eprintln!("{} {remote} has no trust policy; nothing fetched from it was verified", ui::caution());
108
    }
109
    Ok(0)
110
}
111
112
pub fn pull(remote: Option<&str>, as_json: bool) -> Result<i32> {
113
    let repo = open()?;
114
    let remote = remote_name(remote);
115
    let sp = ui::spinner(&format!("Pulling from {remote}…"));
116
    let r = repo.pull(&remote)?;
117
    if as_json {
118
        sp.clear();
119
        let conflicted = repo.snapshot("oxy pull")?.base_snapshot.is_conflicted();
120
        println!("{}", json::obj([("remote", json::s(&remote)), ("heads", json::hashes(&r.heads)), ("changed", json::b(r.changed)), ("checked_out", json::b(r.checked_out)), ("conflicted", json::b(conflicted))]));
121
        return Ok(0);
122
    }
123
    if !r.changed {
124
        sp.done(&format!("{} {}", ui::ok(ui::glyph("✔", "*")), ui::dim("Already up to date.")));
125
        return Ok(0);
126
    }
127
    sp.done(&format!(
128
        "{} {} now has {}{}",
129
        ui::done("Pulled:"),
130
        ui::bookmark(&repo.config.bookmark),
131
        plural(r.heads.len(), "head"),
132
        ui::dim(if r.checked_out { "; working copy updated" } else { "; run oxy checkout to update the working copy" })
133
    ));
134
    let snap = repo.snapshot("oxy pull")?;
135
    if snap.base_snapshot.is_conflicted() {
136
        println!("  {} {}", ui::caution(), ui::warn("the result has conflicts; see oxy status"));
137
    }
138
    Ok(0)
139
}
140
141
pub fn push(remote: Option<&str>, force: bool, bookmark: Option<&str>, as_json: bool) -> Result<i32> {
142
    let repo = open()?;
143
    let remote = remote_name(remote);
144
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
145
    let results = match bookmark {
146
        Some(b) => repo.push_bookmark(&remote, b, force)?,
147
        None => repo.push(&remote, force)?,
148
    };
149
    sp.clear();
150
    if as_json {
151
        println!("{}", json::arr(results.iter().map(|r| json::obj([("name", json::s(&r.name)), ("ok", json::b(r.ok)), ("reason", json::s(&r.reason))]))));
152
        return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 });
153
    }
154
    let mut code = 0;
155
    for r in results {
156
        if r.ok {
157
            println!("{} {}{}{}", ui::done("Pushed"), ui::id(format!("{remote}/{}", r.name)), ui::sep(), ui::dim(if r.reason.is_empty() { "updated" } else { r.reason.as_str() }));
158
        } else {
159
            println!("{} {} {}{}{}", ui::err(ui::glyph("✖", "x")), ui::err("Rejected"), ui::id(format!("{remote}/{}", r.name)), ui::sep(), r.reason);
160
            code = 1;
161
        }
162
    }
163
    Ok(code)
164
}
165
166
pub fn serve(root: PathBuf, bind: &str, tokens: Vec<String>, tls: Option<(PathBuf, PathBuf)>) -> Result<i32> {
167
    let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding {bind}"))?;
168
    let root = root.canonicalize()?;
169
    let tls = tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key });
170
    println!(
171
        "Serving repositories under {} on {}://{bind}/<repo>/oxy/v1/ ({})",
172
        root.display(),
173
        if tls.is_some() { "https" } else { "http" },
174
        if tokens.is_empty() { "open: no push tokens configured" } else { "push requires a token" }
175
    );
176
    if tls.is_none() && !bind.starts_with("127.") && !bind.starts_with("localhost") && !bind.starts_with("[::1]") {
177
        eprintln!("warning: serving plain HTTP on a non-loopback address; put a TLS terminator in front or pass --tls-cert/--tls-key");
178
    }
179
    oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens, tls })?;
180
    Ok(0)
181
}
182
183
pub fn git_export(git_dir: &std::path::Path, branch: Option<&str>) -> Result<i32> {
184
    let repo = open()?;
185
    let sp = ui::spinner("Exporting to Git…");
186
    let r = match branch {
187
        Some(b) => repo.git_export_as(git_dir, b)?,
188
        None => repo.git_export(git_dir)?,
189
    };
190
    sp.clear();
191
    println!(
192
        "{} {} to {} as branch {} {}",
193
        ui::done("Exported"),
194
        plural(r.exported, "new state"),
195
        ui::path(git_dir.display()),
196
        ui::bookmark(&r.branch),
197
        ui::dim(format!("(currently {}; {} already exported)", plural(r.heads, "head"), r.skipped))
198
    );
199
    Ok(0)
200
}
201
202
pub fn git_fetch(url: &str, branch: &str) -> Result<i32> {
203
    let repo = open()?;
204
    let sp = ui::spinner(&format!("Fetching {url} {branch}…"));
205
    let r = repo.git_fetch(url, branch)?;
206
    sp.clear();
207
    if r.commits == 0 {
208
        println!("{} {}", ui::done("Fetched"), ui::dim(format!("nothing new from {url} {branch}")));
209
    } else {
210
        println!(
211
            "{} {} ({} merges) from {} {}{}{} now has {}",
212
            ui::done("Fetched"),
213
            plural(r.commits, "new commit"),
214
            r.merges,
215
            ui::path(url),
216
            ui::bookmark(branch),
217
            ui::sep(),
218
            ui::bookmark(&repo.config.bookmark),
219
            plural(r.heads, "head")
220
        );
221
        if !repo.snapshot("oxy git fetch")?.changed {
222
            println!("  {} {}", ui::note(), ui::dim("working copy updated"));
223
        } else {
224
            println!("  {} {}", ui::note(), ui::dim("run oxy checkout to update the working copy"));
225
        }
226
    }
227
    if r.submodules > 0 {
228
        println!("  {} {}", ui::note(), ui::dim(format!("{} submodule {} imported as subrepos with Git pins", r.submodules, if r.submodules == 1 { "entry" } else { "entries" })));
229
    }
230
    Ok(0)
231
}
232
233
pub fn git_push(url: &str, branch: &str) -> Result<i32> {
234
    let repo = open()?;
235
    let sp = ui::spinner(&format!("Pushing to {url} {branch}…"));
236
    let r = repo.git_push(url, branch)?;
237
    sp.clear();
238
    println!(
239
        "{} {} to {} {} {}",
240
        ui::done("Pushed"),
241
        plural(r.exported, "new commit"),
242
        ui::path(url),
243
        ui::bookmark(&r.branch),
244
        ui::dim(format!("({} already there)", r.skipped))
245
    );
246
    Ok(0)
247
}
248
249
pub fn git_import(git_dir: &std::path::Path, dest: &std::path::Path, branch: &str) -> Result<i32> {
250
    let repo = Repo::init(dest)?;
251
    let started = std::time::Instant::now();
252
    let sp = ui::spinner(&format!("Importing {} {branch}…", git_dir.display()));
253
    let r = repo.import_git(git_dir, branch)?;
254
    sp.clear();
255
    println!(
256
        "{} {} ({} merges) from {} {} into {} {}",
257
        ui::done("Imported"),
258
        plural(r.commits, "commit"),
259
        r.merges,
260
        ui::path(git_dir.display()),
261
        ui::bookmark(branch),
262
        ui::path(repo.root().display()),
263
        ui::dim(format!("in {:.1?}; {} now has {}", started.elapsed(), repo.config.bookmark, plural(r.heads, "head")))
264
    );
265
    if r.submodules > 0 {
266
        println!("  {} {}", ui::note(), ui::dim(format!("{} submodule {} imported as subrepos with Git pins", r.submodules, if r.submodules == 1 { "entry" } else { "entries" })));
267
    }
268
    Ok(0)
269
}
270
271
pub fn submodule_add(url: &str, path: &str, bookmark: Option<&str>) -> Result<i32> {
272
    let repo = open()?;
273
    let sp = ui::spinner(&format!("Cloning {url} into {path}…"));
274
    let pin = repo.submodule_add(url, path, bookmark)?;
275
    sp.done(&format!("{} subrepo {} {} {}", ui::done("Added"), ui::path(path), ui::dim(ui::glyph("→", "->")), ui::dim(pin.summary())));
276
    println!("  {} {}", ui::note(), ui::dim("recorded in .oxymodules; commit to pin it"));
277
    Ok(0)
278
}
279
280
pub fn submodule_update(init: bool, paths: &[String]) -> Result<i32> {
281
    let repo = open()?;
282
    let sp = ui::spinner("Updating subrepos…");
283
    let results = repo.submodule_update(init, paths)?;
284
    sp.clear();
285
    if results.is_empty() {
286
        println!("{}", ui::dim("No subrepos."));
287
    }
288
    let mut code = 0;
289
    for (path, r) in results {
290
        use oxy_core::repo::SubrepoSync::*;
291
        let line = match r {
292
            Updated => ui::done("Updated"),
293
            UpToDate => ui::ok(format!("{} up to date", ui::glyph("✔", "*"))),
294
            Missing => {
295
                code = 1;
296
                ui::warn(format!("{} missing (pass --init to clone)", ui::glyph("⚠", "!")))
297
            }
298
            Dirty => {
299
                code = 1;
300
                ui::warn(format!("{} has uncommitted changes; left as is", ui::glyph("⚠", "!")))
301
            }
302
            Failed(e) => {
303
                code = 1;
304
                ui::err(format!("{} {e}", ui::glyph("✖", "x")))
305
            }
306
        };
307
        println!("{line} {}", ui::path(path));
308
    }
309
    Ok(code)
310
}
311
312
fn pin_json(p: &oxy_core::subrepo::Pin) -> String {
313
    match p {
314
        oxy_core::subrepo::Pin::Oxy { repo, heads, bookmark } => json::obj([("kind", json::s("oxy")), ("repo", json::s(&repo.iter().map(|b| format!("{b:02x}")).collect::<String>())), ("heads", json::hashes(heads)), ("bookmark", json::s(bookmark))]),
315
        oxy_core::subrepo::Pin::Git { sha, branch } => json::obj([("kind", json::s("git")), ("sha", json::s(sha)), ("branch", json::s(branch))]),
316
    }
317
}
318
319
pub fn submodule_status(as_json: bool) -> Result<i32> {
320
    let repo = open()?;
321
    let subs = repo.subrepos()?;
322
    if as_json {
323
        println!(
324
            "{}",
325
            json::arr(subs.iter().map(|s| {
326
                json::obj([
327
                    ("path", json::s(&s.path)),
328
                    ("pin", pin_json(&s.pin)),
329
                    ("actual", json::opt(s.actual.as_ref(), pin_json)),
330
                    ("at_pin", json::b(s.actual.as_ref() == Some(&s.pin))),
331
                    ("dirty", json::b(s.dirty)),
332
                    ("url", json::opt(s.url.as_deref(), json::s)),
333
                ])
334
            }))
335
        );
336
        return Ok(0);
337
    }
338
    if subs.is_empty() {
339
        println!("{}", ui::dim("No subrepos."));
340
    }
341
    for s in subs {
342
        let state = match (&s.actual, s.dirty) {
343
            (None, _) => ui::warn(format!("{} missing", ui::glyph("⚠", "!"))),
344
            (Some(a), dirty) if *a == s.pin => {
345
                if dirty { ui::warn(format!("{} at pin, uncommitted changes", ui::glyph("✎", "~"))) } else { ui::ok(format!("{} at pin", ui::glyph("✔", "*"))) }
346
            }
347
            (Some(a), _) => ui::warn(format!("{} at {}", ui::glyph("↕", "<>"), a.summary())),
348
        };
349
        println!("{} {}{}{}{}{state}", ui::ok(ui::glyph("⧉", "#")), ui::path(&s.path), ui::sep(), ui::dim(s.pin.summary()), ui::sep());
350
        if let Some(u) = s.url {
351
            println!("    {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(u));
352
        }
353
    }
354
    Ok(0)
355
}
356
357
pub fn submodule_remove(path: &str) -> Result<i32> {
358
    let repo = open()?;
359
    repo.submodule_remove(path)?;
360
    println!("{} subrepo {} {}", ui::done("Removed"), ui::path(path), ui::dim("(directory left in place; commit to record the removal)"));
361
    Ok(0)
362
}
363
364
pub fn deepen(remote: &str, depth: u32) -> Result<i32> {
365
    let repo = open()?;
366
    if !repo.is_shallow() {
367
        println!("{}", ui::dim("This repository is not shallow; nothing to deepen."));
368
        return Ok(0);
369
    }
370
    let sp = ui::spinner(&format!("Fetching {depth} more levels from {remote}…"));
371
    let n = repo.deepen(remote, depth)?;
372
    sp.done(&format!("{} {}; {}", ui::ok("Deepened by"), plural(n, "change"), if repo.is_shallow() { "still shallow".to_string() } else { ui::ok("history is now complete") }));
373
    Ok(0)
374
}