OXYGENHUB
# 23b217d75848 oxygen / src / cli / workspace.rs
15.0 KiB · Rust · last changed History editing: oxy amend --into folds the working copy into an older change with dependents rewritten; oxy split; graph: delete/insert conflicts only between commuting changes (spec 0.6l) 1 month ago source blame history raw
1
//! Commands that act on the local repository and working copy.
2
3
use super::format::{deps_line, deps_of, plural, short_id, short_meta_id, signature_line, when};
4
use super::open;
5
use super::ui;
6
use anyhow::Result;
7
use oxy_core::repo::{render_change, ChangeStatus, Repo};
8
use std::path::PathBuf;
9
10
pub fn init(path: Option<PathBuf>) -> Result<i32> {
11
    let path = path.unwrap_or(std::env::current_dir()?);
12
    let repo = Repo::init(&path)?;
13
    println!("{} oxy repository in {}", ui::ok("Initialized"), ui::path(repo.root().display()));
14
    println!("  bookmark {}   author {} <{}>", ui::id(&repo.config.bookmark), repo.config.author_name, repo.config.author_email);
15
    Ok(0)
16
}
17
18
fn status_tag(s: ChangeStatus) -> &'static str {
19
    match s {
20
        ChangeStatus::Added => "A",
21
        ChangeStatus::Modified => "M",
22
        ChangeStatus::Removed => "D",
23
        ChangeStatus::ModeChanged => "X",
24
        ChangeStatus::ConflictEdited => "C!",
25
        ChangeStatus::Resolved => "R",
26
    }
27
}
28
29
pub fn status() -> Result<i32> {
30
    let repo = open()?;
31
    let sp = ui::spinner("Snapshotting the working copy…");
32
    let snap = repo.snapshot("oxy status")?;
33
    let heads = repo.bookmark_heads()?;
34
    sp.clear();
35
    println!(
36
        "{} {} on {} {}",
37
        ui::heading("Working copy"),
38
        ui::id(snap.wc.change_id.short(8)),
39
        ui::id(&repo.config.bookmark),
40
        ui::dim(format!("({})", plural(heads.len(), "head")))
41
    );
42
    if snap.statuses.is_empty() {
43
        println!("  {}", ui::dim("clean"));
44
    }
45
    for s in &snap.statuses {
46
        println!("  {:2} {}", ui::tag(status_tag(s.status)), ui::path(&s.path));
47
    }
48
    if !snap.base_snapshot.is_conflicted() {
49
        return Ok(0);
50
    }
51
    println!();
52
    println!("{} {}", ui::err("Conflicts"), ui::dim("edit the marked files, or the .oxy-conflict-N candidates, then commit"));
53
    for c in &snap.base_snapshot.tree.conflicts {
54
        println!("  tree: {c:?}");
55
    }
56
    for (p, f) in &snap.base_snapshot.files {
57
        if f.is_conflicted() {
58
            let state = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
59
                Some(ChangeStatus::Resolved) => ui::ok("resolved in working copy"),
60
                Some(ChangeStatus::ConflictEdited) => ui::warn("markers remain"),
61
                _ => ui::err("unresolved"),
62
            };
63
            println!("  {}  {state}", ui::path(p));
64
        }
65
    }
66
    Ok(2)
67
}
68
69
pub fn commit(message: &str, depends_on: &[String]) -> Result<i32> {
70
    let repo = open()?;
71
    let mut deps = Vec::new();
72
    for d in depends_on {
73
        deps.push(repo.resolve_change(d)?);
74
    }
75
    let sp = ui::spinner("Committing…");
76
    let h = repo.commit(message, &deps)?;
77
    let c = repo.store().get_change(&h)?;
78
    sp.done(&format!("{} {} {} {}", ui::ok("Committed"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), message.lines().next().unwrap_or("")));
79
    Ok(0)
80
}
81
82
pub fn log() -> Result<i32> {
83
    let repo = open()?;
84
    repo.snapshot("oxy log")?;
85
    for e in repo.log()? {
86
        let c = &e.change;
87
        let mut flags = Vec::new();
88
        if e.is_working_copy {
89
            flags.push("working copy");
90
        }
91
        if e.is_head {
92
            flags.push("head");
93
        }
94
        let flags = if flags.is_empty() { String::new() } else { ui::warn(format!(" ({})", flags.join(", "))) };
95
        println!("{} {}{}", ui::id(short_meta_id(c)), ui::hash(format!("#{}", e.hash.short(12))), flags);
96
        println!("    {}", ui::dim(when(c.hlc.wall_ms)));
97
        if !c.deps.is_empty() {
98
            println!("    {}", ui::dim(format!("deps: {}", deps_of(&c.deps))));
99
        }
100
        let msg = if c.message.is_empty() { "(no description)" } else { &c.message };
101
        for line in msg.lines() {
102
            println!("    {line}");
103
        }
104
        println!();
105
    }
106
    Ok(0)
107
}
108
109
pub fn diff() -> Result<i32> {
110
    let repo = open()?;
111
    let snap = repo.snapshot("oxy diff")?;
112
    print!("{}", render_change(&snap.wc, &snap.base, &snap.base_snapshot));
113
    Ok(0)
114
}
115
116
pub fn show(change: &str) -> Result<i32> {
117
    let repo = open()?;
118
    repo.snapshot("oxy show")?;
119
    let h = repo.resolve_change(change)?;
120
    let c = repo.store().get_change(&h)?;
121
    println!("{} {} {}", ui::heading("change"), ui::id(short_id(&c)), ui::hash(format!("#{h}")));
122
    println!("  kind  {:?}", c.kind);
123
    println!("  date  {}", when(c.hlc.wall_ms));
124
    println!("  deps  {}", ui::dim(deps_line(&c)));
125
    println!("  sig   {}", signature_line(&repo, &h)?);
126
    println!("\n{}\n", if c.message.is_empty() { "(no description)" } else { &c.message });
127
    print!("{}", repo.describe_change(&h)?);
128
    Ok(0)
129
}
130
131
pub fn checkout(changes: &[String], discard: bool) -> Result<i32> {
132
    let repo = open()?;
133
    let heads = if changes.is_empty() {
134
        repo.bookmark_heads()?
135
    } else {
136
        let mut heads = Vec::new();
137
        for c in changes {
138
            heads.extend(repo.resolve_heads(c)?);
139
        }
140
        heads
141
    };
142
    let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") };
143
    let sp = ui::spinner(&format!("Checking out {spec}…"));
144
    let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?;
145
    sp.done(&format!("{} on {}: {} written, {} removed", ui::ok("Working copy"), ui::id(&spec), plural(r.written, "file"), r.removed));
146
    if r.conflicted {
147
        println!("  {}", ui::warn("this state has conflicts; see oxy status"));
148
    }
149
    Ok(0)
150
}
151
152
pub fn resolve() -> Result<i32> {
153
    let repo = open()?;
154
    let snap = repo.snapshot("oxy resolve")?;
155
    if !snap.base_snapshot.is_conflicted() {
156
        println!("The working copy's base has no conflicts.");
157
        return Ok(0);
158
    }
159
    let mut remaining = 0;
160
    for (p, f) in &snap.base_snapshot.files {
161
        if !f.is_conflicted() {
162
            continue;
163
        }
164
        match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
165
            Some(ChangeStatus::Resolved) => println!("resolved   {p}"),
166
            Some(ChangeStatus::ConflictEdited) => {
167
                remaining += 1;
168
                println!("markers    {p}");
169
            }
170
            _ => {
171
                remaining += 1;
172
                println!("unresolved {p}");
173
            }
174
        }
175
    }
176
    for c in &snap.base_snapshot.tree.conflicts {
177
        remaining += 1;
178
        println!("tree       {c:?}");
179
    }
180
    if remaining == 0 {
181
        println!("All conflicts are resolved; `oxy commit` will record the resolution.");
182
        Ok(0)
183
    } else {
184
        Ok(2)
185
    }
186
}
187
188
pub fn undo() -> Result<i32> {
189
    let repo = open()?;
190
    let op = repo.undo()?;
191
    println!("{} now at: {}", ui::ok("Undone;"), ui::dim(&op.command));
192
    Ok(0)
193
}
194
195
pub fn op_log() -> Result<i32> {
196
    let repo = open()?;
197
    for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
198
        println!("{}{} {}  {}", if i == 0 { ui::ok("@ ") } else { "  ".into() }, ui::hash(r.short(12)), ui::dim(when(op.hlc.wall_ms)), op.command);
199
    }
200
    Ok(0)
201
}
202
203
pub fn gc(now: bool) -> Result<i32> {
204
    let repo = open()?;
205
    let sp = ui::spinner("Packing and pruning…");
206
    let r = repo.gc(now)?;
207
    sp.clear();
208
    if r.packed.objects > 0 {
209
        println!(
210
            "Packed {}: {} bytes -> {} bytes ({:.0}%).",
211
            plural(r.packed.objects, "loose object"),
212
            r.packed.raw_bytes,
213
            r.packed.packed_bytes,
214
            100.0 * r.packed.packed_bytes as f64 / r.packed.raw_bytes.max(1) as f64
215
        );
216
    }
217
    println!("{} reachable; pruned {} ({} bytes){}{}; {} present.",
218
        plural(r.reachable, "object"),
219
        plural(r.pruned.objects, "unreachable object"),
220
        r.pruned.bytes,
221
        if r.pruned.deferred > 0 { format!("; {} deferred by the grace period (use --now)", plural(r.pruned.deferred, "object")) } else { String::new() },
222
        if r.merged_packs > 0 { format!("; merged {} into one", plural(r.merged_packs, "pack")) } else { String::new() },
223
        plural(repo.store().pack_count(), "pack")
224
    );
225
    Ok(0)
226
}
227
228
/// The single head the working copy sits on, for commands that default to it.
229
fn current_head(repo: &Repo) -> Result<oxy_core::hash::ChangeHash> {
230
    let snap = repo.snapshot("oxy")?;
231
    let base = snap.base.maximal(&snap.wc.deps);
232
    match base.as_slice() {
233
        [h] => Ok(*h),
234
        _ => anyhow::bail!("the working copy sits on {} heads; name the change explicitly", base.len()),
235
    }
236
}
237
238
pub fn describe(message: &str, change: Option<&str>) -> Result<i32> {
239
    let repo = open()?;
240
    let h = match change {
241
        Some(c) => repo.resolve_change(c)?,
242
        None => current_head(&repo)?,
243
    };
244
    let new = repo.describe(&h, message)?;
245
    let c = repo.store().get_change(&new)?;
246
    println!("{} {} {} {}", ui::ok("Described"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), message.lines().next().unwrap_or(""));
247
    Ok(0)
248
}
249
250
pub fn amend(message: Option<&str>, into: Option<&str>) -> Result<i32> {
251
    let repo = open()?;
252
    let new = match into {
253
        Some(spec) => {
254
            let target = repo.resolve_change(spec)?;
255
            repo.amend_into(&target, message)?
256
        }
257
        None => repo.amend(message)?,
258
    };
259
    let c = repo.store().get_change(&new)?;
260
    println!("{} {} {} {}", ui::ok("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), c.message.lines().next().unwrap_or(""));
261
    Ok(0)
262
}
263
264
pub fn abandon(change: &str) -> Result<i32> {
265
    let repo = open()?;
266
    let h = repo.resolve_change(change)?;
267
    let c = repo.store().get_change(&h)?;
268
    repo.abandon(&h)?;
269
    println!("Abandoned {} #{}; its content remains on disk as uncommitted work if the working copy sat on it.", short_id(&c), h.short(12));
270
    Ok(0)
271
}
272
273
pub fn checkpoint() -> Result<i32> {
274
    let repo = open()?;
275
    let sp = ui::spinner("Writing checkpoint…");
276
    let (r, changes, bytes) = repo.checkpoint()?;
277
    sp.done(&format!("{} {} of {} ({} bytes) for {}", ui::ok("Checkpoint"), ui::hash(format!("#{}", r.short(12))), plural(changes, "change"), bytes, ui::id(&repo.config.bookmark)));
278
    Ok(0)
279
}
280
281
/// For shell prompts: silent (exit 1) outside a repository, otherwise one line.
282
pub fn prompt(format: &str) -> Result<i32> {
283
    let Ok(repo) = Repo::open(&std::env::current_dir()?) else { return Ok(1) };
284
    let info = repo.quick_status()?;
285
    println!("{}", info.format(format));
286
    Ok(0)
287
}
288
289
pub fn sparse(paths: &[String]) -> Result<i32> {
290
    let mut repo = open()?;
291
    if paths.is_empty() {
292
        if repo.config.sparse_paths.is_empty() {
293
            println!("No sparse filter: the whole tree is materialized.");
294
        } else {
295
            println!("{}", ui::heading("Sparse filter"));
296
            for p in &repo.config.sparse_paths {
297
                println!("  {}", ui::path(p));
298
            }
299
        }
300
        return Ok(0);
301
    }
302
    let snap = repo.snapshot("oxy sparse")?;
303
    if !snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty() {
304
        anyhow::bail!("the working copy has changes; commit them before changing the sparse filter");
305
    }
306
    repo.config.set("filter.paths", &paths.join(","))?;
307
    repo.save_config()?;
308
    let heads = repo.bookmark_heads()?;
309
    let sp = ui::spinner("Re-checking out with the new filter…");
310
    let r = repo.checkout(&heads, true, "oxy sparse (checkout)")?;
311
    sp.done(&format!("{} {}: {} written, {} removed", ui::ok("Sparse filter set to"), ui::path(paths.join(",")), plural(r.written, "file"), r.removed));
312
    Ok(0)
313
}
314
315
pub fn tag_create(name: &str, changes: &[String], message: &str) -> Result<i32> {
316
    let repo = open()?;
317
    repo.snapshot("oxy tag")?;
318
    let heads = if changes.is_empty() {
319
        repo.bookmark_heads()?
320
    } else {
321
        let mut heads = Vec::new();
322
        for c in changes {
323
            heads.extend(repo.resolve_heads(c)?);
324
        }
325
        heads
326
    };
327
    let t = repo.tag_create(name, &heads, message)?;
328
    println!(
329
        "{} {} -> {} ({}){}",
330
        ui::ok("Tagged"),
331
        ui::id(&t.name),
332
        ui::hash(format!("#{}", t.state.short(12))),
333
        plural(t.heads.len(), "head"),
334
        t.signer.map(|s| format!(", signed by {s}")).unwrap_or_else(|| ui::dim(", unsigned"))
335
    );
336
    Ok(0)
337
}
338
339
pub fn tag_list() -> Result<i32> {
340
    let repo = open()?;
341
    let tags = repo.tags()?;
342
    if tags.is_empty() {
343
        println!("{}", ui::dim("No tags."));
344
    }
345
    for t in tags {
346
        println!(
347
            "{} {} {}{}{}",
348
            ui::id(&t.name),
349
            ui::hash(format!("#{}", t.state.short(12))),
350
            ui::dim(format!("({})", plural(t.heads.len(), "head"))),
351
            if t.message.is_empty() { String::new() } else { format!("  {}", t.message.lines().next().unwrap_or("")) },
352
            t.signer.map(|s| ui::dim(format!("  [{s}]"))).unwrap_or_default()
353
        );
354
    }
355
    Ok(0)
356
}
357
358
pub fn workspace_add(path: &std::path::Path, changes: &[String]) -> Result<i32> {
359
    let repo = open()?;
360
    let heads = if changes.is_empty() {
361
        None
362
    } else {
363
        let mut heads = Vec::new();
364
        for c in changes {
365
            heads.extend(repo.resolve_heads(c)?);
366
        }
367
        Some(heads)
368
    };
369
    let sp = ui::spinner(&format!("Creating workspace at {}…", path.display()));
370
    let ws = repo.workspace_add(path, heads.as_deref())?;
371
    sp.done(&format!("{} {} (workspace {})", ui::ok("Workspace created at"), ui::path(ws.root().display()), ui::id(ws.workspace().short(8))));
372
    Ok(0)
373
}
374
375
pub fn workspace_list() -> Result<i32> {
376
    let repo = open()?;
377
    for w in repo.workspaces()? {
378
        println!(
379
            "{} {} {}{}",
380
            if w.current { ui::ok("*") } else { " ".into() },
381
            ui::id(w.id.short(8)),
382
            ui::path(w.path.display()),
383
            w.working_copy.map(|h| ui::dim(format!("  wc #{}", h.short(12)))).unwrap_or_else(|| ui::warn("  (not in view)"))
384
        );
385
    }
386
    Ok(0)
387
}
388
389
pub fn workspace_forget(spec: &str) -> Result<i32> {
390
    let repo = open()?;
391
    let all = repo.workspaces()?;
392
    let target = std::fs::canonicalize(spec).ok();
393
    let matches: Vec<_> = all.iter().filter(|w| w.id.starts_with_hex(spec) || target.as_ref().is_some_and(|t| *t == w.path)).collect();
394
    let w = match matches.as_slice() {
395
        [w] => *w,
396
        [] => anyhow::bail!("no workspace matches {spec:?}"),
397
        _ => anyhow::bail!("{spec:?} is ambiguous"),
398
    };
399
    repo.workspace_forget(&w.id)?;
400
    println!("{} workspace {} at {}; its files were left in place.", ui::ok("Forgot"), ui::id(w.id.short(8)), ui::path(w.path.display()));
401
    Ok(0)
402
}
403
404
pub fn lock(path: &str) -> Result<i32> {
405
    let repo = open()?;
406
    repo.lock_path(path)?;
407
    println!("{} {} (advisory, local)", ui::ok("Locked"), ui::path(path));
408
    Ok(0)
409
}
410
411
pub fn unlock(path: &str) -> Result<i32> {
412
    let repo = open()?;
413
    repo.unlock_path(path)?;
414
    println!("{} {}", ui::ok("Unlocked"), ui::path(path));
415
    Ok(0)
416
}
417
418
pub fn split(change: &str, paths: &[String]) -> Result<i32> {
419
    let repo = open()?;
420
    let h = repo.resolve_change(change)?;
421
    let (h1, h2) = repo.split(&h, paths)?;
422
    let c1 = repo.store().get_change(&h1)?;
423
    let c2 = repo.store().get_change(&h2)?;
424
    println!("{} into {} {} and {} {}", ui::ok("Split"), ui::id(short_id(&c1)), ui::hash(format!("#{}", h1.short(12))), ui::id(short_id(&c2)), ui::hash(format!("#{}", h2.short(12))));
425
    Ok(0)
426
}