Partial and interactive commits (commit PATH.., commit -i with hunk closure), oxy blame from atom keys, oxy bisect over dependency-closed prefixes with run and skip (spec 0.8 sections 12.3 and 27) 957fee30826e
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 14:26 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
957fee30826e9be3fbcdea234962be2f3104ebbe4ab4395f9a169ae98ba7885b- Depends on
- 135212de5370 28fc23d7706a 4dee277c327a 4e88e774777a 50a046e439e9 694805cca585 6d3eefbce2f0 6e723b81f5b9 7b9809e67c3d 83e0677b07fe 86fe29fd62ea 94084087a876 96f7610fa666 982864068a59 b7f102fcbb17 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 232aabb5452d c2e1d09047db 69953b507188 c895bc52e4e0 7505194116e7 5b4c51307b14 236f35c0baba fddf64baa3d2 98a1620a3840 2bf22305bc48 d4dbab97e948 e266b7d4065c 073a2115995f f9466021286a 192fad1e46a2 69816d688300 57eaac9107df 86e1824f2d0b 8ab265dc4a51 6a81dbab471d e8d38e47e9d7 bc489b6c7f69 d42369abf180 75167672ebcd e02bf0cd0533 d8c086ca3eb2 c6aa5bd9d1c8 728da0f92161
- 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
- 31 atoms, 1 tree operation, 9 files
Browse the tree at this change
added crates/oxy-core/src/repo/inspect.rs +261 −0
| 1 | + | //! Inspection (spec §27): blame from atom keys, and bisect over |
|
| 2 | + | //! dependency-closed prefixes of the candidates between a good and a bad |
|
| 3 | + | //! state. |
|
| 4 | + | ||
| 5 | + | use super::*; |
|
| 6 | + | ||
| 7 | + | /// One line of `oxy blame`. |
|
| 8 | + | #[derive(Clone, Debug)] |
|
| 9 | + | pub struct BlameLine { |
|
| 10 | + | pub line: usize, |
|
| 11 | + | pub hash: ChangeHash, |
|
| 12 | + | pub index: u32, |
|
| 13 | + | pub change_id: ChangeId, |
|
| 14 | + | pub author: String, |
|
| 15 | + | pub when_ms: u64, |
|
| 16 | + | pub text: Vec<u8>, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | /// Where a bisect stands. |
|
| 20 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 21 | + | pub enum BisectStep { |
|
| 22 | + | /// More `good`/`bad` marks are needed before the search can start. |
|
| 23 | + | NeedMarks { good: usize, bad: usize }, |
|
| 24 | + | /// A state is checked out for testing. |
|
| 25 | + | Testing { heads: Vec<ChangeHash>, remaining: usize, steps_left: u32 }, |
|
| 26 | + | /// The first change whose inclusion makes the state bad. |
|
| 27 | + | Found { culprit: ChangeHash }, |
|
| 28 | + | /// Every remaining candidate was skipped. |
|
| 29 | + | Inconclusive { candidates: Vec<ChangeHash> }, |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | #[derive(Clone, Debug, Default)] |
|
| 33 | + | struct BisectState { |
|
| 34 | + | start: Vec<ChangeHash>, |
|
| 35 | + | good: Vec<ChangeHash>, |
|
| 36 | + | bad: Vec<ChangeHash>, |
|
| 37 | + | candidates: Vec<ChangeHash>, |
|
| 38 | + | lo: usize, |
|
| 39 | + | hi: usize, |
|
| 40 | + | /// The prefix length currently checked out for testing. |
|
| 41 | + | testing: Option<usize>, |
|
| 42 | + | skipped: Vec<usize>, |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | fn hashes_line(v: &[ChangeHash]) -> String { |
|
| 46 | + | v.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(" ") |
|
| 47 | + | } |
|
| 48 | + | fn hashes_from(s: &str) -> Vec<ChangeHash> { |
|
| 49 | + | s.split_whitespace().filter_map(ObjectRef::from_hex).collect() |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | impl Repo { |
|
| 53 | + | /// The introducing change, author, and date of every line of `path` in |
|
| 54 | + | /// the working copy's base state (spec §27). |
|
| 55 | + | pub fn blame(&self, path: &str) -> Result<Vec<BlameLine>> { |
|
| 56 | + | let snap = self.snapshot("oxy blame")?; |
|
| 57 | + | let Some(f) = snap.base_snapshot.files.get(path) else { return fail(format!("{path} is not in the working copy's base state")) }; |
|
| 58 | + | if !matches!(f.content, FileContent::Text { .. }) { |
|
| 59 | + | return fail(format!("{path} is not a text file")); |
|
| 60 | + | } |
|
| 61 | + | let mut out = Vec::new(); |
|
| 62 | + | for (i, ((hash, index), text)) in snap.base.live_lines(&f.node).into_iter().enumerate() { |
|
| 63 | + | let meta = snap.base.get(&hash); |
|
| 64 | + | let author = match meta { |
|
| 65 | + | Some(m) if !m.author.is_zero() => self.store.get_identity(&m.author).map(|id| id.name).unwrap_or_else(|_| m.author.short(12)), |
|
| 66 | + | Some(m) => m.message.lines().rev().find_map(|l| l.strip_prefix("Git-Author: ")).map(|a| a.split('<').next().unwrap_or(a).trim().to_string()).unwrap_or_else(|| "unknown".into()), |
|
| 67 | + | None => "unknown".into(), |
|
| 68 | + | }; |
|
| 69 | + | out.push(BlameLine { |
|
| 70 | + | line: i + 1, |
|
| 71 | + | hash, |
|
| 72 | + | index, |
|
| 73 | + | change_id: meta.map(|m| m.change_id).unwrap_or(ChangeId([0; 16])), |
|
| 74 | + | author, |
|
| 75 | + | when_ms: meta.map(|m| m.hlc.wall_ms).unwrap_or(0), |
|
| 76 | + | text, |
|
| 77 | + | }); |
|
| 78 | + | } |
|
| 79 | + | Ok(out) |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | // ------------------------------------------------------------ bisect |
|
| 83 | + | ||
| 84 | + | fn bisect_dir(&self) -> PathBuf { |
|
| 85 | + | self.oxy.join("bisect") |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | fn bisect_load(&self) -> Result<Option<BisectState>> { |
|
| 89 | + | let dir = self.bisect_dir(); |
|
| 90 | + | if !dir.join("start").exists() { |
|
| 91 | + | return Ok(None); |
|
| 92 | + | } |
|
| 93 | + | let read = |name: &str| fs::read_to_string(dir.join(name)).unwrap_or_default(); |
|
| 94 | + | let nums: Vec<usize> = read("range").split_whitespace().filter_map(|n| n.parse().ok()).collect(); |
|
| 95 | + | Ok(Some(BisectState { |
|
| 96 | + | start: hashes_from(&read("start")), |
|
| 97 | + | good: hashes_from(&read("good")), |
|
| 98 | + | bad: hashes_from(&read("bad")), |
|
| 99 | + | candidates: hashes_from(&read("candidates")), |
|
| 100 | + | lo: nums.first().copied().unwrap_or(0), |
|
| 101 | + | hi: nums.get(1).copied().unwrap_or(0), |
|
| 102 | + | testing: nums.get(2).copied().filter(|_| nums.len() > 2), |
|
| 103 | + | skipped: read("skipped").split_whitespace().filter_map(|n| n.parse().ok()).collect(), |
|
| 104 | + | })) |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | fn bisect_save(&self, st: &BisectState) -> Result<()> { |
|
| 108 | + | let dir = self.bisect_dir(); |
|
| 109 | + | fs::create_dir_all(&dir)?; |
|
| 110 | + | write_atomic(&dir.join("start"), hashes_line(&st.start).as_bytes())?; |
|
| 111 | + | write_atomic(&dir.join("good"), hashes_line(&st.good).as_bytes())?; |
|
| 112 | + | write_atomic(&dir.join("bad"), hashes_line(&st.bad).as_bytes())?; |
|
| 113 | + | write_atomic(&dir.join("candidates"), hashes_line(&st.candidates).as_bytes())?; |
|
| 114 | + | let mut range = format!("{} {}", st.lo, st.hi); |
|
| 115 | + | if let Some(t) = st.testing { |
|
| 116 | + | range.push_str(&format!(" {t}")); |
|
| 117 | + | } |
|
| 118 | + | write_atomic(&dir.join("range"), range.as_bytes())?; |
|
| 119 | + | write_atomic(&dir.join("skipped"), st.skipped.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(" ").as_bytes())?; |
|
| 120 | + | Ok(()) |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | /// Begin a bisect: remembers the working copy's base to restore later. |
|
| 124 | + | pub fn bisect_start(&self) -> Result<()> { |
|
| 125 | + | if self.bisect_load()?.is_some() { |
|
| 126 | + | return fail("a bisect is in progress; finish it or run `oxy bisect reset`"); |
|
| 127 | + | } |
|
| 128 | + | let (_, wc) = self.working_copy()?; |
|
| 129 | + | let start = self.load_state(&wc.deps)?.maximal(&wc.deps); |
|
| 130 | + | self.bisect_save(&BisectState { start, ..Default::default() }) |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | pub fn bisect_reset(&self) -> Result<()> { |
|
| 134 | + | let Some(st) = self.bisect_load()? else { return fail("no bisect in progress") }; |
|
| 135 | + | self.checkout(&st.start, false, "oxy bisect reset")?; |
|
| 136 | + | fs::remove_dir_all(self.bisect_dir())?; |
|
| 137 | + | Ok(()) |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | pub fn bisect_status(&self) -> Result<Option<BisectStep>> { |
|
| 141 | + | let Some(st) = self.bisect_load()? else { return Ok(None) }; |
|
| 142 | + | Ok(Some(self.bisect_step(&st))) |
|
| 143 | + | } |
|
| 144 | + | ||
| 145 | + | fn bisect_step(&self, st: &BisectState) -> BisectStep { |
|
| 146 | + | if st.candidates.is_empty() { |
|
| 147 | + | return BisectStep::NeedMarks { good: st.good.len(), bad: st.bad.len() }; |
|
| 148 | + | } |
|
| 149 | + | if st.hi <= st.lo + 1 { |
|
| 150 | + | return BisectStep::Found { culprit: st.candidates[st.lo] }; |
|
| 151 | + | } |
|
| 152 | + | match st.testing { |
|
| 153 | + | Some(_) => { |
|
| 154 | + | let remaining = st.hi - st.lo; |
|
| 155 | + | BisectStep::Testing { heads: self.bisect_heads(st, st.testing.unwrap()), remaining, steps_left: (remaining as f64).log2().ceil() as u32 } |
|
| 156 | + | } |
|
| 157 | + | None => BisectStep::Inconclusive { candidates: st.candidates[st.lo..st.hi].to_vec() }, |
|
| 158 | + | } |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | /// `good ∪ candidates[..k]`, as heads. |
|
| 162 | + | fn bisect_heads(&self, st: &BisectState, k: usize) -> Vec<ChangeHash> { |
|
| 163 | + | let mut all = st.good.clone(); |
|
| 164 | + | all.extend(st.candidates[..k].iter().copied()); |
|
| 165 | + | self.load_state(&all).map(|s| s.maximal(&all)).unwrap_or(all) |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | /// Check out the midpoint of the open range, skipping indices marked |
|
| 169 | + | /// untestable, and record it as the state under test. |
|
| 170 | + | fn bisect_advance(&self, st: &mut BisectState) -> Result<BisectStep> { |
|
| 171 | + | if st.hi <= st.lo + 1 { |
|
| 172 | + | st.testing = None; |
|
| 173 | + | self.bisect_save(st)?; |
|
| 174 | + | return Ok(BisectStep::Found { culprit: st.candidates[st.lo] }); |
|
| 175 | + | } |
|
| 176 | + | let mid = st.lo + (st.hi - st.lo) / 2; |
|
| 177 | + | // Try mid, then the nearest untried indices on either side. |
|
| 178 | + | let mut order = vec![mid]; |
|
| 179 | + | for d in 1..(st.hi - st.lo) { |
|
| 180 | + | if mid + d < st.hi { |
|
| 181 | + | order.push(mid + d); |
|
| 182 | + | } |
|
| 183 | + | if mid > st.lo + d { |
|
| 184 | + | order.push(mid - d); |
|
| 185 | + | } |
|
| 186 | + | } |
|
| 187 | + | let Some(k) = order.into_iter().find(|k| !st.skipped.contains(k)) else { |
|
| 188 | + | st.testing = None; |
|
| 189 | + | self.bisect_save(st)?; |
|
| 190 | + | return Ok(BisectStep::Inconclusive { candidates: st.candidates[st.lo..st.hi].to_vec() }); |
|
| 191 | + | }; |
|
| 192 | + | let heads = self.bisect_heads(st, k); |
|
| 193 | + | self.checkout(&heads, false, &format!("oxy bisect (testing {k} of {})", st.candidates.len()))?; |
|
| 194 | + | st.testing = Some(k); |
|
| 195 | + | self.bisect_save(st)?; |
|
| 196 | + | let remaining = st.hi - st.lo; |
|
| 197 | + | Ok(BisectStep::Testing { heads, remaining, steps_left: (remaining as f64).log2().ceil() as u32 }) |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | /// Mark states good or bad. With `changes`, before the search starts, |
|
| 201 | + | /// they name known-good or known-bad states; without, the state under |
|
| 202 | + | /// test gets the verdict. `None` for `good` means skip (untestable). |
|
| 203 | + | pub fn bisect_mark(&self, good: Option<bool>, changes: &[ChangeHash]) -> Result<BisectStep> { |
|
| 204 | + | let Some(mut st) = self.bisect_load()? else { return fail("no bisect in progress; run `oxy bisect start`") }; |
|
| 205 | + | if !changes.is_empty() { |
|
| 206 | + | if !st.candidates.is_empty() { |
|
| 207 | + | return fail("the search has started; mark the state under test with plain `oxy bisect good|bad`"); |
|
| 208 | + | } |
|
| 209 | + | match good { |
|
| 210 | + | Some(true) => st.good.extend(changes.iter().copied()), |
|
| 211 | + | Some(false) => st.bad.extend(changes.iter().copied()), |
|
| 212 | + | None => return fail("skip applies to the state under test"), |
|
| 213 | + | } |
|
| 214 | + | if st.good.is_empty() || st.bad.is_empty() { |
|
| 215 | + | self.bisect_save(&st)?; |
|
| 216 | + | return Ok(BisectStep::NeedMarks { good: st.good.len(), bad: st.bad.len() }); |
|
| 217 | + | } |
|
| 218 | + | let good_heads = self.load_state(&st.good)?.maximal(&st.good); |
|
| 219 | + | let bad_heads = self.load_state(&st.bad)?.maximal(&st.bad); |
|
| 220 | + | st.good = good_heads.clone(); |
|
| 221 | + | st.bad = bad_heads.clone(); |
|
| 222 | + | st.candidates = self.negotiate(&good_heads, &bad_heads)?; |
|
| 223 | + | if st.candidates.is_empty() { |
|
| 224 | + | return fail("the bad state adds nothing to the good state; nothing to bisect"); |
|
| 225 | + | } |
|
| 226 | + | st.lo = 0; |
|
| 227 | + | st.hi = st.candidates.len(); |
|
| 228 | + | return self.bisect_advance(&mut st); |
|
| 229 | + | } |
|
| 230 | + | let Some(k) = st.testing else { |
|
| 231 | + | return fail("no state is under test; give `oxy bisect good|bad` a change or bookmark to start with"); |
|
| 232 | + | }; |
|
| 233 | + | match good { |
|
| 234 | + | Some(true) => st.lo = k, |
|
| 235 | + | Some(false) => st.hi = k, |
|
| 236 | + | None => st.skipped.push(k), |
|
| 237 | + | } |
|
| 238 | + | self.bisect_advance(&mut st) |
|
| 239 | + | } |
|
| 240 | + | ||
| 241 | + | /// Automate with a command run in the working copy: exit 0 is good, 125 |
|
| 242 | + | /// skips, anything else is bad. |
|
| 243 | + | pub fn bisect_run(&self, command: &[String]) -> Result<BisectStep> { |
|
| 244 | + | let Some(st) = self.bisect_load()? else { return fail("no bisect in progress; run `oxy bisect start`") }; |
|
| 245 | + | if st.testing.is_none() { |
|
| 246 | + | return Ok(self.bisect_step(&st)); |
|
| 247 | + | } |
|
| 248 | + | loop { |
|
| 249 | + | let status = std::process::Command::new(&command[0]).args(&command[1..]).current_dir(&self.root).status().map_err(|e| RepoError::Msg(format!("running {}: {e}", command[0])))?; |
|
| 250 | + | let verdict = match status.code() { |
|
| 251 | + | Some(0) => Some(true), |
|
| 252 | + | Some(125) => None, |
|
| 253 | + | _ => Some(false), |
|
| 254 | + | }; |
|
| 255 | + | match self.bisect_mark(verdict, &[])? { |
|
| 256 | + | BisectStep::Testing { .. } => continue, |
|
| 257 | + | done => return Ok(done), |
|
| 258 | + | } |
|
| 259 | + | } |
|
| 260 | + | } |
|
| 261 | + | } |
modified crates/oxy-core/src/repo/mod.rs +3 −1
| … | 61 unchanged lines |
||
| 62 | 62 | mod git; |
|
| 63 | 63 | mod history; |
|
| 64 | 64 | mod hooks; |
|
| 65 | + | mod inspect; |
|
| 65 | 66 | mod identity; |
|
| 66 | 67 | mod policy; |
|
| 67 | 68 | mod prompt; |
|
| … | 10 unchanged lines |
||
| 78 | 79 | pub use config::{parse_size, read_config_file, Config}; |
|
| 79 | 80 | pub use gc::GcReport; |
|
| 80 | 81 | pub use hooks::{json_array, json_str, HookOutcome}; |
|
| 82 | + | pub use inspect::{BisectStep, BlameLine}; |
|
| 81 | 83 | pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT}; |
|
| 82 | 84 | pub use git::{GitExportReport, GitImportReport}; |
|
| 83 | 85 | pub use identity::{Rotation, SignatureStatus}; |
|
| 84 | 86 | pub use policy::PolicyUpdate; |
|
| 85 | - | pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, similarity, ChangeStatus, PathStatus, SnapshotResult}; |
|
| 87 | + | pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, render_hunks, similarity, ChangeStatus, Hunk, PathStatus, Selection, SnapshotResult}; |
|
| 86 | 88 | pub use sync::{FetchReport, PullReport}; |
|
| 87 | 89 | pub use tags::TagInfo; |
|
| 88 | 90 | pub use workspace::{SnapshotLock, WorkspaceInfo}; |
|
| … | 789 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +235 −5
| 1 | 1 | //! Working-copy snapshots, commits, and the change builder (spec §12.3). |
|
| 2 | 2 | ||
| 3 | 3 | use super::*; |
|
| 4 | + | use crate::objects::AtomRef; |
|
| 4 | 5 | ||
| 5 | 6 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 6 | 7 | pub enum ChangeStatus { |
|
| … | 240 unchanged lines |
||
| 247 | 248 | ||
| 248 | 249 | /// Turn the working-copy change into a normal change on the bookmark (spec §12.3). |
|
| 249 | 250 | pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result<ChangeHash> { |
|
| 251 | + | self.commit_selection(message, extra_deps, &Selection::All) |
|
| 252 | + | } |
|
| 253 | + | ||
| 254 | + | /// The hunks of the working copy, for `oxy commit --interactive`: each |
|
| 255 | + | /// tree operation and each edit, in the order `select_hunks` numbers them. |
|
| 256 | + | pub fn hunks(&self) -> Result<(SnapshotResult, Vec<Hunk>)> { |
|
| 257 | + | let snap = self.snapshot("oxy commit")?; |
|
| 258 | + | let hunks = render_hunks(&snap.wc, &snap.base, &snap.base_snapshot); |
|
| 259 | + | Ok((snap, hunks)) |
|
| 260 | + | } |
|
| 261 | + | ||
| 262 | + | /// Commit part of the working copy (spec §12.3). What is not selected |
|
| 263 | + | /// stays on disk as uncommitted work: the next snapshot rebuilds it |
|
| 264 | + | /// against the new base, so the working copy always describes the |
|
| 265 | + | /// directory. |
|
| 266 | + | pub fn commit_selection(&self, message: &str, extra_deps: &[ChangeHash], selection: &Selection) -> Result<ChangeHash> { |
|
| 250 | 267 | let snap = self.snapshot("oxy commit")?; |
|
| 251 | 268 | if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() { |
|
| 252 | 269 | return fail("nothing to commit: the working copy matches its base"); |
|
| … | 4 unchanged lines |
||
| 257 | 274 | if message.trim().is_empty() { |
|
| 258 | 275 | return fail("a commit message is required"); |
|
| 259 | 276 | } |
|
| 277 | + | let (tree_ops, edits, partial) = match selection { |
|
| 278 | + | Selection::All => (snap.wc.tree_ops.clone(), snap.wc.edits.clone(), false), |
|
| 279 | + | Selection::Paths(paths) => { |
|
| 280 | + | let (o, e) = select_paths(&snap.wc, &snap.base_snapshot, paths); |
|
| 281 | + | let partial = o.len() != snap.wc.tree_ops.len() || e.len() != snap.wc.edits.len(); |
|
| 282 | + | (o, e, partial) |
|
| 283 | + | } |
|
| 284 | + | Selection::Hunks(indices) => { |
|
| 285 | + | let (o, e) = select_hunks(&snap.wc, indices); |
|
| 286 | + | let partial = o.len() != snap.wc.tree_ops.len() || e.len() != snap.wc.edits.len(); |
|
| 287 | + | (o, e, partial) |
|
| 288 | + | } |
|
| 289 | + | }; |
|
| 290 | + | if tree_ops.is_empty() && edits.is_empty() { |
|
| 291 | + | return fail("nothing selected to commit"); |
|
| 292 | + | } |
|
| 293 | + | if partial && snap.resolves { |
|
| 294 | + | return fail("a conflict resolution must be committed whole"); |
|
| 295 | + | } |
|
| 260 | 296 | // pre-commit hook: may veto. |
|
| 261 | 297 | self.run_hook( |
|
| 262 | 298 | "pre-commit", |
|
| … | 41 unchanged lines |
||
| 304 | 340 | deps.insert(*d); |
|
| 305 | 341 | } |
|
| 306 | 342 | let mut change = Change { |
|
| 307 | - | change_id: snap.wc.change_id, |
|
| 308 | - | deps: deps.into_iter().collect(), |
|
| 343 | + | change_id: if partial { ChangeId::random() } else { snap.wc.change_id }, |
|
| 344 | + | deps: vec![], |
|
| 309 | 345 | author: self.author(), |
|
| 310 | 346 | hlc: Hlc::now(), |
|
| 311 | 347 | message: message.trim_end().to_string(), |
|
| 312 | - | tree_ops: snap.wc.tree_ops.clone(), |
|
| 313 | - | edits: snap.wc.edits.clone(), |
|
| 348 | + | tree_ops, |
|
| 349 | + | edits, |
|
| 314 | 350 | kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal }, |
|
| 315 | 351 | }; |
|
| 352 | + | if partial { |
|
| 353 | + | // Dependencies come from what the selected part references, not |
|
| 354 | + | // from the whole working copy. |
|
| 355 | + | deps = referenced_changes(&change, &snap.base); |
|
| 356 | + | for d in extra_deps { |
|
| 357 | + | deps.insert(*d); |
|
| 358 | + | } |
|
| 359 | + | } |
|
| 360 | + | change.deps = deps.into_iter().collect(); |
|
| 316 | 361 | change.normalize(); |
|
| 317 | 362 | let hash = self.store_signed(&change)?; |
|
| 318 | 363 | ||
| … | 27 unchanged lines |
||
| 346 | 391 | view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?); |
|
| 347 | 392 | view.workspaces.insert(self.workspace, wc_hash); |
|
| 348 | 393 | self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?; |
|
| 349 | - | self.rewrite_manifest_flags(snap.base_snapshot.is_conflicted() && !snap.resolves, false)?; |
|
| 394 | + | if partial { |
|
| 395 | + | // The rest is rebuilt from disk by the next snapshot. |
|
| 396 | + | self.drop_manifest(); |
|
| 397 | + | } else { |
|
| 398 | + | self.rewrite_manifest_flags(snap.base_snapshot.is_conflicted() && !snap.resolves, false)?; |
|
| 399 | + | } |
|
| 350 | 400 | self.maybe_autopack()?; |
|
| 351 | 401 | let _ = self.run_hook("post-commit", &format!("{{\"event\":\"post-commit\",\"change_id\":{},\"hash\":{},\"message\":{}}}", json_str(&snap.wc.change_id.to_hex()), json_str(&hash.to_hex()), json_str(message))); |
|
| 352 | 402 | Ok(hash) |
|
| 353 | 403 | } |
|
| 354 | 404 | } |
|
| 355 | 405 | ||
| 406 | + | /// What part of the working copy a commit takes (spec §12.3). |
|
| 407 | + | #[derive(Clone, Debug)] |
|
| 408 | + | pub enum Selection { |
|
| 409 | + | All, |
|
| 410 | + | /// Every operation and edit on nodes under these paths. |
|
| 411 | + | Paths(Vec<String>), |
|
| 412 | + | /// Hunk indices as `Repo::hunks` numbers them; dependent hunks are added. |
|
| 413 | + | Hunks(Vec<usize>), |
|
| 414 | + | } |
|
| 415 | + | ||
| 416 | + | /// One selectable piece of the working copy: a tree operation or an edit. |
|
| 417 | + | #[derive(Clone, Debug)] |
|
| 418 | + | pub struct Hunk { |
|
| 419 | + | pub index: usize, |
|
| 420 | + | pub path: String, |
|
| 421 | + | /// Rendered like `oxy diff`. |
|
| 422 | + | pub text: String, |
|
| 423 | + | } |
|
| 424 | + | ||
| 425 | + | /// The tree operations and edits on nodes under `paths`, plus the |
|
| 426 | + | /// additions of any new parent directories they need. |
|
| 427 | + | fn select_paths(wc: &Change, base_snapshot: &Snapshot, paths: &[String]) -> (Vec<TreeOp>, Vec<Edit>) { |
|
| 428 | + | let selected = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/')))); |
|
| 429 | + | let by_node = base_snapshot.tree.by_node(); |
|
| 430 | + | let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new(); |
|
| 431 | + | for op in &wc.tree_ops { |
|
| 432 | + | if let TreeOp::AddNode { node, parent, name, kind, .. } = op { |
|
| 433 | + | new_names.insert(*node, (*parent, name.clone(), *kind)); |
|
| 434 | + | } |
|
| 435 | + | } |
|
| 436 | + | let mut nodes: BTreeSet<NodeId> = BTreeSet::new(); |
|
| 437 | + | for n in wc.tree_ops.iter().map(|o| o.node()).chain(wc.edits.iter().map(|e| e.node())) { |
|
| 438 | + | if path_of_node(&n, &by_node, &new_names).is_some_and(|p| selected(&p)) { |
|
| 439 | + | nodes.insert(n); |
|
| 440 | + | } |
|
| 441 | + | } |
|
| 442 | + | // New parents of selected new nodes come along. |
|
| 443 | + | let mut changed = true; |
|
| 444 | + | while changed { |
|
| 445 | + | changed = false; |
|
| 446 | + | for (n, (parent, _, _)) in &new_names { |
|
| 447 | + | if nodes.contains(n) && new_names.contains_key(parent) && nodes.insert(*parent) { |
|
| 448 | + | changed = true; |
|
| 449 | + | } |
|
| 450 | + | } |
|
| 451 | + | } |
|
| 452 | + | let ops = wc.tree_ops.iter().filter(|o| nodes.contains(&o.node())).cloned().collect(); |
|
| 453 | + | let edits = wc.edits.iter().filter(|e| nodes.contains(&e.node())).cloned().collect(); |
|
| 454 | + | (ops, edits) |
|
| 455 | + | } |
|
| 456 | + | ||
| 457 | + | /// The hunks `indices` name plus whatever they refer to within the working |
|
| 458 | + | /// copy (the parent directory of an added node, the insert that introduced |
|
| 459 | + | /// an anchor line), renumbered so the result is a well-formed change. |
|
| 460 | + | fn select_hunks(wc: &Change, indices: &[usize]) -> (Vec<TreeOp>, Vec<Edit>) { |
|
| 461 | + | let n_ops = wc.tree_ops.len(); |
|
| 462 | + | let mut ops_sel = vec![false; n_ops]; |
|
| 463 | + | let mut edits_sel = vec![false; wc.edits.len()]; |
|
| 464 | + | for &i in indices { |
|
| 465 | + | if i < n_ops { |
|
| 466 | + | ops_sel[i] = true; |
|
| 467 | + | } else if i - n_ops < wc.edits.len() { |
|
| 468 | + | edits_sel[i - n_ops] = true; |
|
| 469 | + | } |
|
| 470 | + | } |
|
| 471 | + | // Atom ranges each insert introduces, per node, in the working copy's numbering. |
|
| 472 | + | let mut per_node: HashMap<NodeId, u32> = HashMap::new(); |
|
| 473 | + | let mut insert_ranges: Vec<Option<(NodeId, u32, u32)>> = Vec::new(); |
|
| 474 | + | for e in &wc.edits { |
|
| 475 | + | insert_ranges.push(match e { |
|
| 476 | + | Edit::Insert { node, lines, .. } => { |
|
| 477 | + | let start = per_node.entry(*node).or_default(); |
|
| 478 | + | let r = (*node, *start, *start + lines.len() as u32); |
|
| 479 | + | *start += lines.len() as u32; |
|
| 480 | + | Some(r) |
|
| 481 | + | } |
|
| 482 | + | _ => None, |
|
| 483 | + | }); |
|
| 484 | + | } |
|
| 485 | + | let add_index: HashMap<NodeId, usize> = wc.tree_ops.iter().enumerate().filter_map(|(i, o)| if let TreeOp::AddNode { node, .. } = o { Some((*node, i)) } else { None }).collect(); |
|
| 486 | + | let insert_for = |node: &NodeId, index: u32| -> Option<usize> { insert_ranges.iter().position(|r| matches!(r, Some((n, a, b)) if n == node && *a <= index && index < *b)) }; |
|
| 487 | + | // Closure. |
|
| 488 | + | let mut changed = true; |
|
| 489 | + | while changed { |
|
| 490 | + | changed = false; |
|
| 491 | + | for i in 0..n_ops { |
|
| 492 | + | if !ops_sel[i] { |
|
| 493 | + | continue; |
|
| 494 | + | } |
|
| 495 | + | if let TreeOp::AddNode { parent, .. } = &wc.tree_ops[i] { |
|
| 496 | + | if let Some(&p) = add_index.get(parent) { |
|
| 497 | + | if !ops_sel[p] { |
|
| 498 | + | ops_sel[p] = true; |
|
| 499 | + | changed = true; |
|
| 500 | + | } |
|
| 501 | + | } |
|
| 502 | + | } |
|
| 503 | + | } |
|
| 504 | + | for j in 0..wc.edits.len() { |
|
| 505 | + | if !edits_sel[j] { |
|
| 506 | + | continue; |
|
| 507 | + | } |
|
| 508 | + | let e = &wc.edits[j]; |
|
| 509 | + | if let Some(&a) = add_index.get(&e.node()) { |
|
| 510 | + | if !ops_sel[a] { |
|
| 511 | + | ops_sel[a] = true; |
|
| 512 | + | changed = true; |
|
| 513 | + | } |
|
| 514 | + | } |
|
| 515 | + | if let Edit::Insert { node, after, before, .. } = e { |
|
| 516 | + | for r in [after, before] { |
|
| 517 | + | if let AtomRef::Atom { change, index } = r { |
|
| 518 | + | if change.is_zero() { |
|
| 519 | + | if let Some(k) = insert_for(node, *index) { |
|
| 520 | + | if !edits_sel[k] { |
|
| 521 | + | edits_sel[k] = true; |
|
| 522 | + | changed = true; |
|
| 523 | + | } |
|
| 524 | + | } |
|
| 525 | + | } |
|
| 526 | + | } |
|
| 527 | + | } |
|
| 528 | + | } |
|
| 529 | + | } |
|
| 530 | + | } |
|
| 531 | + | // Renumber self-references: old atom index → new index among selected inserts. |
|
| 532 | + | let mut remap: HashMap<(NodeId, u32), u32> = HashMap::new(); |
|
| 533 | + | let mut new_counts: HashMap<NodeId, u32> = HashMap::new(); |
|
| 534 | + | for (j, r) in insert_ranges.iter().enumerate() { |
|
| 535 | + | if let (true, Some((node, a, b))) = (edits_sel[j], r) { |
|
| 536 | + | let next = new_counts.entry(*node).or_default(); |
|
| 537 | + | for old in *a..*b { |
|
| 538 | + | remap.insert((*node, old), *next); |
|
| 539 | + | *next += 1; |
|
| 540 | + | } |
|
| 541 | + | } |
|
| 542 | + | } |
|
| 543 | + | let map_ref = |r: AtomRef, node: &NodeId| -> AtomRef { |
|
| 544 | + | match r { |
|
| 545 | + | AtomRef::Atom { change, index } if change.is_zero() => AtomRef::Atom { change, index: remap.get(&(*node, index)).copied().unwrap_or(index) }, |
|
| 546 | + | other => other, |
|
| 547 | + | } |
|
| 548 | + | }; |
|
| 549 | + | let ops = wc.tree_ops.iter().enumerate().filter(|(i, _)| ops_sel[*i]).map(|(_, o)| o.clone()).collect(); |
|
| 550 | + | let edits = wc |
|
| 551 | + | .edits |
|
| 552 | + | .iter() |
|
| 553 | + | .enumerate() |
|
| 554 | + | .filter(|(j, _)| edits_sel[*j]) |
|
| 555 | + | .map(|(_, e)| match e { |
|
| 556 | + | Edit::Insert { node, after, before, lines } => Edit::Insert { node: *node, after: map_ref(*after, node), before: map_ref(*before, node), lines: lines.clone() }, |
|
| 557 | + | other => other.clone(), |
|
| 558 | + | }) |
|
| 559 | + | .collect(); |
|
| 560 | + | (ops, edits) |
|
| 561 | + | } |
|
| 562 | + | ||
| 563 | + | /// Render each tree operation and edit of `change` on its own, numbered. |
|
| 564 | + | pub fn render_hunks(change: &Change, base: &State, base_snapshot: &Snapshot) -> Vec<Hunk> { |
|
| 565 | + | let by_node = base_snapshot.tree.by_node(); |
|
| 566 | + | let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new(); |
|
| 567 | + | for op in &change.tree_ops { |
|
| 568 | + | if let TreeOp::AddNode { node, parent, name, kind, .. } = op { |
|
| 569 | + | new_names.insert(*node, (*parent, name.clone(), *kind)); |
|
| 570 | + | } |
|
| 571 | + | } |
|
| 572 | + | let mut out = Vec::new(); |
|
| 573 | + | for (i, op) in change.tree_ops.iter().enumerate() { |
|
| 574 | + | let one = Change { tree_ops: vec![op.clone()], edits: vec![], ..change.clone() }; |
|
| 575 | + | let path = path_of_node(&op.node(), &by_node, &new_names).unwrap_or_default(); |
|
| 576 | + | out.push(Hunk { index: i, path, text: render_change(&one, base, base_snapshot) }); |
|
| 577 | + | } |
|
| 578 | + | for (j, e) in change.edits.iter().enumerate() { |
|
| 579 | + | let one = Change { tree_ops: vec![], edits: vec![e.clone()], ..change.clone() }; |
|
| 580 | + | let path = path_of_node(&e.node(), &by_node, &new_names).unwrap_or_default(); |
|
| 581 | + | out.push(Hunk { index: change.tree_ops.len() + j, path, text: render_change(&one, base, base_snapshot) }); |
|
| 582 | + | } |
|
| 583 | + | out |
|
| 584 | + | } |
|
| 585 | + | ||
| 356 | 586 | /// Builds the tree ops and edits of a working-copy change against a base. |
|
| 357 | 587 | struct ChangeBuilder<'a> { |
|
| 358 | 588 | base: &'a State, |
|
| … | 520 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +140 −0
| 1 | 1 | //! Tests for the repository layer. |
|
| 2 | 2 | ||
| 3 | 3 | use super::*; |
|
| 4 | + | use crate::repo::{BisectStep, Selection}; |
|
| 4 | 5 | use crate::protocol::SignedPolicy; |
|
| 5 | 6 | ||
| 6 | 7 | /// Point `$OXY_HOME` at a throwaway directory once per test process so no |
|
| … | 2306 unchanged lines |
||
| 2313 | 2314 | assert_eq!(git_in(&mirror, &["rev-parse", "main^{tree}"]), orig_tree, "tree round trip"); |
|
| 2314 | 2315 | } |
|
| 2315 | 2316 | ||
| 2317 | + | #[test] |
|
| 2318 | + | fn partial_commits_by_path_and_by_hunk() { |
|
| 2319 | + | hermetic(); |
|
| 2320 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 2321 | + | let root = dir.path(); |
|
| 2322 | + | let repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 2323 | + | write(root, "a.txt", b"1\n2\n3\n"); |
|
| 2324 | + | write(root, "b.txt", b"b\n"); |
|
| 2325 | + | let h0 = repo.commit("base", &[]).unwrap(); |
|
| 2326 | + | write(root, "a.txt", b"0\n1\n2\n3\n4\n"); |
|
| 2327 | + | write(root, "b.txt", b"b\nbb\n"); |
|
| 2328 | + | write(root, "c.txt", b"c\n"); |
|
| 2329 | + | // By path: only a.txt goes in; the rest stays on disk as uncommitted work. |
|
| 2330 | + | let h1 = repo.commit_selection("a only", &[], &Selection::Paths(vec!["a.txt".into()])).unwrap(); |
|
| 2331 | + | let c1 = repo.store().get_change(&h1).unwrap(); |
|
| 2332 | + | assert!(c1.tree_ops.is_empty() && c1.edits.iter().all(|e| e.node() == c1.edits[0].node()), "{:?}", c1.tree_ops); |
|
| 2333 | + | assert_eq!(c1.deps, vec![h0]); |
|
| 2334 | + | let files = repo.load_state(&[h1]).unwrap().materialize().files; |
|
| 2335 | + | assert!(matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n")); |
|
| 2336 | + | assert!(matches!(&files["b.txt"].content, FileContent::Text { bytes, .. } if bytes == b"b\n")); |
|
| 2337 | + | assert!(!files.contains_key("c.txt")); |
|
| 2338 | + | let snap = repo.snapshot("status").unwrap(); |
|
| 2339 | + | assert_eq!(snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect::<Vec<_>>(), vec![("b.txt", ChangeStatus::Modified), ("c.txt", ChangeStatus::Added)]); |
|
| 2340 | + | assert_eq!(fs::read(root.join("b.txt")).unwrap(), b"b\nbb\n", "disk untouched"); |
|
| 2341 | + | // By hunk: pick the b.txt insert; c.txt's addition is left. |
|
| 2342 | + | let (_, hunks) = repo.hunks().unwrap(); |
|
| 2343 | + | let b_hunk = hunks.iter().find(|h| h.path == "b.txt" && h.text.contains("+bb")).unwrap().index; |
|
| 2344 | + | let h2 = repo.commit_selection("b only", &[], &Selection::Hunks(vec![b_hunk])).unwrap(); |
|
| 2345 | + | let files = repo.load_state(&[h2]).unwrap().materialize().files; |
|
| 2346 | + | assert!(matches!(&files["b.txt"].content, FileContent::Text { bytes, .. } if bytes == b"b\nbb\n")); |
|
| 2347 | + | assert!(!files.contains_key("c.txt")); |
|
| 2348 | + | let snap = repo.snapshot("status").unwrap(); |
|
| 2349 | + | assert_eq!(snap.statuses.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), vec!["c.txt"]); |
|
| 2350 | + | // Choosing only the content hunk of a file in a new directory pulls in |
|
| 2351 | + | // the directory and file additions it needs. |
|
| 2352 | + | write(root, "d/e.txt", b"e\n"); |
|
| 2353 | + | let (_, hunks) = repo.hunks().unwrap(); |
|
| 2354 | + | let e_insert = hunks.iter().find(|h| h.path == "d/e.txt" && h.text.contains("+e")).unwrap().index; |
|
| 2355 | + | let h3 = repo.commit_selection("e only", &[], &Selection::Hunks(vec![e_insert])).unwrap(); |
|
| 2356 | + | let c3 = repo.store().get_change(&h3).unwrap(); |
|
| 2357 | + | assert_eq!(c3.tree_ops.len(), 2, "{:?}", c3.tree_ops); |
|
| 2358 | + | let files = repo.load_state(&[h3]).unwrap().materialize().files; |
|
| 2359 | + | assert!(files.contains_key("d/e.txt") && !files.contains_key("c.txt")); |
|
| 2360 | + | // Everything left commits normally; the working copy is then clean. |
|
| 2361 | + | repo.commit("the rest", &[]).unwrap(); |
|
| 2362 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 2363 | + | let files = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize().files; |
|
| 2364 | + | assert!(files.contains_key("c.txt")); |
|
| 2365 | + | } |
|
| 2366 | + | ||
| 2367 | + | #[test] |
|
| 2368 | + | fn blame_attributes_lines_to_introducing_changes_across_renames() { |
|
| 2369 | + | hermetic(); |
|
| 2370 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 2371 | + | let root = dir.path(); |
|
| 2372 | + | let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 2373 | + | repo.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap(); |
|
| 2374 | + | write(root, "a.txt", b"one\n"); |
|
| 2375 | + | let h1 = repo.commit("first", &[]).unwrap(); |
|
| 2376 | + | write(root, "a.txt", b"one\ntwo\n"); |
|
| 2377 | + | let h2 = repo.commit("second", &[]).unwrap(); |
|
| 2378 | + | let b = repo.blame("a.txt").unwrap(); |
|
| 2379 | + | assert_eq!(b.iter().map(|l| (l.line, l.hash, l.text.as_slice())).collect::<Vec<_>>(), vec![(1, h1, b"one\n".as_slice()), (2, h2, b"two\n".as_slice())]); |
|
| 2380 | + | assert!(b.iter().all(|l| l.author == "Ada")); |
|
| 2381 | + | assert_eq!(b[0].change_id, repo.store().get_change(&h1).unwrap().change_id); |
|
| 2382 | + | fs::rename(root.join("a.txt"), root.join("b.txt")).unwrap(); |
|
| 2383 | + | repo.commit("rename", &[]).unwrap(); |
|
| 2384 | + | let b = repo.blame("b.txt").unwrap(); |
|
| 2385 | + | assert_eq!(b.iter().map(|l| l.hash).collect::<Vec<_>>(), vec![h1, h2], "the node survives the rename"); |
|
| 2386 | + | assert!(repo.blame("a.txt").is_err()); |
|
| 2387 | + | } |
|
| 2388 | + | ||
| 2389 | + | #[test] |
|
| 2390 | + | fn bisect_finds_the_first_bad_change() { |
|
| 2391 | + | hermetic(); |
|
| 2392 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 2393 | + | let root = dir.path(); |
|
| 2394 | + | let repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 2395 | + | let mut content = String::new(); |
|
| 2396 | + | let mut hashes = Vec::new(); |
|
| 2397 | + | for i in 1..=8 { |
|
| 2398 | + | content.push_str(&format!("line {i}{}\n", if i == 5 { " bug" } else { "" })); |
|
| 2399 | + | write(root, "f.txt", content.as_bytes()); |
|
| 2400 | + | hashes.push(repo.commit(&format!("step {i}"), &[]).unwrap()); |
|
| 2401 | + | } |
|
| 2402 | + | // Each step appends after the previous line, so the chain is linear. |
|
| 2403 | + | assert_eq!(repo.bookmark_heads().unwrap(), vec![hashes[7]]); |
|
| 2404 | + | repo.bisect_start().unwrap(); |
|
| 2405 | + | assert!(matches!(repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(), BisectStep::NeedMarks { good: 1, bad: 0 })); |
|
| 2406 | + | let step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap(); |
|
| 2407 | + | assert!(matches!(step, BisectStep::Testing { remaining: 7, .. }), "{step:?}"); |
|
| 2408 | + | // Automated: bad when the bug line is present. |
|
| 2409 | + | let step = repo.bisect_run(&["sh".into(), "-c".into(), "! grep -q bug f.txt".into()]).unwrap(); |
|
| 2410 | + | assert_eq!(step, BisectStep::Found { culprit: hashes[4] }); |
|
| 2411 | + | assert_eq!(repo.bisect_status().unwrap(), Some(BisectStep::Found { culprit: hashes[4] })); |
|
| 2412 | + | repo.bisect_reset().unwrap(); |
|
| 2413 | + | assert_eq!(fs::read_to_string(root.join("f.txt")).unwrap(), content); |
|
| 2414 | + | assert!(repo.bisect_status().unwrap().is_none()); |
|
| 2415 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 2416 | + | // Manual marks and a skip. |
|
| 2417 | + | repo.bisect_start().unwrap(); |
|
| 2418 | + | repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(); |
|
| 2419 | + | let mut step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap(); |
|
| 2420 | + | let mut skipped_once = false; |
|
| 2421 | + | let mut guard = 0; |
|
| 2422 | + | while let BisectStep::Testing { .. } = step { |
|
| 2423 | + | guard += 1; |
|
| 2424 | + | assert!(guard < 20); |
|
| 2425 | + | let text = fs::read_to_string(root.join("f.txt")).unwrap(); |
|
| 2426 | + | if !skipped_once && text.contains("line 6") { |
|
| 2427 | + | // An untestable state away from the boundary: the search routes around it. |
|
| 2428 | + | skipped_once = true; |
|
| 2429 | + | step = repo.bisect_mark(None, &[]).unwrap(); |
|
| 2430 | + | continue; |
|
| 2431 | + | } |
|
| 2432 | + | step = repo.bisect_mark(Some(!text.contains("bug")), &[]).unwrap(); |
|
| 2433 | + | } |
|
| 2434 | + | assert!(skipped_once); |
|
| 2435 | + | assert_eq!(step, BisectStep::Found { culprit: hashes[4] }); |
|
| 2436 | + | repo.bisect_reset().unwrap(); |
|
| 2437 | + | // Skipping the culprit itself leaves it among the candidates reported. |
|
| 2438 | + | repo.bisect_start().unwrap(); |
|
| 2439 | + | repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(); |
|
| 2440 | + | let mut step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap(); |
|
| 2441 | + | let mut guard = 0; |
|
| 2442 | + | while let BisectStep::Testing { heads, .. } = &step { |
|
| 2443 | + | guard += 1; |
|
| 2444 | + | assert!(guard < 20); |
|
| 2445 | + | let text = fs::read_to_string(root.join("f.txt")).unwrap(); |
|
| 2446 | + | let is_culprit_state = heads.contains(&hashes[4]) && !heads.iter().any(|h| hashes[5..].contains(h)); |
|
| 2447 | + | step = if is_culprit_state { repo.bisect_mark(None, &[]).unwrap() } else { repo.bisect_mark(Some(!text.contains("bug")), &[]).unwrap() }; |
|
| 2448 | + | } |
|
| 2449 | + | match step { |
|
| 2450 | + | BisectStep::Inconclusive { candidates } => assert!(candidates.contains(&hashes[4]) && candidates.len() <= 2, "{candidates:?}"), |
|
| 2451 | + | other => panic!("{other:?}"), |
|
| 2452 | + | } |
|
| 2453 | + | repo.bisect_reset().unwrap(); |
|
| 2454 | + | } |
|
| 2455 | + |
modified docs/guide.md +28 −0
| … | 61 unchanged lines |
||
| 62 | 62 | oxy show 658990aa # one change by ChangeId prefix, #hash prefix, or @ |
|
| 63 | 63 | ``` |
|
| 64 | 64 | ||
| 65 | + | Commit part of what changed with `oxy commit PATH.. -m "..."` (only those |
|
| 66 | + | paths; the rest stays as uncommitted work) or `oxy commit -i -m "..."`, which |
|
| 67 | + | offers each hunk in turn like `git add -p`; a hunk that another chosen hunk |
|
| 68 | + | needs, such as the directory a new file lives in, comes along by itself. |
|
| 69 | + | ||
| 65 | 70 | `status --json` prints the same for tools, with every conflict's kind, path and |
|
| 66 | 71 | contributing ChangeIds. On a terminal, `status` lists each path with a mark: `✚ added`, `✎ modified`, `✖ removed`, |
|
| 67 | 72 | `➜ renamed`, `⚙ mode`. Renames are detected: a removed file and an added |
|
| … | 51 unchanged lines |
||
| 119 | 124 | reference is re-anchored to the nearest surviving line and `oxy abandon` |
|
| 120 | 125 | prints a note about it. |
|
| 121 | 126 | ||
| 127 | + | ## 4a. Who wrote this, and what broke it |
|
| 128 | + | ||
| 129 | + | ``` |
|
| 130 | + | oxy blame src/parser.rs # every line: the change that introduced it, author, age |
|
| 131 | + | oxy blame src/parser.rs --json |
|
| 132 | + | ``` |
|
| 133 | + | ||
| 134 | + | Blame is exact: each line is an atom that remembers the change that |
|
| 135 | + | introduced it, so renames cost nothing and there is no heuristic. |
|
| 136 | + | ||
| 137 | + | ``` |
|
| 138 | + | oxy bisect start |
|
| 139 | + | oxy bisect good v1.2 # a change, bookmark or tag known to be fine |
|
| 140 | + | oxy bisect bad main # and one known to be broken |
|
| 141 | + | oxy bisect run make test # exit 0 good, 125 skip, anything else bad |
|
| 142 | + | oxy bisect reset # back to where you were |
|
| 143 | + | ``` |
|
| 144 | + | ||
| 145 | + | Without `run`, each step checks out a state to test and you answer with |
|
| 146 | + | `oxy bisect good`, `bad`, or `skip`. The candidates are the changes the bad |
|
| 147 | + | state has that the good one lacks, ordered so every tested state is |
|
| 148 | + | well-formed; the answer is the first change whose inclusion breaks things. |
|
| 149 | + | ||
| 122 | 150 | ## 5. Move around |
|
| 123 | 151 | ||
| 124 | 152 | ``` |
|
| … | 264 unchanged lines |
modified docs/plan.md +3 −3
| … | 41 unchanged lines |
||
| 42 | 42 | ||
| 43 | 43 | - [x] Multiple bookmarks (spec §10.1): `oxy bookmark create|set|delete|list|switch`, per-workspace current bookmark, `oxy merge BOOKMARK`, `oxy checkout NAME`, bookmark names resolved before change prefixes; push/pull/log act on the current bookmark; `oxy push -b`. |
|
| 44 | 44 | - [x] Subrepositories (spec §7.8, §23): node kind `subrepo`, pin encoding, `.oxymodules`, snapshot stops at nested repositories and reads their pin, checkout moves or leaves nested repositories, `oxy submodule add|update|status|remove`; Git import/export of gitlinks and `.gitmodules` (test with a mirror of tetriblox, never the live repository). |
|
| 45 | - | - [ ] Partial commits (spec §12.3): `oxy commit PATH..` and `oxy commit --interactive` (hunk = one edit or tree operation, dependent hunks pulled in), sharing the split machinery. |
|
| 46 | - | - [ ] Blame (spec §27): `oxy blame PATH [--json]` from atom keys, with author and date; renames followed. |
|
| 47 | - | - [ ] Bisect (spec §27): `oxy bisect start|good|bad|run|reset` over dependency-closed prefixes; state under `.oxy/bisect/`. |
|
| 45 | + | - [x] Partial commits (spec §12.3): `oxy commit PATH..` and `oxy commit --interactive` (hunk = one edit or tree operation, dependent hunks pulled in), sharing the split machinery. |
|
| 46 | + | - [x] Blame (spec §27): `oxy blame PATH [--json]` from atom keys, with author and date; renames followed. |
|
| 47 | + | - [x] Bisect (spec §27): `oxy bisect start|good|bad|run|reset` over dependency-closed prefixes; state under `.oxy/bisect/`. |
|
| 48 | 48 | - [ ] Squash (spec §8.5): `oxy squash CHANGE --into DEST` generalising `amend --into`, with rewrite propagation. |
|
| 49 | 49 | - [ ] Rebase (spec §8.5): `oxy rebase CHANGE --onto HEADS..` re-deriving edits against a new base; refused on conflicted files. |
|
| 50 | 50 | - [ ] Guide, README and §26.1 updated for each; spec changelog per item. |
|
| … | 23 unchanged lines |
modified docs/spec.md +2 −0
| … | 880 unchanged lines |
||
| 881 | 881 | | Rewrites (§8.4) | Implemented: describe, amend (also into older changes), split, abandon; orphaned references re-anchored | |
|
| 882 | 882 | | Bookmarks, per-workspace current bookmark, merge by union (§10.1) | Implemented | |
|
| 883 | 883 | | Subrepositories with Oxygen and Git pins, `.oxymodules`, Git gitlink import and export (§7.8, §23) | Implemented | |
|
| 884 | + | | Partial and interactive commits (§12.3) | Implemented | |
|
| 885 | + | | Blame and bisect (§27) | Implemented | |
|
| 884 | 886 | | Operation log, undo (§12.1) | Implemented; `redo` reserved | |
|
| 885 | 887 | | Snapshots and the stat cache (§12.3, §12.4) | Implemented; no file-system monitor yet | |
|
| 886 | 888 | | Storage: loose objects, packs, GC, checkpoints, batching (§11, §13, §21) | Implemented; pack indexes are read fully rather than mapped | |
|
| … | 84 unchanged lines |
modified src/cli/mod.rs +48 −1
| … | 72 unchanged lines |
||
| 73 | 73 | /// Extra explicit dependencies (ChangeId prefix, #hash prefix) |
|
| 74 | 74 | #[arg(long = "depends-on")] |
|
| 75 | 75 | depends_on: Vec<String>, |
|
| 76 | + | /// Commit only these paths; the rest stays as uncommitted work |
|
| 77 | + | paths: Vec<String>, |
|
| 78 | + | /// Choose hunks one by one |
|
| 79 | + | #[arg(short, long)] |
|
| 80 | + | interactive: bool, |
|
| 76 | 81 | }, |
|
| 82 | + | /// Show which change introduced each line of a file |
|
| 83 | + | Blame { |
|
| 84 | + | path: String, |
|
| 85 | + | #[arg(long)] |
|
| 86 | + | json: bool, |
|
| 87 | + | }, |
|
| 88 | + | /// Find the change that introduced a regression |
|
| 89 | + | Bisect { |
|
| 90 | + | #[command(subcommand)] |
|
| 91 | + | cmd: BisectCmd, |
|
| 92 | + | }, |
|
| 77 | 93 | /// List changes reachable from the bookmark and the working copy |
|
| 78 | 94 | Log { |
|
| 79 | 95 | /// Show at most this many changes |
|
| … | 157 unchanged lines |
||
| 237 | 253 | } |
|
| 238 | 254 | ||
| 239 | 255 | #[derive(Subcommand)] |
|
| 256 | + | enum BisectCmd { |
|
| 257 | + | /// Begin; remembers where the working copy was |
|
| 258 | + | Start, |
|
| 259 | + | /// Mark CHANGE... (or the state under test) as good |
|
| 260 | + | Good { changes: Vec<String> }, |
|
| 261 | + | /// Mark CHANGE... (or the state under test) as bad |
|
| 262 | + | Bad { changes: Vec<String> }, |
|
| 263 | + | /// The state under test cannot be tested; try another |
|
| 264 | + | Skip, |
|
| 265 | + | /// Run a command for each step: exit 0 good, 125 skip, else bad |
|
| 266 | + | Run { |
|
| 267 | + | #[arg(required = true, trailing_var_arg = true)] |
|
| 268 | + | command: Vec<String>, |
|
| 269 | + | }, |
|
| 270 | + | /// Where the search stands |
|
| 271 | + | Status, |
|
| 272 | + | /// Restore the working copy and forget the search |
|
| 273 | + | Reset, |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | #[derive(Subcommand)] |
|
| 240 | 277 | enum BookmarkCmd { |
|
| 241 | 278 | /// Create a bookmark at CHANGE... (default: the working copy's base) |
|
| 242 | 279 | Create { name: String, changes: Vec<String> }, |
|
| … | 206 unchanged lines |
||
| 449 | 486 | Cmd::Init { path } => workspace::init(path), |
|
| 450 | 487 | Cmd::Status { json } => workspace::status(json), |
|
| 451 | 488 | Cmd::Prompt { format, glyphs, color } => workspace::prompt(&format, glyphs, color.as_deref()), |
|
| 452 | - | Cmd::Commit { message, depends_on } => workspace::commit(&message, &depends_on), |
|
| 489 | + | Cmd::Commit { message, depends_on, paths, interactive } => workspace::commit(&message, &depends_on, &paths, interactive), |
|
| 490 | + | Cmd::Blame { path, json } => workspace::blame(&path, json), |
|
| 491 | + | Cmd::Bisect { cmd } => match cmd { |
|
| 492 | + | BisectCmd::Start => workspace::bisect_start(), |
|
| 493 | + | BisectCmd::Good { changes } => workspace::bisect_mark(Some(true), &changes), |
|
| 494 | + | BisectCmd::Bad { changes } => workspace::bisect_mark(Some(false), &changes), |
|
| 495 | + | BisectCmd::Skip => workspace::bisect_mark(None, &[]), |
|
| 496 | + | BisectCmd::Run { command } => workspace::bisect_run(&command), |
|
| 497 | + | BisectCmd::Status => workspace::bisect_status(), |
|
| 498 | + | BisectCmd::Reset => workspace::bisect_reset(), |
|
| 499 | + | }, |
|
| 453 | 500 | Cmd::Log { limit, verbose } => workspace::log(limit, verbose), |
|
| 454 | 501 | Cmd::Diff => workspace::diff(), |
|
| 455 | 502 | Cmd::Describe { message, change } => workspace::describe(&message, change.as_deref()), |
|
| … | 80 unchanged lines |
modified src/cli/workspace.rs +162 −2
| … | 76 unchanged lines |
||
| 77 | 77 | Ok(2) |
|
| 78 | 78 | } |
|
| 79 | 79 | ||
| 80 | - | pub fn commit(message: &str, depends_on: &[String]) -> Result<i32> { |
|
| 80 | + | pub fn commit(message: &str, depends_on: &[String], paths: &[String], interactive: bool) -> Result<i32> { |
|
| 81 | 81 | let repo = open()?; |
|
| 82 | 82 | let mut deps = Vec::new(); |
|
| 83 | 83 | for d in depends_on { |
|
| 84 | 84 | deps.push(repo.resolve_change(d)?); |
|
| 85 | 85 | } |
|
| 86 | + | let selection = if interactive { |
|
| 87 | + | let (_, hunks) = repo.hunks()?; |
|
| 88 | + | if hunks.is_empty() { |
|
| 89 | + | anyhow::bail!("nothing to commit: the working copy matches its base"); |
|
| 90 | + | } |
|
| 91 | + | let hunks: Vec<_> = hunks.into_iter().filter(|h| paths.is_empty() || paths.iter().any(|p| h.path == *p || h.path.starts_with(&format!("{}/", p.trim_end_matches('/'))))).collect(); |
|
| 92 | + | let mut chosen = Vec::new(); |
|
| 93 | + | let stdin = std::io::stdin(); |
|
| 94 | + | let mut take_rest = false; |
|
| 95 | + | for (n, h) in hunks.iter().enumerate() { |
|
| 96 | + | if take_rest { |
|
| 97 | + | chosen.push(h.index); |
|
| 98 | + | continue; |
|
| 99 | + | } |
|
| 100 | + | println!("{} {} {}", ui::section(format!("hunk {} of {}", n + 1, hunks.len())), ui::path(&h.path), ui::dim(format!("(#{})", h.index))); |
|
| 101 | + | for line in h.text.lines() { |
|
| 102 | + | let styled = if line.starts_with('+') { ui::ok(line) } else if line.starts_with('-') { ui::err(line) } else { ui::dim(line) }; |
|
| 103 | + | println!(" {styled}"); |
|
| 104 | + | } |
|
| 105 | + | loop { |
|
| 106 | + | print!("{} ", ui::warn("Commit this hunk? [y]es [n]o [a]ll remaining [q]uit:")); |
|
| 107 | + | use std::io::Write as _; |
|
| 108 | + | std::io::stdout().flush()?; |
|
| 109 | + | let mut answer = String::new(); |
|
| 110 | + | if stdin.read_line(&mut answer)? == 0 { |
|
| 111 | + | anyhow::bail!("aborted"); |
|
| 112 | + | } |
|
| 113 | + | match answer.trim() { |
|
| 114 | + | "y" | "Y" => { |
|
| 115 | + | chosen.push(h.index); |
|
| 116 | + | break; |
|
| 117 | + | } |
|
| 118 | + | "n" | "N" | "" => break, |
|
| 119 | + | "a" | "A" => { |
|
| 120 | + | chosen.push(h.index); |
|
| 121 | + | take_rest = true; |
|
| 122 | + | break; |
|
| 123 | + | } |
|
| 124 | + | "q" | "Q" => anyhow::bail!("aborted; nothing committed"), |
|
| 125 | + | _ => continue, |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | if chosen.is_empty() { |
|
| 130 | + | anyhow::bail!("no hunks chosen; nothing committed"); |
|
| 131 | + | } |
|
| 132 | + | oxy_core::repo::Selection::Hunks(chosen) |
|
| 133 | + | } else if paths.is_empty() { |
|
| 134 | + | oxy_core::repo::Selection::All |
|
| 135 | + | } else { |
|
| 136 | + | oxy_core::repo::Selection::Paths(paths.to_vec()) |
|
| 137 | + | }; |
|
| 86 | 138 | let sp = ui::spinner("Committing…"); |
|
| 87 | - | let h = repo.commit(message, &deps)?; |
|
| 139 | + | let h = repo.commit_selection(message, &deps, &selection)?; |
|
| 88 | 140 | let c = repo.store().get_change(&h)?; |
|
| 89 | 141 | sp.done(&format!("{} {} {} {}", ui::done("Committed"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), ui::subject(subject_of(message)))); |
|
| 142 | + | if !matches!(selection, oxy_core::repo::Selection::All) { |
|
| 143 | + | let rest = repo.snapshot("oxy commit")?; |
|
| 144 | + | if !rest.statuses.is_empty() { |
|
| 145 | + | println!(" {} {}", ui::note(), ui::dim(format!("{} left uncommitted", plural(rest.statuses.len(), "path")))); |
|
| 146 | + | } |
|
| 147 | + | } |
|
| 148 | + | Ok(0) |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | pub fn blame(path: &str, json: bool) -> Result<i32> { |
|
| 152 | + | let repo = open()?; |
|
| 153 | + | let lines = repo.blame(path)?; |
|
| 154 | + | if json { |
|
| 155 | + | let items = lines.iter().map(|l| { |
|
| 156 | + | format!( |
|
| 157 | + | "{{\"line\":{},\"change_id\":{},\"hash\":{},\"index\":{},\"author\":{},\"when_ms\":{}}}", |
|
| 158 | + | l.line, |
|
| 159 | + | oxy_core::repo::json_str(&l.change_id.to_hex()), |
|
| 160 | + | oxy_core::repo::json_str(&l.hash.to_hex()), |
|
| 161 | + | l.index, |
|
| 162 | + | oxy_core::repo::json_str(&l.author), |
|
| 163 | + | l.when_ms |
|
| 164 | + | ) |
|
| 165 | + | }); |
|
| 166 | + | println!("{}", oxy_core::repo::json_array(items)); |
|
| 167 | + | return Ok(0); |
|
| 168 | + | } |
|
| 169 | + | let width = lines.iter().map(|l| l.author.chars().count()).max().unwrap_or(0).min(20); |
|
| 170 | + | let digits = lines.len().to_string().len(); |
|
| 171 | + | for l in &lines { |
|
| 172 | + | let author: String = l.author.chars().take(20).collect(); |
|
| 173 | + | let text = String::from_utf8_lossy(&l.text); |
|
| 174 | + | println!( |
|
| 175 | + | "{} {} {} {:>digits$}{} {}", |
|
| 176 | + | ui::id(l.change_id.short(8)), |
|
| 177 | + | ui::author(format!("{author:<width$}")), |
|
| 178 | + | ui::dim(ui::ago(l.when_ms)), |
|
| 179 | + | l.line, |
|
| 180 | + | ui::dim(ui::glyph("│", "|")), |
|
| 181 | + | text.trim_end_matches('\n') |
|
| 182 | + | ); |
|
| 183 | + | } |
|
| 184 | + | Ok(0) |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | fn bisect_report(repo: &Repo, step: &oxy_core::repo::BisectStep) -> Result<i32> { |
|
| 188 | + | use oxy_core::repo::BisectStep::*; |
|
| 189 | + | match step { |
|
| 190 | + | NeedMarks { good, bad } => { |
|
| 191 | + | println!("{} {} {}", ui::note(), ui::dim(format!("{good} good, {bad} bad;")), ui::dim(if *good == 0 { "mark a known-good change or bookmark: oxy bisect good CHANGE" } else { "mark a known-bad change or bookmark: oxy bisect bad CHANGE" })); |
|
| 192 | + | Ok(0) |
|
| 193 | + | } |
|
| 194 | + | Testing { heads, remaining, steps_left } => { |
|
| 195 | + | println!("{} {} {}", ui::done("Testing"), ui::dim(format!("{} in the state under test;", plural(heads.len(), "head"))), ui::dim(format!("{remaining} candidates left, about {steps_left} more steps"))); |
|
| 196 | + | println!(" {} {}", ui::note(), ui::dim("run your test, then oxy bisect good | bad | skip")); |
|
| 197 | + | Ok(0) |
|
| 198 | + | } |
|
| 199 | + | Found { culprit } => { |
|
| 200 | + | let c = repo.store().get_change(culprit)?; |
|
| 201 | + | println!("{} {} {} {}", ui::done("Found"), ui::id(short_id(&c)), ui::hash(format!("#{}", culprit.short(12))), ui::subject(subject_of(&c.message))); |
|
| 202 | + | println!(" {} {}", ui::note(), ui::dim("the first change whose inclusion makes the state bad; oxy bisect reset to return")); |
|
| 203 | + | Ok(0) |
|
| 204 | + | } |
|
| 205 | + | Inconclusive { candidates } => { |
|
| 206 | + | println!("{} {}", ui::caution(), ui::warn(format!("every remaining candidate was skipped ({}); oxy bisect reset to return", plural(candidates.len(), "change")))); |
|
| 207 | + | Ok(1) |
|
| 208 | + | } |
|
| 209 | + | } |
|
| 210 | + | } |
|
| 211 | + | ||
| 212 | + | pub fn bisect_start() -> Result<i32> { |
|
| 213 | + | let repo = open()?; |
|
| 214 | + | repo.bisect_start()?; |
|
| 215 | + | println!("{} bisect; {}", ui::done("Started"), ui::dim("now: oxy bisect good CHANGE and oxy bisect bad CHANGE")); |
|
| 216 | + | Ok(0) |
|
| 217 | + | } |
|
| 218 | + | ||
| 219 | + | pub fn bisect_mark(good: Option<bool>, changes: &[String]) -> Result<i32> { |
|
| 220 | + | let repo = open()?; |
|
| 221 | + | let mut heads = Vec::new(); |
|
| 222 | + | for c in changes { |
|
| 223 | + | heads.extend(repo.resolve_heads(c)?); |
|
| 224 | + | } |
|
| 225 | + | let step = repo.bisect_mark(good, &heads)?; |
|
| 226 | + | bisect_report(&repo, &step) |
|
| 227 | + | } |
|
| 228 | + | ||
| 229 | + | pub fn bisect_run(command: &[String]) -> Result<i32> { |
|
| 230 | + | let repo = open()?; |
|
| 231 | + | let step = repo.bisect_run(command)?; |
|
| 232 | + | bisect_report(&repo, &step) |
|
| 233 | + | } |
|
| 234 | + | ||
| 235 | + | pub fn bisect_status() -> Result<i32> { |
|
| 236 | + | let repo = open()?; |
|
| 237 | + | match repo.bisect_status()? { |
|
| 238 | + | None => { |
|
| 239 | + | println!("{}", ui::dim("No bisect in progress.")); |
|
| 240 | + | Ok(0) |
|
| 241 | + | } |
|
| 242 | + | Some(step) => bisect_report(&repo, &step), |
|
| 243 | + | } |
|
| 244 | + | } |
|
| 245 | + | ||
| 246 | + | pub fn bisect_reset() -> Result<i32> { |
|
| 247 | + | let repo = open()?; |
|
| 248 | + | repo.bisect_reset()?; |
|
| 249 | + | println!("{} bisect; working copy restored", ui::done("Reset")); |
|
| 90 | 250 | Ok(0) |
|
| 91 | 251 | } |
|
| 92 | 252 | ||
| … | 490 unchanged lines |