OXYGENHUB
# 9ac1fbe211b4 oxygen / src / cli / workspace.rs
18.3 KiB · Rust · last changed Spec review pass (Draft 0.7): oxy status --json, section 24 aligned with the CLI, reference implementation status table, capabilities and snapshot wording aligned with the implementation 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]) -> 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 sp = ui::spinner("Committing…");
87
    let h = repo.commit(message, &deps)?;
88
    let c = repo.store().get_change(&h)?;
89
    sp.done(&format!("{} {} {}  {}", ui::done("Committed"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), ui::subject(subject_of(message))));
90
    Ok(0)
91
}
92
93
/// The log: one entry per change, newest first, joined by a rail. The
94
/// default view is the subject and a metadata line; `verbose` adds the full
95
/// message, dependencies, and the authenticating key.
96
pub fn log(limit: Option<usize>, verbose: bool) -> Result<i32> {
97
    let repo = open()?;
98
    let snap = repo.snapshot("oxy log")?;
99
    let conflicted = snap.base_snapshot.is_conflicted();
100
    let entries = repo.log()?;
101
    let total = entries.len();
102
    let shown = limit.unwrap_or(total).min(total);
103
    let rail = ui::rail();
104
    for (i, e) in entries.iter().take(shown).enumerate() {
105
        let c = &e.change;
106
        let bullet = ui::log_bullet(e.is_working_copy, e.is_head, e.is_working_copy && conflicted);
107
        let mut labels = Vec::new();
108
        if e.is_working_copy {
109
            labels.push(ui::warn("working copy"));
110
        }
111
        if c.kind == ChangeKind::Resolution {
112
            labels.push(ui::warn("resolution"));
113
        }
114
        let labels = if labels.is_empty() { String::new() } else { format!(" {}{}{}", ui::dim("("), labels.join(ui::dim(", ").as_str()), ui::dim(")")) };
115
        let subject = if e.is_working_copy && c.message.is_empty() {
116
            ui::dim(if snap.statuses.is_empty() { "(clean)" } else { "(uncommitted changes)" })
117
        } else {
118
            ui::subject(subject_of(&c.message))
119
        };
120
        println!("{bullet} {}{labels}  {subject}", ui::id(short_meta_id(c)));
121
        let meta = format!(
122
            "{}{}{}{}{}{}{}",
123
            ui::hash(format!("#{}", e.hash.short(12))),
124
            ui::sep(),
125
            ui::author(author_name(&repo, c)),
126
            ui::sep(),
127
            ui::dim(ui::ago(c.hlc.wall_ms)),
128
            ui::sep(),
129
            if e.is_working_copy { ui::dim("not yet committed") } else { signature_mark(&repo, &e.hash)? }
130
        );
131
        println!("{rail} {meta}");
132
        if verbose {
133
            if !e.is_working_copy {
134
                println!("{rail} {}", ui::dim(format!("{} {}", when(c.hlc.wall_ms), signature_line(&repo, &e.hash)?)));
135
            }
136
            if !c.deps.is_empty() {
137
                println!("{rail} {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(deps_of(&c.deps)));
138
            }
139
            let body: Vec<&str> = c.message.lines().skip(1).skip_while(|l| l.trim().is_empty()).collect();
140
            if !body.is_empty() {
141
                println!("{rail}");
142
                for line in body {
143
                    println!("{rail}   {line}");
144
                }
145
            }
146
        }
147
        if i + 1 < shown {
148
            println!("{rail}");
149
        }
150
    }
151
    if shown < total {
152
        println!("{}", ui::dim(format!("{} {} more; pass -n to see them", ui::glyph("…", "..."), total - shown)));
153
    }
154
    Ok(0)
155
}
156
157
pub fn diff() -> Result<i32> {
158
    let repo = open()?;
159
    let snap = repo.snapshot("oxy diff")?;
160
    print!("{}", render_change(&snap.wc, &snap.base, &snap.base_snapshot));
161
    Ok(0)
162
}
163
164
pub fn show(change: &str) -> Result<i32> {
165
    let repo = open()?;
166
    repo.snapshot("oxy show")?;
167
    let h = repo.resolve_change(change)?;
168
    let c = repo.store().get_change(&h)?;
169
    println!("{} {} {}", ui::section("change"), ui::id(short_id(&c)), ui::hash(format!("#{h}")));
170
    println!("{}", ui::kv("kind", format!("{:?}", c.kind).to_lowercase()));
171
    println!("{}", ui::kv("author", ui::author(author_name(&repo, &oxy_core::state::ChangeMeta::of(&c)))));
172
    println!("{}", ui::kv("date", format!("{} {}", when(c.hlc.wall_ms), ui::dim(format!("({})", ui::ago(c.hlc.wall_ms))))));
173
    println!("{}", ui::kv("deps", ui::dim(deps_line(&c))));
174
    println!("{}", ui::kv("sig", signature_line(&repo, &h)?));
175
    println!();
176
    for line in (if c.message.is_empty() { "(no description)" } else { &c.message }).lines() {
177
        println!("    {line}");
178
    }
179
    println!();
180
    print!("{}", repo.describe_change(&h)?);
181
    Ok(0)
182
}
183
184
pub fn checkout(changes: &[String], discard: bool) -> Result<i32> {
185
    let repo = open()?;
186
    let heads = if changes.is_empty() {
187
        repo.bookmark_heads()?
188
    } else {
189
        let mut heads = Vec::new();
190
        for c in changes {
191
            heads.extend(repo.resolve_heads(c)?);
192
        }
193
        heads
194
    };
195
    let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") };
196
    let sp = ui::spinner(&format!("Checking out {spec}…"));
197
    let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?;
198
    sp.done(&format!("{} on {}: {} written, {} removed", ui::done("Working copy"), ui::bookmark(&spec), plural(r.written, "file"), r.removed));
199
    if r.conflicted {
200
        println!("  {} {}", ui::caution(), ui::warn("this state has conflicts; see oxy status"));
201
    }
202
    Ok(0)
203
}
204
205
pub fn resolve() -> Result<i32> {
206
    let repo = open()?;
207
    let snap = repo.snapshot("oxy resolve")?;
208
    if !snap.base_snapshot.is_conflicted() {
209
        println!("The working copy's base has no conflicts.");
210
        return Ok(0);
211
    }
212
    let mut remaining = 0;
213
    for (p, f) in &snap.base_snapshot.files {
214
        if !f.is_conflicted() {
215
            continue;
216
        }
217
        match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
218
            Some(ChangeStatus::Resolved) => println!("resolved   {p}"),
219
            Some(ChangeStatus::ConflictEdited) => {
220
                remaining += 1;
221
                println!("markers    {p}");
222
            }
223
            _ => {
224
                remaining += 1;
225
                println!("unresolved {p}");
226
            }
227
        }
228
    }
229
    for c in &snap.base_snapshot.tree.conflicts {
230
        remaining += 1;
231
        println!("tree       {c:?}");
232
    }
233
    if remaining == 0 {
234
        println!("All conflicts are resolved; `oxy commit` will record the resolution.");
235
        Ok(0)
236
    } else {
237
        Ok(2)
238
    }
239
}
240
241
pub fn undo() -> Result<i32> {
242
    let repo = open()?;
243
    let op = repo.undo()?;
244
    println!("{} now at: {}", ui::done("Undone;"), ui::dim(&op.command));
245
    Ok(0)
246
}
247
248
pub fn op_log() -> Result<i32> {
249
    let repo = open()?;
250
    for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
251
        let bullet = if i == 0 { ui::ok(ui::glyph("●", "@")) } else { ui::dim(ui::glyph("○", "o")) };
252
        let cmd = subject_of(&op.command).split("\\n").next().unwrap_or("");
253
        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() });
254
    }
255
    Ok(0)
256
}
257
258
pub fn gc(now: bool) -> Result<i32> {
259
    let repo = open()?;
260
    let sp = ui::spinner("Packing and pruning…");
261
    let r = repo.gc(now)?;
262
    sp.clear();
263
    if r.packed.objects > 0 {
264
        println!(
265
            "Packed {}: {} bytes -> {} bytes ({:.0}%).",
266
            plural(r.packed.objects, "loose object"),
267
            r.packed.raw_bytes,
268
            r.packed.packed_bytes,
269
            100.0 * r.packed.packed_bytes as f64 / r.packed.raw_bytes.max(1) as f64
270
        );
271
    }
272
    println!("{} reachable; pruned {} ({} bytes){}{}; {} present.",
273
        plural(r.reachable, "object"),
274
        plural(r.pruned.objects, "unreachable object"),
275
        r.pruned.bytes,
276
        if r.pruned.deferred > 0 { format!("; {} deferred by the grace period (use --now)", plural(r.pruned.deferred, "object")) } else { String::new() },
277
        if r.merged_packs > 0 { format!("; merged {} into one", plural(r.merged_packs, "pack")) } else { String::new() },
278
        plural(repo.store().pack_count(), "pack")
279
    );
280
    Ok(0)
281
}
282
283
/// The single head the working copy sits on, for commands that default to it.
284
fn current_head(repo: &Repo) -> Result<oxy_core::hash::ChangeHash> {
285
    let snap = repo.snapshot("oxy")?;
286
    let base = snap.base.maximal(&snap.wc.deps);
287
    match base.as_slice() {
288
        [h] => Ok(*h),
289
        _ => anyhow::bail!("the working copy sits on {} heads; name the change explicitly", base.len()),
290
    }
291
}
292
293
pub fn describe(message: &str, change: Option<&str>) -> Result<i32> {
294
    let repo = open()?;
295
    let h = match change {
296
        Some(c) => repo.resolve_change(c)?,
297
        None => current_head(&repo)?,
298
    };
299
    let new = repo.describe(&h, message)?;
300
    let c = repo.store().get_change(&new)?;
301
    println!("{} {} {}  {}", ui::done("Described"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(message)));
302
    Ok(0)
303
}
304
305
pub fn amend(message: Option<&str>, into: Option<&str>) -> Result<i32> {
306
    let repo = open()?;
307
    let new = match into {
308
        Some(spec) => {
309
            let target = repo.resolve_change(spec)?;
310
            repo.amend_into(&target, message)?
311
        }
312
        None => repo.amend(message)?,
313
    };
314
    let c = repo.store().get_change(&new)?;
315
    println!("{} {} {}  {}", ui::done("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(&c.message)));
316
    Ok(0)
317
}
318
319
pub fn abandon(change: &str) -> Result<i32> {
320
    let repo = open()?;
321
    let h = repo.resolve_change(change)?;
322
    let c = repo.store().get_change(&h)?;
323
    let notes = repo.abandon(&h)?;
324
    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"));
325
    for n in notes {
326
        println!("  {} {n}", ui::note());
327
    }
328
    Ok(0)
329
}
330
331
pub fn checkpoint() -> Result<i32> {
332
    let repo = open()?;
333
    let sp = ui::spinner("Writing checkpoint…");
334
    let (r, changes, bytes) = repo.checkpoint()?;
335
    sp.done(&format!("{} {} of {} ({} bytes) for {}", ui::done("Checkpoint"), ui::hash(format!("#{}", r.short(12))), plural(changes, "change"), bytes, ui::id(&repo.config.bookmark)));
336
    Ok(0)
337
}
338
339
/// For shell prompts: silent (exit 1) outside a repository, otherwise one line.
340
pub fn prompt(format: &str) -> Result<i32> {
341
    let Ok(repo) = Repo::open(&std::env::current_dir()?) else { return Ok(1) };
342
    let info = repo.quick_status()?;
343
    println!("{}", info.format(format));
344
    Ok(0)
345
}
346
347
pub fn sparse(paths: &[String]) -> Result<i32> {
348
    let mut repo = open()?;
349
    if paths.is_empty() {
350
        if repo.config.sparse_paths.is_empty() {
351
            println!("No sparse filter: the whole tree is materialized.");
352
        } else {
353
            println!("{}", ui::heading("Sparse filter"));
354
            for p in &repo.config.sparse_paths {
355
                println!("  {}", ui::path(p));
356
            }
357
        }
358
        return Ok(0);
359
    }
360
    let snap = repo.snapshot("oxy sparse")?;
361
    if !snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty() {
362
        anyhow::bail!("the working copy has changes; commit them before changing the sparse filter");
363
    }
364
    repo.config.set("filter.paths", &paths.join(","))?;
365
    repo.save_config()?;
366
    let heads = repo.bookmark_heads()?;
367
    let sp = ui::spinner("Re-checking out with the new filter…");
368
    let r = repo.checkout(&heads, true, "oxy sparse (checkout)")?;
369
    sp.done(&format!("{} {}: {} written, {} removed", ui::ok("Sparse filter set to"), ui::path(paths.join(",")), plural(r.written, "file"), r.removed));
370
    Ok(0)
371
}
372
373
pub fn tag_create(name: &str, changes: &[String], message: &str) -> Result<i32> {
374
    let repo = open()?;
375
    repo.snapshot("oxy tag")?;
376
    let heads = if changes.is_empty() {
377
        repo.bookmark_heads()?
378
    } else {
379
        let mut heads = Vec::new();
380
        for c in changes {
381
            heads.extend(repo.resolve_heads(c)?);
382
        }
383
        heads
384
    };
385
    let t = repo.tag_create(name, &heads, message)?;
386
    println!(
387
        "{} {} {} {} ({}){}",
388
        ui::done("Tagged"),
389
        ui::bookmark(&t.name),
390
        ui::dim(ui::glyph("→", "->")),
391
        ui::hash(format!("#{}", t.state.short(12))),
392
        plural(t.heads.len(), "head"),
393
        t.signer.map(|s| format!(", signed by {s}")).unwrap_or_else(|| ui::dim(", unsigned"))
394
    );
395
    Ok(0)
396
}
397
398
pub fn tag_list() -> Result<i32> {
399
    let repo = open()?;
400
    let tags = repo.tags()?;
401
    if tags.is_empty() {
402
        println!("{}", ui::dim("No tags."));
403
    }
404
    for t in tags {
405
        println!(
406
            "{} {} {}{}{}{}",
407
            ui::ok(ui::glyph("⚑", "#")),
408
            ui::bookmark(&t.name),
409
            ui::hash(format!("#{}", t.state.short(12))),
410
            ui::sep() + &ui::dim(plural(t.heads.len(), "head")),
411
            if t.message.is_empty() { String::new() } else { format!("  {}", ui::subject(subject_of(&t.message))) },
412
            t.signer.map(|s| ui::sep() + &ui::ok(format!("{} {s}", ui::glyph("✔", "v")))).unwrap_or_default()
413
        );
414
    }
415
    Ok(0)
416
}
417
418
pub fn workspace_add(path: &std::path::Path, changes: &[String]) -> Result<i32> {
419
    let repo = open()?;
420
    let heads = if changes.is_empty() {
421
        None
422
    } else {
423
        let mut heads = Vec::new();
424
        for c in changes {
425
            heads.extend(repo.resolve_heads(c)?);
426
        }
427
        Some(heads)
428
    };
429
    let sp = ui::spinner(&format!("Creating workspace at {}…", path.display()));
430
    let ws = repo.workspace_add(path, heads.as_deref())?;
431
    sp.done(&format!("{} {} (workspace {})", ui::done("Workspace created at"), ui::path(ws.root().display()), ui::id(ws.workspace().short(8))));
432
    Ok(0)
433
}
434
435
pub fn workspace_list() -> Result<i32> {
436
    let repo = open()?;
437
    for w in repo.workspaces()? {
438
        println!(
439
            "{} {} {}{}",
440
            if w.current { ui::ok(ui::glyph("●", "*")) } else { ui::dim(ui::glyph("○", "o")) },
441
            ui::id(w.id.short(8)),
442
            ui::path(w.path.display()),
443
            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"))
444
        );
445
    }
446
    Ok(0)
447
}
448
449
pub fn workspace_forget(spec: &str) -> Result<i32> {
450
    let repo = open()?;
451
    let all = repo.workspaces()?;
452
    let target = std::fs::canonicalize(spec).ok();
453
    let matches: Vec<_> = all.iter().filter(|w| w.id.starts_with_hex(spec) || target.as_ref().is_some_and(|t| *t == w.path)).collect();
454
    let w = match matches.as_slice() {
455
        [w] => *w,
456
        [] => anyhow::bail!("no workspace matches {spec:?}"),
457
        _ => anyhow::bail!("{spec:?} is ambiguous"),
458
    };
459
    repo.workspace_forget(&w.id)?;
460
    println!("{} workspace {} at {}; its files were left in place.", ui::done("Forgot"), ui::id(w.id.short(8)), ui::path(w.path.display()));
461
    Ok(0)
462
}
463
464
pub fn lock(path: &str) -> Result<i32> {
465
    let repo = open()?;
466
    repo.lock_path(path)?;
467
    println!("{} {} {}", ui::done("Locked"), ui::path(path), ui::dim("(advisory, local)"));
468
    Ok(0)
469
}
470
471
pub fn unlock(path: &str) -> Result<i32> {
472
    let repo = open()?;
473
    repo.unlock_path(path)?;
474
    println!("{} {}", ui::done("Unlocked"), ui::path(path));
475
    Ok(0)
476
}
477
478
pub fn split(change: &str, paths: &[String]) -> Result<i32> {
479
    let repo = open()?;
480
    let h = repo.resolve_change(change)?;
481
    let (h1, h2) = repo.split(&h, paths)?;
482
    let c1 = repo.store().get_change(&h1)?;
483
    let c2 = repo.store().get_change(&h2)?;
484
    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))));
485
    Ok(0)
486
}