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) 6e723b81f5b9
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 23:41 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
6e723b81f5b9f7032fbe8a2821b12bc1f20d264fd1345a9afebea388841cdd00- Depends on
- 135212de5370 28fc23d7706a 315167f3e208 7421309f5372 94084087a876 982864068a59 c3069537d7c5 cdb5beba7f05 fce44ae9ad29 ff0af1ee299d
- Depended on by
- c2e1d09047db e8e6e0cac26a d616faf5d136 40a40bb7b798 2306a4d24779 a87c7b9d563a dfb23a3a8cfa 958fb27dda7c f7510b63cceb 75167672ebcd 957fee30826e b7f102fcbb17 30ea9c1e70fa
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 27 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/oxy-core/src/repo/mod.rs +1 −1
| … | 75 unchanged lines |
||
| 76 | 76 | pub use git::{GitExportReport, GitImportReport}; |
|
| 77 | 77 | pub use identity::{Rotation, SignatureStatus}; |
|
| 78 | 78 | pub use policy::PolicyUpdate; |
|
| 79 | - | pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult}; |
|
| 79 | + | pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, similarity, ChangeStatus, PathStatus, SnapshotResult}; |
|
| 80 | 80 | pub use sync::{FetchReport, PullReport}; |
|
| 81 | 81 | pub use tags::TagInfo; |
|
| 82 | 82 | pub use workspace::{SnapshotLock, WorkspaceInfo}; |
|
| … | 627 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +117 −2
| … | 6 unchanged lines |
||
| 7 | 7 | Added, |
|
| 8 | 8 | Modified, |
|
| 9 | 9 | Removed, |
|
| 10 | + | /// Moved from another path (detected by similarity, tracked as a placement change). |
|
| 11 | + | Renamed, |
|
| 10 | 12 | ModeChanged, |
|
| 11 | 13 | /// The file is conflicted in the base state and still contains conflict markers. |
|
| 12 | 14 | ConflictEdited, |
|
| … | 60 unchanged lines |
||
| 73 | 75 | fs_entries.iter().filter(|(p, _)| p.as_str() == ".oxyattributes" || p.ends_with("/.oxyattributes")).map(|(p, e)| (p.as_str(), e.content.as_slice())), |
|
| 74 | 76 | ); |
|
| 75 | 77 | let mut b = ChangeBuilder::new(base, base_snapshot, reuse).with_attributes(attrs); |
|
| 78 | + | // Rename detection (spec §7.1, §23): a removed file and an added file |
|
| 79 | + | // of the same kind whose contents are at least half alike become one |
|
| 80 | + | // move of the same node, so identity survives across the rename. |
|
| 81 | + | let renames = detect_renames(base_snapshot, fs_entries, |p| self.in_scope(p)); |
|
| 82 | + | let renamed_from: HashMap<&str, &str> = renames.iter().map(|(old, new)| (new.as_str(), old.as_str())).collect(); |
|
| 83 | + | let renamed_to: HashMap<&str, &str> = renames.iter().map(|(old, new)| (old.as_str(), new.as_str())).collect(); |
|
| 76 | 84 | for (path, fs_entry) in fs_entries { |
|
| 77 | 85 | match base_snapshot.files.get(path) { |
|
| 78 | - | None => b.add_path(self, path, fs_entry)?, |
|
| 86 | + | None => match renamed_from.get(path.as_str()) { |
|
| 87 | + | Some(old) => b.move_path(self, old, path, fs_entry, &base_snapshot.files[*old])?, |
|
| 88 | + | None => b.add_path(self, path, fs_entry)?, |
|
| 89 | + | }, |
|
| 79 | 90 | Some(expected) => b.update_path(self, path, fs_entry, expected)?, |
|
| 80 | 91 | } |
|
| 81 | 92 | } |
|
| 82 | 93 | for (path, expected) in &base_snapshot.files { |
|
| 83 | - | if fs_entries.contains_key(path) || !self.in_scope(path) { |
|
| 94 | + | if fs_entries.contains_key(path) || !self.in_scope(path) || renamed_to.contains_key(path.as_str()) { |
|
| 84 | 95 | continue; |
|
| 85 | 96 | } |
|
| 86 | 97 | // A directory stays while anything under it exists or is out of scope. |
|
| … | 279 unchanged lines |
||
| 366 | 377 | Ok(()) |
|
| 367 | 378 | } |
|
| 368 | 379 | ||
| 380 | + | /// Move the node at `old` to `new` and apply `e`'s content to it. |
|
| 381 | + | fn move_path(&mut self, repo: &Repo, old: &str, new: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> { |
|
| 382 | + | let entry = &self.base_snapshot.tree.entries[old]; |
|
| 383 | + | let (dir, name) = split_path(new); |
|
| 384 | + | let parent = self.ensure_dir(dir); |
|
| 385 | + | self.tree_ops.push(TreeOp::Move { node: expected.node, old: op_ref(entry.placement), parent, name: name.to_string() }); |
|
| 386 | + | self.update_path(repo, old, e, expected)?; |
|
| 387 | + | // update_path reports under the old path; the rename is what the user sees. |
|
| 388 | + | self.statuses.retain(|s| s.path != old); |
|
| 389 | + | self.statuses.push(PathStatus { path: new.to_string(), status: ChangeStatus::Renamed }); |
|
| 390 | + | Ok(()) |
|
| 391 | + | } |
|
| 392 | + | ||
| 369 | 393 | fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) { |
|
| 370 | 394 | let entry = &self.base_snapshot.tree.entries[path]; |
|
| 371 | 395 | self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) }); |
|
| … | 294 unchanged lines |
||
| 666 | 690 | } |
|
| 667 | 691 | out |
|
| 668 | 692 | } |
|
| 693 | + | ||
| 694 | + | /// Pair removed files with added files of the same kind whose contents are |
|
| 695 | + | /// at least `RENAME_THRESHOLD` percent alike, best matches first. Beyond |
|
| 696 | + | /// `RENAME_LIMIT` candidate pairs only identical contents are paired. |
|
| 697 | + | const RENAME_THRESHOLD: u32 = 50; |
|
| 698 | + | const RENAME_LIMIT: usize = 10_000; |
|
| 699 | + | ||
| 700 | + | fn detect_renames(base_snapshot: &Snapshot, fs_entries: &BTreeMap<String, FsEntry>, in_scope: impl Fn(&str) -> bool) -> Vec<(String, String)> { |
|
| 701 | + | let removed: Vec<(&String, &crate::state::FileState)> = base_snapshot |
|
| 702 | + | .files |
|
| 703 | + | .iter() |
|
| 704 | + | .filter(|(p, f)| f.kind != NodeKind::Dir && !fs_entries.contains_key(*p) && in_scope(p) && !f.is_conflicted()) |
|
| 705 | + | .collect(); |
|
| 706 | + | let added: Vec<(&String, &FsEntry)> = fs_entries.iter().filter(|(p, e)| e.kind != NodeKind::Dir && !base_snapshot.files.contains_key(*p)).collect(); |
|
| 707 | + | if removed.is_empty() || added.is_empty() { |
|
| 708 | + | return vec![]; |
|
| 709 | + | } |
|
| 710 | + | let exact_only = removed.len() * added.len() > RENAME_LIMIT; |
|
| 711 | + | let old_content = |f: &crate::state::FileState| -> Option<Vec<u8>> { |
|
| 712 | + | match &f.content { |
|
| 713 | + | FileContent::Text { bytes, .. } => Some(bytes.clone()), |
|
| 714 | + | _ => None, |
|
| 715 | + | } |
|
| 716 | + | }; |
|
| 717 | + | let mut scored: Vec<(u32, usize, usize)> = Vec::new(); |
|
| 718 | + | for (ri, (_, f)) in removed.iter().enumerate() { |
|
| 719 | + | let old = old_content(f); |
|
| 720 | + | let old_blob = match &f.content { |
|
| 721 | + | FileContent::Opaque { blobs } if blobs.len() == 1 => Some(blobs[0]), |
|
| 722 | + | _ => None, |
|
| 723 | + | }; |
|
| 724 | + | for (ai, (_, e)) in added.iter().enumerate() { |
|
| 725 | + | if e.kind != f.kind { |
|
| 726 | + | continue; |
|
| 727 | + | } |
|
| 728 | + | let score = match (&old, old_blob) { |
|
| 729 | + | (Some(o), _) => { |
|
| 730 | + | if exact_only { |
|
| 731 | + | if *o == e.content { 100 } else { 0 } |
|
| 732 | + | } else { |
|
| 733 | + | similarity(o, &e.content) |
|
| 734 | + | } |
|
| 735 | + | } |
|
| 736 | + | (None, Some(b)) if Repo::blob_ref(&e.content) == b => 100, |
|
| 737 | + | _ => 0, |
|
| 738 | + | }; |
|
| 739 | + | if score >= RENAME_THRESHOLD { |
|
| 740 | + | scored.push((score, ri, ai)); |
|
| 741 | + | } |
|
| 742 | + | } |
|
| 743 | + | } |
|
| 744 | + | scored.sort_by(|a, b| b.0.cmp(&a.0).then(removed[a.1].0.cmp(removed[b.1].0)).then(added[a.2].0.cmp(added[b.2].0))); |
|
| 745 | + | let mut used_r = vec![false; removed.len()]; |
|
| 746 | + | let mut used_a = vec![false; added.len()]; |
|
| 747 | + | let mut out = Vec::new(); |
|
| 748 | + | for (_, ri, ai) in scored { |
|
| 749 | + | if used_r[ri] || used_a[ai] { |
|
| 750 | + | continue; |
|
| 751 | + | } |
|
| 752 | + | used_r[ri] = true; |
|
| 753 | + | used_a[ai] = true; |
|
| 754 | + | out.push((removed[ri].0.clone(), added[ai].0.clone())); |
|
| 755 | + | } |
|
| 756 | + | out |
|
| 757 | + | } |
|
| 758 | + | ||
| 759 | + | /// Percentage of the larger file's bytes found in lines the two share |
|
| 760 | + | /// (multiset intersection of lines), like Git's similarity index. |
|
| 761 | + | pub fn similarity(a: &[u8], b: &[u8]) -> u32 { |
|
| 762 | + | if a == b { |
|
| 763 | + | return 100; |
|
| 764 | + | } |
|
| 765 | + | if a.is_empty() || b.is_empty() { |
|
| 766 | + | return 0; |
|
| 767 | + | } |
|
| 768 | + | let mut counts: HashMap<&[u8], usize> = HashMap::new(); |
|
| 769 | + | for l in split_lines(a) { |
|
| 770 | + | *counts.entry(l).or_default() += 1; |
|
| 771 | + | } |
|
| 772 | + | let mut shared = 0usize; |
|
| 773 | + | for l in split_lines(b) { |
|
| 774 | + | if let Some(n) = counts.get_mut(l) { |
|
| 775 | + | if *n > 0 { |
|
| 776 | + | *n -= 1; |
|
| 777 | + | shared += l.len(); |
|
| 778 | + | } |
|
| 779 | + | } |
|
| 780 | + | } |
|
| 781 | + | (shared * 100 / a.len().max(b.len())) as u32 |
|
| 782 | + | } |
|
| 783 | + |
modified crates/oxy-core/src/repo/tests.rs +65 −2
| … | 1028 unchanged lines |
||
| 1029 | 1029 | git(&["commit", "-q", "-m", "resolve clash"]); |
|
| 1030 | 1030 | git(&["rm", "-q", "logo.png"]); |
|
| 1031 | 1031 | git(&["commit", "-q", "-m", "drop logo"]); |
|
| 1032 | + | // A rename with a small edit is imported as a move of the same node. |
|
| 1033 | + | git(&["mv", "src/lib.rs", "src/lib2.rs"]); |
|
| 1034 | + | write(&g, "src/lib2.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n"); |
|
| 1035 | + | git(&["add", "."]); |
|
| 1036 | + | git(&["commit", "-q", "-m", "rename lib"]); |
|
| 1032 | 1037 | let expected_count: usize = git(&["rev-list", "--count", "main"]).parse().unwrap(); |
|
| 1033 | 1038 | ||
| 1034 | 1039 | let dest = root.join("oxy"); |
|
| … | 2 unchanged lines |
||
| 1037 | 1042 | assert_eq!(rep.commits, expected_count); |
|
| 1038 | 1043 | assert_eq!(rep.merges, 2); |
|
| 1039 | 1044 | // The working copy equals the Git tree at main. |
|
| 1040 | - | assert_eq!(fs::read(dest.join("src/lib.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n"); |
|
| 1045 | + | assert_eq!(fs::read(dest.join("src/lib2.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n"); |
|
| 1046 | + | assert!(!dest.join("src/lib.rs").exists()); |
|
| 1041 | 1047 | assert_eq!(fs::read(dest.join("README.md")).unwrap(), b"# Hi\n\nMore.\n"); |
|
| 1042 | 1048 | assert!(!dest.join("logo.png").exists()); |
|
| 1043 | 1049 | #[cfg(unix)] |
|
| … | 6 unchanged lines |
||
| 1050 | 1056 | let st = repo.load_state(&heads).unwrap(); |
|
| 1051 | 1057 | assert!(!st.materialize().is_conflicted()); |
|
| 1052 | 1058 | assert_eq!(st.len(), expected_count); |
|
| 1059 | + | let tip = repo.log().unwrap().into_iter().find(|e| e.change.message.starts_with("rename lib")).unwrap(); |
|
| 1060 | + | let c = repo.store().get_change(&tip.hash).unwrap(); |
|
| 1061 | + | assert!(c.tree_ops.iter().any(|op| matches!(op, TreeOp::Move { name, .. } if name == "lib2.rs")), "{:?}", c.tree_ops); |
|
| 1062 | + | assert!(!c.tree_ops.iter().any(|op| matches!(op, TreeOp::AddNode { .. }))); |
|
| 1053 | 1063 | // Messages carry Git trailers; the Change-Id trailer was honoured. |
|
| 1054 | 1064 | let log = repo.log().unwrap(); |
|
| 1055 | 1065 | let second = log.iter().find(|e| e.change.message.starts_with("second")).unwrap(); |
|
| … | 10 unchanged lines |
||
| 1066 | 1076 | // Round trip back out to Git reproduces the tip tree. |
|
| 1067 | 1077 | let mirror = root.join("mirror.git"); |
|
| 1068 | 1078 | repo.git_export(&mirror).unwrap(); |
|
| 1079 | + | let out = std::process::Command::new("git").arg("-C").arg(&mirror).args(["show", "main:src/lib2.rs"]).output().unwrap(); |
|
| 1080 | + | assert_eq!(String::from_utf8_lossy(&out.stdout), "pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n"); |
|
| 1069 | 1081 | let out = std::process::Command::new("git").arg("-C").arg(&mirror).args(["show", "main:src/lib.rs"]).output().unwrap(); |
|
| 1070 | - | assert_eq!(String::from_utf8_lossy(&out.stdout), "pub fn a() {}\npub fn b() {}\npub fn c() {}\n"); |
|
| 1082 | + | assert!(!out.status.success(), "the old path is gone from the export"); |
|
| 1071 | 1083 | } |
|
| 1072 | 1084 | ||
| 1073 | 1085 | #[test] |
|
| … | 647 unchanged lines |
||
| 1721 | 1733 | assert_eq!(key_c_own, key_c); |
|
| 1722 | 1734 | } |
|
| 1723 | 1735 | ||
| 1736 | + | #[test] |
|
| 1737 | + | fn renames_are_detected_and_tracked_as_moves() { |
|
| 1738 | + | hermetic(); |
|
| 1739 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1740 | + | let root = dir.path(); |
|
| 1741 | + | let repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 1742 | + | write(root, "src/old.rs", b"fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n"); |
|
| 1743 | + | write(root, "img.bin", b"\x89PNG\0\0\x01\x02"); |
|
| 1744 | + | write(root, "tiny.txt", b"x\n"); |
|
| 1745 | + | let h1 = repo.commit("base", &[]).unwrap(); |
|
| 1746 | + | let node_old = repo.load_state(&[h1]).unwrap().materialize().files["src/old.rs"].node; |
|
| 1747 | + | let node_img = repo.load_state(&[h1]).unwrap().materialize().files["img.bin"].node; |
|
| 1748 | + | ||
| 1749 | + | // Rename with a small edit, a binary moved unchanged, and a rewrite |
|
| 1750 | + | // that shares nothing with the removed file. |
|
| 1751 | + | fs::remove_file(root.join("src/old.rs")).unwrap(); |
|
| 1752 | + | write(root, "lib/new.rs", b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n"); |
|
| 1753 | + | fs::remove_file(root.join("img.bin")).unwrap(); |
|
| 1754 | + | write(root, "assets/img.bin", b"\x89PNG\0\0\x01\x02"); |
|
| 1755 | + | fs::remove_file(root.join("tiny.txt")).unwrap(); |
|
| 1756 | + | write(root, "other.txt", b"completely different\n"); |
|
| 1757 | + | let snap = repo.snapshot("status").unwrap(); |
|
| 1758 | + | let statuses: Vec<(&str, ChangeStatus)> = snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect(); |
|
| 1759 | + | assert_eq!( |
|
| 1760 | + | statuses, |
|
| 1761 | + | vec![("assets/img.bin", ChangeStatus::Renamed), ("lib/new.rs", ChangeStatus::Renamed), ("other.txt", ChangeStatus::Added), ("src", ChangeStatus::Removed), ("tiny.txt", ChangeStatus::Removed)] |
|
| 1762 | + | ); |
|
| 1763 | + | let h2 = repo.commit("move things", &[]).unwrap(); |
|
| 1764 | + | let c = repo.store().get_change(&h2).unwrap(); |
|
| 1765 | + | let moves: Vec<_> = c.tree_ops.iter().filter(|op| matches!(op, TreeOp::Move { .. })).collect(); |
|
| 1766 | + | assert_eq!(moves.len(), 2, "{:?}", c.tree_ops); |
|
| 1767 | + | assert!(c.tree_ops.iter().any(|op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_old && name == "new.rs"))); |
|
| 1768 | + | assert!(c.tree_ops.iter().any(|op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_img && name == "img.bin"))); |
|
| 1769 | + | let snap = repo.load_state(&[h2]).unwrap().materialize(); |
|
| 1770 | + | assert_eq!(snap.files["lib/new.rs"].node, node_old, "the node survives the rename"); |
|
| 1771 | + | assert!(matches!(&snap.files["lib/new.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n")); |
|
| 1772 | + | assert!(!snap.files.contains_key("src/old.rs") && !snap.files.contains_key("src")); |
|
| 1773 | + | assert!(render_change(&c, &repo.load_state(&[h1]).unwrap(), &repo.load_state(&[h1]).unwrap().materialize()).contains("R ")); |
|
| 1774 | + | // A fresh checkout reproduces the tree. |
|
| 1775 | + | let other = tempfile::tempdir().unwrap(); |
|
| 1776 | + | let clone = Repo::clone(&root.to_string_lossy(), other.path()).unwrap(); |
|
| 1777 | + | assert_eq!(fs::read(other.path().join("lib/new.rs")).unwrap(), b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n"); |
|
| 1778 | + | assert!(!other.path().join("src").exists()); |
|
| 1779 | + | assert!(!clone.snapshot("status").unwrap().changed); |
|
| 1780 | + | // Similarity is line based, byte weighted. |
|
| 1781 | + | assert_eq!(similarity(b"a\nb\n", b"a\nb\n"), 100); |
|
| 1782 | + | assert_eq!(similarity(b"a\nb\nc\nd\n", b"a\nb\nx\ny\n"), 50); |
|
| 1783 | + | assert_eq!(similarity(b"hello\n", b"world\n"), 0); |
|
| 1784 | + | assert_eq!(similarity(b"", b"x\n"), 0); |
|
| 1785 | + | } |
|
| 1786 | + |
modified docs/spec.md +6 −4
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.6n | |
|
| 5 | + | | **Status** | Draft 0.6o | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 226 unchanged lines |
||
| 235 | 235 | ||
| 236 | 236 | A placement is `(parent NodeId, name)`. Names are byte strings that MUST be valid UTF-8, MUST NOT be empty, `.`, or `..`, and MUST NOT contain `/` or NUL. Placements that a particular file system cannot represent distinctly, such as names differing only by case, are workspace conflicts (§9.7), not errors in the state. |
|
| 237 | 237 | ||
| 238 | - | Renames and moves are placement changes, so rename tracking is exact and never heuristic. |
|
| 238 | + | Renames and moves are placement changes, so rename tracking is exact once recorded. A working copy has no way to report a move, so when a snapshot finds a removed file and an added file of the same kind whose contents are at least 50% alike (the share of the larger file's bytes found in lines both have; opaque files must be identical), it records a `Move` of the existing node rather than a removal and an addition. Beyond 10,000 candidate pairs only identical contents are paired. The same detection applies to Git import (§23). |
|
| 239 | 239 | ||
| 240 | 240 | ### 7.2 Tree operations |
|
| 241 | 241 | ||
| … | 537 unchanged lines |
||
| 779 | 779 | ||
| 780 | 780 | `oxy git import <repo>` and `oxy git export <repo>` provide a lossless-enough bridge; `oxy git fetch/push` operate against a Git remote directly. |
|
| 781 | 781 | ||
| 782 | - | - **Import.** Each Git commit becomes one change whose edits are the diff between the union of its parents' states and its tree, so a merge commit becomes a resolution exactly when the parents conflict in the change model, and an ordinary change otherwise. The commit's author and hash are recorded as `Git-Author` and `Git-Commit` trailers; the author key is the zero KeyId, since nobody can sign on the original author's behalf. Submodule entries are skipped. Renames are detected heuristically (Git similarity ≥ 50%) on import only, then tracked exactly; an implementation MAY import renames as delete-and-add until it does. The Git commit's `Change-Id` trailer, if present and 128 bits, becomes the ChangeId; otherwise one is derived as BLAKE3-128 of the Git commit hash, so re-import is idempotent. Note that a change's own closure is what it depends on, not the Git snapshot: the imported bookmark's state equals the Git tree, but checking out an individual imported change yields only the files it and its dependencies touch. |
|
| 782 | + | - **Import.** Each Git commit becomes one change whose edits are the diff between the union of its parents' states and its tree, so a merge commit becomes a resolution exactly when the parents conflict in the change model, and an ordinary change otherwise. The commit's author and hash are recorded as `Git-Author` and `Git-Commit` trailers; the author key is the zero KeyId, since nobody can sign on the original author's behalf. Submodule entries are skipped. Renames are detected on import exactly as for a snapshot (§7.1, similarity ≥ 50%), then tracked exactly. The Git commit's `Change-Id` trailer, if present and 128 bits, becomes the ChangeId; otherwise one is derived as BLAKE3-128 of the Git commit hash, so re-import is idempotent. Note that a change's own closure is what it depends on, not the Git snapshot: the imported bookmark's state equals the Git tree, but checking out an individual imported change yields only the files it and its dependencies touch. |
|
| 783 | 783 | - **Export.** Each state along a bookmark becomes a Git commit whose tree is the materialized state. The ChangeId is written as a `Change-Id:` trailer and the ChangeHash as `Oxy-Hash:`. Conflicted states are exported with §9.5 markers and an `Oxy-Conflicted: true` trailer. Signatures are not exported; Git commits MAY be additionally signed with the Ed25519 key via SSH signature format. |
|
| 784 | 784 | - **Round trip.** Import followed by export MUST reproduce every tree hash. Export followed by import MUST reproduce every ChangeId. |
|
| 785 | 785 | ||
| … | 60 unchanged lines |
||
| 846 | 846 | ||
| 847 | 847 | --- |
|
| 848 | 848 | ||
| 849 | - | *Changelog.* Draft 0.6n (2026-09-27): identity lifecycle (§14.2): every identity edit is a rotation that supersedes rather than revokes, expiry bounds authenticated changes, revocations recorded locally; policy proposals for multi-signer updates, chain-aware authorization, bootstrap rule, and policy history on `refs` and in push bundles (§14.5, §16.3). |
|
| 849 | + | *Changelog.* Draft 0.6o (2026-09-27): rename detection on snapshot and Git import records moves of the existing node (§7.1, §23). |
|
| 850 | + | ||
| 851 | + | Draft 0.6n (2026-09-27): identity lifecycle (§14.2): every identity edit is a rotation that supersedes rather than revokes, expiry bounds authenticated changes, revocations recorded locally; policy proposals for multi-signer updates, chain-aware authorization, bootstrap rule, and policy history on `refs` and in push bundles (§14.5, §16.3). |
|
| 850 | 852 | ||
| 851 | 853 | Draft 0.6m (2026-09-27): orphaned references on rewrite are reified by re-anchoring rather than refused (§8.4). |
|
| 852 | 854 | ||
| … | 41 unchanged lines |
modified src/cli/ui.rs +4 −2
| … | 131 unchanged lines |
||
| 132 | 132 | "D" => ("✖", "-", "removed"), |
|
| 133 | 133 | "X" => ("⚙", "x", "mode"), |
|
| 134 | 134 | "C!" => ("‼", "!", "markers"), |
|
| 135 | - | "R" => ("✔", "r", "resolved"), |
|
| 135 | + | "R" => ("➜", ">", "renamed"), |
|
| 136 | + | "V" => ("✔", "r", "resolved"), |
|
| 136 | 137 | _ => ("?", "?", "unknown"), |
|
| 137 | 138 | }; |
|
| 138 | 139 | let text = format!("{} {word:<9}", glyph(g, a)); |
|
| … | 2 unchanged lines |
||
| 141 | 142 | ||
| 142 | 143 | fn tag_style(t: &str, text: &str) -> String { |
|
| 143 | 144 | match t { |
|
| 144 | - | "A" | "R" => s(text).green().to_string(), |
|
| 145 | + | "A" | "V" => s(text).green().to_string(), |
|
| 146 | + | "R" => s(text).cyan().to_string(), |
|
| 145 | 147 | "M" | "X" => s(text).yellow().to_string(), |
|
| 146 | 148 | "D" | "C!" => s(text).red().to_string(), |
|
| 147 | 149 | _ => text.to_string(), |
|
| … | 83 unchanged lines |
modified src/cli/workspace.rs +2 −1
| … | 21 unchanged lines |
||
| 22 | 22 | ChangeStatus::Added => "A", |
|
| 23 | 23 | ChangeStatus::Modified => "M", |
|
| 24 | 24 | ChangeStatus::Removed => "D", |
|
| 25 | + | ChangeStatus::Renamed => "R", |
|
| 25 | 26 | ChangeStatus::ModeChanged => "X", |
|
| 26 | 27 | ChangeStatus::ConflictEdited => "C!", |
|
| 27 | - | ChangeStatus::Resolved => "R", |
|
| 28 | + | ChangeStatus::Resolved => "V", |
|
| 28 | 29 | } |
|
| 29 | 30 | } |
|
| 30 | 31 | ||
| … | 451 unchanged lines |