OXYGENHUB

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