fix(list): match --project, --area and --tag values literally - #266
Merged
Merged
Conversation
The list filters matched titles with LIKE and passed the value straight in as the pattern. So `things --project '%'` matched every project — and since #260 lifted the closed-project fold for all of them at once — and a title holding a % or a _ matched more than itself. Filter values are now escaped for %, _ and the escape character, and every LIKE that takes one declares ESCAPE. Matching stays case-insensitive, which is what a name filter wants and what it did before; only the wildcards lose their meaning. The uuid arm keeps the raw value, since it is an equality test rather than a pattern. `things projects --area` takes the same flag and is escaped the same way, so the two commands cannot disagree about what a name matches. The golden SQL file is the record: 100 of the 200 view-and-filter combinations moved, which is exactly the five filter-carrying shapes across ten views and both --include-completed states, and every one of the 100 differs only by the added ESCAPE clause. No combination without a filter moved. Measured against a build of origin/main on the live database: `--project '%'` went from 540 rows to none, while `--area 'Personal Projects'` returned the same 43 either way. No project, area or tag title in that database contains a % or a _, so no real name changes behaviour. The lookup and search paths are deliberately left alone. They wrap the value in %…% because substring matching is what `things show <title>` and `things search` are for. They have the same escaping gap, which is worse there than it was here, but fixing it changes what those commands match and belongs with its own tests and its own issue. Closes #262
This was referenced Sep 10, 2026
ryanlewis
added a commit
that referenced
this pull request
Sep 10, 2026
Closes #267 ## Release note **A task reference and a search query now match literally — `%` and `_` are characters to find, not wildcards — so `things complete` can no longer resolve to a task you did not name.** ## What changed `FindTasksByTitle` and `SearchTasks` matched with `LIKE` and used the caller's text as the pattern, so `_` stood for any character and `%` for any run of them. `GetTask` falls back to `FindTasksByTitle` and acts on a lookup that matches exactly one row, so `things complete '20_30 review'` could resolve to, and complete, a task called `20:30 review`. #266 fixed the same gap for the list filters; this is the more dangerous half, because a write command acts on the result. Both now escape the value and declare `ESCAPE`, from the constant #266 introduced. Substring matching is unchanged — the wildcards either side are the CLI's, and only the characters inside the value stop being pattern syntax. Matching stays case-insensitive. ## Measured on the live database | command | before | after | | --- | --- | --- | | `search '50%'` | 45 rows | 0 | | items whose title or notes contain the literal text `50%` | 0 | 0 | | `search 'review'` | 129 | 129 | ## My first regression tests were worthless, and that is the part worth reading Both passed against the **unfixed** code. `/code-review` caught it by reverting the fix and running them. Two separate mistakes, both easy to repeat: - `GetTask` resolves an exact title before the `LIKE` is ever reached, so seeding the exact title under test never exercised the path. - `LIKE` wildcards act on the **pattern**, not on the stored value. I put the underscore in the seeded title and queried plain text, which tests nothing. They are rebuilt around the real failure: resolve a reference that must miss, then resolve a substring that forces the `LIKE` path. I reverted `tasks.go` and confirmed they now fail, rather than taking the review's word: ``` GetTask("20_30 review") resolved to "t-colon"; no task is called that GetTask("20_30 rev"): ambiguous task: "20_30 rev" matches 2 tasks resolved to "t-colon" — a write would have hit a task the user never named search 50%: got [s-pct s-fifty s-note], want [s-pct s-note] search a_b: got [s-under s-axb], want [s-under] ``` ## Docs Nothing documented changes, because wildcards were never offered for `<task>` or `search`. The README, the site's Searching and Inspecting sections, and the bundled skill now say the matching is literal, so the absence is stated rather than assumed. `make test` and `make lint` are clean; `hugo` builds the site.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #262
What changed
The list filters matched titles with
LIKEand passed the value straight in as the pattern.things --project '%'matched every project — and since #260, lifted the closed-project fold for all of them at once — and a title holding a%or a_matched more than itself.Filter values are now escaped for
%,_and the escape character, and everyLIKEthat takes one declaresESCAPE. Matching stays case-insensitive, which is what a name filter wants and what it did before; only the wildcards lose their meaning. The uuid arm keeps the raw value, since it is an equality test rather than a pattern.things projects --areatakes the same flag and is escaped the same way, so the two commands cannot disagree about what a name matches.The golden file bounds it
100 of the 200 combinations moved: exactly the five filter-carrying shapes across ten views and both
--include-completedstates. No combination without a filter moved, and every one of the 100 differs only by the addedESCAPEclause — checked mechanically, line by line, not by eye.Measured against a base build
Built
origin/mainand compared on the live database:--project '%'--area 'Personal Projects'anytimeNo project, area or tag title in that database contains a
%or a_, so no real name changes behaviour.A worse instance of the same bug, left for its own issue
/code-reviewfound thatFindTasksByTitleandSearchTaskswrap the raw value in%…%with no escaping. That is the same gap on a more dangerous surface, becauseGetTaskfalls back toFindTasksByTitleand auto-selects when exactly one row comes back — sothings complete '20_30 review'can resolve to and complete a task titled20:30 review. There are 4 open tasks with an underscore in the title on this database.The search half is live and measurable today:
things search '50%'returns 45 rows, while the number of tasks whose title or notes contain the literal text50%is 0. It is matching%50%%.Not fixed here. Escaping those changes what
show,completeandsearchmatch, on a different command surface from the filters, and needs its own tests and a docs note — the README documents<task>as taking a title substring. Worth an issue.Docs
None needed. The README already describes these flags as matching a name or UUID, which is what they now actually do; the pattern behaviour was accidental. The
<task>substring claim is about the lookup path, which this does not touch.make testandmake lintare clean.