OXYGENHUB
# 957fee30826e oxygen / src / cli / mod.rs
21.2 KiB · Rust · last changed Partial and interactive commits (commit PATH.., commit -i with hunk closure), oxy blame from atom keys, oxy bisect over dependency-closed prefixes with run and skip (spec 0.8 sections 12.3 and 27) 1 month ago source blame history raw
1
//! Command-line definitions and dispatch.
2
3
mod format;
4
mod identity;
5
mod sync;
6
pub mod ui;
7
mod workspace;
8
9
use anyhow::{Context, Result};
10
use clap::{Parser, Subcommand};
11
use oxy_core::repo::Repo;
12
use std::path::PathBuf;
13
14
#[derive(Parser)]
15
#[command(name = "oxy", version, about = "Oxygen: breathing fresh air into version control")]
16
struct Cli {
17
    /// Plain output: no colour, no spinner (also implied by NO_COLOR or a non-terminal)
18
    #[arg(long, global = true)]
19
    plain: bool,
20
    #[command(subcommand)]
21
    cmd: Cmd,
22
}
23
24
#[derive(Subcommand)]
25
enum Cmd {
26
    /// Create a repository in PATH (default: current directory)
27
    Init { path: Option<PathBuf> },
28
    /// Clone a repository from a local path or an http(s) URL
29
    Clone {
30
        url: String,
31
        dest: Option<PathBuf>,
32
        /// Leave blobs larger than this (e.g. 1M) to be fetched on first read
33
        #[arg(long)]
34
        max_blob: Option<String>,
35
        /// Materialize only these paths (repeatable); implies lazy blobs
36
        #[arg(long = "sparse")]
37
        sparse: Vec<String>,
38
        /// Shallow clone: fetch only this many levels of history below the heads
39
        #[arg(long)]
40
        depth: Option<u32>,
41
    },
42
    /// Fetch more history below a shallow clone's boundary
43
    Deepen {
44
        #[arg(default_value_t = 10)]
45
        depth: u32,
46
        #[arg(long, default_value = "origin")]
47
        remote: String,
48
    },
49
    /// Show or set the sparse filter (paths to materialize); re-checks out the bookmark
50
    Sparse { paths: Vec<String> },
51
    /// Show the working copy: changed paths and conflicts
52
    Status {
53
        /// Machine-readable output: paths, and every conflict with its kind and contributing changes
54
        #[arg(long)]
55
        json: bool,
56
    },
57
    /// One-line summary for shell prompts; fast and records nothing
58
    Prompt {
59
        /// Placeholders: %b bookmark, %c change id, %h heads, %d dirty (*), %x conflict (!), %A/%D ahead/behind (+n/-n), %a/%B raw counts, %s state glyph, %U/%O ahead/behind arrows, %i icon
60
        #[arg(long, default_value = oxy_core::repo::DEFAULT_PROMPT_FORMAT)]
61
        format: String,
62
        /// Glyph format (%i %b %c %s%U%O) instead of the plain default
63
        #[arg(long)]
64
        glyphs: bool,
65
        /// Colour by state (cyan clean, yellow dirty, red conflicted): raw escapes, or guarded for a bash or zsh PS1
66
        #[arg(long, value_parser = ["raw", "bash", "zsh"])]
67
        color: Option<String>,
68
    },
69
    /// Record the working copy as a change on the current bookmark
70
    Commit {
71
        #[arg(short, long)]
72
        message: String,
73
        /// Extra explicit dependencies (ChangeId prefix, #hash prefix)
74
        #[arg(long = "depends-on")]
75
        depends_on: Vec<String>,
76
        /// Commit only these paths; the rest stays as uncommitted work
77
        paths: Vec<String>,
78
        /// Choose hunks one by one
79
        #[arg(short, long)]
80
        interactive: bool,
81
    },
82
    /// Show which change introduced each line of a file
83
    Blame {
84
        path: String,
85
        #[arg(long)]
86
        json: bool,
87
    },
88
    /// Find the change that introduced a regression
89
    Bisect {
90
        #[command(subcommand)]
91
        cmd: BisectCmd,
92
    },
93
    /// List changes reachable from the bookmark and the working copy
94
    Log {
95
        /// Show at most this many changes
96
        #[arg(short = 'n', long)]
97
        limit: Option<usize>,
98
        /// Show full messages, dependencies, and signatures
99
        #[arg(short, long)]
100
        verbose: bool,
101
    },
102
    /// Show what the working copy changes against its base
103
    Diff,
104
    /// Change the message of a head (default: the head the working copy sits on)
105
    Describe {
106
        #[arg(short, long)]
107
        message: String,
108
        change: Option<String>,
109
    },
110
    /// Fold the working copy into the head it sits on, or into CHANGE with --into
111
    Amend {
112
        #[arg(short, long)]
113
        message: Option<String>,
114
        /// Amend an older change; the edits must touch only lines it already knows
115
        #[arg(long)]
116
        into: Option<String>,
117
    },
118
    /// Split a head: the named paths become a separate first change
119
    Split {
120
        change: String,
121
        paths: Vec<String>,
122
    },
123
    /// Drop a head from the bookmark; its content stays on disk as uncommitted work
124
    Abandon { change: String },
125
    /// Show one change (ChangeId prefix, #hash prefix, or @)
126
    Show { change: String },
127
    /// Move the working copy to the state formed by CHANGE... (default: the bookmark heads)
128
    Checkout {
129
        changes: Vec<String>,
130
        /// Throw away uncommitted working-copy changes
131
        #[arg(long)]
132
        discard: bool,
133
    },
134
    /// Report which conflicts of the working copy's base are resolved by the current files
135
    Resolve,
136
    /// Undo the last operation
137
    Undo,
138
    /// Write a checkpoint of the current bookmark's state now
139
    Checkpoint,
140
    /// Pack loose objects and prune unreachable ones older than the grace period
141
    Gc {
142
        /// Ignore the grace period (core.gc-grace) and prune immediately
143
        #[arg(long)]
144
        now: bool,
145
    },
146
    /// List operations
147
    #[command(name = "op")]
148
    Op {
149
        #[command(subcommand)]
150
        cmd: OpCmd,
151
    },
152
    /// Take the advisory local lock on a lockable file, making it writable
153
    Lock { path: String },
154
    /// Release the local lock and make the file read-only again
155
    Unlock { path: String },
156
    /// Workspaces: extra checkout directories sharing this repository
157
    Workspace {
158
        #[command(subcommand)]
159
        cmd: WorkspaceCmd,
160
    },
161
    /// Bookmarks: named states; each workspace works on one
162
    Bookmark {
163
        #[command(subcommand)]
164
        cmd: BookmarkCmd,
165
    },
166
    /// Union another bookmark's heads into the current one (no merge change)
167
    Merge { bookmark: String },
168
    /// Subrepositories: nested repositories pinned to a state
169
    Submodule {
170
        #[command(subcommand)]
171
        cmd: SubmoduleCmd,
172
    },
173
    /// Tags: immutable named states
174
    Tag {
175
        #[command(subcommand)]
176
        cmd: TagCmd,
177
    },
178
    /// Manage remotes
179
    Remote {
180
        #[command(subcommand)]
181
        cmd: RemoteCmd,
182
    },
183
    /// Fetch every bookmark of a remote into its tracking references
184
    Fetch { remote: Option<String> },
185
    /// Fetch, then union the remote bookmark into the local one (no merge change)
186
    Pull { remote: Option<String> },
187
    /// Push the current bookmark to a remote
188
    Push {
189
        remote: Option<String>,
190
        /// Allow dropping heads the remote has
191
        #[arg(long)]
192
        force: bool,
193
        /// Push this bookmark instead of the workspace's current one
194
        #[arg(short = 'b', long)]
195
        bookmark: Option<String>,
196
    },
197
    /// Serve repositories under ROOT over HTTPS (with --tls-cert/--tls-key) or plain HTTP
198
    Serve {
199
        /// Directory with one repository per subdirectory
200
        #[arg(long, default_value = ".")]
201
        root: PathBuf,
202
        #[arg(long, default_value = "127.0.0.1:8890")]
203
        bind: String,
204
        /// Bearer token allowed to push (repeatable); none means open
205
        #[arg(long = "token")]
206
        tokens: Vec<String>,
207
        /// PEM certificate chain; enables HTTPS together with --tls-key
208
        #[arg(long, requires = "tls_key")]
209
        tls_cert: Option<PathBuf>,
210
        /// PEM private key
211
        #[arg(long, requires = "tls_cert")]
212
        tls_key: Option<PathBuf>,
213
    },
214
    /// Manage the signing identity
215
    Identity {
216
        #[command(subcommand)]
217
        cmd: IdentityCmd,
218
    },
219
    /// Verify the signatures on a change (default: every change in the log)
220
    Verify { change: Option<String> },
221
    /// Git interoperability
222
    Git {
223
        #[command(subcommand)]
224
        cmd: GitCmd,
225
    },
226
    /// Manage the trust policy (who may update which bookmarks)
227
    Policy {
228
        #[command(subcommand)]
229
        cmd: PolicyCmd,
230
    },
231
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, filter.max-blob, filter.paths, workspace.bookmark, remote.<name>.url|token|ca)
232
    Config {
233
        key: String,
234
        value: String,
235
        /// Write to the user file (~/.config/oxy/config.toml) instead of this repository
236
        #[arg(long)]
237
        user: bool,
238
    },
239
}
240
241
#[derive(Subcommand)]
242
enum OpCmd {
243
    Log,
244
}
245
246
#[derive(Subcommand)]
247
enum WorkspaceCmd {
248
    /// Create a workspace at PATH checked out on CHANGE... (default: the bookmark heads)
249
    Add { path: PathBuf, changes: Vec<String> },
250
    List,
251
    /// Stop tracking a workspace (by id prefix or path); its files stay
252
    Forget { workspace: String },
253
}
254
255
#[derive(Subcommand)]
256
enum BisectCmd {
257
    /// Begin; remembers where the working copy was
258
    Start,
259
    /// Mark CHANGE... (or the state under test) as good
260
    Good { changes: Vec<String> },
261
    /// Mark CHANGE... (or the state under test) as bad
262
    Bad { changes: Vec<String> },
263
    /// The state under test cannot be tested; try another
264
    Skip,
265
    /// Run a command for each step: exit 0 good, 125 skip, else bad
266
    Run {
267
        #[arg(required = true, trailing_var_arg = true)]
268
        command: Vec<String>,
269
    },
270
    /// Where the search stands
271
    Status,
272
    /// Restore the working copy and forget the search
273
    Reset,
274
}
275
276
#[derive(Subcommand)]
277
enum BookmarkCmd {
278
    /// Create a bookmark at CHANGE... (default: the working copy's base)
279
    Create { name: String, changes: Vec<String> },
280
    /// Point an existing bookmark at CHANGE...
281
    Set { name: String, changes: Vec<String> },
282
    /// Remove a bookmark; its changes stay
283
    Delete { name: String },
284
    /// List bookmarks with their heads and difference from origin
285
    List,
286
    /// Make NAME this workspace's bookmark and check it out
287
    Switch {
288
        name: String,
289
        /// Throw away uncommitted working-copy changes
290
        #[arg(long)]
291
        discard: bool,
292
    },
293
}
294
295
#[derive(Subcommand)]
296
enum SubmoduleCmd {
297
    /// Clone URL (Oxygen or Git) into PATH and register it in .oxymodules
298
    Add {
299
        url: String,
300
        path: String,
301
        /// Bookmark (Oxygen) or branch (Git) to check out and track
302
        #[arg(long)]
303
        bookmark: Option<String>,
304
    },
305
    /// Bring nested repositories to their pins
306
    Update {
307
        /// Clone nested repositories that are absent
308
        #[arg(long)]
309
        init: bool,
310
        paths: Vec<String>,
311
    },
312
    /// List subrepos with their pins and on-disk state
313
    Status,
314
    /// Unregister PATH; the directory stays, the node goes with the next commit
315
    Remove { path: String },
316
}
317
318
#[derive(Subcommand)]
319
enum TagCmd {
320
    /// Tag the state of CHANGE... (default: the bookmark heads)
321
    Create {
322
        name: String,
323
        changes: Vec<String>,
324
        #[arg(short, long, default_value = "")]
325
        message: String,
326
    },
327
    List,
328
}
329
330
#[derive(Subcommand)]
331
enum RemoteCmd {
332
    Add { name: String, url: String },
333
    Remove { name: String },
334
    List,
335
}
336
337
#[derive(Subcommand)]
338
enum IdentityCmd {
339
    /// Generate a composite Ed25519 + ML-DSA-65 key pair and make it your author identity
340
    Create {
341
        #[arg(long)]
342
        name: Option<String>,
343
        #[arg(long)]
344
        email: Option<String>,
345
        /// Record the author in this repository only, not in the user config
346
        #[arg(long)]
347
        local: bool,
348
        /// Expire the identity this many days from now
349
        #[arg(long)]
350
        expires: Option<u64>,
351
    },
352
    /// Show the configured identity
353
    Show,
354
    /// Write your identity object to a file, to hand to a policy holder
355
    Export { path: Option<PathBuf> },
356
    /// Move to a fresh key pair; the policy follows if it names your key
357
    Rotate,
358
    /// Re-issue your identity with a new expiry (in days from now; 0 = never)
359
    Renew {
360
        #[arg(long)]
361
        expires: u64,
362
    },
363
    /// Revoke a key: yours by default, or any key if you hold a policy root
364
    Revoke {
365
        /// KeyId prefix (default: your own key)
366
        key: Option<String>,
367
        #[arg(long, default_value = "")]
368
        reason: String,
369
    },
370
    /// Keys allowed to sign or seal on your behalf
371
    Delegate {
372
        #[command(subcommand)]
373
        cmd: DelegateCmd,
374
    },
375
}
376
377
#[derive(Subcommand)]
378
enum DelegateCmd {
379
    /// Add a delegate from an exported identity file
380
    Add { identity_file: PathBuf },
381
    /// Remove a delegate by KeyId prefix
382
    Remove { key: String },
383
    /// List delegates
384
    List,
385
}
386
387
#[derive(Subcommand)]
388
enum GitCmd {
389
    /// Export the current bookmark to a Git repository (created bare if absent)
390
    Export {
391
        git_dir: PathBuf,
392
        /// Git branch to write (default: the bookmark name)
393
        #[arg(long)]
394
        branch: Option<String>,
395
    },
396
    /// Fetch a branch from a Git remote (path or URL) into the current bookmark
397
    Fetch {
398
        url: String,
399
        #[arg(long, default_value = "main")]
400
        branch: String,
401
    },
402
    /// Push the current bookmark to a branch of a Git remote (fetch first)
403
    Push {
404
        url: String,
405
        #[arg(long, default_value = "main")]
406
        branch: String,
407
    },
408
    /// Import a Git branch into a new Oxygen repository at DEST
409
    Import {
410
        git_dir: PathBuf,
411
        dest: PathBuf,
412
        #[arg(long, default_value = "main")]
413
        branch: String,
414
    },
415
}
416
417
#[derive(Subcommand)]
418
enum PolicyCmd {
419
    /// Create the initial policy with your identity as the only root
420
    Init,
421
    /// Show the current policy
422
    Show,
423
    /// Allow an identity (from `oxy identity export`) to update bookmarks
424
    Grant {
425
        identity_file: PathBuf,
426
        /// Reference pattern the grant applies to (default: every bookmark)
427
        #[arg(long)]
428
        pattern: Option<String>,
429
        /// Where to write the proposal when more root signatures are needed
430
        #[arg(long)]
431
        out: Option<PathBuf>,
432
    },
433
    /// Revoke a key (KeyId prefix of an identity in the policy)
434
    Revoke {
435
        key: String,
436
        /// Where to write the proposal when more root signatures are needed
437
        #[arg(long)]
438
        out: Option<PathBuf>,
439
    },
440
    /// Manage root keys and the signature threshold for policy updates
441
    Root {
442
        #[command(subcommand)]
443
        cmd: RootCmd,
444
    },
445
    /// Add your root signature to a proposal file; applies it once the threshold is met
446
    Sign {
447
        proposal: PathBuf,
448
        /// Where to write the proposal if it still needs signatures (default: in place)
449
        #[arg(long)]
450
        out: Option<PathBuf>,
451
    },
452
}
453
454
#[derive(Subcommand)]
455
enum RootCmd {
456
    /// Make an exported identity a root key
457
    Add {
458
        identity_file: PathBuf,
459
        #[arg(long)]
460
        out: Option<PathBuf>,
461
    },
462
    /// Remove a root key by KeyId prefix
463
    Remove {
464
        key: String,
465
        #[arg(long)]
466
        out: Option<PathBuf>,
467
    },
468
    /// Require this many root signatures on policy updates
469
    Threshold {
470
        n: u32,
471
        #[arg(long)]
472
        out: Option<PathBuf>,
473
    },
474
}
475
476
/// Open the repository containing the current directory.
477
fn open() -> Result<Repo> {
478
    Repo::open(&std::env::current_dir()?).context("opening repository")
479
}
480
481
/// Parse arguments and run; returns the process exit code.
482
pub fn run() -> Result<i32> {
483
    let cli = Cli::parse();
484
    ui::init(cli.plain);
485
    match cli.cmd {
486
        Cmd::Init { path } => workspace::init(path),
487
        Cmd::Status { json } => workspace::status(json),
488
        Cmd::Prompt { format, glyphs, color } => workspace::prompt(&format, glyphs, color.as_deref()),
489
        Cmd::Commit { message, depends_on, paths, interactive } => workspace::commit(&message, &depends_on, &paths, interactive),
490
        Cmd::Blame { path, json } => workspace::blame(&path, json),
491
        Cmd::Bisect { cmd } => match cmd {
492
            BisectCmd::Start => workspace::bisect_start(),
493
            BisectCmd::Good { changes } => workspace::bisect_mark(Some(true), &changes),
494
            BisectCmd::Bad { changes } => workspace::bisect_mark(Some(false), &changes),
495
            BisectCmd::Skip => workspace::bisect_mark(None, &[]),
496
            BisectCmd::Run { command } => workspace::bisect_run(&command),
497
            BisectCmd::Status => workspace::bisect_status(),
498
            BisectCmd::Reset => workspace::bisect_reset(),
499
        },
500
        Cmd::Log { limit, verbose } => workspace::log(limit, verbose),
501
        Cmd::Diff => workspace::diff(),
502
        Cmd::Describe { message, change } => workspace::describe(&message, change.as_deref()),
503
        Cmd::Amend { message, into } => workspace::amend(message.as_deref(), into.as_deref()),
504
        Cmd::Split { change, paths } => workspace::split(&change, &paths),
505
        Cmd::Abandon { change } => workspace::abandon(&change),
506
        Cmd::Show { change } => workspace::show(&change),
507
        Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard),
508
        Cmd::Resolve => workspace::resolve(),
509
        Cmd::Undo => workspace::undo(),
510
        Cmd::Gc { now } => workspace::gc(now),
511
        Cmd::Checkpoint => workspace::checkpoint(),
512
        Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(),
513
        Cmd::Clone { url, dest, max_blob, sparse, depth } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth),
514
        Cmd::Deepen { depth, remote } => sync::deepen(&remote, depth),
515
        Cmd::Sparse { paths } => workspace::sparse(&paths),
516
        Cmd::Lock { path } => workspace::lock(&path),
517
        Cmd::Unlock { path } => workspace::unlock(&path),
518
        Cmd::Workspace { cmd } => match cmd {
519
            WorkspaceCmd::Add { path, changes } => workspace::workspace_add(&path, &changes),
520
            WorkspaceCmd::List => workspace::workspace_list(),
521
            WorkspaceCmd::Forget { workspace } => workspace::workspace_forget(&workspace),
522
        },
523
        Cmd::Bookmark { cmd } => match cmd {
524
            BookmarkCmd::Create { name, changes } => workspace::bookmark_create(&name, &changes),
525
            BookmarkCmd::Set { name, changes } => workspace::bookmark_set(&name, &changes),
526
            BookmarkCmd::Delete { name } => workspace::bookmark_delete(&name),
527
            BookmarkCmd::List => workspace::bookmark_list(),
528
            BookmarkCmd::Switch { name, discard } => workspace::bookmark_switch(&name, discard),
529
        },
530
        Cmd::Merge { bookmark } => workspace::merge(&bookmark),
531
        Cmd::Submodule { cmd } => match cmd {
532
            SubmoduleCmd::Add { url, path, bookmark } => sync::submodule_add(&url, &path, bookmark.as_deref()),
533
            SubmoduleCmd::Update { init, paths } => sync::submodule_update(init, &paths),
534
            SubmoduleCmd::Status => sync::submodule_status(),
535
            SubmoduleCmd::Remove { path } => sync::submodule_remove(&path),
536
        },
537
        Cmd::Tag { cmd } => match cmd {
538
            TagCmd::Create { name, changes, message } => workspace::tag_create(&name, &changes, &message),
539
            TagCmd::List => workspace::tag_list(),
540
        },
541
        Cmd::Remote { cmd } => match cmd {
542
            RemoteCmd::Add { name, url } => sync::remote_add(&name, &url),
543
            RemoteCmd::Remove { name } => sync::remote_remove(&name),
544
            RemoteCmd::List => sync::remote_list(),
545
        },
546
        Cmd::Fetch { remote } => sync::fetch(remote.as_deref()),
547
        Cmd::Pull { remote } => sync::pull(remote.as_deref()),
548
        Cmd::Push { remote, force, bookmark } => sync::push(remote.as_deref(), force, bookmark.as_deref()),
549
        Cmd::Serve { root, bind, tokens, tls_cert, tls_key } => sync::serve(root, &bind, tokens, tls_cert.zip(tls_key)),
550
        Cmd::Identity { cmd } => match cmd {
551
            IdentityCmd::Create { name, email, local, expires } => identity::create(name, email, local, expires),
552
            IdentityCmd::Show => identity::show(),
553
            IdentityCmd::Export { path } => identity::export(path),
554
            IdentityCmd::Rotate => identity::rotate(),
555
            IdentityCmd::Renew { expires } => identity::renew(expires),
556
            IdentityCmd::Revoke { key, reason } => identity::revoke(key.as_deref(), &reason),
557
            IdentityCmd::Delegate { cmd } => match cmd {
558
                DelegateCmd::Add { identity_file } => identity::delegate_add(&identity_file),
559
                DelegateCmd::Remove { key } => identity::delegate_remove(&key),
560
                DelegateCmd::List => identity::delegate_list(),
561
            },
562
        },
563
        Cmd::Git { cmd: GitCmd::Export { git_dir, branch } } => sync::git_export(&git_dir, branch.as_deref()),
564
        Cmd::Git { cmd: GitCmd::Fetch { url, branch } } => sync::git_fetch(&url, &branch),
565
        Cmd::Git { cmd: GitCmd::Push { url, branch } } => sync::git_push(&url, &branch),
566
        Cmd::Git { cmd: GitCmd::Import { git_dir, dest, branch } } => sync::git_import(&git_dir, &dest, &branch),
567
        Cmd::Policy { cmd } => match cmd {
568
            PolicyCmd::Init => identity::policy_init(),
569
            PolicyCmd::Show => identity::policy_show(),
570
            PolicyCmd::Grant { identity_file, pattern, out } => identity::policy_grant(&identity_file, pattern.as_deref(), out),
571
            PolicyCmd::Revoke { key, out } => identity::policy_revoke(&key, out),
572
            PolicyCmd::Root { cmd } => match cmd {
573
                RootCmd::Add { identity_file, out } => identity::policy_root_add(&identity_file, out),
574
                RootCmd::Remove { key, out } => identity::policy_root_remove(&key, out),
575
                RootCmd::Threshold { n, out } => identity::policy_threshold(n, out),
576
            },
577
            PolicyCmd::Sign { proposal, out } => identity::policy_sign(&proposal, out),
578
        },
579
        Cmd::Verify { change } => identity::verify(change.as_deref()),
580
        Cmd::Config { key, value, user } => identity::config(&key, &value, user),
581
    }
582
}