OXYGENHUB
# 5b9c6ad45f6e oxygen / src / main.rs
18.0 KiB · Rust · last changed Import the Oxygen specification and the first Rust implementation (root change) 1 month ago source blame history raw
ff0af1ee unknown 1
//! `oxy`: command-line interface for Oxygen.
2
3
use anyhow::{bail, Context, Result};
4
use clap::{Parser, Subcommand};
1983686f Andy McCall ↶ 5
use oxy_core::repo::{ChangeStatus, Repo, SignatureStatus};
ff0af1ee unknown 6
use std::path::PathBuf;
7
8
#[derive(Parser)]
9
#[command(name = "oxy", version, about = "Oxygen: breathing fresh air into version control")]
10
struct Cli {
11
    #[command(subcommand)]
12
    cmd: Cmd,
13
}
14
15
#[derive(Subcommand)]
16
enum Cmd {
17
    /// Create a repository in PATH (default: current directory)
18
    Init { path: Option<PathBuf> },
5b9c6ad4 Andy McCall ↶ 19
    /// Clone a repository from a local path
20
    Clone { url: String, dest: Option<PathBuf> },
21
    /// Manage remotes
22
    Remote {
23
        #[command(subcommand)]
24
        cmd: RemoteCmd,
25
    },
26
    /// Fetch every bookmark of a remote into its tracking references
27
    Fetch { remote: Option<String> },
28
    /// Fetch, then union the remote bookmark into the local one (no merge change)
29
    Pull { remote: Option<String> },
30
    /// Push the current bookmark to a remote
31
    Push {
32
        remote: Option<String>,
33
        /// Allow dropping heads the remote has
34
        #[arg(long)]
35
        force: bool,
36
    },
ff0af1ee unknown 37
    /// Show the working copy: changed paths and conflicts
38
    Status,
39
    /// Record the working copy as a change on the current bookmark
40
    Commit {
41
        #[arg(short, long)]
42
        message: String,
43
        /// Extra explicit dependencies (ChangeId prefix, #hash prefix)
44
        #[arg(long = "depends-on")]
45
        depends_on: Vec<String>,
46
    },
47
    /// List changes reachable from the bookmark and the working copy
48
    Log,
49
    /// Show what the working copy changes against its base
50
    Diff,
51
    /// Show one change (ChangeId prefix, #hash prefix, or @)
52
    Show { change: String },
f9c98dee unknown ↶ 53
    /// Move the working copy to the state formed by CHANGE... (default: the bookmark heads)
54
    Checkout {
55
        changes: Vec<String>,
56
        /// Throw away uncommitted working-copy changes
57
        #[arg(long)]
58
        discard: bool,
59
    },
20247a03 Andy McCall ↶ 60
    /// Report which conflicts of the working copy's base are resolved by the current files
61
    Resolve,
ff0af1ee unknown 62
    /// Undo the last operation
63
    Undo,
1983686f Andy McCall ↶ 64
    /// Manage the signing identity
65
    Identity {
66
        #[command(subcommand)]
67
        cmd: IdentityCmd,
68
    },
69
    /// Verify the signatures on a change (default: every change in the log)
70
    Verify { change: Option<String> },
71
    /// Set a repository configuration key (author.name, author.email, author.key, core.sign, workspace.bookmark)
72
    Config { key: String, value: String },
ff0af1ee unknown 73
    /// List operations
74
    #[command(name = "op")]
75
    Op {
76
        #[command(subcommand)]
77
        cmd: OpCmd,
78
    },
79
}
80
81
#[derive(Subcommand)]
82
enum OpCmd {
83
    Log,
84
}
85
1983686f Andy McCall ↶ 86
#[derive(Subcommand)]
5b9c6ad4 Andy McCall ↶ 87
enum RemoteCmd {
88
    Add { name: String, url: String },
89
    Remove { name: String },
90
    List,
91
}
92
93
#[derive(Subcommand)]
1983686f Andy McCall ↶ 94
enum IdentityCmd {
95
    /// Generate a composite Ed25519 + ML-DSA-65 key pair and make it the author
96
    Create {
97
        #[arg(long)]
98
        name: Option<String>,
99
        #[arg(long)]
100
        email: Option<String>,
101
    },
102
    /// Show the configured identity
103
    Show,
104
}
105
106
fn signature_line(repo: &Repo, h: &oxy_core::hash::ChangeHash) -> Result<String> {
5b9c6ad4 Andy McCall ↶ 107
    Ok(match repo.authenticate_change(h)? {
1983686f Andy McCall ↶ 108
        SignatureStatus::Unsigned => "unsigned".to_string(),
5b9c6ad4 Andy McCall ↶ 109
        SignatureStatus::Valid { key, name, email, sealed } => {
110
            format!("{} by {name} <{email}> key {}", if sealed { "sealed" } else { "signed" }, key.short(12))
111
        }
1983686f Andy McCall ↶ 112
        SignatureStatus::Invalid(why) => format!("INVALID signature: {why}"),
113
    })
114
}
115
ff0af1ee unknown 116
fn main() {
5b1114a0 Andy McCall ↶ 117
    // Behave like other CLI tools when output is piped into `head`: die
118
    // quietly on SIGPIPE instead of panicking on a failed write.
119
    #[cfg(unix)]
120
    unsafe {
121
        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
122
    }
ff0af1ee unknown 123
    match run() {
124
        Ok(code) => std::process::exit(code),
125
        Err(e) => {
126
            eprintln!("error: {e:#}");
127
            std::process::exit(1);
128
        }
129
    }
130
}
131
132
fn open() -> Result<Repo> {
133
    Repo::open(&std::env::current_dir()?).context("opening repository")
134
}
135
136
fn short_id(c: &oxy_core::objects::Change) -> String {
137
    c.change_id.short(8)
138
}
139
140
fn when(ms: u64) -> String {
141
    let secs = (ms / 1000) as i64;
142
    let days = secs.div_euclid(86_400);
143
    let rem = secs.rem_euclid(86_400);
144
    // Civil-from-days (Howard Hinnant), UTC.
145
    let z = days + 719_468;
146
    let era = z.div_euclid(146_097);
147
    let doe = z - era * 146_097;
148
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
149
    let y = yoe + era * 400;
150
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
151
    let mp = (5 * doy + 2) / 153;
152
    let d = doy - (153 * mp + 2) / 5 + 1;
153
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
154
    let y = if m <= 2 { y + 1 } else { y };
155
    format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
156
}
157
158
fn run() -> Result<i32> {
159
    let cli = Cli::parse();
160
    match cli.cmd {
161
        Cmd::Init { path } => {
162
            let path = path.unwrap_or(std::env::current_dir()?);
163
            let repo = Repo::init(&path)?;
164
            println!("Initialized oxy repository in {}", repo.root().display());
165
            println!("Bookmark: {}   Author: {} <{}>", repo.config.bookmark, repo.config.author_name, repo.config.author_email);
166
            Ok(0)
167
        }
5b9c6ad4 Andy McCall ↶ 168
        Cmd::Clone { url, dest } => {
169
            let dest = dest.unwrap_or_else(|| {
170
                let base = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
171
                PathBuf::from(base)
172
            });
173
            let repo = Repo::clone(&url, &dest)?;
174
            let heads = repo.bookmark_heads()?;
175
            println!("Cloned into {} ({} on {}: {} head{})", repo.root().display(), url, repo.config.bookmark, heads.len(), if heads.len() == 1 { "" } else { "s" });
176
            Ok(0)
177
        }
178
        Cmd::Remote { cmd } => {
179
            let mut repo = open()?;
180
            match cmd {
181
                RemoteCmd::Add { name, url } => {
182
                    repo.config.set(&format!("remote.{name}.url"), &url)?;
183
                    repo.save_config()?;
184
                    println!("Added remote {name} = {url}");
185
                }
186
                RemoteCmd::Remove { name } => {
187
                    repo.config.set(&format!("remote.{name}.url"), "")?;
188
                    repo.save_config()?;
189
                    println!("Removed remote {name}");
190
                }
191
                RemoteCmd::List => {
192
                    let view = repo.view()?;
193
                    for (name, url) in &repo.config.remotes {
194
                        println!("{name}\t{url}");
195
                        if let Some(bms) = view.remotes.get(name) {
196
                            for (bm, st) in bms {
197
                                let heads = repo.store().get_state(st).map(|s| s.heads.len()).unwrap_or(0);
198
                                println!("    {bm}: {heads} head{}", if heads == 1 { "" } else { "s" });
199
                            }
200
                        }
201
                    }
202
                }
203
            }
204
            Ok(0)
205
        }
206
        Cmd::Fetch { remote } => {
207
            let repo = open()?;
208
            let remote = remote.unwrap_or_else(|| "origin".into());
209
            for (name, heads, n) in repo.fetch(&remote)? {
210
                println!("{remote}/{name}: {} head{}, {n} new change{}", heads.len(), if heads.len() == 1 { "" } else { "s" }, if n == 1 { "" } else { "s" });
211
            }
212
            Ok(0)
213
        }
214
        Cmd::Pull { remote } => {
215
            let repo = open()?;
216
            let remote = remote.unwrap_or_else(|| "origin".into());
217
            let r = repo.pull(&remote)?;
218
            if !r.changed {
219
                println!("Already up to date.");
220
            } else {
221
                println!("{} now has {} head{}{}", repo.config.bookmark, r.heads.len(), if r.heads.len() == 1 { "" } else { "s" }, if r.checked_out { "; working copy updated" } else { "; run `oxy checkout` to update the working copy" });
222
                let snap = repo.snapshot("oxy pull")?;
223
                if snap.base_snapshot.is_conflicted() {
224
                    println!("The result has conflicts; see oxy status.");
225
                }
226
            }
227
            Ok(0)
228
        }
229
        Cmd::Push { remote, force } => {
230
            let repo = open()?;
231
            let remote = remote.unwrap_or_else(|| "origin".into());
232
            let mut code = 0;
233
            for r in repo.push(&remote, force)? {
234
                if r.ok {
235
                    println!("{remote}/{}: updated", r.name);
236
                } else {
237
                    println!("{remote}/{}: rejected: {}", r.name, r.reason);
238
                    code = 1;
239
                }
240
            }
241
            Ok(code)
242
        }
ff0af1ee unknown 243
        Cmd::Status => {
244
            let repo = open()?;
245
            let snap = repo.snapshot("oxy status")?;
246
            let heads = repo.bookmark_heads()?;
247
            println!(
248
                "Working copy: {} on {} ({} head{})",
249
                snap.wc.change_id.short(8),
250
                repo.config.bookmark,
251
                heads.len(),
252
                if heads.len() == 1 { "" } else { "s" }
253
            );
254
            if snap.statuses.is_empty() {
255
                println!("The working copy is clean.");
256
            }
257
            for s in &snap.statuses {
258
                let tag = match s.status {
259
                    ChangeStatus::Added => "A",
260
                    ChangeStatus::Modified => "M",
261
                    ChangeStatus::Removed => "D",
262
                    ChangeStatus::ModeChanged => "X",
263
                    ChangeStatus::ConflictEdited => "C!",
20247a03 Andy McCall ↶ 264
                    ChangeStatus::Resolved => "R",
ff0af1ee unknown 265
                };
266
                println!("{tag:2} {}", s.path);
267
            }
268
            let mut code = 0;
269
            if snap.base_snapshot.is_conflicted() {
20247a03 Andy McCall ↶ 270
                println!("\nConflicts in the base state (edit the marked files, or the .oxy-conflict-N candidates, then commit):");
ff0af1ee unknown 271
                for c in &snap.base_snapshot.tree.conflicts {
272
                    println!("  tree: {c:?}");
273
                }
274
                for (p, f) in &snap.base_snapshot.files {
275
                    if f.is_conflicted() {
20247a03 Andy McCall ↶ 276
                        let state = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
277
                            Some(ChangeStatus::Resolved) => "resolved in working copy",
278
                            Some(ChangeStatus::ConflictEdited) => "markers remain",
279
                            _ => "unresolved",
280
                        };
281
                        println!("  {p}  ({state})");
ff0af1ee unknown 282
                    }
283
                }
284
                code = 2;
285
            }
286
            Ok(code)
287
        }
288
        Cmd::Commit { message, depends_on } => {
289
            let repo = open()?;
290
            let mut deps = Vec::new();
291
            for d in &depends_on {
292
                deps.push(repo.resolve_change(d)?);
293
            }
294
            let h = repo.commit(&message, &deps)?;
295
            let c = repo.store().get_change(&h)?;
296
            println!("Committed {} #{}: {}", short_id(&c), h.short(12), message.lines().next().unwrap_or(""));
297
            Ok(0)
298
        }
299
        Cmd::Log => {
300
            let repo = open()?;
301
            repo.snapshot("oxy log")?;
302
            for e in repo.log()? {
303
                let c = &e.change;
304
                let mut flags = Vec::new();
305
                if e.is_working_copy {
306
                    flags.push("working copy");
307
                }
308
                if e.is_head {
309
                    flags.push("head");
310
                }
311
                let flags = if flags.is_empty() { String::new() } else { format!(" ({})", flags.join(", ")) };
312
                println!("{} #{}{}", short_id(c), e.hash.short(12), flags);
313
                println!("    {}", when(c.hlc.wall_ms));
314
                if !c.deps.is_empty() {
315
                    println!("    deps: {}", c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" "));
316
                }
317
                let msg = if c.message.is_empty() { "(no description)" } else { &c.message };
318
                for line in msg.lines() {
319
                    println!("    {line}");
320
                }
321
                println!();
322
            }
323
            Ok(0)
324
        }
325
        Cmd::Diff => {
326
            let repo = open()?;
327
            let snap = repo.snapshot("oxy diff")?;
328
            print!("{}", oxy_core::repo::render_change(&snap.wc, &snap.base, &snap.base_snapshot));
329
            Ok(0)
330
        }
331
        Cmd::Show { change } => {
332
            let repo = open()?;
333
            repo.snapshot("oxy show")?;
334
            let h = repo.resolve_change(&change)?;
335
            let c = repo.store().get_change(&h)?;
336
            println!("change {} #{}", short_id(&c), h);
337
            println!("kind:   {:?}", c.kind);
338
            println!("date:   {}", when(c.hlc.wall_ms));
339
            println!("deps:   {}", c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" "));
1983686f Andy McCall ↶ 340
            println!("sig:    {}", signature_line(&repo, &h)?);
ff0af1ee unknown 341
            println!("\n{}\n", if c.message.is_empty() { "(no description)" } else { &c.message });
342
            print!("{}", repo.describe_change(&h)?);
343
            Ok(0)
344
        }
f9c98dee unknown ↶ 345
        Cmd::Checkout { changes, discard } => {
346
            let repo = open()?;
347
            let heads = if changes.is_empty() {
348
                repo.bookmark_heads()?
349
            } else {
350
                changes.iter().map(|c| repo.resolve_change(c)).collect::<Result<Vec<_>, _>>()?
351
            };
352
            let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") };
353
            let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?;
354
            println!("Working copy now on {spec}: {} file(s) written, {} removed", r.written, r.removed);
355
            if r.conflicted {
356
                println!("This state has conflicts; see oxy status.");
357
            }
358
            Ok(0)
359
        }
20247a03 Andy McCall ↶ 360
        Cmd::Resolve => {
361
            let repo = open()?;
362
            let snap = repo.snapshot("oxy resolve")?;
363
            if !snap.base_snapshot.is_conflicted() {
364
                println!("The working copy's base has no conflicts.");
365
                return Ok(0);
366
            }
367
            let mut remaining = 0;
368
            for (p, f) in &snap.base_snapshot.files {
369
                if !f.is_conflicted() {
370
                    continue;
371
                }
372
                match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
373
                    Some(ChangeStatus::Resolved) => println!("resolved   {p}"),
374
                    Some(ChangeStatus::ConflictEdited) => {
375
                        remaining += 1;
376
                        println!("markers    {p}");
377
                    }
378
                    _ => {
379
                        remaining += 1;
380
                        println!("unresolved {p}");
381
                    }
382
                }
383
            }
384
            for c in &snap.base_snapshot.tree.conflicts {
385
                remaining += 1;
386
                println!("tree       {c:?}");
387
            }
388
            if remaining == 0 {
389
                println!("All conflicts are resolved; `oxy commit` will record the resolution.");
390
                Ok(0)
391
            } else {
392
                Ok(2)
393
            }
394
        }
ff0af1ee unknown 395
        Cmd::Undo => {
396
            let repo = open()?;
397
            let op = repo.undo()?;
398
            println!("Undid the last operation; now at: {}", op.command);
399
            Ok(0)
400
        }
1983686f Andy McCall ↶ 401
        Cmd::Identity { cmd: IdentityCmd::Create { name, email } } => {
402
            let mut repo = open()?;
403
            let name = name.unwrap_or_else(|| repo.config.author_name.clone());
404
            let email = email.unwrap_or_else(|| repo.config.author_email.clone());
405
            let (key, _) = repo.create_identity(&name, &email)?;
406
            println!("Created identity {name} <{email}>");
407
            println!("KeyId: {key}");
408
            println!("Secret key stored under {}", oxy_core::repo::default_keys_dir().display());
409
            if !repo.config.sign_changes {
410
                println!("Changes will carry this author; run `oxy config core.sign change` to sign each commit.");
411
            }
412
            Ok(0)
413
        }
414
        Cmd::Identity { cmd: IdentityCmd::Show } => {
415
            let repo = open()?;
416
            match repo.config.author_key {
417
                None => println!("No identity configured; run `oxy identity create`."),
418
                Some(key) => {
419
                    let id = repo.identity(&key)?;
420
                    println!("{} <{}>", id.name, id.email);
421
                    println!("KeyId:   {key}");
422
                    println!("Created: {}", when(id.created));
423
                    println!("Secret:  {}", if repo.secret_key()?.is_some() { "available" } else { "NOT FOUND in key directory" });
424
                    println!("Signing: {}", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" });
425
                }
426
            }
427
            Ok(0)
428
        }
429
        Cmd::Verify { change } => {
430
            let repo = open()?;
431
            let hashes: Vec<oxy_core::hash::ChangeHash> = match change {
432
                Some(c) => vec![repo.resolve_change(&c)?],
433
                None => repo.log()?.into_iter().filter(|e| !e.is_working_copy).map(|e| e.hash).collect(),
434
            };
435
            let mut bad = 0;
436
            for h in hashes {
437
                let c = repo.store().get_change(&h)?;
438
                let line = signature_line(&repo, &h)?;
439
                if line.starts_with("INVALID") {
440
                    bad += 1;
441
                }
442
                println!("{} #{}  {}", short_id(&c), h.short(12), line);
443
            }
444
            Ok(if bad > 0 { 1 } else { 0 })
445
        }
446
        Cmd::Config { key, value } => {
447
            let mut repo = open()?;
448
            repo.config.set(&key, &value)?;
449
            repo.save_config()?;
450
            println!("{key} = {value}");
451
            Ok(0)
452
        }
ff0af1ee unknown 453
        Cmd::Op { cmd: OpCmd::Log } => {
454
            let repo = open()?;
455
            for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
456
                println!("{}{} {}  {}", if i == 0 { "@ " } else { "  " }, r.short(12), when(op.hlc.wall_ms), op.command);
457
            }
458
            Ok(0)
459
        }
460
    }
461
    .map_err(|e: anyhow::Error| e)
462
    .or_else(|e| {
463
        if e.to_string().is_empty() {
464
            bail!("unknown error")
465
        } else {
466
            Err(e)
467
        }
468
    })
469
}