Skip to content

Fix inverted stage_changes in CodeOwnership.validate! - #187

Merged
dduugg merged 3 commits into
mainfrom
fix-validate-stage-changes
Oct 21, 2026
Merged

dduugg merged 3 commits into
mainfrom
fix-validate-stage-changes

Conversation

@dduugg

@dduugg dduugg commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

CodeOwnership.validate! stages the regenerated .github/CODEOWNERS only when it is told not to.

The bug

validate! is documented to stage the regenerated file by default and to leave it unstaged with stage_changes: false (lib/code_ownership.rb:184-192, README.md:189). The CLI's --skip-stage maps to stage_changes: false (lib/code_ownership/cli.rb:52 and :85). Both are inverted:

Call Documented Before this PR
validate!, bin/codeownership validate staged not staged
validate!(stage_changes: false), bin/codeownership validate --skip-stage not staged staged

Root cause

  1. lib/code_ownership.rb:235 called ::RustCodeOwners.generate_and_validate(files, !stage_changes), so the second argument meant "skip staging", as sorbet/rbi/manual.rbi:12 declared: def generate_and_validate(files, skip_stage).
  2. The extension received it as skip_stage and passed it unchanged as the third argument of codeowners-rs's runner::generate_and_validate (ext/code_ownership/src/lib.rs:88-91).
  3. In codeowners-rs that parameter is git_stage, which means "stage it" (src/runner/api.rs:30 and src/runner.rs:199-232 at v0.5.0, the tag ext/code_ownership/Cargo.toml pins).

codeowners-rs added git_stage in rubyatscale/codeowners-rs#65, and v0.2.7, which the extension pinned when it landed in #126, already had it. So the inversion is as old as the extension. Nothing ever failed: codeowners-rs discards the result of its git add (let _ = Command::new("git").arg("add")...output()), and the CLI specs stub validate! and only check the arguments it receives.

The fix

validate! now passes stage_changes straight through, and the extension's parameter and the RBI's are renamed git_stage, matching codeowners-rs, so the flag means "stage it" at every layer. The flip at the extension boundary is what hid this bug, so the fix removes it rather than adding a second negation.

The extension's behavior is unchanged; only its parameter name is. So:

  • lib/code_ownership/code_ownership.bundle needs no rebuild. The fix works with the bundle as committed.
  • The six spec before blocks that call RustCodeOwners.generate_and_validate(nil, false) to write a CODEOWNERS file keep their meaning: false still doesn't stage.

Specs

  • .validate! when the project is a git repository. Runs git init in the spec's temp project, then checks git diff --cached --name-only after validate! (expects .github/CODEOWNERS) and after validate!(stage_changes: false) (expects nothing, with the file written). Both examples fail without the fix: the first gets [], the second [".github/CODEOWNERS"]. git runs with the temp project as its working directory and needs no identity, since nothing is committed. A before(:suite) hook in spec/spec_helper.rb first clears the variables git rev-parse --local-env-vars lists, because hooks and linked worktrees export GIT_DIR and GIT_INDEX_FILE, which would otherwise aim git, including the extension's git add, at the repository running the suite. It covers the whole suite, not just this context, because the fix makes every real validate! call with default arguments stage, including two in cli_spec.rb.
  • .validate! when a package.yml is "", "# no keys yet\n" or "---\n". Each must read as a package with no owner: validate! must not raise, which it does when a package.yml fails to parse, and for_file(..., from_codeowners: false) must return the file's annotated team without printing anything. On a parse failure for_file only prints the error and falls back to the annotation, so the spec checks stderr too. This guards the upgrade to a codeowners-rs release with Replace serde_yaml with serde-saphyr to drop unsafe-libyaml codeowners-rs#142, which moves YAML parsing to serde-saphyr. serde-saphyr's unreleased 2.1.0 no longer reads a null document as an empty struct (serde-saphyr#233, fixed by Fix null deserialization for collection targets bourumir-wyngs/serde-saphyr#240), so that PR reads package.yml as an Option to keep an empty file meaning no owner. Validation and the for_file check are separate examples, so each fails on its own. They pass on v0.5.0 and on that PR's head. With serde-saphyr 2.1.0 patched in, they fail if package.yml is read as a plain struct. They pass stage_changes: false because staging is not what they test.

Verification

Ruby 4.0.7, Rust 1.98.1, git 2.54, macOS arm64. rake compile overwrites the tracked bundle, so each run checked which extension $LOADED_FEATURES actually loaded.

  • bundle exec rake (CI's compile and spec step): 104 examples, 0 failures, 1 pending, with the freshly built extension loaded. On main (8ac6a59): 96 examples, 0 failures, 1 pending. The eight new examples are the two staging ones and two for each of the three package.yml cases.
  • bundle exec rspec against the tracked bundle as committed, with no compile: 104 examples, 0 failures, 1 pending.
  • With the one-line Ruby fix reverted: the two staging examples fail.
  • With GIT_DIR, GIT_INDEX_FILE and GIT_WORK_TREE pointing at a separate repository, as in a hook, the whole suite passes and that repository's index is unchanged. Before the scrub moved to spec_helper.rb, the two cli_spec.rb examples that run the real validate! staged .github/CODEOWNERS into it.
  • With an unparsable package.yml (owner: [) added to the empty-file cases, both its examples fail, so the spec would catch a file that stops parsing.
  • Built against Replace serde_yaml with serde-saphyr to drop unsafe-libyaml codeowners-rs#142's head (1721171) through a temporary cargo patch, the empty-file examples pass.
  • bundle exec srb tc: no errors. RuboCop, which CI doesn't run: no new offenses. The one Style/RegexpLiteral at spec/lib/code_ownership_spec.rb:600 is already on main.
  • After the suite, git diff --cached --name-only is empty and git status --porcelain lists at most lib/code_ownership/code_ownership.bundle, which rake compile can overwrite with a dev build. The specs create and stage nothing.
  • After the version bump: bundle exec rspec gives the same 104 examples, 0 failures, 1 pending, bundle exec srb tc reports no errors, and the command CD uses to read the version prints 2.2.1, which RubyGems doesn't have yet.

Behavior change

From 2.2.1, bin/codeownership validate and CodeOwnership.validate! stage .github/CODEOWNERS by default again, as documented and as the Ruby implementation did before #126, and --skip-stage or stage_changes: false no longer stages it. A CI step that runs validate and then git diff --exit-code to catch a stale CODEOWNERS will stop seeing the change, because it is staged; such a step should run validate --skip-autocorrect, which raises when CODEOWNERS is stale, or pass --skip-stage or compare against HEAD.

Release

This bumps the gem to 2.2.1, a patch release because this is a bug fix. CD publishes any version RubyGems doesn't have once CI passes on main, so merging releases 2.2.1. The release also ships what main has gained since 2.2.0, including #186's updates to the extension's Rust dependencies, among them rb-sys 0.9.130 and serde_magnus 0.11.

  • I bumped the gem version (or don't need to) 💎

validate! is documented to stage the regenerated .github/CODEOWNERS by
default and to leave it unstaged with stage_changes: false, which is
what bin/codeownership validate --skip-stage passes. It did the
opposite.

validate! passed !stage_changes to the extension, whose
generate_and_validate took it as skip_stage, as the RBI declared, but
handed it unchanged to codeowners-rs's runner::generate_and_validate,
whose third parameter is git_stage. That has been the case since the
extension was added in #126: the codeowners-rs it pinned already took
git_stage (rubyatscale/codeowners-rs#65). codeowners-rs ignores the
result of its git add, so nothing ever failed.

Pass stage_changes through, and rename the extension's parameter and
the RBI's to git_stage to match codeowners-rs, so the flag means the
same thing at every layer. The extension's behavior is unchanged, so
the tracked bundle needs no rebuild: the fix works with it as
committed, and the specs that call
RustCodeOwners.generate_and_validate(nil, false) to write a CODEOWNERS
file still don't stage it.

Add specs that validate! stages CODEOWNERS by default in a git
repository and leaves it unstaged with stage_changes: false. Both fail
without the fix. They clear git's repo-local environment variables
first, so a run from a hook or a linked worktree can't stage into the
enclosing repository. The CLI specs only check the arguments that
reach validate!, so nothing covered what it does to the index.

Add a regression guard for an empty package.yml.
rubyatscale/codeowners-rs#142 moves YAML parsing to serde-saphyr, whose
unreleased 2.1.0 no longer reads a null document as an empty struct
(the fix for bourumir-wyngs/serde-saphyr#233). That PR reads
package.yml as an Option so an empty file still means no owner. The
spec covers an empty file, a comment-only file and a bare "---".
validate! raises if such a file fails to parse, while for_file only
prints the error and falls back to the file's annotation, so the spec
also checks that for_file prints nothing. It passes today.
@dduugg
dduugg requested a review from a team as a code owner October 21, 2026 04:33
@github-project-automation github-project-automation Bot moved this to Triage in Modularity Oct 21, 2026
The fix makes every real validate! call with default arguments run git
add, not only the new staging specs. Two cli_spec examples do that, and
under a hook's GIT_DIR they staged .github/CODEOWNERS into the enclosing
repository, since the scrub only wrapped the staging context. Move it
to a before(:suite) hook so every example's git runs against its own
temp project.

Split the empty package.yml example in two, so validation and the
for_file parse-error check pass or fail separately, without nesting
one expect inside another.
@dduugg
dduugg merged commit 6fc6342 into main Oct 21, 2026
28 checks passed
@dduugg
dduugg deleted the fix-validate-stage-changes branch October 21, 2026 17:18
@github-project-automation github-project-automation Bot moved this from Triage to Done in Modularity Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant