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