OXYGENHUB

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 +
}