OXYGENHUB

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