OXYGENHUB
# 957fee30826e oxygen / src / cli / workspace.rs
28.6 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
//! Commands that act on the local repository and working copy.
2
3
use super::format::{author_name, deps_line, deps_of, plural, short_id, short_meta_id, signature_line, signature_mark, subject_of, when};
4
use super::open;
5
use super::ui;
6
use anyhow::Result;
7
use oxy_core::objects::ChangeKind;
8
use oxy_core::repo::{render_change, ChangeStatus, Repo};
9
use std::path::PathBuf;
10
11
pub fn init(path: Option<PathBuf>) -> Result<i32> {
12
    let path = path.unwrap_or(std::env::current_dir()?);
13
    let repo = Repo::init(&path)?;
14
    println!("{} oxy repository in {}", ui::done("Initialized"), ui::path(repo.root().display()));
15
    println!("{}", ui::kv("bookmark", ui::bookmark(&repo.config.bookmark)));
16
    println!("{}", ui::kv("author", format!("{} <{}>", ui::author(&repo.config.author_name), repo.config.author_email)));
17
    Ok(0)
18
}
19
20
fn status_tag(s: ChangeStatus) -> &'static str {
21
    match s {
22
        ChangeStatus::Added => "A",
23
        ChangeStatus::Modified => "M",
24
        ChangeStatus::Removed => "D",
25
        ChangeStatus::Renamed => "R",
26
        ChangeStatus::ModeChanged => "X",
27
        ChangeStatus::ConflictEdited => "C!",
28
        ChangeStatus::Resolved => "V",
29
    }
30
}
31
32
pub fn status(json: bool) -> Result<i32> {
33
    let repo = open()?;
34
    let sp = ui::spinner("Snapshotting the working copy…");
35
    let snap = repo.snapshot("oxy status")?;
36
    let heads = repo.bookmark_heads()?;
37
    sp.clear();
38
    if json {
39
        println!("{}", repo.status_json(&snap)?);
40
        return Ok(if snap.base_snapshot.is_conflicted() { 2 } else { 0 });
41
    }
42
    println!(
43
        "{} {}{}working copy {}{}{}",
44
        ui::dim("On"),
45
        ui::bookmark(&repo.config.bookmark),
46
        ui::sep(),
47
        ui::id(snap.wc.change_id.short(8)),
48
        ui::sep(),
49
        ui::dim(plural(heads.len(), "head"))
50
    );
51
    if snap.statuses.is_empty() {
52
        println!("  {} {}", ui::ok(ui::glyph("✔", "*")), ui::dim("nothing to commit, working copy clean"));
53
    } else {
54
        println!();
55
        for s in &snap.statuses {
56
            println!("  {} {}", ui::status_mark(status_tag(s.status)), ui::path(&s.path));
57
        }
58
    }
59
    if !snap.base_snapshot.is_conflicted() {
60
        return Ok(0);
61
    }
62
    println!();
63
    println!("{} {}", ui::section(ui::err("Conflicts")), ui::dim("edit the marked files, or the .oxy-conflict-N candidates, then commit"));
64
    for c in &snap.base_snapshot.tree.conflicts {
65
        println!("  {} tree: {c:?}", ui::err(ui::glyph("✱", "!")));
66
    }
67
    for (p, f) in &snap.base_snapshot.files {
68
        if f.is_conflicted() {
69
            let state = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
70
                Some(ChangeStatus::Resolved) => ui::ok(format!("{} resolved in working copy", ui::glyph("✔", "*"))),
71
                Some(ChangeStatus::ConflictEdited) => ui::warn(format!("{} markers remain", ui::glyph("‼", "!"))),
72
                _ => ui::err(format!("{} unresolved", ui::glyph("✱", "!"))),
73
            };
74
            println!("  {}  {state}", ui::path(p));
75
        }
76
    }
77
    Ok(2)
78
}
79
80
pub fn commit(message: &str, depends_on: &[String], paths: &[String], interactive: bool) -> Result<i32> {
81
    let repo = open()?;
82
    let mut deps = Vec::new();
83
    for d in depends_on {
84
        deps.push(repo.resolve_change(d)?);
85
    }
86
    let selection = if interactive {
87
        let (_, hunks) = repo.hunks()?;
88
        if hunks.is_empty() {
89
            anyhow::bail!("nothing to commit: the working copy matches its base");
90
        }
91
        let hunks: Vec<_> = hunks.into_iter().filter(|h| paths.is_empty() || paths.iter().any(|p| h.path == *p || h.path.starts_with(&format!("{}/", p.trim_end_matches('/'))))).collect();
92
        let mut chosen = Vec::new();
93
        let stdin = std::io::stdin();
94
        let mut take_rest = false;
95
        for (n, h) in hunks.iter().enumerate() {
96
            if take_rest {
97
                chosen.push(h.index);
98
                continue;
99
            }
100
            println!("{} {} {}", ui::section(format!("hunk {} of {}", n + 1, hunks.len())), ui::path(&h.path), ui::dim(format!("(#{})", h.index)));
101
            for line in h.text.lines() {
102
                let styled = if line.starts_with('+') { ui::ok(line) } else if line.starts_with('-') { ui::err(line) } else { ui::dim(line) };
103
                println!("  {styled}");
104
            }
105
            loop {
106
                print!("{} ", ui::warn("Commit this hunk? [y]es [n]o [a]ll remaining [q]uit:"));
107
                use std::io::Write as _;
108
                std::io::stdout().flush()?;
109
                let mut answer = String::new();
110
                if stdin.read_line(&mut answer)? == 0 {
111
                    anyhow::bail!("aborted");
112
                }
113
                match answer.trim() {
114
                    "y" | "Y" => {
115
                        chosen.push(h.index);
116
                        break;
117
                    }
118
                    "n" | "N" | "" => break,
119
                    "a" | "A" => {
120
                        chosen.push(h.index);
121
                        take_rest = true;
122
                        break;
123
                    }
124
                    "q" | "Q" => anyhow::bail!("aborted; nothing committed"),
125
                    _ => continue,
126
                }
127
            }
128
        }
129
        if chosen.is_empty() {
130
            anyhow::bail!("no hunks chosen; nothing committed");
131
        }
132
        oxy_core::repo::Selection::Hunks(chosen)
133
    } else if paths.is_empty() {
134
        oxy_core::repo::Selection::All
135
    } else {
136
        oxy_core::repo::Selection::Paths(paths.to_vec())
137
    };
138
    let sp = ui::spinner("Committing…");
139
    let h = repo.commit_selection(message, &deps, &selection)?;
140
    let c = repo.store().get_change(&h)?;
141
    sp.done(&format!("{} {} {}  {}", ui::done("Committed"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), ui::subject(subject_of(message))));
142
    if !matches!(selection, oxy_core::repo::Selection::All) {
143
        let rest = repo.snapshot("oxy commit")?;
144
        if !rest.statuses.is_empty() {
145
            println!("  {} {}", ui::note(), ui::dim(format!("{} left uncommitted", plural(rest.statuses.len(), "path"))));
146
        }
147
    }
148
    Ok(0)
149
}
150
151
pub fn blame(path: &str, json: bool) -> Result<i32> {
152
    let repo = open()?;
153
    let lines = repo.blame(path)?;
154
    if json {
155
        let items = lines.iter().map(|l| {
156
            format!(
157
                "{{\"line\":{},\"change_id\":{},\"hash\":{},\"index\":{},\"author\":{},\"when_ms\":{}}}",
158
                l.line,
159
                oxy_core::repo::json_str(&l.change_id.to_hex()),
160
                oxy_core::repo::json_str(&l.hash.to_hex()),
161
                l.index,
162
                oxy_core::repo::json_str(&l.author),
163
                l.when_ms
164
            )
165
        });
166
        println!("{}", oxy_core::repo::json_array(items));
167
        return Ok(0);
168
    }
169
    let width = lines.iter().map(|l| l.author.chars().count()).max().unwrap_or(0).min(20);
170
    let digits = lines.len().to_string().len();
171
    for l in &lines {
172
        let author: String = l.author.chars().take(20).collect();
173
        let text = String::from_utf8_lossy(&l.text);
174
        println!(
175
            "{} {} {} {:>digits$}{} {}",
176
            ui::id(l.change_id.short(8)),
177
            ui::author(format!("{author:<width$}")),
178
            ui::dim(ui::ago(l.when_ms)),
179
            l.line,
180
            ui::dim(ui::glyph("│", "|")),
181
            text.trim_end_matches('\n')
182
        );
183
    }
184
    Ok(0)
185
}
186
187
fn bisect_report(repo: &Repo, step: &oxy_core::repo::BisectStep) -> Result<i32> {
188
    use oxy_core::repo::BisectStep::*;
189
    match step {
190
        NeedMarks { good, bad } => {
191
            println!("{} {} {}", ui::note(), ui::dim(format!("{good} good, {bad} bad;")), ui::dim(if *good == 0 { "mark a known-good change or bookmark: oxy bisect good CHANGE" } else { "mark a known-bad change or bookmark: oxy bisect bad CHANGE" }));
192
            Ok(0)
193
        }
194
        Testing { heads, remaining, steps_left } => {
195
            println!("{} {} {}", ui::done("Testing"), ui::dim(format!("{} in the state under test;", plural(heads.len(), "head"))), ui::dim(format!("{remaining} candidates left, about {steps_left} more steps")));
196
            println!("  {} {}", ui::note(), ui::dim("run your test, then oxy bisect good | bad | skip"));
197
            Ok(0)
198
        }
199
        Found { culprit } => {
200
            let c = repo.store().get_change(culprit)?;
201
            println!("{} {} {}  {}", ui::done("Found"), ui::id(short_id(&c)), ui::hash(format!("#{}", culprit.short(12))), ui::subject(subject_of(&c.message)));
202
            println!("  {} {}", ui::note(), ui::dim("the first change whose inclusion makes the state bad; oxy bisect reset to return"));
203
            Ok(0)
204
        }
205
        Inconclusive { candidates } => {
206
            println!("{} {}", ui::caution(), ui::warn(format!("every remaining candidate was skipped ({}); oxy bisect reset to return", plural(candidates.len(), "change"))));
207
            Ok(1)
208
        }
209
    }
210
}
211
212
pub fn bisect_start() -> Result<i32> {
213
    let repo = open()?;
214
    repo.bisect_start()?;
215
    println!("{} bisect; {}", ui::done("Started"), ui::dim("now: oxy bisect good CHANGE and oxy bisect bad CHANGE"));
216
    Ok(0)
217
}
218
219
pub fn bisect_mark(good: Option<bool>, changes: &[String]) -> Result<i32> {
220
    let repo = open()?;
221
    let mut heads = Vec::new();
222
    for c in changes {
223
        heads.extend(repo.resolve_heads(c)?);
224
    }
225
    let step = repo.bisect_mark(good, &heads)?;
226
    bisect_report(&repo, &step)
227
}
228
229
pub fn bisect_run(command: &[String]) -> Result<i32> {
230
    let repo = open()?;
231
    let step = repo.bisect_run(command)?;
232
    bisect_report(&repo, &step)
233
}
234
235
pub fn bisect_status() -> Result<i32> {
236
    let repo = open()?;
237
    match repo.bisect_status()? {
238
        None => {
239
            println!("{}", ui::dim("No bisect in progress."));
240
            Ok(0)
241
        }
242
        Some(step) => bisect_report(&repo, &step),
243
    }
244
}
245
246
pub fn bisect_reset() -> Result<i32> {
247
    let repo = open()?;
248
    repo.bisect_reset()?;
249
    println!("{} bisect; working copy restored", ui::done("Reset"));
250
    Ok(0)
251
}
252
253
/// The log: one entry per change, newest first, joined by a rail. The
254
/// default view is the subject and a metadata line; `verbose` adds the full
255
/// message, dependencies, and the authenticating key.
256
pub fn log(limit: Option<usize>, verbose: bool) -> Result<i32> {
257
    let repo = open()?;
258
    let snap = repo.snapshot("oxy log")?;
259
    let conflicted = snap.base_snapshot.is_conflicted();
260
    let entries = repo.log()?;
261
    let total = entries.len();
262
    let shown = limit.unwrap_or(total).min(total);
263
    let rail = ui::rail();
264
    for (i, e) in entries.iter().take(shown).enumerate() {
265
        let c = &e.change;
266
        let bullet = ui::log_bullet(e.is_working_copy, e.is_head, e.is_working_copy && conflicted);
267
        let mut labels = Vec::new();
268
        if e.is_working_copy {
269
            labels.push(ui::warn("working copy"));
270
        }
271
        if c.kind == ChangeKind::Resolution {
272
            labels.push(ui::warn("resolution"));
273
        }
274
        let labels = if labels.is_empty() { String::new() } else { format!(" {}{}{}", ui::dim("("), labels.join(ui::dim(", ").as_str()), ui::dim(")")) };
275
        let subject = if e.is_working_copy && c.message.is_empty() {
276
            ui::dim(if snap.statuses.is_empty() { "(clean)" } else { "(uncommitted changes)" })
277
        } else {
278
            ui::subject(subject_of(&c.message))
279
        };
280
        println!("{bullet} {}{labels}  {subject}", ui::id(short_meta_id(c)));
281
        let meta = format!(
282
            "{}{}{}{}{}{}{}",
283
            ui::hash(format!("#{}", e.hash.short(12))),
284
            ui::sep(),
285
            ui::author(author_name(&repo, c)),
286
            ui::sep(),
287
            ui::dim(ui::ago(c.hlc.wall_ms)),
288
            ui::sep(),
289
            if e.is_working_copy { ui::dim("not yet committed") } else { signature_mark(&repo, &e.hash)? }
290
        );
291
        println!("{rail} {meta}");
292
        if verbose {
293
            if !e.is_working_copy {
294
                println!("{rail} {}", ui::dim(format!("{} {}", when(c.hlc.wall_ms), signature_line(&repo, &e.hash)?)));
295
            }
296
            if !c.deps.is_empty() {
297
                println!("{rail} {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(deps_of(&c.deps)));
298
            }
299
            let body: Vec<&str> = c.message.lines().skip(1).skip_while(|l| l.trim().is_empty()).collect();
300
            if !body.is_empty() {
301
                println!("{rail}");
302
                for line in body {
303
                    println!("{rail}   {line}");
304
                }
305
            }
306
        }
307
        if i + 1 < shown {
308
            println!("{rail}");
309
        }
310
    }
311
    if shown < total {
312
        println!("{}", ui::dim(format!("{} {} more; pass -n to see them", ui::glyph("…", "..."), total - shown)));
313
    }
314
    Ok(0)
315
}
316
317
pub fn diff() -> Result<i32> {
318
    let repo = open()?;
319
    let snap = repo.snapshot("oxy diff")?;
320
    print!("{}", render_change(&snap.wc, &snap.base, &snap.base_snapshot));
321
    Ok(0)
322
}
323
324
pub fn show(change: &str) -> Result<i32> {
325
    let repo = open()?;
326
    repo.snapshot("oxy show")?;
327
    let h = repo.resolve_change(change)?;
328
    let c = repo.store().get_change(&h)?;
329
    println!("{} {} {}", ui::section("change"), ui::id(short_id(&c)), ui::hash(format!("#{h}")));
330
    println!("{}", ui::kv("kind", format!("{:?}", c.kind).to_lowercase()));
331
    println!("{}", ui::kv("author", ui::author(author_name(&repo, &oxy_core::state::ChangeMeta::of(&c)))));
332
    println!("{}", ui::kv("date", format!("{} {}", when(c.hlc.wall_ms), ui::dim(format!("({})", ui::ago(c.hlc.wall_ms))))));
333
    println!("{}", ui::kv("deps", ui::dim(deps_line(&c))));
334
    println!("{}", ui::kv("sig", signature_line(&repo, &h)?));
335
    println!();
336
    for line in (if c.message.is_empty() { "(no description)" } else { &c.message }).lines() {
337
        println!("    {line}");
338
    }
339
    println!();
340
    print!("{}", repo.describe_change(&h)?);
341
    Ok(0)
342
}
343
344
pub fn checkout(changes: &[String], discard: bool) -> Result<i32> {
345
    let repo = open()?;
346
    let heads = if changes.is_empty() {
347
        repo.bookmark_heads()?
348
    } else {
349
        let mut heads = Vec::new();
350
        for c in changes {
351
            heads.extend(repo.resolve_heads(c)?);
352
        }
353
        heads
354
    };
355
    let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") };
356
    let sp = ui::spinner(&format!("Checking out {spec}…"));
357
    let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?;
358
    sp.done(&format!("{} on {}: {} written, {} removed", ui::done("Working copy"), ui::bookmark(&spec), plural(r.written, "file"), r.removed));
359
    for n in &r.notes {
360
        println!("  {} {n}", ui::note());
361
    }
362
    if r.conflicted {
363
        println!("  {} {}", ui::caution(), ui::warn("this state has conflicts; see oxy status"));
364
    }
365
    Ok(0)
366
}
367
368
pub fn resolve() -> Result<i32> {
369
    let repo = open()?;
370
    let snap = repo.snapshot("oxy resolve")?;
371
    if !snap.base_snapshot.is_conflicted() {
372
        println!("The working copy's base has no conflicts.");
373
        return Ok(0);
374
    }
375
    let mut remaining = 0;
376
    for (p, f) in &snap.base_snapshot.files {
377
        if !f.is_conflicted() {
378
            continue;
379
        }
380
        match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
381
            Some(ChangeStatus::Resolved) => println!("resolved   {p}"),
382
            Some(ChangeStatus::ConflictEdited) => {
383
                remaining += 1;
384
                println!("markers    {p}");
385
            }
386
            _ => {
387
                remaining += 1;
388
                println!("unresolved {p}");
389
            }
390
        }
391
    }
392
    for c in &snap.base_snapshot.tree.conflicts {
393
        remaining += 1;
394
        println!("tree       {c:?}");
395
    }
396
    if remaining == 0 {
397
        println!("All conflicts are resolved; `oxy commit` will record the resolution.");
398
        Ok(0)
399
    } else {
400
        Ok(2)
401
    }
402
}
403
404
pub fn undo() -> Result<i32> {
405
    let repo = open()?;
406
    let op = repo.undo()?;
407
    println!("{} now at: {}", ui::done("Undone;"), ui::dim(&op.command));
408
    Ok(0)
409
}
410
411
pub fn op_log() -> Result<i32> {
412
    let repo = open()?;
413
    for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
414
        let bullet = if i == 0 { ui::ok(ui::glyph("●", "@")) } else { ui::dim(ui::glyph("○", "o")) };
415
        let cmd = subject_of(&op.command).split("\\n").next().unwrap_or("");
416
        println!("{bullet} {}{}{}  {}", ui::hash(r.short(12)), ui::sep(), ui::dim(format!("{:<16}", ui::ago(op.hlc.wall_ms))), if i == 0 { ui::subject(cmd) } else { cmd.to_string() });
417
    }
418
    Ok(0)
419
}
420
421
pub fn gc(now: bool) -> Result<i32> {
422
    let repo = open()?;
423
    let sp = ui::spinner("Packing and pruning…");
424
    let r = repo.gc(now)?;
425
    sp.clear();
426
    if r.packed.objects > 0 {
427
        println!(
428
            "Packed {}: {} bytes -> {} bytes ({:.0}%).",
429
            plural(r.packed.objects, "loose object"),
430
            r.packed.raw_bytes,
431
            r.packed.packed_bytes,
432
            100.0 * r.packed.packed_bytes as f64 / r.packed.raw_bytes.max(1) as f64
433
        );
434
    }
435
    println!("{} reachable; pruned {} ({} bytes){}{}; {} present.",
436
        plural(r.reachable, "object"),
437
        plural(r.pruned.objects, "unreachable object"),
438
        r.pruned.bytes,
439
        if r.pruned.deferred > 0 { format!("; {} deferred by the grace period (use --now)", plural(r.pruned.deferred, "object")) } else { String::new() },
440
        if r.merged_packs > 0 { format!("; merged {} into one", plural(r.merged_packs, "pack")) } else { String::new() },
441
        plural(repo.store().pack_count(), "pack")
442
    );
443
    Ok(0)
444
}
445
446
/// The single head the working copy sits on, for commands that default to it.
447
fn current_head(repo: &Repo) -> Result<oxy_core::hash::ChangeHash> {
448
    let snap = repo.snapshot("oxy")?;
449
    let base = snap.base.maximal(&snap.wc.deps);
450
    match base.as_slice() {
451
        [h] => Ok(*h),
452
        _ => anyhow::bail!("the working copy sits on {} heads; name the change explicitly", base.len()),
453
    }
454
}
455
456
pub fn describe(message: &str, change: Option<&str>) -> Result<i32> {
457
    let repo = open()?;
458
    let h = match change {
459
        Some(c) => repo.resolve_change(c)?,
460
        None => current_head(&repo)?,
461
    };
462
    let new = repo.describe(&h, message)?;
463
    let c = repo.store().get_change(&new)?;
464
    println!("{} {} {}  {}", ui::done("Described"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(message)));
465
    Ok(0)
466
}
467
468
pub fn amend(message: Option<&str>, into: Option<&str>) -> Result<i32> {
469
    let repo = open()?;
470
    let new = match into {
471
        Some(spec) => {
472
            let target = repo.resolve_change(spec)?;
473
            repo.amend_into(&target, message)?
474
        }
475
        None => repo.amend(message)?,
476
    };
477
    let c = repo.store().get_change(&new)?;
478
    println!("{} {} {}  {}", ui::done("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(&c.message)));
479
    Ok(0)
480
}
481
482
pub fn abandon(change: &str) -> Result<i32> {
483
    let repo = open()?;
484
    let h = repo.resolve_change(change)?;
485
    let c = repo.store().get_change(&h)?;
486
    let notes = repo.abandon(&h)?;
487
    println!("{} {} {}  {}", ui::done("Abandoned"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), ui::dim("its content remains on disk as uncommitted work if the working copy sat on it"));
488
    for n in notes {
489
        println!("  {} {n}", ui::note());
490
    }
491
    Ok(0)
492
}
493
494
pub fn checkpoint() -> Result<i32> {
495
    let repo = open()?;
496
    let sp = ui::spinner("Writing checkpoint…");
497
    let (r, changes, bytes) = repo.checkpoint()?;
498
    sp.done(&format!("{} {} of {} ({} bytes) for {}", ui::done("Checkpoint"), ui::hash(format!("#{}", r.short(12))), plural(changes, "change"), bytes, ui::id(&repo.config.bookmark)));
499
    Ok(0)
500
}
501
502
/// For shell prompts: silent (exit 1) outside a repository, otherwise one line.
503
pub fn prompt(format: &str, glyphs: bool, color: Option<&str>) -> Result<i32> {
504
    let Ok(repo) = Repo::open(&std::env::current_dir()?) else { return Ok(1) };
505
    let info = repo.quick_status()?;
506
    let fmt = if glyphs && format == oxy_core::repo::DEFAULT_PROMPT_FORMAT { oxy_core::repo::GLYPH_PROMPT_FORMAT } else { format };
507
    let line = match color {
508
        Some("bash") => info.format_colored(fmt, ("\\[", "\\]")),
509
        Some("zsh") => info.format_colored(fmt, ("%{", "%}")),
510
        Some(_) => info.format_colored(fmt, ("", "")),
511
        None => info.format(fmt),
512
    };
513
    println!("{line}");
514
    Ok(0)
515
}
516
517
pub fn sparse(paths: &[String]) -> Result<i32> {
518
    let mut repo = open()?;
519
    if paths.is_empty() {
520
        if repo.config.sparse_paths.is_empty() {
521
            println!("No sparse filter: the whole tree is materialized.");
522
        } else {
523
            println!("{}", ui::heading("Sparse filter"));
524
            for p in &repo.config.sparse_paths {
525
                println!("  {}", ui::path(p));
526
            }
527
        }
528
        return Ok(0);
529
    }
530
    let snap = repo.snapshot("oxy sparse")?;
531
    if !snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty() {
532
        anyhow::bail!("the working copy has changes; commit them before changing the sparse filter");
533
    }
534
    repo.config.set("filter.paths", &paths.join(","))?;
535
    repo.save_config()?;
536
    let heads = repo.bookmark_heads()?;
537
    let sp = ui::spinner("Re-checking out with the new filter…");
538
    let r = repo.checkout(&heads, true, "oxy sparse (checkout)")?;
539
    sp.done(&format!("{} {}: {} written, {} removed", ui::ok("Sparse filter set to"), ui::path(paths.join(",")), plural(r.written, "file"), r.removed));
540
    Ok(0)
541
}
542
543
pub fn tag_create(name: &str, changes: &[String], message: &str) -> Result<i32> {
544
    let repo = open()?;
545
    repo.snapshot("oxy tag")?;
546
    let heads = if changes.is_empty() {
547
        repo.bookmark_heads()?
548
    } else {
549
        let mut heads = Vec::new();
550
        for c in changes {
551
            heads.extend(repo.resolve_heads(c)?);
552
        }
553
        heads
554
    };
555
    let t = repo.tag_create(name, &heads, message)?;
556
    println!(
557
        "{} {} {} {} ({}){}",
558
        ui::done("Tagged"),
559
        ui::bookmark(&t.name),
560
        ui::dim(ui::glyph("→", "->")),
561
        ui::hash(format!("#{}", t.state.short(12))),
562
        plural(t.heads.len(), "head"),
563
        t.signer.map(|s| format!(", signed by {s}")).unwrap_or_else(|| ui::dim(", unsigned"))
564
    );
565
    Ok(0)
566
}
567
568
pub fn tag_list() -> Result<i32> {
569
    let repo = open()?;
570
    let tags = repo.tags()?;
571
    if tags.is_empty() {
572
        println!("{}", ui::dim("No tags."));
573
    }
574
    for t in tags {
575
        println!(
576
            "{} {} {}{}{}{}",
577
            ui::ok(ui::glyph("⚑", "#")),
578
            ui::bookmark(&t.name),
579
            ui::hash(format!("#{}", t.state.short(12))),
580
            ui::sep() + &ui::dim(plural(t.heads.len(), "head")),
581
            if t.message.is_empty() { String::new() } else { format!("  {}", ui::subject(subject_of(&t.message))) },
582
            t.signer.map(|s| ui::sep() + &ui::ok(format!("{} {s}", ui::glyph("✔", "v")))).unwrap_or_default()
583
        );
584
    }
585
    Ok(0)
586
}
587
588
pub fn workspace_add(path: &std::path::Path, changes: &[String]) -> Result<i32> {
589
    let repo = open()?;
590
    let heads = if changes.is_empty() {
591
        None
592
    } else {
593
        let mut heads = Vec::new();
594
        for c in changes {
595
            heads.extend(repo.resolve_heads(c)?);
596
        }
597
        Some(heads)
598
    };
599
    let sp = ui::spinner(&format!("Creating workspace at {}…", path.display()));
600
    let ws = repo.workspace_add(path, heads.as_deref())?;
601
    sp.done(&format!("{} {} (workspace {})", ui::done("Workspace created at"), ui::path(ws.root().display()), ui::id(ws.workspace().short(8))));
602
    Ok(0)
603
}
604
605
pub fn workspace_list() -> Result<i32> {
606
    let repo = open()?;
607
    for w in repo.workspaces()? {
608
        println!(
609
            "{} {} {}{}",
610
            if w.current { ui::ok(ui::glyph("●", "*")) } else { ui::dim(ui::glyph("○", "o")) },
611
            ui::id(w.id.short(8)),
612
            ui::path(w.path.display()),
613
            w.working_copy.map(|h| ui::sep() + &ui::dim(format!("working copy #{}", h.short(12)))).unwrap_or_else(|| ui::sep() + &ui::warn("not in view"))
614
        );
615
    }
616
    Ok(0)
617
}
618
619
pub fn workspace_forget(spec: &str) -> Result<i32> {
620
    let repo = open()?;
621
    let all = repo.workspaces()?;
622
    let target = std::fs::canonicalize(spec).ok();
623
    let matches: Vec<_> = all.iter().filter(|w| w.id.starts_with_hex(spec) || target.as_ref().is_some_and(|t| *t == w.path)).collect();
624
    let w = match matches.as_slice() {
625
        [w] => *w,
626
        [] => anyhow::bail!("no workspace matches {spec:?}"),
627
        _ => anyhow::bail!("{spec:?} is ambiguous"),
628
    };
629
    repo.workspace_forget(&w.id)?;
630
    println!("{} workspace {} at {}; its files were left in place.", ui::done("Forgot"), ui::id(w.id.short(8)), ui::path(w.path.display()));
631
    Ok(0)
632
}
633
634
pub fn lock(path: &str) -> Result<i32> {
635
    let repo = open()?;
636
    repo.lock_path(path)?;
637
    println!("{} {} {}", ui::done("Locked"), ui::path(path), ui::dim("(advisory, local)"));
638
    Ok(0)
639
}
640
641
pub fn unlock(path: &str) -> Result<i32> {
642
    let repo = open()?;
643
    repo.unlock_path(path)?;
644
    println!("{} {}", ui::done("Unlocked"), ui::path(path));
645
    Ok(0)
646
}
647
648
pub fn split(change: &str, paths: &[String]) -> Result<i32> {
649
    let repo = open()?;
650
    let h = repo.resolve_change(change)?;
651
    let (h1, h2) = repo.split(&h, paths)?;
652
    let c1 = repo.store().get_change(&h1)?;
653
    let c2 = repo.store().get_change(&h2)?;
654
    println!("{} into {} {} and {} {}", ui::done("Split"), ui::id(short_id(&c1)), ui::hash(format!("#{}", h1.short(12))), ui::id(short_id(&c2)), ui::hash(format!("#{}", h2.short(12))));
655
    Ok(0)
656
}
657
658
fn heads_from(repo: &Repo, changes: &[String]) -> Result<Vec<oxy_core::hash::ChangeHash>> {
659
    let mut heads = Vec::new();
660
    for c in changes {
661
        heads.extend(repo.resolve_heads(c)?);
662
    }
663
    Ok(heads)
664
}
665
666
pub fn bookmark_create(name: &str, changes: &[String]) -> Result<i32> {
667
    let repo = open()?;
668
    repo.snapshot("oxy bookmark create")?;
669
    let heads = if changes.is_empty() { None } else { Some(heads_from(&repo, changes)?) };
670
    let heads = repo.bookmark_create(name, heads.as_deref())?;
671
    println!("{} bookmark {} {} {}", ui::done("Created"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")), ui::dim(plural(heads.len(), "head")));
672
    println!("  {} {}", ui::note(), ui::dim(format!("switch to it with oxy bookmark switch {name}")));
673
    Ok(0)
674
}
675
676
pub fn bookmark_set(name: &str, changes: &[String]) -> Result<i32> {
677
    let repo = open()?;
678
    repo.snapshot("oxy bookmark set")?;
679
    let heads = heads_from(&repo, changes)?;
680
    let heads = repo.bookmark_set(name, &heads)?;
681
    println!("{} bookmark {} {} {}", ui::done("Moved"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")), ui::dim(plural(heads.len(), "head")));
682
    Ok(0)
683
}
684
685
pub fn bookmark_delete(name: &str) -> Result<i32> {
686
    let repo = open()?;
687
    repo.bookmark_delete(name)?;
688
    println!("{} bookmark {} {}", ui::done("Deleted"), ui::bookmark(name), ui::dim("(its changes stay until unreachable)"));
689
    Ok(0)
690
}
691
692
pub fn bookmark_list() -> Result<i32> {
693
    let repo = open()?;
694
    for b in repo.bookmarks()? {
695
        let mark = if b.current { ui::ok(ui::glyph("●", "*")) } else { ui::dim(ui::glyph("○", "o")) };
696
        let mut tail = String::new();
697
        if let Some(n) = b.ahead.filter(|n| *n > 0) {
698
            tail += &ui::ok(format!(" {}{n}", ui::glyph("↑", "+")));
699
        }
700
        if let Some(n) = b.behind.filter(|n| *n > 0) {
701
            tail += &ui::warn(format!(" {}{n}", ui::glyph("↓", "-")));
702
        }
703
        let head = b.heads.first().map(|h| repo.store().get_change(h).map(|c| ui::subject(subject_of(&c.message))).unwrap_or_default()).unwrap_or_else(|| ui::dim("(empty)"));
704
        println!("{mark} {}{}{}{tail}  {head}", ui::bookmark(&b.name), ui::sep(), ui::dim(plural(b.heads.len(), "head")));
705
    }
706
    Ok(0)
707
}
708
709
pub fn bookmark_switch(name: &str, discard: bool) -> Result<i32> {
710
    let mut repo = open()?;
711
    let sp = ui::spinner(&format!("Switching to {name}…"));
712
    let r = repo.bookmark_switch(name, discard)?;
713
    sp.done(&format!("{} to {}: {} written, {} removed", ui::done("Switched"), ui::bookmark(name), plural(r.written, "file"), r.removed));
714
    if r.conflicted {
715
        println!("  {} {}", ui::caution(), ui::warn("this state has conflicts; see oxy status"));
716
    }
717
    Ok(0)
718
}
719
720
pub fn merge(bookmark: &str) -> Result<i32> {
721
    let repo = open()?;
722
    let sp = ui::spinner(&format!("Merging {bookmark}…"));
723
    let r = repo.merge(bookmark)?;
724
    if !r.changed {
725
        sp.done(&format!("{} {}", ui::ok(ui::glyph("✔", "*")), ui::dim(format!("{} already contains {bookmark}", repo.config.bookmark))));
726
        return Ok(0);
727
    }
728
    sp.done(&format!(
729
        "{} {} into {}: now {}{}",
730
        ui::done("Merged"),
731
        ui::bookmark(bookmark),
732
        ui::bookmark(&repo.config.bookmark),
733
        plural(r.heads.len(), "head"),
734
        ui::dim(if r.checked_out { "; working copy updated" } else { "; run oxy checkout to update the working copy" })
735
    ));
736
    let snap = repo.snapshot("oxy merge")?;
737
    if snap.base_snapshot.is_conflicted() {
738
        println!("  {} {}", ui::caution(), ui::warn("the result has conflicts; see oxy status"));
739
    }
740
    Ok(0)
741
}
742