Conversation
Dependabot opens pull requests against both branches here, and nothing merged them, so every dependency bump waited on a manual merge and the action pins went stale between sweeps. Carry the two jobs the contract requires. The fleet canonical also carries merge-codegen and merge-upstream-version, and this repository runs neither, so both are left out rather than carried as conditions that can never match. The App-token secrets the workflow needs are already configured in both the Actions and Dependabot stores. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add OPERATIONS.md The hub requires this file of every repo, checked for presence only, so its content is entirely this repository's own. It exists because the convention was emerging unevenly and the same operational material was landing under ad-hoc names. Carry the five declared headings with real content rather than a stub: the local gates that mirror CI, the dispatch-driven release, the runtime dependency verify has on Docker and on a database of its own, what the three exit codes distinguish, and why exiftool validation warnings are logged at debug level only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Say how the CI invocation differs from the local one The runbook claimed CI runs the same commands, and it does not: locally csharpier formats and writes, while CI runs it in check mode and only verifies. A reader following the text would expect CI to fix formatting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Describe the commands this branch actually has The file documented the verify command, the exiftool -validate flag, and a per-file-failure exit code of 2. None of those exist on develop: they are on the unmerged verify branch, and this branch was cut from develop. Describe what is here instead. Exit codes are 0 and 1, a per-file failure does not change them, exiftool is invoked without -validate, and the application needs no Docker daemon at runtime. The verify operational content follows once that work merges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Restore the line endings on OPERATIONS.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Name the two places CI differs, and the lint step the snippet omitted The text claimed CI runs dotnet test unchanged, and it adds coverage collection so coverlet can emit the report Codecov consumes. The lint snippet listed three of the four Lint tasks, leaving out actionlint, so following it ran less than the local surface it claimed to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Say that the local linters and CI's are the same rules, not the same builds The local commands pull :latest while CI reaches three of the four through SHA-pinned wrappers, so a local result can differ from CI once an upstream release lands ahead of the pin. Name CI as authoritative and the difference as a version gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Recommend the key file rather than naming it the only mechanism The CLI accepts an inline --apikey too, and the two are mutually exclusive by validator, so stating the file form as the only one was wrong. Recommend it and say why. Drop the trailing claim that a local Docker linter run matches what CI resolved, which contradicted the paragraph above it saying local pulls latest while CI reaches three of the four through pinned wrappers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Name the parse error among the exit-1 causes BypassStartup short-circuits when the parse result carries errors, so a command-line mistake exits 1 before any work starts. The list named only cancellation and unhandled exceptions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Consume the fleet prose rules from the hub The prose rules that govern this repository live in the hub, and until now nothing here ran them, so a comment or a sentence breaking a documented rule reached main with every linter green. Consume the hub's composite action rather than vendoring its checker, so a rule change lands in one place instead of in every repository holding a copy. A develop-targeted run reads the rules from hub develop, so an unpromoted change is exercised here before it is promoted, and every other run uses the copy bundled at the pinned commit so a released build stays reproducible. The gate reports only lines a change touches, so the repository's existing prose backlog blocks nothing and is corrected as each file is next edited. Checkout gains full history in the lint job, because diffing against the base branch needs that branch present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Point the prose gate at the branch it merges into The gate runs on push, because this repository has no pull_request trigger, so there is no event base to read and the first run diffed against an empty ref. Unresolvable, it reported the whole repository instead of the lines this change touches. Name the base explicitly as the branch being merged into, and skip main, which only receives promotion merges already gated on develop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Skip the prose gate on a publish run A publish reaches validate-task through build-release-task, where the content was already gated when it was pushed. Re-reading it there would diff the whole unpromoted delta against main and could fail a release on prose that already passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Cut the publish-run comment to one sentence per line Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Describe what this workflow does, and mark the pin temporary The comment described the action's main-run behavior, which this workflow never reaches because it skips main outright. A maintainer reading it would assume a prose gate runs on main. The pin targets an unmerged hub commit, and that intent lived only in the pull request description, which does not survive the merge. State it inline instead, including that Dependabot cannot bump a pin resolving to no tag, so the repoint is manual. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Only pay for full history on the runs that use it The lint job always full-cloned, including publish runs where the prose gate is skipped, which is time and network a release does not need. Tie the fetch depth to the same condition the gate uses, so the two cannot drift apart. Repoint the pin to the hub branch head, which carries the fix for a multi-line paths input that scanned only its first entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Quote the fetch depths so the true branch is not falsy An unquoted 0 is falsy in an Actions expression, so the ternary collapsed to 1 whenever the condition held and every run shallow-cloned, leaving the prose gate no base branch to diff against. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Cut the wrapped comment sentences to one per line Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Repoint the prose gate at the hub commit that carries it The pin named the head of an unmerged hub branch, which would have gone unreachable once that branch was squashed and deleted, breaking this repository's gate later with nothing here to explain it. ptr727/ProjectTemplate#520 has landed, so point at the commit on hub develop instead. It still carries no release tag, so Dependabot cannot bump it yet and the note says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Describe the pin as a SHA rather than as a branch A pin that calls itself develop stops being true the moment develop moves. Name what it contains instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add a verify command and always-on exiftool validation Adds `verify`, a standalone pipeline step that answers whether Immich can generate a preview for each file, and turns on exiftool `-validate` in the metadata read that `process` and `import` already perform. Fixes #25, where ~8,200 HEIC files were byte-complete, passed every existing check, uploaded successfully, and then failed thumbnail generation forever. An `iloc` box re-encoded from version 1 to version 0 loses `construction_method`, so the `grid` descriptors Apple stores in `idat` are read as absolute file offsets and land on the `ftyp` header. That incident is only one of three classes. Two more are open against Immich: DNGs libraw cannot parse, and RAW from cameras it does not yet recognize. Neither is corruption, so no parser can predict them - only running the decoder Immich runs can. `verify` therefore has two passes: - A structural ISO-BMFF box walk, in-process and free, reporting extents past EOF, an `iloc` v0 alongside an `idat` box, and `grid` geometry that disagrees with the `dimg` reference count. `--quick` stops here and needs no Docker. - A decode pass that calls Immich's own compiled `MediaRepository`, `defaults`, and `ThumbnailConfig` inside the `immich-server` image, batching paths over stdin. Calling Immich's code rather than reimplementing its pipeline means behavior tracks Immich across releases. A preflight runs before any file is judged, so an unreachable Docker or a missing image exits Error rather than condemning the collection. Measured on a real collection, `-validate` costs nothing on top of the existing exiftool call, but ~75% of healthy files carry warnings, so only an error count fails a file; warnings are logged at debug level. Commands now share one exit-code contract: 0 success, 1 could not run, 2 ran to completion with per-file failures. This is a behavior change - `process` previously exited 0 while logging failed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Document the verify database behavior No behavior change. The verified state is a single bit rather than a record of which check produced it, and two consequences of that were undocumented: - A --quick run after a full run skips every file the full run passed, so it can legitimately do nothing. Correct, since a full run subsumes the structural checks, but surprising without a note. --reprocess forces it. - The bit lives in the same is_processed column that process writes, so pointing verify --db at a Process.db silently skips every file as "already verified" when they were only processed. Nothing detects this, so the requirement to use a separate Verify.db is now stated rather than implied. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Bring comments in the verify code to house style The verify code was written with XML doc blocks on internal members and multi-sentence prose comments, matching neither the documented rules nor the surrounding code. GOVERNANCE.md bans class/type/file header summary blocks, calls for one sentence per line with no wrapping, and defaults to a single line. CODESTYLE.md scopes XML documentation to public surfaces, and everything here is internal. The pre-existing PhotoCleaner sources carry two XML summaries in total. Removes every XML doc block from the new files, collapses the header blocks to the single non-obvious point each type actually needs, and rewrites the wrapped prose comments as one sentence per line. Net 196 lines of comment removed with no code change; tests unchanged at 367 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Parallelize the verify partition pass PartitionAsync hashed every file in a sequential foreach while every other bulk path in the codebase uses Parallel.ForEachAsync bounded by --threads. On a real 264k-file tree that measured 446 files/min, putting roughly ten hours of single-threaded hashing in front of the first verification. Now matches IndexTask.ExecuteAsync: parallel, with a ConcurrentBag for the candidate list and Interlocked counters. Measured 908 files/min on the same storage. The remaining ceiling is the disk, not the code, since hashing reads every byte of the tree off spinning disks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Verify per file in quick mode and per batch in full mode Verification ran as three global barriers: hash every file, structurally check every file, then decode in batches, recording database state only at the very end. On a 264k-file tree that meant roughly five hours of hashing before the first file was verified, every file read twice with nothing left in cache between the two reads, and no progress retained if the run was interrupted. Quick mode now processes one file at a time in parallel, matching the shape import and process already use. Full mode does the same work per batch: hash the batch, decode it in one container, then act on the results and record them. Batches still run concurrently under --threads, so the container round trip is amortized without holding the whole tree in front of the first result. Two container flags follow from running this against a live Docker host. The image's healthcheck cannot pass under an overridden entrypoint, so every container reported unhealthy and alerted anything watching Docker; disable it. And the image may be the same one an existing container already runs, so the containers this tool starts carry a label and are never selected by image. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Cut wrapped prose comments back to one sentence per line A sweep across every comment syntax the project ships found prose sentences wrapped mid-thought throughout the C# sources, which the comment rules disallow. Rewrites 32 of them as one sentence per line, cutting each block back to its load-bearing point, and drops the two remaining XML documentation blocks from non-public members. Comments-only, no behavior change. Label-and-example blocks are left alone, since a lead line followed by examples is not a wrapped sentence. The workflow YAML and repo-config carry the same pattern but are verbatim fleet content, so they are left for the hub rather than forked here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Decide the exiftool verdict from its output, not its exit code The metadata read ran with CliWrap's default exit-code validation, but exiftool exits non-zero on exactly the files whose verdict reports an error, while still writing the JSON that carries it. The call therefore threw before the verdict could be parsed, the file was counted as a pipeline failure, and the fail-on-error branch was unreachable. Disables exit-code validation for that read and throws only when exiftool returns no JSON at all, so a genuine tool failure still surfaces. The error text now rides along in the log rather than only a count. Reverting the validation change fails the new test, so the case proves the fault it names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Translate paths across the container boundary The decode pass mounted the media tree at its own absolute host path and wrote host paths to the container. A container path must be Linux-style, so that mount is invalid on a Windows host even though the repo ships and documents a Windows build. Mounts at a fixed container path instead and translates each path across the boundary, mapping the verdicts back by the same table. This drops the same-path assumption rather than branching on the operating system, so one code path serves every host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Log container start and stop for the decode pass The batch size trades container startup against how long a file waits between being hashed and being decoded, and neither side of that trade was measurable. Adds paired start and stop debug lines around each container invocation, so a debug-level log carries the duration of every launch without a timer in the code. The preflight carries no files, so its own pair isolates the fixed cost. Measured over 200 synthetic launches: about 3.8s median fixed overhead, of which roughly 2.3s is Immich's module graph rather than the container itself. That part is a floor, since loading those modules is what makes this Immich's decoder rather than an approximation. At the current batch size the work outruns startup by about seventy times. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Correct what quick mode records in the database Both the code comment and the README claimed a quick run records nothing. It records content hashes through the shared index path whenever --db is given, and those hashes are then cached for a later run. What it withholds is the verified bit, so a full run still decodes the file. Documentation only, no behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Count an unreadable file as failed rather than verified The structural check caught every exception, logged a warning, and returned true. A file it could not open was therefore counted as verified, so a permission error or an I/O fault produced a clean result for a file nothing had actually checked. For a command whose whole purpose is to answer whether a file is good, a false clean is the worst possible outcome. Now follows the per-file pattern the other tasks use: a file that no longer exists is logged and uncounted, since that races with any other run, and any other exception is an error against the failed count. Neither path reports the file as verified, and neither reports it as invalid, because being unable to read a file is not evidence of damage. Restoring the old catch fails the new test on Verified being 1 rather than 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Say that exit 2 also covers files that could not be verified The exit code table claimed a 2 meant files were found to be bad, which stopped being true once an unreadable file started counting against the failed total. A run that could not read a file now exits 2 while nothing was actually judged bad, so a script reading that wording would draw the wrong conclusion. Says invalid or could not be verified, and points at the two counts that tell those apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Raise the decode batch to 1024 files Per-file decode cost spans fiftyfold across real media, from about 20ms for a thumbnail to over a second for a raw frame, while container startup is a flat four seconds. At 256 files a batch of thumbnails spent 37 percent of its time starting the container, a multiplier of less than two. 1024 puts the cheap end at sevenfold and typical photographs near fiftyfold. Going further would risk producing fewer batches than threads on a small tree, which costs more in lost parallelism than it saves in startup. Measured on three real corpora: 20ms per file for thumbnails, 168ms for five megapixel JPEGs, and 1103ms for HEIC and video. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Match the missing-file branch on the exception alone The filter also accepted any exception where File.Exists returned false, and File.Exists returns false when the path cannot be read at all, not only when it is absent. A permission error on an unreadable directory therefore landed in the branch meant for files that went away, logged at information and counted nowhere, so a run could exit 0 with files that were never checked. Matching FileNotFoundException and DirectoryNotFoundException keeps that branch to files that genuinely vanished and lets every other error reach the failed count, which is what the surrounding pattern already intends. Restoring the old filter fails the new test on Failed being 0 rather than 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Narrow the same missing-file filter in the process command Applies the previous commit's correction to the only other place carrying the pattern, so both command paths classify a missing file the same way. Import and index never had the lenient branch, routing every exception to the failed count, so the two are now the whole set. Left as two inline filters rather than a shared predicate. The condition is a single type pattern, and an exception filter reads better where it is caught than behind a call. The mechanism is proven by the verify test, which fails on the broad filter. Reaching it through the process command needs a directory to become unreadable between enumeration and processing, which is a race rather than something a test can stage honestly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Document verify and replace the icloudpd examples with kei Carries the maintainer's 1.1 release notes and rewrites the workflow example around kei, since icloudpd is no longer maintained. kei keeps its settings in a TOML file rather than on the command line, so the example leads with the config and the later forms point at it: an interactive login to store the session, a one-shot sync, and the service form that keeps mirroring on the watch interval. The stack-specific parts of a real deployment are left out so the compose example stays portable. Adds the verify command to the overview, framed around the question metadata checks cannot answer. Also corrects three typos that the spelling gate covers, and adds the kei author's handle to the dictionary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Build the index task once instead of per file Verify constructed a new IndexTask for every file it considered. The class holds no mutable state, so one instance serves the whole run, matching how the index and import commands already build theirs. The saving is an allocation per file against work measured in megabytes of hashing, so this is consistency rather than a measurable gain. Sharing the instance across the parallel loop is safe for the same reason it was safe to rebuild it: nothing on it is written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Refine application description to enhance clarity across documentation and command line interface * Cover the invalid import path with a test Import gained an Invalid outcome for files exiftool reports errors on, but nothing asserted it. The case now drives a real file through ImportTask and checks that invalid is one while failed stays zero, that nothing is copied to the output, and that the source is left alone. Disabling the branch fails it on invalid being 0 rather than 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Count a vanished file as failed in import and verify Only process rewrites the tree it walks, so only there is a name that has gone missing the expected result of the command's own work. Import copies without renaming its source and verify only reads, so under either of those a file that disappears between indexing and use means something outside the run changed the tree, and the run no longer covers what it was asked to. Verify's lenient branch is removed, leaving one catch that counts everything as failed, which also brings it in line with import and index. Process keeps its branch, and all three now carry a comment saying which case they are and why. Adds a case for a path that is gone by the time it is verified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fail an item info box that declares more entries than it holds The entry loop stopped once the remaining bytes could not hold another header, then reported success. A container whose iinf promises more entries than it carries was therefore treated as sound, and because the missing entries are where item types live, a grid item could go unrecognized and skip the geometry check that exists to catch it. Running out of box before the declared count is now a malformed structure. Checked against real media before tightening, since a validator that flags a good file is worse than one that misses: unchanged on the regression corpus, and no new findings across roughly fifteen thousand healthy HEIC, MP4 and MOV files from three collections. Also carries the maintainer's workspace edit, sorting the extension recommendations and adjusting the list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Clear the prose gate on the lines this branch changed The spaced hyphen and the mid-sentence semicolon are both out, replaced by commas, separate sentences, or parentheses rather than an em dash, which the character set rules ban in the same terms. Confined to the lines this branch touched, per the rule that existing prose is corrected as a file is next edited rather than swept. In the Copilot instructions that means the repository layout and pipeline sections only, none of the three sections carried from the hub. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Describe the real concurrency and drop an unused enum Two comments described verify as working through files and batches one after another, when both paths run in parallel under the thread limit. A reader reasoning about ordering, or about when a defect reaches the log, would have been misled by them. VerifyStatus was left over from an earlier shape of the code and never referenced once the counts record took over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Bring the prose to spec across the repository The gate reports clean on the whole tree, down from 245 findings, ahead of the repository going public. The carried files were stale rather than wrong. Every one of them is clean at the hub, so where a sentence differs only by the correction the hub already made, its wording is adopted rather than reinvented, which brings the two back into step instead of forking them. The runbook sections of the Copilot instructions are re-carried whole, since the hub had rewritten one of them in substance and not only in style. Nothing was copied wholesale. The two sections of GOVERNANCE.md that describe this repository's own tree and devcontainer, and the equivalent sections of the Copilot instructions, are deliberate adaptations, and taking the hub's text would have replaced this repository's layout with the hub's own. All eighteen byte-locked sections were checked against the hub before and after, and match. The rest is this repository's own prose, rewritten by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Keep a per-file verify failure from aborting the run An exception thrown inside either parallel loop of VerifyTask escapes Parallel.ForEachAsync and ends the whole command, so a single unreadable file aborts a run that may span an entire library and reports nothing about the files already verified. The hashing call that runs before the structural check when a database is in use is the exposed path. Guard the per-file body of both loops, logging the file and counting it failed, so the command reaches its summary and exits 2 rather than 1. The regression test sets a database, which is why the existing unreadable-file test did not cover this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Compare ISO-BMFF bounds by subtracting so a crafted file cannot overflow Three bounds checks add two attacker-controlled values before comparing. Each addend is already non-negative, but their sum can exceed long.MaxValue and wrap negative, so the comparison passes and a malformed file is reported clean, which is the one outcome a validator must not produce. An extent at offset long.MaxValue with length 2 returned None. Compare by subtracting instead, in the extent bounds check, the grid descriptor range check, and the top-level box walk, which is the only walk reading a 64-bit size and so the only one able to overflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Describe the types VerifyResult.cs actually declares The architecture map credits the file with a VerifyStatus enum that exists nowhere in the codebase, sending a reader looking for a type that was never written. Name the two types it does declare. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Warn on unrecognized ISO-BMFF structure instead of condemning the file The structural pass treated any structure the box walk could not follow as a defect, which conflates two different things. A file it can name a defect in is damaged; a file it merely cannot parse may just be a format the parser has never met, and from the inside those look identical. That asymmetry is measurable. Across 236,797 files the named-defect checks produced 16,394 hits and no false positives, while the generic check produced 7 hits, all false positives, on Samsung MP4s carrying a proprietary SEF trailer after the last box. Every future vendor quirk would arrive the same way, as a corruption report against someone else's valid library. MalformedBoxStructure now counts Suspect, logs a warning, and lets the file through to the decoder, which is the authority on whether Immich can use it. Suspect never affects the exit code. Recognize the SEF trailer specifically so the common case is silent rather than merely non-fatal. Keeping the check advisory rather than dropping it matters, because a truncated file decodes: Immich renders a thumbnail from bad-truncated.heic and reports nothing wrong. This is the only signal that class of damage has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Make the README help blocks match what the CLI prints The blocks are presented as literal --help output, but eight option descriptions across four commands had drifted from the code, so the documented CLI and the real one disagreed. Copilot found one instance; the same class covered process, import, index, and verify. Four descriptions were wrong in the code rather than the docs, because the options are single instances shared by every command that takes them. A shared option cannot say "the directory path to process" when verify, index, trash, and undo do not process anything, so those now read neutrally. The long --quick text moved out of help, where the README option notes already carry it in full. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Refuse to map a file outside the mount into a container path ToContainerPath built a container path from a relative path it never checked, so a file outside the mount root would produce a path containing '..' that resolves somewhere else inside the container. Immich would then judge a different file and report the verdict against the original name, which is worse than an error because it looks like a result. The file list is enumerated under the mount root today, so this is an unchecked invariant rather than a live bug. TryToContainerPath now returns false instead, and the caller logs the file and counts it failed rather than throwing, since a throw there would abort the whole run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fail closed when box nesting outruns the depth guard WalkBoxes returned true past MaxBoxDepth, so a container nesting meta boxes deeper than the guard stopped the walk and reported success. No iloc was ever recorded, so Validate returned None and a file built specifically to outrun the parser got a clean bill of health. Real containers nest two deep, since only meta and iinf recurse and every other box is skipped by declared size, so passing the guard means the structure cannot be followed rather than that the file is unusual. Return false, which now reports Suspect and defers to the decoder rather than condemning the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop the grid check reading a descriptor it cannot locate Construction method 1 means the item offset is relative to idat, so with no idat box in the file there is no base to resolve against. IdatOffset stayed at its default of zero, and the offset was read as absolute, pulling four arbitrary bytes and treating them as an ImageGrid descriptor. Those bytes then decided the tile geometry, so the check could invent a GridTileCountMismatch out of unrelated data. That is the worst direction for this feature, because a mismatch is a named defect that condemns the file. A synthesized case returned GridTileCountMismatch before this change. Report the contradiction instead, which is advisory and leaves the verdict to the decoder. Also require the box walk to consume its whole range, since a remainder too small to hold a header belongs to no box. Measured first: across 20,000 real files from the collection, every single one consumed its range exactly, so this costs nothing on real media. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Import System.Globalization in the file that reads it The namespace is a global using, so the file compiled without the import and the import changes nothing at build time. It is kept because a reader seeing NumberStyles and CultureInfo in this file has no local evidence of where they come from, which is what led a reviewer to call the file uncompilable. Verified that no formatter strips it: csharpier, dotnet format style in apply mode, and husky all leave it in place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop counting a dropped file twice, and refuse an unlocatable grid descriptor Two independent problems, both reported against the same round. The decode-batch failure handler counted one failure per entry in batch, but a file dropped during path mapping was already counted there and never entered mapped. Adding the mapping filter in 67e9a37 created the gap, since mapped and batch were the same length before it. Count over mapped instead. CheckGridGeometry read any construction method other than 1 as an absolute offset, so method 2, which is relative to another item, pulled four arbitrary bytes and let them decide the tile geometry. A synthesized case returned GridTileCountMismatch, so the file was condemned by a named defect derived from an accidental read. This is the same shape as the missing-idat case fixed in 88455e3, and the remaining branch of it. Report the contradiction instead, which is advisory and leaves the verdict to the decoder. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Bound the dimg reference reads to their own box ParseItemReference read from_item_ID and reference_count straight after the dimg header without checking either fits inside the box. A dimg declaring only its 8-byte header carries no fields at all, so both reads continued into whatever followed, and the resulting count went into the map the grid check consults. Where those bytes land decides which way it goes wrong. A synthesized header-only dimg filed the count under an item id nothing references, so the grid item lost its entry and the file passed as clean. Bytes that happen to decode to a real grid item's id give the opposite result, a tile count invented from unrelated data, which the grid check reports as a named defect. Require the box to hold the two fields and the reference list it declares, and report malformed structure otherwise, which is advisory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Make Immich's decoder the only judge, and drop the container parser The hand-rolled ISO-BMFF validator is removed along with the --quick mode it powered. Verify now runs Immich's decoder and nothing else. The parser was a standing liability rather than a one-off bug source. Five consecutive review rounds each found a real defect in it, every one the same shape: a read that was not bounded by the structure it claimed to parse, fed into the check that condemns a file by name. Two of those produced a tile count invented from unrelated bytes, which is the worst outcome available here, since it marks a healthy photo corrupt. The corpus it was tested against is one collection, and the failure mode of a format it has never met is indistinguishable from damage, so shipping it publicly meant condemning other people's media on evidence it could not actually read. Removing it costs the truncation signal, which decodes cleanly and so no decoder reports. That is a real loss, and the deliberate price of not guessing about files this tool has never seen. Verify keeps the existence check, because a file that vanished mid-run still counts as failed rather than damaged, and without it the missing path would reach Immich and come back as unrenderable. The exiftool -validate read is untouched. Verify has no offline mode now, so the tests that drive it skip when the image is absent, and the ones that judged synthetic bytes use real media. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Log a file missing during process at debug rather than information Process rewrites the tree it walks, so a name that has gone is usually its own earlier rename. It could equally be an external deletion, and the two are not cheaply told apart, so the message neither fails the run nor claims to know which happened. Debug is the level for something that is expected most of the time and diagnostic the rest. Import and verify keep counting a missing file as failed, since neither modifies its input and a vanished file there can only be external. Verify reaches that verdict through an explicit check rather than an exception, because without a database it never opens the file, so the wording now matches import's and the comments say why the mechanisms differ. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Keep a file missing during process at information, not debug Process rewrites the tree it walks, so a name that has gone is usually its own earlier rename, and it neither fails the run nor claims to know whether an external deletion caused it instead. The line stays at information rather than dropping to debug, so a run still accounts for why a file is gone without the reader having to raise the level to find out. Verify and import are deliberately louder: both log an error and count the file as failed, because neither modifies its input, so a vanished file there can only be an external change to the tree mid-run. Counting it failed is what makes the command exit 2, and the error level agrees with that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop the preflight failure pointing at an option that no longer exists Removing --quick left the preflight message telling the reader to pass it, so the one message a user sees when Docker is missing offered a way out that the CLI now rejects. It states the actual requirement instead: docker and the Immich image, with no offline mode, because the decoder is the whole check. Also drop the System.Buffers.Binary import, unused since the tests that synthesized box headers went with the parser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Test that an unknown verify option is rejected, without naming one The case names a specific flag that no released version ever accepted, so the name kept a option alive in the codebase that a reader could never have used. What is worth guarding is the general behavior: an option the command does not define is an error rather than something silently ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Count an unreadable file as failed whether or not a database is configured With a database the hash read opens every file, so a permission or I/O failure throws and the per-file guard counts it failed. Without one nothing opened the file before the container did, so the same file was handed to Immich, came back unrenderable, and was counted invalid. The verdict for a tooling gap therefore depended on an unrelated flag, and in one of the two paths reported a readable-media problem as damaged media. Probe the file in the no-database path and let the existing guard count the throw, so both paths reach the same verdict. Also rename the launch profile that still said Quick. The two verify profiles differ by whether they pass a database, so the name says that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Count a file that vanished before the decode as failed, not invalid A file removed after its batch was assembled makes the decoder fail, and that failure was reported as "Immich cannot render", so a file that is simply gone was recorded as damaged media. Check the host before believing a rejection, so the vanished case reaches the same failed count as every other place the tree changes mid-run. Also correct two claims in the verify flow. The media directory mounts at the fixed container path rather than at its own absolute path, and paths are translated onto it, which is what lets a host path that is not a valid container path work at all. The command exits 2 for a failed file as well as an invalid one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Split the untested-race note across two comment lines Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Reject a path carrying a line break before sending it to the container Paths reach the container one per line, so a name containing a newline or a carriage return cannot be expressed in that protocol. Without this the name splits and the container is asked to decode fragments of it, and the run reports the generic "no verification result returned" rather than the reason. The counts are unchanged, because a split path matches no returned verdict and so already counted failed. What this buys is an accurate reason and a well-formed payload, not a different tally. No path under the collection carries either character, so this guards a hazard rather than fixing an observed failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Skip the Immich preflight when nothing needs decoding Verify preflighted the Docker image before looking at what it had been given, so a directory holding no supported media exited 1 on a host without Docker, and paid four seconds of container startup on one that had it. The command reported an infrastructure failure for work it was never going to do. Preflight now runs only when at least one file carries a supported extension. The partition loop is untouched, so non-media files are still counted ignored and still drive the unknown-extension warnings. Proven end to end with docker removed from PATH: the same directory of text files exits 1 before this change and 0 after. The test is deliberately not gated on the Immich image, because a host without it is the case this protects, which is also what CI is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Mount the media directory in a form that survives the characters in a path The volume spec is colon delimited, so a colon anywhere in the media directory made docker reject the spec outright, and the whole batch failed with "too many colons" against a directory that was perfectly valid. Switching to --mount alone only moves the problem, because that flag is comma delimited and a comma is far the more likely of the two in a photo directory name: this collection holds 285 paths containing a comma and none containing a colon, so the naive swap would trade a hazard that has never occurred for one that already exists. Quoting the whole source field as CSV takes both, with any embedded quote doubled per CSV rules. Verified against directories named with a colon, a comma, both, and both plus a quote. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Use a neutral example path in the kei instructions The examples named a real media tree rather than an illustrative one, which puts a reader's own directory layout in front of them and invites anyone following along to copy a path that means nothing on their machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Defer the Immich preflight until a file actually reaches the decoder Gating the preflight on any supported extension being present left a database-backed run that skips every file still demanding Docker, so an incremental run over an already-verified tree failed on a host without it. The comment claimed nothing to decode means nothing needs Docker while the code only honoured that for a tree holding no media at all. The first batch to find a candidate now triggers the preflight, once, and the rest await it. A run that decodes nothing never starts a container. The earlier reason for not doing this was wrong. Batches are processed as they are chunked, so the wait for a Docker failure is bounded by one batch rather than by hashing the whole tree, which is seconds rather than hours. Verified with docker off PATH: a fully cached run exits 0 having skipped its files, and an uncached one still exits 1 with the message intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Probe readability for every file that reaches the decoder The probe only ran when no database was configured, on the assumption that the hash read would open the file otherwise. It does not: a row matching on size and mtime returns cached hashes without reading the file, and stat needs no read permission, so an unreadable file with a cached row went straight to the decoder unopened. Immich runs as another user and may well read it, so a gap in this tool's own access came back as a verdict about the media, which is the outcome this branch has been closing off everywhere else. The probe now sits at the end of the skip decision, so it covers exactly the files that get judged and costs nothing on the ignored, missing, and already-verified paths that an incremental run spends its time on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Check readability in the shared metadata call, not only in verify Reading a file's attributes needs no permission on its content, so size and mtime prove nothing about whether the bytes can be reached. Verify was given its own probe for that, which left the same hole open everywhere else. Measured against an unreadable file before this change: process reported Failed 0 and exited 0, and import reported Imported 1. Both claimed success over a file they never read. Index was already correct, because it hashes and therefore opens. The probe now sits in the shared exiftool call, so process and import inherit it, and verify calls the same helper for the path that hands the file to Immich rather than reading it here. Keeping it ahead of exiftool is what makes the distinction work. exiftool reports a file it cannot open and a file it cannot parse the same way, as an error alongside well formed JSON, so keying on that field would have turned damaged media into a tooling failure. Garbage bytes in a readable file still count Invalid, and only an unreadable one counts Failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Name the no-extension case, and stop the trash comment overstating A file with no extension tracks as an empty string, so the summary printed "Unknown file extension: ''", a quoted nothing that reads as a bug in the message rather than a fact about the tree. All four commands feed the same tracker, so naming the case there covers every one of them. No file in the 264,044 verified carried this, which makes it latent noise rather than an observed defect. The trash comment claimed a page either fetches or throws, so no partial state exists. Pagination also stops early on a NextPage value it cannot use, warning and then returning success, which leaves the database short of the server. The comment now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Exit 2 when trash syncs only part of the server Pagination stops early on a NextPage value it cannot use, and the command then reported success over a database holding fewer hashes than the server. That database is what import --trashdb and process --trashdb consult to skip files, so a short one silently re-imports assets that were trashed, and a pipeline gating on the exit code had no way to see it. The stop is now an error rather than a warning, and the command exits 2, which is the existing meaning of a run that completed while carrying a failure. Whatever pages were read are still kept, since a partial database is more useful than none as long as the caller knows it is partial. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Drain the docker probe's pipes before waiting on it The image-availability probe redirected both streams and read neither, so the child could fill a pipe buffer and block on the write while the wait blocked on the child. Measured at 5,662 bytes against a 64 KB buffer today, which is why nothing has hung, but the margin belongs to the image rather than to this code. Both streams are now read before the wait, and the probe asks for an empty rendering since only the exit code is wanted, so there is almost nothing to buffer either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Name every exit code, list verify, and finish the trash comment Three findings from suppressed review comments that had not been answered. CommandRunner logged completion for anything that was not Failed, so an Error would have reported the command complete. Nothing returns Error from the work itself today and the catch blocks return it directly, so this is a guard on a future caller rather than a live defect. The architecture map still described five subcommands and omitted verify, having been written before this branch added it. The trash comment covered pagination stopping early but not a page that throws, which leaves the database short as well, having kept the pages before it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hub advanced on six byte-locked regions since the last conformance round, and the audit reports each as a stale carry: a region matching a past hub revision rather than the current canonical. AGENTS.md "Context and Delegation Discipline" no longer ends a session on a third review round, since a loop still producing findings is the deliverable in progress, and it gains the rule that a wait separates "met", "not yet", and "cannot be reached" instead of rendering all three as silence. "Where the Rules Live" gains the router row for quoting data into agent-authored text. GOVERNANCE.md gains "Representative Data in Agent-Authored Text", absent here entirely, which binds agent-authored text to constructed data rather than data observed in the maintainer's environment. "Git and Commit Rules" gains the rule that an authorization to commit carries the push, because nothing reviews a local commit. "Verification Discipline" gains the rule that a launched process is not a result. "PR Review Etiquette" gains the five outcomes that close a finding, the rule that a low-confidence finding is not a low-value one, the requirement that a decline carry proof rather than an assertion, the answer format for a suppressed finding, and the warning that a review's own overview cannot be trusted to say whether findings exist. The new "PR Review Etiquette" text points at scripts/pr_review.py, which this repo does not carry. That is a known open question at the hub, and a verbatim region is carried unchanged rather than locally patched. Audit run 2026-08-03T16:52:36Z, hub 1ed0cc8, against develop@39c896b. All six regions are promoted to hub main a9cd154. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The audit's mechanical check only presence-checks the sections of an intent-fidelity carried file, so these four drifted silently while every byte-locked region stayed green. Hand-diffing each against the hub found stale paragraphs in all four, and one guarantee this repo describes but does not implement. CODESTYLE.md regains the Python section it had trimmed. The file's own intro says a repo keeps it whole and that an unused-language section costs nothing, so trimming it contradicted the sentence directly above the cut. Four paragraphs re-vendor: the clean-compile bullet now says it is not the whole gate and points at the repo's whole lint gate, MD033 now permits details and summary, HISTORY.md is now framed as mirroring the README opening rather than sharing a header, and the intro names the verification discipline. The Python tasks-mirror reference pointed at a hub catalog path this repo does not have, so it names the repo's own .vscode/tasks.json instead, which is what the file's VS Code config rule requires. WORKFLOW.md re-vendors D2.2, which now explains that the smoke check exits early while the job still reports success, and why a job-level if: would be wrong. D4.2 names GitCommitId, which is what get-version-task.yml already outputs and what github-release already passes as target_commitish. D1.1, D1.2, D1.4, D1.5 and D4.1 described machinery this repo does not have: a paths-filter changes job, smoke-build needing the validation job, and a publish-plan-task.yml. Each is adapted to what the tree actually does. Both absences are stricter than the guarantee rather than looser, since every push smoke-builds and no push publishes at all, so the fix is to state the repo's shape rather than build machinery it does not want. The S1, S2, S3, S5 and S6 trace scenarios are corrected the same way. .github/copilot-instructions.md re-vendors two runbook sections. Triggering and Polling still scoped the suppressed-finding query to the current head, which is how a finding stops appearing without ever being answered, so it now reads every round and marks which round each came from. The reviewer login note gains the third spelling, the REST timeline's bare Copilot. Bounded Retry Workflow gains the pending-request-nobody-picked-up state, which is invisible from the reviews alone and indistinguishable from patience, along with the clear-and-re-request recovery. repo-config/README.md re-vendors one paragraph and is now byte-identical to the hub. D4.5 is absent from this repo's WORKFLOW.md and is deliberately not added here. The pipeline does not satisfy it, so stating the guarantee before implementing it would replace a silent gap with a false claim. It lands with its fix. Audit run 2026-08-03T16:52:36Z, hub 1ed0cc8, against develop@39c896b. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hub's model is a CRLF default with declared LF exceptions. This repo
had dropped the `[*]` `end_of_line = crlf` default and enumerated CRLF per
file type instead, which produces the same result for the types it listed
and leaves every other type with no declared ending at all: `.slnx`, the
`.code-workspace`, `LICENSE`, `.gitignore`, `.dockerignore`, and
`.editorconfig` itself were all uncovered. Restoring the default covers
them and makes the per-type CRLF lines redundant, so they go, along with
the `[*.{json,jsonc}]` and `[*.{cmd,bat,ps1}]` sections that carried
nothing else.
Two LF pins were missing, and one of them matters. `Docker/Dockerfile` had
no pin in either file although this repo ships one, and a CRLF there breaks
RUN heredocs and line continuations. It is LF in the tree today, so intent
held by accident rather than by rule; the pin is what keeps it that way
through a checkout or a renormalize. `.husky/pre-commit` is an
extensionless shebang script that matches no extension rule, so the CRLF
default would have claimed it. `.gitattributes` already pinned it and
`.editorconfig` did not, which is exactly the gap the restored default
exposes.
`.gitattributes` itself was LF while the hub's is CRLF, and under the
restored default that is a violation of the repo's own rule, so it is
rewritten as CRLF. Its comments regain the configure, renormalize, and
inspect commands the hub carries.
The hub's `catalog/`, `.github/actions/`, `scripts/*.py`,
`host-setup/`, `spec/*.py` and `uv.lock` pins are hub-only paths and stay
out.
Verified: `editorconfig-checker` passes over the whole tree, and
`git add --renormalize .` stages nothing beyond these two files, so the
new pins describe the tree exactly rather than proposing a conversion.
`git check-attr` confirms the Dockerfile pin resolves.
Audit run 2026-08-03T16:52:36Z, hub 1ed0cc8, against develop@39c896b.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
WORKFLOW.md D4.5 requires that a build failure block every publish target. This repo's WORKFLOW.md was missing the guarantee entirely, and the pipeline did not satisfy it. build-docker needed only [get-version, validate, validate-release] and guarded solely on cancellation. The image builds from source and consumes no executable artifact, so the two builds were independent and nothing forced an ordering. On a real publish where build-executable failed, github-release skipped, so no tag and no release were cut, while build-docker was untouched and pushed the multi-arch image anyway, moving `latest`. An image shipped with no release behind it. build-docker becomes the terminal publish target: it needs build-executable, and its `if` gains `!failure()`. The distinction that matters is failed versus skipped. A failed upstream build must stop the push, and a skipped one must not, because `validate` is skipped on every smoke run and that run still has to build the image. The explicit get-version and validate-release result checks stay, so a job that somehow did not run cannot feed empty version inputs into a build. The cost is on smoke, where build-docker now waits for build-executable instead of running beside it, adding roughly the executable build's duration to PR feedback. Correctness over a subset of the wall clock. WORKFLOW.md gains D4.5 in the same commit rather than in the doc-refresh PR ahead of it, so the contract and the code that satisfies it land together instead of the file claiming a guarantee the pipeline breaks. The failure path is not reachable from a pull request, since smoke never publishes, so it is established from the needs graph rather than by observation. actionlint passes. Audit run 2026-08-03T16:52:36Z, hub 1ed0cc8, against develop@39c896b. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
HISTORY.md declares Version 1.1 for the verify command and the breaking exit-code changes, while version.json still floored at 1.0, so the next release cut from main would have been 1.0.18 and contradicted it. Only version.json moves. The Version and InformationalVersion properties in PhotoCleaner.csproj are local-build placeholders that CI overrides with the values Nerdbank.GitVersioning computes, so they do not track this file. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Open the local gate chain with a dotnet tool restore CSharpier and Husky.Net are local tools declared in .config/dotnet-tools.json, so on a clone whose package cache does not already hold them the documented chain failed on its first command with "Run dotnet tool restore to make the csharpier command available" and exit 1. CI already restored before its CSharpier step, and the prose called that out as a difference between the two. It is no longer one, so that clause is dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Attribute the restore to the clone, not to the .NET Format task The paragraph introduces the chain as the `.NET Format` task, so saying the chain opens with a restore read as a claim about that task. It is not one: `.NET Format` depends on `CSharpier Format` and `.NET Build`, and none of the three restores, so the task fails on a fresh clone exactly as the shell chain did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Not ready to approve
OPERATIONS.md currently contains outdated statements about exit-code semantics and Docker runtime requirements that conflict with the new ExitCode contract and the verify command’s Docker dependency.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This promotion merges develop into main, bringing in the new verify command (Docker/Immich-based decode verification), standardizing command exit codes, enabling ExifTool validation in metadata reads, and completing a broad hub-conformance refresh (docs, workflows, line-ending model, and operational runbooks). It also raises the NBGV version.json floor to 1.1 to match the declared release notes.
Changes:
- Add the
verifycommand and its supporting in-container Immich verification protocol, including tests. - Standardize exit codes across commands (
ExitCode) and add ExifTool-validatehandling (invalid vs failed outcomes) inprocess/import. - Refresh governance/intent docs, workflows (including prose gate + publish gating), operational documentation, and configuration baselines; bump
version.jsonto1.1.
File summaries
| File | Description |
|---|---|
| version.json | Raise NBGV version floor to 1.1. |
| repo-config/README.md | Prose clarifications for repo-config purpose and rulesets. |
| PhotoCleanerTests/VerifyTaskTests.cs | New unit/integration tests for VerifyTask parsing, mapping, and Docker-gated execution paths. |
| PhotoCleanerTests/UndoTaskTests.cs | Comment wording tweak for clarity. |
| PhotoCleanerTests/TrashCommandTests.cs | Add test ensuring invalid pagination (NextPage) results in exit code 2 / failed run. |
| PhotoCleanerTests/TempDirectoryFixture.cs | Comment cleanups and clarification around exiftool exit-code suppression. |
| PhotoCleanerTests/ProcessTaskTests.cs | Add tests for unreadable files and ExifTool validation warning/error behavior. |
| PhotoCleanerTests/IndexTaskTests.cs | Comment clarification on rehash and --processed behavior. |
| PhotoCleanerTests/ImportTaskTests.cs | Add invalid-file import test; update tuple shape to include invalid count. |
| PhotoCleanerTests/ExifToolJsonTests.cs | Add tests for ExifToolJson.ParseValidate. |
| PhotoCleanerTests/DirectoryCleanerTests.cs | Comment wording cleanup. |
| PhotoCleanerTests/CommandLineTests.cs | Add CLI parsing/registration tests for the new verify subcommand. |
| PhotoCleaner/VerifyTask.cs | Implement VerifyTask: batching, Docker preflight, path translation, and result tallying. |
| PhotoCleaner/VerifyResult.cs | Add JSON model + source-gen context for verify script output protocol. |
| PhotoCleaner/VerifyCommand.cs | Add verify command orchestration, logging, and exit-code handling. |
| PhotoCleaner/UndoTask.cs | Comment refinements in undo algorithm documentation. |
| PhotoCleaner/UndoCommand.cs | Return ExitCode based on per-file failures instead of always-success. |
| PhotoCleaner/TrashCommand.cs | Treat invalid pagination as a failed (exit 2) partial sync rather than success. |
| PhotoCleaner/SkippedExtensionTracker.cs | Improve logging for empty-extension case. |
| PhotoCleaner/ProcessTask.cs | Add Invalid result and enforce ExifTool validation error handling. |
| PhotoCleaner/ProcessCommand.cs | Track/report invalid count and return ExitCode.Failed when invalid/failed > 0. |
| PhotoCleaner/MediaUtilities.cs | Enable ExifTool -validate -all, add EnsureReadable, and handle empty JSON output. |
| PhotoCleaner/IndexCommand.cs | Return ExitCode.Failed when indexing reports failures. |
| PhotoCleaner/ImportTask.cs | Add Invalid outcome for ExifTool validation errors; include invalid count in results. |
| PhotoCleaner/ImportCommand.cs | Log invalid count and return ExitCode.Failed when invalid/failed > 0. |
| PhotoCleaner/ImmichVerifyScript.cs | Add in-container Node script strings (preflight + verify) that call Immich internals. |
| PhotoCleaner/ExitCode.cs | Introduce shared exit-code contract constants (0/1/2). |
| PhotoCleaner/ExifToolJson.cs | Add ExifTool validation/error fields and implement ParseValidate. |
| PhotoCleaner/CommandRunner.cs | Update runner to return/label command exit codes and unify error handling. |
| PhotoCleaner/CommandLine.cs | Register verify command; refine help/option descriptions. |
| PhotoCleaner.code-workspace | Update workspace settings and extension recommendations. |
| OPERATIONS.md | Add operations/runbook documentation (local gates, lint tooling, release process). |
| HISTORY.md | Add 1.1 release notes covering verify, validation, and exit-code changes. |
| GOVERNANCE.md | Add representative-data rule text; refine review-loop and verification discipline guidance. |
| Docker/README.md | Align Docker README description wording with repository description. |
| Docker/Dockerfile | Align image label description wording with repository description. |
| cspell.json | Add/adjust project vocabulary words. |
| CODESTYLE.md | Refresh carried code-style rules; expand Python section and clarify lint/CI expectations. |
| AUDIT.md | Prose clarification for audit scope and ruleset expectations. |
| AGENTS.md | Refresh session-scope guidance and add rule-router row for representative data. |
| .vscode/launch.json | Add debug launch configurations for verify. |
| .github/workflows/validate-task.yml | Add base-branch history fetch for prose diffing; add hub prose gate step. |
| .github/workflows/merge-bot-pull-request.yml | Add merge-bot workflow for Dependabot PRs (auto-merge enable/disable). |
| .github/workflows/build-release-task.yml | Gate docker publish on successful executable build (needs + !failure()). |
| .gitattributes | Restore line-ending governance and pin Dockerfiles + scripts to LF where required. |
| .editorconfig | Restore CRLF default model with explicit LF exceptions; add rationale comments. |
Review details
- Files reviewed: 49/49 changed files
- Comments generated: 2
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Two statements were written true and were left behind by the verify command. The exit-code section said a per-file failure does not change the exit code and listed only 0 and 1. ExitCode.Failed is 2 and every command returns it, so the note asserted the opposite of the behavior and the list was missing a code. It now points at the README table rather than restating it, and keeps only the operational reading: 0 and 2 both mean the command ran to completion. The tool section said the application needs no Docker daemon at runtime. verify runs the Immich decoder inside the immich-server image and its preflight throws when docker is missing, so Docker is a runtime dependency of that one command. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Not ready to approve
A new test helper can leak a hanging docker subprocess on timeout; it should terminate the child process to avoid destabilizing the test run.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (1)
PhotoCleanerTests/VerifyTaskTests.cs:502
- In ImmichImageAvailable(), if
docker image inspecthangs andWaitForExittimes out, the method returns false without terminating the child process. That can leak a runningdockerprocess into the test run and potentially affect subsequent tests.
- Files reviewed: 49/49 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Suppressed finding answered (round 2, on head
|
* Kill the docker probe when its wait times out ImmichImageAvailable started `docker image inspect` and returned false on a 60-second timeout without ending the child. Disposing a Process frees its handles only, so the probe outlived the test that started it and kept running alongside the rest of the suite. Confirmed the semantics with a standalone probe rather than assuming them: a child started the same way and left to Dispose was still present in /proc after the using block exited, and the same child was gone when Kill ran first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Wait out the killed probe and observe its drain tasks Kill signals termination rather than performing it, so the previous commit still returned while the child was alive. A bounded WaitForExit after the kill is what makes the child gone by the time the helper returns. The early return also left the two ReadToEndAsync drains unobserved. The kill closes their pipes, so a bounded WaitAll finishes them, and the AggregateException catch observes a drain that faulted on the closing pipe. Measured the asynchrony rather than reasoning about it: across five trials a killed child was still present in /proc when Kill returned, and gone after the bounded wait in every one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Human review recommended
It promotes a large, cross-cutting change set (new command pipeline + shared exit-code contract + workflow and line-ending governance changes) that warrants final human sign-off despite no specific defects found in review.
Review details
- Files reviewed: 49/49 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Promotes twelve commits to
main. This is the first promotion that carries a functional change: theverifycommand, a set of breaking exit-code changes, and theversion.jsonfloor raised to1.1to match.What is in it
verifycommand: runs the Immich decoder inside the Immich image to confirm each file renders. Breaking exit-code changes come with itvalidate-task.ymlOPERATIONS.md: how the repository is run, meaning the local gates, the release pipeline, and the external toolsbuild-dockerneedsbuild-executable, so a failed executable build can no longer push an image with no releaseversion.jsonfloor to1.1dotnet tool restoreOPERATIONS.mddockerprobe in theverifytest helperVersioning: this promotion does bump the floor
Unlike the previous two rounds,
version.jsonmoves, from1.0to1.1, in #43.HISTORY.mdalready declared**Version: 1.1**for theverifycommand and the breaking exit-code changes, while the floor still read1.0, so the next release cut frommainwould have been1.0.18and would have contradicted the changelog shipping with it. Latest release is1.0.17.That is the Release Model working as intended: the 2-digit line is raised by maintainer instruction for a functional change, and NBGV owns the patch position from git height. Only
version.jsonchanged. TheVersionandInformationalVersionproperties inPhotoCleaner/PhotoCleaner.csprojare local-build placeholders that never reach a published artifact, sinceget-version-task.ymlruns NBGV once and threads the computed values into the build jobs.Two changes worth reading closely
The
verifycommand carries no container parser of its own, deliberately. Such a parser condemns whatever it fails to understand, so an unfamiliar but valid format is indistinguishable from a damaged one from the inside. Running the Immich decoder makes that decoder the only judge of a file health, which is the judgment that actually matters for an Immich library. Docker is therefore required forverify.The D4.5 publish gate closes a partial-publish hole. Before #42, a failed
build-executableskippedgithub-releasewhilebuild-dockerstill pushed and movedlatest, leaving an image with no release behind it.build-dockernow needsbuild-executableand guards with!failure() && !cancelled(), so a failed upstream build stops the push while a skipped one does not.Review audit
Every constituent PR was audited across all its reviews rather than only the head one, because a suppressed finding carries no thread, so
reviewThreadsreads zero unresolved andmergeStateStatusreadsCLEANwhile it stands.OPERATIONS.mdtool-restore finding on #37 merged head. Confirmed against a cold package cache, fixed in #44, and answered on #37.ptr727/ProjectTemplate#521.OPERATIONS.mdclaimed a per-file failure does not change the exit code, whenExitCode.Failedis2and seven call sites return it, and it claimed the application needs no Docker daemon at runtime, whenverifyrequires one. Neither was catchable reviewing againstdevelop, where that file is unchanged. Diffing againstmainis what surfaced them, which is an argument for reading a promotion diff as a whole-file review rather than a formality.verifytest helper starteddocker image inspectand returned on a 60-second timeout without ending the child, so the probe outlived the test. Notably the review-polling script reportedsuppressed=0for that round, because its parser expects a<summary>shape and that review used a###heading. Reading every review body directly is what caught it.&& 0 || 1falsy-expression bug from #36 is fixed ondevelop, with both depths quoted and a comment recording why they must stay quoted.Verification
mainapplies with zero conflicts across 49 files. No EOL trap on these paths despite #41 touching the line-ending model.dotnet test: 372 passed, 0 failed, 0 skipped. The Docker-gated tests ran rather than silently skipping.TreatWarningsAsErrors.dotnet format style --verify-no-changes: 0 of 57 files.dotnet husky run: pass.**/*.md: 0 issues in 11 files. editorconfig-checker clean. Hubprose_lint.pyclean.audit run 2026-08-03T17:46:41Z, hub943b564). That run was against252f5c5, so it covers #35 through #42 but not the four commits added since. Those four touchversion.json,OPERATIONS.mdand a test helper, none of which is hub-carried content, so no re-audit was run for them.What happens after this merges
Merging publishes nothing.
publish-release.ymlruns onworkflow_dispatchand a weeklyscheduleonly, never on a human push. The next scheduledmainrun (Mon 02:00 UTC) or a manual dispatch cuts the release, which will be the first1.1.x.Merge with a plain merge commit. Never squash, and never
--delete-branch, since the head of this PR isdevelopitself.Issue #14 stays open: the merge-bot shipped in #38, so only the date badge remains.