Ignore rules apply to untracked paths only: tracked files hidden by .oxyignore stay tracked in snapshots, amend, checkout manifests and the prompt 9ac1fbe211b4
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 12:45 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
9ac1fbe211b4041a3fff0b11cb5d7fe2f7caf5ec085ecc034693154332225126- Depends on
- 135212de5370 50a046e439e9 614541c3cebd 982864068a59 b7f102fcbb17 ee06a6c557b7 ff0af1ee299d
- Depended on by
- e96a430c78f3 3604179679eb d4dbab97e948 f57bf6702628 36e51aa806c6 76740263089c eaf3e9068180 cc7a00565161 e7ddd552b619 75167672ebcd 4dee277c327a b837d2b55853
- 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
- 18 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified crates/oxy-core/src/repo/checkout.rs +1 −1
| … | 107 unchanged lines |
||
| 108 | 108 | _ => None, |
|
| 109 | 109 | }) |
|
| 110 | 110 | .collect(); |
|
| 111 | - | let entries = self.walk_working_copy()?; |
|
| 111 | + | let entries = self.walk_working_copy_with(None, &Self::tracked_paths(&target))?; |
|
| 112 | 112 | self.write_manifest_entries(report.conflicted, false, &entries, &blobs)?; |
|
| 113 | 113 | let _ = self.run_hook( |
|
| 114 | 114 | "post-checkout", |
|
| … | 68 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +45 −20
| … | 572 unchanged lines |
||
| 573 | 573 | } |
|
| 574 | 574 | ||
| 575 | 575 | fn walk_working_copy(&self) -> Result<BTreeMap<String, FsEntry>> { |
|
| 576 | - | self.walk_working_copy_with(None) |
|
| 576 | + | self.walk_working_copy_with(None, &BTreeSet::new()) |
|
| 577 | 577 | } |
|
| 578 | 578 | ||
| 579 | 579 | /// Walk the working copy; with a manifest, a file whose size and mtime |
|
| 580 | 580 | /// match it is not read: an opaque file yields its known blob, a text |
|
| 581 | 581 | /// file is read but keeps its known hash. Files modified within two |
|
| 582 | 582 | /// seconds of the manifest are never trusted (a racy stat). |
|
| 583 | - | fn walk_working_copy_with(&self, cache: Option<&prompt::Manifest>) -> Result<BTreeMap<String, FsEntry>> { |
|
| 583 | + | /// |
|
| 584 | + | /// `.oxyignore` applies to untracked paths only: every path in `tracked` |
|
| 585 | + | /// (the base state's files) that exists on disk is included even when |
|
| 586 | + | /// an ignore rule matches it, as in Git. |
|
| 587 | + | fn walk_working_copy_with(&self, cache: Option<&prompt::Manifest>, tracked: &BTreeSet<String>) -> Result<BTreeMap<String, FsEntry>> { |
|
| 584 | 588 | let mut wb = ignore::WalkBuilder::new(&self.root); |
|
| 585 | 589 | wb.hidden(false) |
|
| 586 | 590 | .git_ignore(false) |
|
| … | 21 unchanged lines |
||
| 608 | 612 | out.insert(rel, FsEntry::new(NodeKind::Symlink, false, target.to_string_lossy().as_bytes().to_vec())); |
|
| 609 | 613 | } else if ft.is_file() { |
|
| 610 | 614 | let meta = entry.metadata().map_err(|e| RepoError::Msg(e.to_string()))?; |
|
| 611 | - | let exec = is_executable(&meta); |
|
| 612 | - | let cached = cache.and_then(|m| { |
|
| 613 | - | let e = m.entries.get(&rel)?; |
|
| 614 | - | let mtime_ns = prompt::mtime_ns(&meta); |
|
| 615 | - | let racy = mtime_ns + 2_000_000_000 >= m.written_ns; |
|
| 616 | - | (e.size == meta.len() && e.mtime_ns == mtime_ns && !racy).then_some(e) |
|
| 617 | - | }); |
|
| 618 | - | match cached { |
|
| 619 | - | Some(e) if e.blob.is_some_and(|b| self.store.has(&b)) => { |
|
| 620 | - | out.insert(rel, FsEntry { kind: NodeKind::File, exec, content: vec![], blob: e.blob, content_hash: Some(e.hash.clone()) }); |
|
| 621 | - | } |
|
| 622 | - | Some(e) => { |
|
| 623 | - | out.insert(rel, FsEntry { kind: NodeKind::File, exec, content: fs::read(path)?, blob: None, content_hash: Some(e.hash.clone()) }); |
|
| 624 | - | } |
|
| 625 | - | None => { |
|
| 626 | - | out.insert(rel, FsEntry::new(NodeKind::File, exec, fs::read(path)?)); |
|
| 627 | - | } |
|
| 628 | - | } |
|
| 615 | + | let e = self.file_entry(&rel, path, &meta, cache)?; |
|
| 616 | + | out.insert(rel, e); |
|
| 629 | 617 | } |
|
| 630 | 618 | } |
|
| 619 | + | // Tracked paths the ignore rules hid. |
|
| 620 | + | for rel in tracked { |
|
| 621 | + | if out.contains_key(rel) || !self.in_scope(rel) { |
|
| 622 | + | continue; |
|
| 623 | + | } |
|
| 624 | + | let path = self.root.join(rel); |
|
| 625 | + | let Ok(meta) = fs::symlink_metadata(&path) else { continue }; |
|
| 626 | + | let ft = meta.file_type(); |
|
| 627 | + | if ft.is_symlink() { |
|
| 628 | + | let target = fs::read_link(&path)?; |
|
| 629 | + | out.insert(rel.clone(), FsEntry::new(NodeKind::Symlink, false, target.to_string_lossy().as_bytes().to_vec())); |
|
| 630 | + | } else if ft.is_file() { |
|
| 631 | + | let e = self.file_entry(rel, &path, &meta, cache)?; |
|
| 632 | + | out.insert(rel.clone(), e); |
|
| 633 | + | } |
|
| 634 | + | } |
|
| 631 | 635 | Ok(out) |
|
| 632 | 636 | } |
|
| 637 | + | ||
| 638 | + | /// The entry for one regular file, read or served from the stat cache. |
|
| 639 | + | fn file_entry(&self, rel: &str, path: &Path, meta: &fs::Metadata, cache: Option<&prompt::Manifest>) -> Result<FsEntry> { |
|
| 640 | + | let exec = is_executable(meta); |
|
| 641 | + | let cached = cache.and_then(|m| { |
|
| 642 | + | let e = m.entries.get(rel)?; |
|
| 643 | + | let mtime_ns = prompt::mtime_ns(meta); |
|
| 644 | + | let racy = mtime_ns + 2_000_000_000 >= m.written_ns; |
|
| 645 | + | (e.size == meta.len() && e.mtime_ns == mtime_ns && !racy).then_some(e) |
|
| 646 | + | }); |
|
| 647 | + | Ok(match cached { |
|
| 648 | + | Some(e) if e.blob.is_some_and(|b| self.store.has(&b)) => FsEntry { kind: NodeKind::File, exec, content: vec![], blob: e.blob, content_hash: Some(e.hash.clone()) }, |
|
| 649 | + | Some(e) => FsEntry { kind: NodeKind::File, exec, content: fs::read(path)?, blob: None, content_hash: Some(e.hash.clone()) }, |
|
| 650 | + | None => FsEntry::new(NodeKind::File, exec, fs::read(path)?), |
|
| 651 | + | }) |
|
| 652 | + | } |
|
| 653 | + | ||
| 654 | + | /// The non-directory paths of a snapshot, for the walker's `tracked` set. |
|
| 655 | + | pub(crate) fn tracked_paths(snapshot: &Snapshot) -> BTreeSet<String> { |
|
| 656 | + | snapshot.files.iter().filter(|(_, f)| f.kind != NodeKind::Dir).map(|(p, _)| p.clone()).collect() |
|
| 657 | + | } |
|
| 633 | 658 | } |
|
| 634 | 659 | ||
| 635 | 660 | impl Repo { |
|
| … | 112 unchanged lines |
modified crates/oxy-core/src/repo/prompt.rs +16 −1
| … | 239 unchanged lines |
||
| 240 | 240 | return Ok(Some(true)); |
|
| 241 | 241 | } |
|
| 242 | 242 | } |
|
| 243 | - | Ok(Some(seen != manifest.entries.len())) |
|
| 243 | + | if seen == manifest.entries.len() { |
|
| 244 | + | return Ok(Some(false)); |
|
| 245 | + | } |
|
| 246 | + | // Manifest entries the walk skipped: tracked files that ignore rules |
|
| 247 | + | // hide. They count as present when still on disk and unchanged. |
|
| 248 | + | for (rel, me) in &manifest.entries { |
|
| 249 | + | let path = self.root.join(rel); |
|
| 250 | + | let Ok(m) = fs::symlink_metadata(&path) else { return Ok(Some(true)) }; |
|
| 251 | + | if m.file_type().is_symlink() { |
|
| 252 | + | continue; |
|
| 253 | + | } |
|
| 254 | + | if m.len() != me.size || (mtime_ns(&m) != me.mtime_ns && blake3::hash(&fs::read(&path)?).to_hex().as_str() != me.hash) { |
|
| 255 | + | return Ok(Some(true)); |
|
| 256 | + | } |
|
| 257 | + | } |
|
| 258 | + | Ok(Some(false)) |
|
| 244 | 259 | } |
|
| 245 | 260 | ||
| 246 | 261 | /// Prompt information without recording anything. |
|
| … | 26 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +2 −2
| … | 64 unchanged lines |
||
| 65 | 65 | /// change from `base` to the working directory. `reuse` maps paths to the |
|
| 66 | 66 | /// NodeIds an earlier version of the same change assigned to new paths. |
|
| 67 | 67 | pub(crate) fn build_against(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Result<BuiltChange> { |
|
| 68 | - | let fs_entries = self.walk_working_copy()?; |
|
| 68 | + | let fs_entries = self.walk_working_copy_with(None, &Self::tracked_paths(base_snapshot))?; |
|
| 69 | 69 | self.build_from_entries(base, base_snapshot, reuse, &fs_entries) |
|
| 70 | 70 | } |
|
| 71 | 71 | ||
| … | 72 unchanged lines |
||
| 144 | 144 | let reuse = added_node_paths(&old_wc, &base_snapshot); |
|
| 145 | 145 | // The last manifest lets unchanged opaque files go unread. |
|
| 146 | 146 | let manifest = self.read_manifest()?.map(|(_, _, m)| m); |
|
| 147 | - | let fs_entries = self.walk_working_copy_with(manifest.as_ref())?; |
|
| 147 | + | let fs_entries = self.walk_working_copy_with(manifest.as_ref(), &Self::tracked_paths(&base_snapshot))?; |
|
| 148 | 148 | let built = self.build_from_entries(&base, &base_snapshot, reuse, &fs_entries)?; |
|
| 149 | 149 | ||
| 150 | 150 | let mut wc = Change { |
|
| … | 728 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +39 −0
| … | 2016 unchanged lines |
||
| 2017 | 2017 | assert!(json.contains("\"clean\":true,\"conflicted\":false,\"paths\":[],\"conflicts\":[]"), "{json}"); |
|
| 2018 | 2018 | } |
|
| 2019 | 2019 | ||
| 2020 | + | #[test] |
|
| 2021 | + | fn ignore_rules_do_not_untrack_files() { |
|
| 2022 | + | hermetic(); |
|
| 2023 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 2024 | + | let root = dir.path(); |
|
| 2025 | + | let repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 2026 | + | write(root, "src.c", b"int x;\n"); |
|
| 2027 | + | write(root, "build/out.o", b"\0obj"); |
|
| 2028 | + | write(root, "sub/keep.log", b"keep\n"); |
|
| 2029 | + | write(root, "sub/junk.log", b"junk\n"); |
|
| 2030 | + | write(root, ".oxyignore", b"/build\n*.log\n!sub/keep.log\n"); |
|
| 2031 | + | let snap = repo.snapshot("status").unwrap(); |
|
| 2032 | + | let added: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect(); |
|
| 2033 | + | assert_eq!(added, vec![".oxyignore", "src.c", "sub/keep.log"], "anchored, glob, and negated rules"); |
|
| 2034 | + | let h1 = repo.commit("base", &[]).unwrap(); |
|
| 2035 | + | // Ignoring a tracked file changes nothing about it. |
|
| 2036 | + | write(root, ".oxyignore", b"/build\n*.log\n!sub/keep.log\nsrc.c\n"); |
|
| 2037 | + | let snap = repo.snapshot("status").unwrap(); |
|
| 2038 | + | assert_eq!(snap.statuses.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), vec![".oxyignore"]); |
|
| 2039 | + | let h2 = repo.commit("ignore src.c", &[]).unwrap(); |
|
| 2040 | + | assert!(repo.load_state(&[h2]).unwrap().materialize().files.contains_key("src.c")); |
|
| 2041 | + | // Its edits are still seen, by status and by the prompt. |
|
| 2042 | + | assert_eq!(repo.quick_dirty().unwrap(), Some(false)); |
|
| 2043 | + | write(root, "src.c", b"int x, y;\n"); |
|
| 2044 | + | assert_eq!(repo.quick_dirty().unwrap(), Some(true)); |
|
| 2045 | + | let snap = repo.snapshot("status").unwrap(); |
|
| 2046 | + | assert_eq!(snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect::<Vec<_>>(), vec![("src.c", ChangeStatus::Modified)]); |
|
| 2047 | + | let h3 = repo.commit("edit src.c", &[]).unwrap(); |
|
| 2048 | + | assert!(matches!(&repo.load_state(&[h3]).unwrap().materialize().files["src.c"].content, FileContent::Text { bytes, .. } if bytes == b"int x, y;\n")); |
|
| 2049 | + | // Deleting it on disk removes it, ignored or not; a checkout brings it back. |
|
| 2050 | + | fs::remove_file(root.join("src.c")).unwrap(); |
|
| 2051 | + | let snap = repo.snapshot("status").unwrap(); |
|
| 2052 | + | assert_eq!(snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect::<Vec<_>>(), vec![("src.c", ChangeStatus::Removed)]); |
|
| 2053 | + | repo.checkout(&[h3], true, "checkout").unwrap(); |
|
| 2054 | + | assert_eq!(fs::read(root.join("src.c")).unwrap(), b"int x, y;\n"); |
|
| 2055 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 2056 | + | let _ = h1; |
|
| 2057 | + | } |
|
| 2058 | + |
modified docs/guide.md +5 −2
| … | 43 unchanged lines |
||
| 44 | 44 | oxy status |
|
| 45 | 45 | ``` |
|
| 46 | 46 | ||
| 47 | - | `oxy init` creates `.oxy/`. Add `.oxyignore` (gitignore syntax) for files |
|
| 48 | - | that should never be tracked; `/target` and `*.tmp` are typical entries. |
|
| 47 | + | `oxy init` creates `.oxy/`. Add `.oxyignore` (gitignore syntax, per |
|
| 48 | + | directory, negations with `!`) for files that should never be tracked; |
|
| 49 | + | `/target` and `*.tmp` are typical entries. As in Git, ignore rules only |
|
| 50 | + | affect untracked files: a file already in history stays tracked until you |
|
| 51 | + | delete it. |
|
| 49 | 52 | ||
| 50 | 53 | To take over an existing Git project, see section 12. |
|
| 51 | 54 | ||
| … | 292 unchanged lines |
modified docs/spec.md +1 −1
| … | 737 unchanged lines |
||
| 738 | 738 | ||
| 739 | 739 | | File | Purpose | |
|
| 740 | 740 | |---|---| |
|
| 741 | - | | `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. | |
|
| 741 | + | | `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. | |
|
| 742 | 742 | | `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. | |
|
| 743 | 743 | | `.oxy/config` | Repository-local settings, TOML. | |
|
| 744 | 744 | | `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `core.sign`, and `remote.<name>.token`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. | |
|
| … | 189 unchanged lines |