OXYGENHUB
# f77698833f9f oxygen / crates / oxy-core / src / repo / snapshot.rs
23.3 KiB · Rust · last changed Packed storage: zstd packs with sorted indexes, oxy gc, auto-pack at 4096 loose objects; README 1 month ago source blame history raw
1
//! Working-copy snapshots, commits, and the change builder (spec §12.3).
2
3
use super::*;
4
5
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6
pub enum ChangeStatus {
7
    Added,
8
    Modified,
9
    Removed,
10
    ModeChanged,
11
    /// The file is conflicted in the base state and still contains conflict markers.
12
    ConflictEdited,
13
    /// The file was conflicted in the base state and the working copy resolves it.
14
    Resolved,
15
}
16
17
/// Lines that mark a conflict region in a materialized file (spec §9.5).
18
pub fn has_conflict_markers(content: &[u8]) -> bool {
19
    split_lines(content).iter().any(|l| {
20
        l.starts_with(b"<<<<<<< oxy conflict") || l.starts_with(b"======= oxy conflict") || l.starts_with(b">>>>>>> oxy conflict")
21
    })
22
}
23
24
#[derive(Clone, Debug, PartialEq, Eq)]
25
pub struct PathStatus {
26
    pub path: String,
27
    pub status: ChangeStatus,
28
}
29
30
/// Result of snapshotting the working copy.
31
pub struct SnapshotResult {
32
    pub wc_hash: ChangeHash,
33
    pub wc: Change,
34
    /// The base state (closure of the working-copy change's dependencies).
35
    pub base: State,
36
    pub base_snapshot: Snapshot,
37
    pub statuses: Vec<PathStatus>,
38
    /// Whether the snapshot rewrote the working-copy change.
39
    pub changed: bool,
40
    /// Whether the working copy resolves conflicts of its base (spec §9.6).
41
    pub resolves: bool,
42
    /// Changes that contributed to the conflicts being resolved.
43
    pub contributors: Vec<ChangeHash>,
44
}
45
46
pub fn is_binary(content: &[u8]) -> bool {
47
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
48
}
49
50
impl Repo {
51
    /// Snapshot the working copy into the working-copy change (spec §12.3).
52
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
53
        let (op_ref, op) = self.head_op()?;
54
        let (old_hash, old_wc) = self.working_copy()?;
55
        let base = self.load_state(&old_wc.deps)?;
56
        let base_snapshot = base.materialize();
57
        let fs_entries = self.walk_working_copy()?;
58
59
        // Reuse the NodeIds the previous working-copy version assigned to new
60
        // paths, so an unchanged working copy hashes identically (spec §12.3).
61
        let reuse = added_node_paths(&old_wc, &base_snapshot);
62
        let mut b = ChangeBuilder::new(&base, &base_snapshot, reuse);
63
        for (path, fs_entry) in &fs_entries {
64
            match base_snapshot.files.get(path) {
65
                None => b.add_path(self, path, fs_entry)?,
66
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
67
            }
68
        }
69
        for (path, expected) in &base_snapshot.files {
70
            if fs_entries.contains_key(path) {
71
                continue;
72
            }
73
            let still_needed = expected.kind == NodeKind::Dir && fs_entries.keys().any(|p| p.starts_with(&format!("{path}/")));
74
            if !still_needed {
75
                b.remove_path(path, expected);
76
            }
77
        }
78
79
        let mut wc = Change {
80
            change_id: old_wc.change_id,
81
            deps: vec![],
82
            author: self.author(),
83
            hlc: old_wc.hlc,
84
            message: old_wc.message.clone(),
85
            tree_ops: b.tree_ops,
86
            edits: b.edits,
87
            kind: ChangeKind::WorkingCopy,
88
        };
89
        let base_heads = base.maximal(&old_wc.deps);
90
        let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect();
91
        deps.extend(referenced_changes(&wc, &base));
92
        deps.extend(b.contributors.iter().copied());
93
        wc.deps = deps.into_iter().collect();
94
        wc.normalize();
95
        let resolves = b.resolves;
96
        let contributors: Vec<ChangeHash> = b.contributors.iter().copied().collect();
97
98
        let mut statuses = b.statuses;
99
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
100
        // Empty snapshot: hash the candidate before storing; only record an op on change.
101
        let new_hash = wc.hash();
102
        let changed = new_hash != old_hash;
103
        if changed {
104
            self.store.put_change(&wc)?;
105
            let mut view = op.view.clone();
106
            view.workspaces.insert(self.workspace, new_hash);
107
            self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?;
108
        }
109
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves, contributors })
110
    }
111
112
    /// Turn the working-copy change into a normal change on the bookmark (spec §12.3).
113
    pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result<ChangeHash> {
114
        let snap = self.snapshot("oxy commit")?;
115
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
116
            return fail("nothing to commit: the working copy matches its base");
117
        }
118
        if let Some(s) = snap.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) {
119
            return fail(format!("{} still contains conflict markers", s.path));
120
        }
121
        if message.trim().is_empty() {
122
            return fail("a commit message is required");
123
        }
124
        let (op_ref, op) = self.head_op()?;
125
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
126
        // A resolution depends on every change that contributed to the
127
        // conflict (spec §8.1). Tree and opaque conflicts are resolved through
128
        // ordinary operations whose references already cover the contributors.
129
        deps.extend(snap.contributors.iter().copied());
130
        for d in extra_deps {
131
            if !snap.base.contains(d) {
132
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
133
            }
134
            deps.insert(*d);
135
        }
136
        if self.config.sign_changes && self.secret_key()?.is_none() {
137
            return fail("core.sign is \"change\" but no secret key is available for author.key");
138
        }
139
        let mut change = Change {
140
            change_id: snap.wc.change_id,
141
            deps: deps.into_iter().collect(),
142
            author: self.author(),
143
            hlc: Hlc::now(),
144
            message: message.trim_end().to_string(),
145
            tree_ops: snap.wc.tree_ops.clone(),
146
            edits: snap.wc.edits.clone(),
147
            kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
148
        };
149
        change.normalize();
150
        let hash = self.store.put_change(&change)?;
151
        if self.config.sign_changes {
152
            self.sign_change(&hash)?;
153
        }
154
155
        // New bookmark heads: maximal elements of old heads plus the new change.
156
        // The working copy continues on its own base plus the new change, which
157
        // is exactly what the files on disk contain; the bookmark may hold
158
        // other heads (and conflicts) the working copy has not checked out.
159
        let old_heads = self.heads_of(&op.view, &self.config.bookmark)?;
160
        let old_base = snap.base.maximal(&snap.wc.deps);
161
        let mut all: Vec<ChangeHash> = old_heads.clone();
162
        all.extend(old_base.iter().copied());
163
        all.push(hash);
164
        let mut st = self.load_state(&all)?;
165
        st.add(change)?;
166
        let mut with_new = old_heads.clone();
167
        with_new.push(hash);
168
        let new_heads = st.maximal(&with_new);
169
        let mut wc_base = old_base.clone();
170
        wc_base.push(hash);
171
        let wc_base = st.maximal(&wc_base);
172
173
        let wc = self.new_working_copy(wc_base, "");
174
        let wc_hash = self.store.put_change(&wc)?;
175
        let mut view = op.view.clone();
176
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?);
177
        view.workspaces.insert(self.workspace, wc_hash);
178
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
179
        self.maybe_autopack()?;
180
        Ok(hash)
181
    }
182
}
183
184
/// Builds the tree ops and edits of a working-copy change against a base.
185
struct ChangeBuilder<'a> {
186
    base: &'a State,
187
    base_snapshot: &'a Snapshot,
188
    tree_ops: Vec<TreeOp>,
189
    edits: Vec<Edit>,
190
    statuses: Vec<PathStatus>,
191
    dir_nodes: HashMap<String, NodeId>,
192
    reuse: HashMap<String, (NodeId, NodeKind)>,
193
    /// Changes that contributed to conflicts this working copy resolves.
194
    contributors: BTreeSet<ChangeHash>,
195
    resolves: bool,
196
}
197
198
impl<'a> ChangeBuilder<'a> {
199
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
200
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
201
        dir_nodes.insert(String::new(), NodeId::ROOT);
202
        for (p, f) in &base_snapshot.files {
203
            if f.kind == NodeKind::Dir {
204
                dir_nodes.insert(p.clone(), f.node);
205
            }
206
        }
207
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false }
208
    }
209
210
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
211
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
212
    }
213
214
    fn ensure_dir(&mut self, path: &str) -> NodeId {
215
        if let Some(n) = self.dir_nodes.get(path) {
216
            return *n;
217
        }
218
        let mut parent = NodeId::ROOT;
219
        let mut prefix = String::new();
220
        for comp in path.split('/') {
221
            if !prefix.is_empty() {
222
                prefix.push('/');
223
            }
224
            prefix.push_str(comp);
225
            parent = match self.dir_nodes.get(&prefix) {
226
                Some(n) => *n,
227
                None => {
228
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
229
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
230
                    self.dir_nodes.insert(prefix.clone(), node);
231
                    node
232
                }
233
            };
234
        }
235
        parent
236
    }
237
238
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
239
        let (dir, name) = split_path(path);
240
        let parent = self.ensure_dir(dir);
241
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
242
        let opaque = e.kind == NodeKind::Symlink || is_binary(&e.content);
243
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
244
        if opaque {
245
            let blob = repo.store_blob(&e.content)?;
246
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
247
        } else if !e.content.is_empty() {
248
            self.edits.push(Edit::Insert {
249
                node,
250
                after: crate::objects::AtomRef::Start,
251
                before: crate::objects::AtomRef::End,
252
                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
253
            });
254
        }
255
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
256
        Ok(())
257
    }
258
259
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
260
        let entry = &self.base_snapshot.tree.entries[path];
261
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
262
        if path.contains(".oxy-conflict-") || expected.is_conflicted() {
263
            self.resolves = true;
264
        }
265
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
266
    }
267
268
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
269
        if expected.kind != e.kind {
270
            self.remove_path(path, expected);
271
            self.statuses.pop();
272
            self.add_path(repo, path, e)?;
273
            self.statuses.pop();
274
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
275
            return Ok(());
276
        }
277
        let entry = &self.base_snapshot.tree.entries[path];
278
        let node = expected.node;
279
        let mode_atom = entry.mode_atom.map(op_ref);
280
        let mut status: Option<ChangeStatus> = None;
281
        match &expected.content {
282
            FileContent::Dir => {}
283
            FileContent::Text { bytes, materialized } => {
284
                if materialized.is_conflicted() {
285
                    if bytes == &e.content {
286
                        return Ok(());
287
                    }
288
                    if has_conflict_markers(&e.content) {
289
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
290
                        return Ok(());
291
                    }
292
                    // Resolution (spec §9.6): diff against the unconflicted lines
293
                    // with each region as a gap, then retire every region atom.
294
                    // Whatever the author kept from a region is re-inserted as
295
                    // new atoms, which leaves the graph totally ordered.
296
                    let regions = self.base.conflict_regions(&node);
297
                    let mut edits = text_edits(node, &regions.plain, &e.content);
298
                    if !regions.region_atoms.is_empty() {
299
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
300
                    }
301
                    self.edits.extend(edits);
302
                    self.contributors.extend(regions.contributors);
303
                    self.resolves = true;
304
                    if e.exec != expected.mode.exec {
305
                        if let Some(old) = mode_atom {
306
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
307
                        }
308
                    }
309
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
310
                    return Ok(());
311
                }
312
                if bytes != &e.content {
313
                    if is_binary(&e.content) {
314
                        // Text → opaque (spec §7.5).
315
                        if let Some(old) = mode_atom {
316
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
317
                        }
318
                        let live: Vec<_> = repo_live_keys(self.base, &node);
319
                        if !live.is_empty() {
320
                            self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
321
                        }
322
                        let blob = repo.store_blob(&e.content)?;
323
                        self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
324
                        status = Some(ChangeStatus::Modified);
325
                    } else {
326
                        let old = self.base.live_lines(&node);
327
                        let edits = text_edits(node, &old, &e.content);
328
                        if !edits.is_empty() {
329
                            self.edits.extend(edits);
330
                            status = Some(ChangeStatus::Modified);
331
                        }
332
                    }
333
                }
334
                let switched_to_opaque = status == Some(ChangeStatus::Modified) && is_binary(&e.content);
335
                if !switched_to_opaque && e.exec != expected.mode.exec {
336
                    if let Some(old) = mode_atom {
337
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
338
                        status.get_or_insert(ChangeStatus::ModeChanged);
339
                    }
340
                }
341
            }
342
            FileContent::Opaque { blobs } => {
343
                let new_ref = Repo::blob_ref(&e.content);
344
                if blobs.len() > 1 {
345
                    if !blobs.contains(&new_ref) {
346
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
347
                    }
348
                    return Ok(());
349
                }
350
                let current = blobs.first().copied();
351
                if current != Some(new_ref) {
352
                    if e.kind == NodeKind::File && !is_binary(&e.content) {
353
                        // Opaque → text (spec §7.5).
354
                        if let Some(old) = mode_atom {
355
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
356
                        }
357
                        self.edits.push(Edit::Replace { node, old: current, new: None });
358
                        if !e.content.is_empty() {
359
                            self.edits.push(Edit::Insert {
360
                                node,
361
                                after: crate::objects::AtomRef::Start,
362
                                before: crate::objects::AtomRef::End,
363
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
364
                            });
365
                        }
366
                    } else {
367
                        let blob = repo.store_blob(&e.content)?;
368
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
369
                    }
370
                    status = Some(ChangeStatus::Modified);
371
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
372
                    if let Some(old) = mode_atom {
373
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
374
                        status = Some(ChangeStatus::ModeChanged);
375
                    }
376
                }
377
            }
378
        }
379
        if let Some(s) = status {
380
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
381
        }
382
        Ok(())
383
    }
384
}
385
386
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
387
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
388
}
389
390
/// The changes a change's scope references (spec §8.1): introducers of every
391
/// atom, placement, and mode it touches, and of every node it operates on.
392
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
393
    let mut out = BTreeSet::new();
394
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
395
        if !r.change.is_zero() {
396
            out.insert(r.change);
397
        }
398
    };
399
    let mut nodes: Vec<NodeId> = Vec::new();
400
    for op in &change.tree_ops {
401
        match op {
402
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
403
            TreeOp::RemoveNode { node, placement } => {
404
                nodes.push(*node);
405
                opref(placement, &mut out);
406
            }
407
            TreeOp::Move { node, old, parent, .. } => {
408
                nodes.push(*node);
409
                nodes.push(*parent);
410
                opref(old, &mut out);
411
            }
412
            TreeOp::SetMode { node, old, .. } => {
413
                nodes.push(*node);
414
                opref(old, &mut out);
415
            }
416
        }
417
    }
418
    for e in &change.edits {
419
        nodes.push(e.node());
420
        match e {
421
            Edit::Insert { after, before, .. } => {
422
                for r in [after, before] {
423
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
424
                        if !change.is_zero() {
425
                            out.insert(*change);
426
                        }
427
                    }
428
                }
429
            }
430
            Edit::Delete { atoms, .. } => {
431
                for r in atoms {
432
                    if !r.change.is_zero() {
433
                        out.insert(r.change);
434
                    }
435
                }
436
            }
437
            Edit::Replace { .. } => {}
438
        }
439
    }
440
    for n in nodes {
441
        if let Some(h) = base.tree().introducer(&n) {
442
            out.insert(h);
443
        }
444
    }
445
    out
446
}
447
448
/// Paths of the nodes a change adds on top of `base_snapshot`.
449
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
450
    let by_node = base_snapshot.tree.by_node();
451
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
452
    for op in &change.tree_ops {
453
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
454
            new_names.insert(*node, (*parent, name.clone(), *kind));
455
        }
456
    }
457
    let mut out = HashMap::new();
458
    for (node, (_, _, kind)) in &new_names {
459
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
460
            out.insert(path, (*node, *kind));
461
        }
462
    }
463
    out
464
}
465
466
fn path_of_node(
467
    n: &NodeId,
468
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
469
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
470
) -> Option<String> {
471
    if let Some(e) = by_node.get(n) {
472
        return Some(e.path.clone());
473
    }
474
    let mut parts = Vec::new();
475
    let mut cur = *n;
476
    loop {
477
        if cur.is_root() {
478
            break;
479
        }
480
        if let Some(e) = by_node.get(&cur) {
481
            if !e.path.is_empty() {
482
                parts.push(e.path.clone());
483
            }
484
            break;
485
        }
486
        let (parent, name, _) = new_names.get(&cur)?;
487
        parts.push(name.clone());
488
        cur = *parent;
489
    }
490
    parts.reverse();
491
    Some(parts.join("/"))
492
}
493
494
/// Render a change's operations with line content, using `base` for deleted lines.
495
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
496
    let by_node = base_snapshot.tree.by_node();
497
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
498
    for op in &change.tree_ops {
499
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
500
            new_names.insert(*node, (*parent, name.clone(), *kind));
501
        }
502
    }
503
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
504
505
    let mut out = String::new();
506
    for op in &change.tree_ops {
507
        match op {
508
            TreeOp::AddNode { node, kind, mode, .. } => {
509
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
510
            }
511
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
512
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
513
            TreeOp::SetMode { node, mode, .. } => {
514
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
515
            }
516
        }
517
    }
518
    let mut last_node: Option<NodeId> = None;
519
    for e in &change.edits {
520
        let n = e.node();
521
        if last_node != Some(n) {
522
            out.push_str(&format!("--- {}\n", path_of(&n)));
523
            last_node = Some(n);
524
        }
525
        match e {
526
            Edit::Insert { lines, .. } => {
527
                for l in lines {
528
                    out.push('+');
529
                    out.push_str(&String::from_utf8_lossy(l));
530
                    if !l.ends_with(b"\n") {
531
                        out.push('\n');
532
                    }
533
                }
534
            }
535
            Edit::Delete { atoms, .. } => {
536
                for r in atoms {
537
                    for i in r.from..r.to {
538
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
539
                        out.push('-');
540
                        out.push_str(&String::from_utf8_lossy(content));
541
                        if !content.ends_with(b"\n") {
542
                            out.push('\n');
543
                        }
544
                    }
545
                }
546
            }
547
            Edit::Replace { old, new, .. } => {
548
                out.push_str(&format!(
549
                    "  opaque content {} -> {}\n",
550
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
551
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
552
                ));
553
            }
554
        }
555
    }
556
    out
557
}