rule:exists misses an existing directory depending on unrelated files
Summary
Since 18.2, rules:exists checks that a dir exists when the path ends in a /, but the lookup can fail silently depending on other files in the directory.
Steps to reproduce
-
Create a project with these files:
.gitlab-ci.yml Cargo.toml README.md a.txt b.txt c.txt docs/guide.md src/main.rs z.txtand this
.gitlab-ci.yml:docs-dir: script: echo docs/ exists rules: - exists: [docs/] src-dir: script: echo src/ exists rules: - exists: [src/] docs-glob: script: echo docs/**/* exists rules: - exists: ["docs/**/*"] always: script: echo always -
Push. The pipeline has
always,docs-globandsrc-dir, but nodocs-dir. -
Delete
z.txt, which has nothing to do withdocs/, and push. The pipeline now has all four jobs.
What is the current bug behavior?
exists: [docs/] is false even though docs/guide.md exists
What is the expected correct behavior?
exists: [docs/] is true whenever a file under docs/ exists, as it is for docs/**/*
Relevant logs and/or screenshots
The cause is in exact_matches?:
paths.bsearch { |path| path.start_with?(glob) }With a boolean block, bsearch runs in find-minimum mode, which needs the block to be false for a leading part of the sorted array and true for the rest. start_with?(glob) is false before the directory, true inside it, and false again after it. For the files above, the search probes b.txt (false, go right), src/main.rs (false, go right) and z.txt (false), and returns nil without ever looking at docs/guide.md. Without z.txt, the second probe lands on docs/guide.md and the directory is found.
The existing spec passes because its repository is small enough that the probe lands inside the directory.
Output of checks
Reproduced on GitLab CE 19.4.1 (gitlab/gitlab-ce:latest), self-managed. Not tested on GitLab.com; the code is the same on master.
Possible fixes
Possible fixes
Look for the first path that is not less than the glob. Paths in the directory come first among those, so the block is monotonic and one start_with? decides:
paths.bsearch { |path| path >= glob }&.start_with?(glob)On 200,000 random sorted path lists this agrees with paths.any? { |path| path.start_with?(glob) } every time; the current code disagrees on 14,593 of them. With the change, the reproduction above gives docs-dir in both pipelines.
Workaround until then: exists: ["docs/**/*"].
This issue was initially discovered, proven and drafted through the help of Claude Code.