ADR-00027: The ignore filter applies only to paths the tree does not hold¶
| Field | Value |
|---|---|
| Status | proposed |
| Date | 2026-10-30 |
Context and Problem Statement¶
A repository has two path filters (lore-revision/src/filter.rs). The view (.lore/view) declares the sparse subset of the repository that is materialized on disk. The ignore file (.loreignore) declares paths that are not to be staged. Each operation declares which of the two it consults through a FilterMode. Before this decision, operations on the working tree and the staging area — staging, status, dirty marking, unstaging — consulted both, and asked the ignore file about every path whether or not the tree held it (system-design.md §12.4).
--force stages a path past the ignore file, and that is how an ignored file gets into the tree. Once it is there, every operation that asked the ignore file first lost sight of it. A report against 0.12.1 (lukasz.jastrzebski/test_ignored_scan) shows the result:
lore statusandlore status debug.logdo not show a stageddebug.log, because the comparison against the staged state filters withFilterMode::Full.lore unstage .leaves it staged, and the commit then takes it although the user can see nothing staged.- Once committed, a change to
debug.logis invisible tostatus --scanandstage --scan, andlore dirty debug.logdoes nothing. Staging a change takeslore dirty --forceand thenlore stage --force.
Revision 2044 ("List staged changes to ignored paths in lore status") fixed the first two: a FilterMode::KeepStaged bit stops the ignore file from excluding a node with something staged at or below it, in status and in unstage. It left the third as it was on purpose, keeping a change that is only marked dirty hidden on an ignored path, as a scan hides it. Revision 2066 ("Keep view-excluded directories only for the content below them") went further the same way for deletions: an unforced stage keeps a committed file the ignore file names when its directory leaves the working tree, and a committed empty directory the ignore file names that the working tree lacks.
The rest of the design record says the opposite. system-design.md §12.3 and the glossary say the ignore file is about new content only, and that a file already in the committed revision keeps flowing through until it is explicitly removed. Git behaves that way too: .gitignore has no effect on a tracked file. Should the ignore file be asked about a path the tree already holds?
Decision Drivers¶
- Nothing in the tree is hidden. A path that will be committed must be visible to
statusand reachable byunstage. Hiding staged content is the worst outcome, because the commit takes what the user cannot see. - The ignore file keeps doing its job. New files it names, at any depth, including untracked files below a tracked directory it names, must stay out of
status --scanandstage --scanwithout--force. - The view is unchanged. A sparse working tree never holds what the view leaves out, so the view has to go on filtering tracked paths as well as untracked ones.
- One meaning for
--force.--forcealready puts a staging walk past both filters. Tracked content should not need it to flow through ordinary staging. - Least surprise. Users coming from Git expect a tracked file to stay tracked whatever
.gitignoresays.
Considered Options¶
- Ask the ignore file only about paths the tree does not hold.
- Keep asking it about every path except what is staged, as revision 2044 does: tracked ignored files stay reachable only through
--force. - Keep the behavior and document it.
Decision Outcome¶
Chosen option: "Ask the ignore file only about paths the tree does not hold", because it is the only option that hides nothing that will be committed, matches §12.3, the glossary and Git, and needs no --force to keep a tracked file current.
A node is tracked for this purpose when it is staged, including a path staged past the ignore file with --force, or when the revision the tree was built on holds it (State::tracks). That is decided by the node, not by its path or its dirty flags: a tree shares its node IDs with the revision it was built on, so the revision holds a node where its own tree holds one of the same type at the same ID, which is how staging already tells a committed node from an uncommitted one. Everything else in the tree is only marked, and the ignore rules apply to it as to any untracked file:
- An add marked dirty names a file nobody has asked to add, so a rule added after
lore dirtymarked it drops it again, as Git drops an untracked file a new.gitignorerule names. The same goes for an addlore dirty --forcemarked, until it is staged, and for each new directory above it that the mark creates. - A copy marked with
lore dirty copyis a new node, so a copy into a directory the ignore file names is left out until it is staged. - A move marked with
lore dirty movekeeps the committed node it moves, so it stays tracked at its new path, even inside a directory the ignore file names. Git differs: a rename it has not staged leaves the destination untracked.lore dirty moveis an explicit statement that the file moved, of content already committed, so it is kept.
A new directory only marked dirty is recorded for staging only where it is empty; one holding nodes is carried by them, since staged or re-marked whole it would take in everything the working tree holds below it.
A path is tracked as the kind of node the tree holds there. Revision 2055 asks the filter about each path as the file or directory it is, and a directory-only rule such as Intermediate/ names directories alone. Where the working tree holds a directory at a path the tree holds a file at, or the reverse, the working-tree entry is new content and answers to the ignore file, and the tree's node is reported and staged deleted like any other (State::tracks_as).
- One filter bit. Revision 2044's bit becomes
FilterMode::KeepTracked, and its modeFilterMode::STAGEDbecomesFilterMode::TRACKED: the view, and the ignore rules for what the tree does not track. A walk clears the bit withFilterMode::atfor each node the tree does not hold. The ignore slot is still stepped for a tracked node, so an untracked file below a tracked, ignored directory is still ignored. Whole-path queries honour the bit as well, since every ancestor of a tracked path is tracked. - Comparisons of trees. The comparison against the staged state in
status, andunstage, askatwhether something tracked is staged or marked dirty at or below a node (State::holds_tracked_change), where revision 2044 asked about staged alone. A node only the staged side holds, such as a move destination, is asked about by its node. Every node they reach is tracked, and a subtree with nothing changed in it has nothing to report, so an ignored one is still pruned without being entered, and a deleted ignored directory is still listed as one change. - Walks over the working tree. The filesystem diff (
status --scan), the staging walk and the dirty walk look each entry up in the tree before asking the filter, and askTRACKED.atwhether the tree holds it. So do the path-level checks on a path the caller names, including one the filter leaves out whatever it holds, which is dropped unread only where the tree does not track it. A path inside a layer is filtered where it is mounted, and looked up where the layer's trees hold it, below the layer's source. - Walks over the tree alone. The collections of dirty paths ask
TRACKED.atwhether each node is tracked: the onestagewithout--scanresolves a directory to, and the one a staged-state rebase (after a sync or a branch switch) and a commit replay onto the new revision, so neither drops a dirty mark on a tracked ignored file. The delete passes in the dirty and filesystem walks consult the view alone, since everything they delete is in the current revision. - Deletions. The staging walk's deletion of what the working tree lacks asks
TRACKED.attoo, so an unforcedstagedeletes a committed file the ignore file names along with its directory, and a committed empty directory the ignore file names, where revision 2066 kept them. The view still keeps what it excludes, and an empty directory it excludes, which nothing materializes. - Cost. Every walk asks the filter without the tracked bit first, and asks the tree only where the ignore rules would leave a node out (
Filter::tracked_mode). The revision a staged tree was built on is read once, apart from the tree, the first time it is asked, and kept on the tree for as long as it is built on that revision. - What does not change. Operations on committed state already consult the view only.
--forcestill puts a staging walk past both filters, and still restages a node whose content is unchanged.
What changes in practice¶
Each case below assumes a .loreignore containing *.log. "Before" is the behavior on 0.12.1 or with revision 2044; "after" is this decision.
1. A force-staged file is visible and can be taken back.
- Before 0.12.1:
debug.logis not listed.lore unstage .leaves it staged, andlore committakes it. - Revision 2044: listed as staged, and
unstagereaches it. - After: the same as 2044, and a file force-staged with unchanged content is also listed, as a staged modification.
2. A committed ignored file is edited.
$ echo changed > debug.log # debug.log is in the current revision
$ lore status --scan
$ lore stage --scan
- Before:
status --scanis silent andstage --scanstages nothing. The change takeslore dirty --force debug.logand thenlore stage --force. - After:
debug.logis reported as modified, andlore stage --scanstages it, without--force.
3. An ignore rule is added after the file was committed.
$ lore commit -m "add data.bin"
$ echo "*.bin" >> .loreignore
$ echo changed > data.bin
$ lore status --scan
- Before:
data.bindrops out ofstatusandstage, though it is still in the revision. - After:
data.binstill shows as modified. Like Git, the rule does not apply to a tracked file. To drop it, delete it from the revision.
4. A new file the rule names is still ignored.
$ echo noise > new.log # never committed or staged
$ lore status --scan # new.log is not listed
$ lore stage --scan # new.log is not staged
- Before and after: unchanged. The rule governs what the tree does not hold.
new.logbeside a trackeddebug.logis ignored whiledebug.logis not.
5. An untracked file below a tracked, ignored directory.
build/ # ignored by the rule "build/", but build/keep.o is tracked
build/keep.o # tracked: edited changes are reported
build/new.o # untracked: stays ignored
- Before: neither file is reported.
- After:
build/keep.ois reported, andbuild/new.ois not, because the ignore slot is still stepped for a tracked directory.
6. lore dirty on a file that was only marked.
An add that is only marked dirty is not tracked. After a rule is added that names it, the mark is dropped again, as Git drops an untracked file a new .gitignore rule names. Staging it first makes it tracked.
7. A layer.
A layer mounted at lay/ with a tracked lay/debug.log and the parent's *.log rule: lore dirty lay/debug.log marks it without --force, while an untracked lay/new.log beside it stays ignored.
8. A committed ignored file is deleted from the working tree.
- With revision 2066: an unforced
stagekeepsdebug.login the revision, and the deletion takeslore --force stage. The same holds when the directory holding it is removed, and for a committed empty directory a rule names. - After:
debug.logis reported deleted, andlore stage --scanstages the deletion, without--force. A file the view excludes is still never deleted this way, since a sparse working tree lacks it on purpose.
9. A directory replaces a committed file.
src/Intermediate # a committed file
src/Intermediate/ # replaced on disk by a directory, under the rule "Intermediate/"
- After:
lore statusreports the file deleted, and leaves out what the directory holds. The tree tracks a file at that path, not a directory, so the directory is new content and the rule names it.
Consequences¶
- Good, because staged content is always visible to
statusand reachable byunstage, as revision 2044 made it. - Good, because a committed file keeps flowing through
status --scan,stageanddirtywhatever the ignore file says, as §12.3 already promised. - Good, because the behavior matches Git, which is what most users expect.
- Bad, because adding an ignore rule no longer hides changes to files that are already tracked. A user who wants such a file gone from the revision has to delete it from the revision, as in Git.
- Bad, because removing a directory from the working tree now stages the deletion of the committed files below it that the ignore file names, where revision 2066 kept them unless the stage was forced. Git's
git add -Adoes the same. - Bad, because the walks over the working tree look an entry up in the tree before filtering it, where they used to skip an ignored entry unread. The walks that already claim entries against a directory listing pay nothing. A path-level check pays one lookup, and only for a path the filter would otherwise drop.
- Neutral, because the comparison against the staged state now enters an ignored directory that holds dirty marks. It enters only the directories along those marks, which is what it pays for marks anywhere else, and none where nothing is marked.
- Neutral, because a file force-staged with unchanged content now shows in
statusas a staged modification, where it used to be hidden. A commit of nothing else still fails with "Nothing staged for commit", as before, andunstageclears it.
Pros and Cons of the Options¶
Ask the ignore file only about paths the tree does not hold¶
- Good, because nothing in the tree is hidden from any operation that works on it.
- Good, because it is what §12.3, the glossary and Git all describe.
- Bad, because it touches every working-tree walk.
Keep asking it about every path except what is staged¶
- Good, because it is already on main, as revision 2044.
- Bad, because a committed ignored file still needs
--forceto stage a change, which is the confusing half of the report. - Bad, because §12.3 and the glossary would have to be changed to describe it.
Keep the behavior and document it¶
- Good, because nothing changes.
- Bad, because a commit can take staged content that status does not show.
More Information¶
system-design.md§12.4 is updated with this rule.- Revision 2044 (change request
01a18d5f-d21e-7130-a073-3bb3ef029e48) is the base this builds on. Its tests inlore-revision/tests/dirty.rsthat expected a dirty change below an ignored directory to stay hidden now expect it listed, as does the outbound half oftest_urcignore_committed_file(scripts/test/test_urcignore.py), which expected a committed file to drop out ofstatusandstageonce an ignore rule named it.test_layer_stage_is_filtered_by_the_mount_path(scripts/test/test_layer.py) keeps its point, that a rule is matched against the mount path, with a new file below the mount instead of a tracked one. - Revision 2066 (change request
01a18ee4-033f-7590-8491-5bd1376e30d9) kept tracked content the ignore file names out of deletions. Its tests inlore-revision/tests/stage_intent.rsthat expected that now expect the deletion:a_staged_delete_deletes_a_tracked_child_the_ignore_file_names,a_staged_delete_deletes_a_directory_holding_only_tracked_content_the_ignore_file_names,a_marking_delete_marks_a_tracked_child_the_ignore_file_namesanda_scanning_stage_deletes_a_tracked_empty_directory_the_ignore_file_names, and inlore-revision/tests/stage.rsa_stage_scan_stages_the_removal_of_a_tracked_file_the_ignore_file_excludes, which expected the removal only under--force. Its view half is unchanged, and is covered inlore-revision/tests/sync_views.rs. - A directory a forced dirty mark creates is covered by
a_forced_dirty_add_below_a_new_ignored_directory_stays_ignored(lore-revision/tests/dirty.rs) anda_new_directory_is_recorded_only_where_it_is_empty(lore-revision/tests/unit/state.rs). - What a dirty mark leaves untracked is covered by
a_dirty_copy_into_an_ignored_directory_stays_ignored_and_a_dirty_move_does_not(lore-revision/tests/dirty.rs) anda_tracked_path_the_ignore_file_names_is_recorded_and_a_marked_add_or_copy_is_not(lore-revision/tests/unit/state.rs), which build the tree on a committed revision. - The replay of dirty marks is covered by
rebase_staged_state_carries_a_dirty_tracked_file_an_ignore_rule_namesanda_commit_carries_a_dirty_tracked_file_an_ignore_rule_names(lore-revision/tests/dirty.rs). - The rule as the kind the tree holds is covered by revision 2055's
a_status_of_a_file_replaced_by_a_directory_a_directory_only_rule_names_reports_its_removal(lore-revision/tests/dirty.rs). - The rule is covered by
a_tracked_step_answers_to_the_view_alone(lore-revision/tests/filter_gitignore.rs),a_tracked_node_passes_the_ignore_slot_and_its_untracked_children_do_not(lore-revision/tests/unit/filter.rs),status_lists_forced_and_dirty_changes_below_an_ignored_directoryandthe_staged_comparison_enters_an_ignored_directory_only_along_its_changes(lore-revision/tests/dirty.rs),a_marking_walk_reports_a_tracked_file_the_ignore_file_names(lore-revision/tests/stage_intent.rs) and the regression tests inscripts/test/regression/test_force_staged_ignored_file.py, which cover a layer too.