OXYGENHUB
# b2ef93bb2366 oxygen / src / cli / workspace.rs
12.3 KiB · Rust · last changed Tags: immutable named states with tag attestations, in views and on the wire, policy-governed, exported to Git (spec 0.6h) 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>) -> Result<i32> {
251
    let repo = open()?;
252
    let new = repo.amend(message)?;
253
    let c = repo.store().get_change(&new)?;
254
    println!("{} {} {} {}", ui::ok("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), c.message.lines().next().unwrap_or(""));
255
    Ok(0)
256
}
257
258
pub fn abandon(change: &str) -> Result<i32> {
259
    let repo = open()?;
260
    let h = repo.resolve_change(change)?;
261
    let c = repo.store().get_change(&h)?;
262
    repo.abandon(&h)?;
263
    println!("Abandoned {} #{}; its content remains on disk as uncommitted work if the working copy sat on it.", short_id(&c), h.short(12));
264
    Ok(0)
265
}
266
267
pub fn checkpoint() -> Result<i32> {
268
    let repo = open()?;
269
    let sp = ui::spinner("Writing checkpoint…");
270
    let (r, changes, bytes) = repo.checkpoint()?;
271
    sp.done(&format!("{} {} of {} ({} bytes) for {}", ui::ok("Checkpoint"), ui::hash(format!("#{}", r.short(12))), plural(changes, "change"), bytes, ui::id(&repo.config.bookmark)));
272
    Ok(0)
273
}
274
275
/// For shell prompts: silent (exit 1) outside a repository, otherwise one line.
276
pub fn prompt(format: &str) -> Result<i32> {
277
    let Ok(repo) = Repo::open(&std::env::current_dir()?) else { return Ok(1) };
278
    let info = repo.quick_status()?;
279
    println!("{}", info.format(format));
280
    Ok(0)
281
}
282
283
pub fn sparse(paths: &[String]) -> Result<i32> {
284
    let mut repo = open()?;
285
    if paths.is_empty() {
286
        if repo.config.sparse_paths.is_empty() {
287
            println!("No sparse filter: the whole tree is materialized.");
288
        } else {
289
            println!("{}", ui::heading("Sparse filter"));
290
            for p in &repo.config.sparse_paths {
291
                println!("  {}", ui::path(p));
292
            }
293
        }
294
        return Ok(0);
295
    }
296
    let snap = repo.snapshot("oxy sparse")?;
297
    if !snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty() {
298
        anyhow::bail!("the working copy has changes; commit them before changing the sparse filter");
299
    }
300
    repo.config.set("filter.paths", &paths.join(","))?;
301
    repo.save_config()?;
302
    let heads = repo.bookmark_heads()?;
303
    let sp = ui::spinner("Re-checking out with the new filter…");
304
    let r = repo.checkout(&heads, true, "oxy sparse (checkout)")?;
305
    sp.done(&format!("{} {}: {} written, {} removed", ui::ok("Sparse filter set to"), ui::path(paths.join(",")), plural(r.written, "file"), r.removed));
306
    Ok(0)
307
}
308
309
pub fn tag_create(name: &str, changes: &[String], message: &str) -> Result<i32> {
310
    let repo = open()?;
311
    repo.snapshot("oxy tag")?;
312
    let heads = if changes.is_empty() {
313
        repo.bookmark_heads()?
314
    } else {
315
        let mut heads = Vec::new();
316
        for c in changes {
317
            heads.extend(repo.resolve_heads(c)?);
318
        }
319
        heads
320
    };
321
    let t = repo.tag_create(name, &heads, message)?;
322
    println!(
323
        "{} {} -> {} ({}){}",
324
        ui::ok("Tagged"),
325
        ui::id(&t.name),
326
        ui::hash(format!("#{}", t.state.short(12))),
327
        plural(t.heads.len(), "head"),
328
        t.signer.map(|s| format!(", signed by {s}")).unwrap_or_else(|| ui::dim(", unsigned"))
329
    );
330
    Ok(0)
331
}
332
333
pub fn tag_list() -> Result<i32> {
334
    let repo = open()?;
335
    let tags = repo.tags()?;
336
    if tags.is_empty() {
337
        println!("{}", ui::dim("No tags."));
338
    }
339
    for t in tags {
340
        println!(
341
            "{} {} {}{}{}",
342
            ui::id(&t.name),
343
            ui::hash(format!("#{}", t.state.short(12))),
344
            ui::dim(format!("({})", plural(t.heads.len(), "head"))),
345
            if t.message.is_empty() { String::new() } else { format!("  {}", t.message.lines().next().unwrap_or("")) },
346
            t.signer.map(|s| ui::dim(format!("  [{s}]"))).unwrap_or_default()
347
        );
348
    }
349
    Ok(0)
350
}