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