The history of a range of lines: oxy log -L START,END:PATH 83d61e803ccd
A range of lines is exactly the atoms on some path in the file's graph from its first line to its last, the lines' own atoms and those deleted from between them (FileGraph::between). Its history is every change that inserted or deleted one of them (FileGraph::touching), newest first (Repo::line_history): edits elsewhere in the file are not in it, and edits within it stay in it. oxy log -L START,END:PATH shows it, lines numbered as in the working copy's base, --json included. Spec §27, Draft 0.8n. The hub will use the same query with tree-sitter ranges. closes #22
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 05:56 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
83d61e803ccd1f6568e03de9cf45b021fd5100c7ff332ef2cc9ec6a58b5e5d75- Depends on
- 22cc934f9b60 28fee1c9eb6c 50a046e439e9 62794ecc7f74 6a81dbab471d 75036530b621 75167672ebcd 86fe29fd62ea 94084087a876 982864068a59 bc489b6c7f69 c6aa5bd9d1c8 d42369abf180 f7510b63cceb fce44ae9ad29 ff0af1ee299d
- Depended on by
- c2e1d09047db 2c9ddac3c387 47ce8b8e2d4b 4cce912a0953 69816d688300 fc1a466a1e66 1b2ce97050fe
- 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
- 17 atoms, 0 tree operations, 8 files
Browse the tree at this change
modified crates/oxy-core/src/graph.rs +61 −0
| … | 224 unchanged lines |
||
| 225 | 225 | /// The changes that inserted a live atom directly after `k`. A change |
|
| 226 | 226 | /// that deletes `k` must depend on them (spec §8.1), or `is_ghost` |
|
| 227 | 227 | /// reads the deletion as concurrent with those insertions. |
|
| 228 | + | /// Every atom on some path from `first` to `last`, both included, live |
|
| 229 | + | /// or deleted (spec §8): the atoms a range of lines is made of, and |
|
| 230 | + | /// those that were deleted from between them. Empty when `last` does |
|
| 231 | + | /// not follow `first`. |
|
| 232 | + | pub fn between(&self, first: &AtomKey, last: &AtomKey) -> Vec<AtomKey> { |
|
| 233 | + | let (from, to) = (Vertex::Atom(*first), Vertex::Atom(*last)); |
|
| 234 | + | let mut ahead: HashSet<Vertex> = HashSet::default(); |
|
| 235 | + | let mut stack = vec![from]; |
|
| 236 | + | while let Some(v) = stack.pop() { |
|
| 237 | + | if ahead.insert(v) { |
|
| 238 | + | stack.extend( |
|
| 239 | + | self.succ |
|
| 240 | + | .get(&v) |
|
| 241 | + | .map(|e| e.as_slice()) |
|
| 242 | + | .unwrap_or(&[]) |
|
| 243 | + | .iter() |
|
| 244 | + | .copied(), |
|
| 245 | + | ); |
|
| 246 | + | } |
|
| 247 | + | } |
|
| 248 | + | if !ahead.contains(&to) { |
|
| 249 | + | return vec![]; |
|
| 250 | + | } |
|
| 251 | + | let mut pred: HashMap<Vertex, Vec<Vertex>> = HashMap::default(); |
|
| 252 | + | for (u, es) in &self.succ { |
|
| 253 | + | if ahead.contains(u) { |
|
| 254 | + | for w in es.iter() { |
|
| 255 | + | pred.entry(*w).or_default().push(*u); |
|
| 256 | + | } |
|
| 257 | + | } |
|
| 258 | + | } |
|
| 259 | + | let mut behind: HashSet<Vertex> = HashSet::default(); |
|
| 260 | + | let mut stack = vec![to]; |
|
| 261 | + | while let Some(v) = stack.pop() { |
|
| 262 | + | if behind.insert(v) { |
|
| 263 | + | stack.extend(pred.get(&v).into_iter().flatten().copied()); |
|
| 264 | + | } |
|
| 265 | + | } |
|
| 266 | + | let mut out: Vec<AtomKey> = ahead |
|
| 267 | + | .intersection(&behind) |
|
| 268 | + | .filter_map(|v| match v { |
|
| 269 | + | Vertex::Atom(k) if self.atoms.contains_key(k) => Some(*k), |
|
| 270 | + | _ => None, |
|
| 271 | + | }) |
|
| 272 | + | .collect(); |
|
| 273 | + | out.sort(); |
|
| 274 | + | out |
|
| 275 | + | } |
|
| 276 | + | ||
| 277 | + | /// The changes that inserted or deleted any of `atoms`. |
|
| 278 | + | pub fn touching(&self, atoms: &[AtomKey]) -> BTreeSet<ChangeHash> { |
|
| 279 | + | let mut out = BTreeSet::new(); |
|
| 280 | + | for k in atoms { |
|
| 281 | + | out.insert(k.0); |
|
| 282 | + | if let Some(info) = self.atoms.get(k) { |
|
| 283 | + | out.extend(info.deleted_by.iter().copied()); |
|
| 284 | + | } |
|
| 285 | + | } |
|
| 286 | + | out |
|
| 287 | + | } |
|
| 288 | + | ||
| 228 | 289 | pub fn live_successor_changes(&self, k: &AtomKey) -> Vec<ChangeHash> { |
|
| 229 | 290 | self.succ |
|
| 230 | 291 | .get(&Vertex::Atom(*k)) |
|
| … | 874 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +40 −0
| … | 1380 unchanged lines |
||
| 1381 | 1381 | } |
|
| 1382 | 1382 | ||
| 1383 | 1383 | /// The log of a state already loaded, newest first. |
|
| 1384 | + | /// The history of lines `first..=last` (1-based) of `path` in the |
|
| 1385 | + | /// state `heads` make (spec §27): every change that inserted or |
|
| 1386 | + | /// deleted one of their atoms, or an atom deleted from between them, |
|
| 1387 | + | /// newest first. Exact, as atoms keep their identity across edits. |
|
| 1388 | + | pub fn line_history( |
|
| 1389 | + | &self, |
|
| 1390 | + | heads: &[ChangeHash], |
|
| 1391 | + | path: &str, |
|
| 1392 | + | first: usize, |
|
| 1393 | + | last: usize, |
|
| 1394 | + | ) -> Result<Vec<LogEntry>> { |
|
| 1395 | + | if first == 0 || last < first { |
|
| 1396 | + | return fail(format!( |
|
| 1397 | + | "lines {first},{last}: expected FIRST,LAST from 1 with FIRST ≤ LAST" |
|
| 1398 | + | )); |
|
| 1399 | + | } |
|
| 1400 | + | let mut state = self.load_state_history(heads)?; |
|
| 1401 | + | let Some(node) = state.tree().view().entries.get(path).map(|e| e.node) else { |
|
| 1402 | + | return fail(format!("{path} is not in that state")); |
|
| 1403 | + | }; |
|
| 1404 | + | self.load_graphs_of(&mut state, &[node])?; |
|
| 1405 | + | let live = state.live_lines(&node); |
|
| 1406 | + | if last > live.len() { |
|
| 1407 | + | return fail(format!( |
|
| 1408 | + | "{path} has {} lines; asked for {first},{last}", |
|
| 1409 | + | live.len() |
|
| 1410 | + | )); |
|
| 1411 | + | } |
|
| 1412 | + | let Some(g) = state.file(&node) else { |
|
| 1413 | + | return fail(format!("{path} is not a text file")); |
|
| 1414 | + | }; |
|
| 1415 | + | let atoms = g.between(&live[first - 1].0, &live[last - 1].0); |
|
| 1416 | + | let touched = g.touching(&atoms); |
|
| 1417 | + | Ok(self |
|
| 1418 | + | .log_of(&state) |
|
| 1419 | + | .into_iter() |
|
| 1420 | + | .filter(|e| touched.contains(&e.hash)) |
|
| 1421 | + | .collect()) |
|
| 1422 | + | } |
|
| 1423 | + | ||
| 1384 | 1424 | pub fn log_of(&self, state: &crate::state::State) -> Vec<LogEntry> { |
|
| 1385 | 1425 | let head_set: BTreeSet<ChangeHash> = state.heads().into_iter().collect(); |
|
| 1386 | 1426 | let mut entries: Vec<LogEntry> = state |
|
| … | 213 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +52 −0
| … | 5334 unchanged lines |
||
| 5335 | 5335 | } |
|
| 5336 | 5336 | ); |
|
| 5337 | 5337 | } |
|
| 5338 | + | ||
| 5339 | + | /// The history of a range of lines (spec §27): the changes that wrote |
|
| 5340 | + | /// or deleted its atoms, or deleted atoms from between them; edits outside |
|
| 5341 | + | /// the range and changes the range's lines survived are not in it. |
|
| 5342 | + | #[test] |
|
| 5343 | + | fn line_ranges_have_exact_histories() { |
|
| 5344 | + | hermetic(); |
|
| 5345 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5346 | + | let root = dir.path(); |
|
| 5347 | + | let repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 5348 | + | write(root, "f.rs", b"use x;\nfn a() {\n one();\n two();\n}\n"); |
|
| 5349 | + | let base = repo.commit("add f", &[]).unwrap(); |
|
| 5350 | + | write(root, "f.rs", b"use y;\nfn a() {\n one();\n two();\n}\n"); |
|
| 5351 | + | let outside = repo.commit("change the import", &[]).unwrap(); |
|
| 5352 | + | write(root, "f.rs", b"use y;\nfn a() {\n ONE();\n two();\n}\n"); |
|
| 5353 | + | let inside = repo.commit("edit a line of a", &[]).unwrap(); |
|
| 5354 | + | write(root, "f.rs", b"use y;\nfn a() {\n ONE();\n}\n"); |
|
| 5355 | + | let deleted = repo.commit("delete a line of a", &[]).unwrap(); |
|
| 5356 | + | write(root, "f.rs", b"use y;\nfn a() {\n ONE();\n}\nfn b() {}\n"); |
|
| 5357 | + | let after = repo.commit("add b after a", &[]).unwrap(); |
|
| 5358 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 5359 | + | ||
| 5360 | + | // Lines 2..4 are `fn a() {`, `ONE();`, `}` now. |
|
| 5361 | + | let hist: Vec<ChangeHash> = repo |
|
| 5362 | + | .line_history(&heads, "f.rs", 2, 4) |
|
| 5363 | + | .unwrap() |
|
| 5364 | + | .iter() |
|
| 5365 | + | .map(|e| e.hash) |
|
| 5366 | + | .collect(); |
|
| 5367 | + | for (h, what) in [ |
|
| 5368 | + | (base, "wrote it"), |
|
| 5369 | + | (inside, "edited a line"), |
|
| 5370 | + | (deleted, "deleted a line from inside"), |
|
| 5371 | + | ] { |
|
| 5372 | + | assert!(hist.contains(&h), "{what}"); |
|
| 5373 | + | } |
|
| 5374 | + | for (h, what) in [(outside, "an edit above"), (after, "an addition below")] { |
|
| 5375 | + | assert!(!hist.contains(&h), "{what}"); |
|
| 5376 | + | } |
|
| 5377 | + | assert_eq!(hist[0], deleted, "newest first"); |
|
| 5378 | + | // One line: only what touched it. |
|
| 5379 | + | let one: Vec<ChangeHash> = repo |
|
| 5380 | + | .line_history(&heads, "f.rs", 1, 1) |
|
| 5381 | + | .unwrap() |
|
| 5382 | + | .iter() |
|
| 5383 | + | .map(|e| e.hash) |
|
| 5384 | + | .collect(); |
|
| 5385 | + | assert!(one.contains(&outside) && !one.contains(&inside)); |
|
| 5386 | + | assert!(repo.line_history(&heads, "f.rs", 3, 9).is_err()); |
|
| 5387 | + | assert!(repo.line_history(&heads, "f.rs", 0, 1).is_err()); |
|
| 5388 | + | assert!(repo.line_history(&heads, "nope.rs", 1, 1).is_err()); |
|
| 5389 | + | } |
modified docs/backlog-overnight-2.md +2 −0
| … | 84 unchanged lines |
||
| 85 | 85 | - Shareable link to a conflict. |
|
| 86 | 86 | - First single-user, then P1b. |
|
| 87 | 87 | - [x] P1b Real-time collaboration on the resolver: presence, live edits merged between viewers (SSE or WebSocket), and whoever is viewing sees the resolution land. |
|
| 88 | + | - [x] P2a Function history in oxy-core and the CLI: `Repo::line_history`, `oxy log -L START,END:PATH` (first part of P2, below). |
|
| 89 | + | - [ ] P2b Function history on the hub: tree-sitter function boundaries (Rust, TypeScript, Python, shell), and a function's history as a list and a small graph from the file page. |
|
| 88 | 90 | - [ ] P2 **Function history** (item 5; needs no semantic-deps): |
|
| 89 | 91 | - oxy-core: a history query over a set of atoms (the changes that inserted or deleted any of them, with deleted atoms included). |
|
| 90 | 92 | - Hub: function boundaries by tree-sitter for the languages we host (Rust, TypeScript, Python, shell). |
|
| … | 449 unchanged lines |
modified docs/spec.md +5 −3
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.8m | |
|
| 5 | + | | **Status** | Draft 0.8n | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 833 unchanged lines |
||
| 842 | 842 | oxy init [PATH] [--bare] oxy clone URL [--sparse PATH].. [--depth N] [--max-blob SIZE] |
|
| 843 | 843 | oxy status [--json] oxy diff oxy prompt [--format F] |
|
| 844 | 844 | oxy commit -m MSG [--depends-on CHANGE].. |
|
| 845 | - | oxy log [-n N] [-v] oxy show CHANGE |
|
| 845 | + | oxy log [-n N] [-v] [-L S,E:PATH] oxy show CHANGE |
|
| 846 | 846 | oxy describe [CHANGE] [-m MSG] oxy amend [-m MSG] [--into CHANGE] |
|
| 847 | 847 | oxy split CHANGE [PATH].. oxy abandon CHANGE oxy resolve |
|
| 848 | 848 | oxy checkout [CHANGE].. [--discard|--carry] oxy undo oxy op log |
|
| … | 83 unchanged lines |
||
| 932 | 932 | ||
| 933 | 933 | **Blame.** Every line of a file in a state is an atom identified by the change that introduced it (§7.3), so `oxy blame PATH` is a lookup, not a heuristic: for each line it prints the introducing change's ChangeId, author, and date, and `--json` gives the ChangeHash and atom index. A line inside a conflict region is attributed to the side that introduced it. Blame follows renames for free, since the node survives them, and it MUST cost no more than materializing the file. |
|
| 934 | 934 | ||
| 935 | + | **Line history.** For the same reason the history of a range of lines, such as a function, is exact: `oxy log -L START,END:PATH` lists every change that inserted or deleted an atom on some path in the file graph from the range's first line to its last (the lines' own atoms and those deleted from between them), newest first, with lines numbered as in the working copy's base. Changes elsewhere in the file are not in it, and edits within the range stay in it however the lines moved around them. Hosts use the same query for a function's history (tree-sitter gives the range). |
|
| 936 | + | ||
| 935 | 937 | **Bisect.** A regression is a change, and the candidates lie between a known-good state and a known-bad one: `candidates = closure(bad) \ closure(good)`. `oxy bisect start`, then `oxy bisect good CHANGE..` and `oxy bisect bad CHANGE..` (either may name a bookmark), orders the candidates so that every prefix is dependency-closed and checks out `good ∪ prefix` at the midpoint. Each further `good`/`bad` halves the remaining range, and the search ends by naming the first change whose inclusion makes the state bad; `oxy bisect run CMD` automates it with the command's exit status (0 good, 125 skip, anything else bad). `oxy bisect reset` restores the working copy from before the search. Because prefixes are closed under dependencies, every tested state is well-formed; a change that cannot be tested in isolation because it commutes with the culprit is simply ordered after it. Bisect state lives under `.oxy/bisect/` and is not versioned. |
|
| 936 | 938 | ||
| 937 | 939 | ## 28. Open questions |
|
| … | 6 unchanged lines |
||
| 944 | 946 | ||
| 945 | 947 | --- |
|
| 946 | 948 | ||
| 947 | - | *Changelog.* Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 949 | + | *Changelog.* Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 948 | 950 | ||
| 949 | 951 | Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 950 | 952 | ||
| … | 61 unchanged lines |
modified src/cli/mod.rs +7 −1
| … | 144 unchanged lines |
||
| 145 | 145 | /// Only changes touching this path (renames followed) |
|
| 146 | 146 | #[arg(long)] |
|
| 147 | 147 | path: Option<String>, |
|
| 148 | + | /// Only changes that wrote or deleted lines START to END of PATH |
|
| 149 | + | /// (numbered as in the working copy's base), or deleted lines from |
|
| 150 | + | /// between them: the history of a function |
|
| 151 | + | #[arg(short = 'L', value_name = "START,END:PATH", conflicts_with = "path")] |
|
| 152 | + | lines: Option<String>, |
|
| 148 | 153 | /// Machine-readable output: one JSON document on stdout |
|
| 149 | 154 | #[arg(long)] |
|
| 150 | 155 | json: bool, |
|
| … | 764 unchanged lines |
||
| 915 | 920 | limit, |
|
| 916 | 921 | verbose, |
|
| 917 | 922 | path, |
|
| 923 | + | lines, |
|
| 918 | 924 | json, |
|
| 919 | - | } => workspace::log(limit, verbose, path.as_deref(), json), |
|
| 925 | + | } => workspace::log(limit, verbose, path.as_deref(), lines.as_deref(), json), |
|
| 920 | 926 | Cmd::Diff { change, json } => workspace::diff(change.as_deref(), json), |
|
| 921 | 927 | Cmd::Describe { |
|
| 922 | 928 | message, |
|
| … | 210 unchanged lines |
modified src/cli/workspace.rs +26 −4
| … | 473 unchanged lines |
||
| 474 | 474 | ])) |
|
| 475 | 475 | } |
|
| 476 | 476 | ||
| 477 | - | pub fn log(limit: Option<usize>, verbose: bool, path: Option<&str>, as_json: bool) -> Result<i32> { |
|
| 477 | + | /// `START,END:PATH` for `oxy log -L`. |
|
| 478 | + | fn parse_line_range(spec: &str) -> Result<(usize, usize, &str)> { |
|
| 479 | + | let bad = || anyhow::anyhow!("-L {spec}: expected START,END:PATH, as 10,24:src/lib.rs"); |
|
| 480 | + | let (range, path) = spec.split_once(':').ok_or_else(bad)?; |
|
| 481 | + | let (a, b) = range.split_once(',').ok_or_else(bad)?; |
|
| 482 | + | Ok(( |
|
| 483 | + | a.trim().parse().map_err(|_| bad())?, |
|
| 484 | + | b.trim().parse().map_err(|_| bad())?, |
|
| 485 | + | path, |
|
| 486 | + | )) |
|
| 487 | + | } |
|
| 488 | + | ||
| 489 | + | pub fn log( |
|
| 490 | + | limit: Option<usize>, |
|
| 491 | + | verbose: bool, |
|
| 492 | + | path: Option<&str>, |
|
| 493 | + | lines: Option<&str>, |
|
| 494 | + | as_json: bool, |
|
| 495 | + | ) -> Result<i32> { |
|
| 478 | 496 | let repo = open()?; |
|
| 479 | 497 | let snap = repo.snapshot_status("oxy log")?; |
|
| 480 | 498 | let conflicted = snap.base_snapshot.is_conflicted(); |
|
| 481 | - | let entries = match path { |
|
| 482 | - | Some(p) => repo.log_for_path(p)?, |
|
| 483 | - | None => repo.log()?, |
|
| 499 | + | let entries = match (path, lines) { |
|
| 500 | + | (_, Some(spec)) => { |
|
| 501 | + | let (first, last, p) = parse_line_range(spec)?; |
|
| 502 | + | repo.line_history(&snap.base.maximal(&snap.wc.deps), p, first, last)? |
|
| 503 | + | } |
|
| 504 | + | (Some(p), None) => repo.log_for_path(p)?, |
|
| 505 | + | (None, None) => repo.log()?, |
|
| 484 | 506 | }; |
|
| 485 | 507 | if as_json { |
|
| 486 | 508 | let shown = limit.unwrap_or(entries.len()).min(entries.len()); |
|
| … | 1279 unchanged lines |
modified tests/cli_json.rs +28 −0
| … | 269 unchanged lines |
||
| 270 | 270 | assert_eq!(d["distance"], 2); |
|
| 271 | 271 | assert_eq!(d["describe"], format!("v0.1.0-2-{}", &change[..8])); |
|
| 272 | 272 | } |
|
| 273 | + | ||
| 274 | + | /// `oxy log -L START,END:PATH`: the history of a range of lines. |
|
| 275 | + | #[test] |
|
| 276 | + | fn the_history_of_a_function() { |
|
| 277 | + | let tmp = tempfile::tempdir().unwrap(); |
|
| 278 | + | let home = tmp.path().join("home"); |
|
| 279 | + | std::fs::create_dir_all(&home).unwrap(); |
|
| 280 | + | let dir = tmp.path().join("r"); |
|
| 281 | + | std::fs::create_dir_all(&dir).unwrap(); |
|
| 282 | + | assert_eq!(oxy(&dir, &home, &["init"]).0, 0); |
|
| 283 | + | write(&dir, "f.rs", "use x;\nfn a() {\n one();\n}\n"); |
|
| 284 | + | assert_eq!(oxy(&dir, &home, &["commit", "-m", "add f"]).0, 0); |
|
| 285 | + | write(&dir, "f.rs", "use y;\nfn a() {\n one();\n}\n"); |
|
| 286 | + | assert_eq!(oxy(&dir, &home, &["commit", "-m", "import"]).0, 0); |
|
| 287 | + | write(&dir, "f.rs", "use y;\nfn a() {\n two();\n}\n"); |
|
| 288 | + | assert_eq!(oxy(&dir, &home, &["commit", "-m", "body"]).0, 0); |
|
| 289 | + | let v = json(&dir, &home, &["log", "--json", "-L", "2,4:f.rs"]); |
|
| 290 | + | let messages: Vec<&str> = v["changes"] |
|
| 291 | + | .as_array() |
|
| 292 | + | .unwrap() |
|
| 293 | + | .iter() |
|
| 294 | + | .map(|c| c["message"].as_str().unwrap()) |
|
| 295 | + | .collect(); |
|
| 296 | + | assert_eq!(messages, ["body", "add f"], "not the import"); |
|
| 297 | + | let (code, _, err) = oxy(&dir, &home, &["log", "-L", "2:f.rs"]); |
|
| 298 | + | assert_ne!(code, 0); |
|
| 299 | + | assert!(err.contains("START,END:PATH"), "{err}"); |
|
| 300 | + | } |