From 419cd7a95823820b5bb39b70c150d6cb0dc949de Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sun, 16 Aug 2026 15:43:58 +0200 Subject: [PATCH 1/6] feat(generator): gitignore the generated tailwind.sources.css fleet-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary bin/build-css regenerates app/assets/stylesheets/tailwind.sources.css on every build with machine-specific absolute gem paths — committed, it churns on every rebuild by a different machine/Ruby, and no build consumes the committed copy (.dockerignore excludes it; every build path regenerates it first). The install generator now adds the .gitignore entry (additive, idempotent, tolerant of a hand-added entry with or without the leading slash, and NOT --sync-guarded — --sync is how the fleet picks it up). A copy already tracked by git is flagged by SyncReport with the exact `git rm --cached` command — warn-only, the generator never mutates git state. ## Test Coverage - appends the entry to an existing .gitignore / creates one when absent - idempotent re-run and hand-added-variant tolerance (no duplicates) - --sync adds the entry (the upgrade path) - drift warning when the file is git-tracked; silent when not a repo; warn-only (the index is untouched) ## Verification - [x] bundle exec rubocop passes - [x] bundle exec rspec passes (99 generator examples, suite green) Closes #71 --- README.md | 1 + .../docs_kit/install/install_generator.rb | 27 +++++++ .../docs_kit/install/sync_report.rb | 27 ++++++- spec/generators/install_generator_spec.rb | 77 +++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 31f7775..97dd16e 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ generators: | `app/helpers/icon_helper.rb` | docs-kit renders icons via rails_icons (`DocsUI::Icon`) | Delete the file. | | Hand-pinned docs-kit lines in `config/importmap.rb` | the engine auto-pins the `docs-nav` controller and its assets | Delete the manual `pin`/`pin_all_from` lines for docs-kit. | | `Dockerfile` stamped by an older docs-kit (`# docs-kit Dockerfile vX.Y.Z`) | docs-kit ships an optimized, multi-stage Dockerfile; a stale copy misses image-size wins | Diff yours against the current template (`lib/generators/docs_kit/install/templates/Dockerfile.tt` in the gem), adopt the changes or replace it. See [Upgrading your Dockerfile](#upgrading-your-dockerfile). | +| `app/assets/stylesheets/tailwind.sources.css` committed to git | `bin/build-css` regenerates it on every build with machine-specific absolute gem paths — the committed copy churns per machine/Ruby and no build consumes it. The generator adds the `.gitignore` entry, but gitignoring doesn't untrack an already-committed copy. | `git rm --cached app/assets/stylesheets/tailwind.sources.css` and commit. | ### Upgrading your Dockerfile diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index 38c308e..e436045 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -57,6 +57,12 @@ class InstallGenerator < ::Rails::Generators::Base def self.synced_stamp(version = DocsKit::VERSION) = "# docs-kit synced: v#{version}" + # The generated Tailwind @source globs file bin/build-css rewrites on + # every build — gitignored fleet-wide (see ignore_generated_css_sources). + TAILWIND_SOURCES = SyncReport::TAILWIND_SOURCES + # A non-negated .gitignore line already covering it, leading slash or not. + TAILWIND_SOURCES_IGNORED_RE = %r{^/?#{Regexp.escape(TAILWIND_SOURCES)}\s*$} + # The RuboCop wiring docs-kit injects. REQUIRE loads the cops; # INHERIT_GEM/INHERIT_PATH enable + scope them (see config/rubocop/docs_kit.yml). RUBOCOP_REQUIRE = "docs_kit/rubocop" @@ -192,6 +198,27 @@ def create_css_build create_file "app/assets/builds/.keep", "" end + # Gitignore the bin/build-css-generated @source globs file (#71). It + # carries machine-specific absolute gem paths, so a committed copy churns + # on every rebuild by a different machine/Ruby — and no build consumes it + # (.dockerignore excludes it; every build path runs bin/build-css first). + # Additive + idempotent (tolerant of a hand-added entry with or without + # the leading slash), and NOT --sync-guarded: the ignore is the fleet-wide + # upgrade this step exists to ship. Untracking an already-committed copy + # is the site's call — SyncReport warns with the exact command instead + # (the generator never mutates git state). + def ignore_generated_css_sources + entry = "# Generated by bin/build-css (resolved gem @source globs).\n/#{TAILWIND_SOURCES}\n" + path = File.join(destination_root, ".gitignore") + return create_file(".gitignore", entry) unless File.exist?(path) + + if File.read(path).match?(TAILWIND_SOURCES_IGNORED_RE) + return say_status(:identical, ".gitignore (tailwind.sources.css)", :blue) + end + + append_to_file ".gitignore", "\n#{entry}" + end + # Install the `docs_kit:og` rake task — gem-owned wiring, refreshed on every # run so a site picks up task fixes. It does NOT ship an OG image: the # social-share image is SITE content, generated into the site's OWN diff --git a/lib/generators/docs_kit/install/sync_report.rb b/lib/generators/docs_kit/install/sync_report.rb index 2d9f1e5..3f6339b 100644 --- a/lib/generators/docs_kit/install/sync_report.rb +++ b/lib/generators/docs_kit/install/sync_report.rb @@ -17,10 +17,14 @@ module Generators # - a dead IconHelper copy — the gem renders icons via rails_icons. # - a Dockerfile stamped by an OLDER docs-kit than the gem now ships — the # site should diff against the current template and adopt the improvements. + # - a git-tracked tailwind.sources.css — generated per-build with machine + # absolute gem paths (#71); gitignored now, but untracking a committed + # copy stages a deletion, so the site runs `git rm --cached` itself. class SyncReport APPLICATION_CONTROLLER = "app/controllers/application_controller.rb" ICON_HELPER = "app/helpers/icon_helper.rb" DOCKERFILE = "Dockerfile" + TAILWIND_SOURCES = "app/assets/stylesheets/tailwind.sources.css" # Matches the version stamp the Dockerfile template writes, e.g. # `# docs-kit Dockerfile v1.0.2`. Absent on a hand-written Dockerfile a site @@ -34,7 +38,7 @@ def initialize(destination_root) # The drift messages, in the order a site should act on them. Empty when # the site is clean. def items - [render_page_drift, icon_helper_drift, dockerfile_drift].compact + [render_page_drift, icon_helper_drift, dockerfile_drift, tailwind_sources_drift].compact end def clean? @@ -79,6 +83,27 @@ def dockerfile_drift "diff against the template (bin/rails g docs_kit:install shows the path) and adopt the changes." end + # tailwind.sources.css is regenerated by bin/build-css with machine-local + # absolute gem paths — the generator gitignores it, but a copy committed + # before the ignore stays tracked (gitignore doesn't untrack). Untracking + # stages a deletion, so we hand the site the exact command instead of + # touching its index. + def tailwind_sources_drift + return unless tracked_by_git?(TAILWIND_SOURCES) + + "#{TAILWIND_SOURCES} is generated by bin/build-css but tracked by git — " \ + "run `git rm --cached #{TAILWIND_SOURCES}` and commit (the ignore entry is in place)." + end + + # True when the site's git index tracks `rel`. Conservative: no git on + # PATH, not a repo, or an untracked file all read as "no drift". ls-files + # only consults the index (no commit needed), and `git -C` resolves the + # repo upward, so a docs site living in a subdir of a larger repo works. + def tracked_by_git?(rel) + system("git", "-C", @root, "ls-files", "--error-unmatch", rel, + out: File::NULL, err: File::NULL) == true + end + def read(rel) path = File.join(@root, rel) File.exist?(path) ? File.read(path) : nil diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 995f413..958dc7b 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -1057,6 +1057,83 @@ def render_page(view) end end + # Fleet convention (#71): bin/build-css regenerates tailwind.sources.css on + # every build with machine-specific absolute gem paths — committed, it churns + # per machine/Ruby and no build consumes the committed copy. The generator + # gitignores it (additive, idempotent, runs under --sync too); untracking an + # already-committed copy is warned via the drift report, never automated. + describe "gitignoring the generated tailwind.sources.css" do + let(:sources_path) { "app/assets/stylesheets/tailwind.sources.css" } + + it "appends the ignore entry to an existing .gitignore" do + build_skeleton + write(".gitignore", "/node_modules\n") + + run_generator + + gitignore = read(".gitignore") + expect(gitignore).to include("/node_modules") + expect(gitignore).to match(%r{^/#{Regexp.escape(sources_path)}$}) + end + + it "creates a .gitignore carrying the entry when the site has none" do + build_skeleton + + run_generator + + expect(read(".gitignore")).to match(%r{^/#{Regexp.escape(sources_path)}$}) + end + + it "is idempotent — a re-run adds no duplicate entry" do + build_skeleton + run_generator + run_generator + + expect(read(".gitignore").scan(sources_path).size).to eq(1) + end + + it "tolerates a hand-added entry without a leading slash (no duplicate)" do + build_skeleton + write(".gitignore", "#{sources_path}\n") + + run_generator + + expect(read(".gitignore").scan(sources_path).size).to eq(1) + end + + it "adds the entry on --sync (the fleet-wide upgrade path)" do + build_skeleton + write(".gitignore", "/node_modules\n") + + run_generator(sync: true) + + expect(read(".gitignore")).to match(%r{^/#{Regexp.escape(sources_path)}$}) + end + + it "warns to git rm --cached when the file is tracked by git (warn-only, never mutates git)" do + build_skeleton + write(sources_path, "/* stale committed copy */\n") + system("git", "-C", destination, "init", "-q") + system("git", "-C", destination, "add", sources_path) + + output = capture_generator(sync: true) + + expect(output).to include("git rm --cached #{sources_path}") + # Warn-only: still tracked, file untouched. + expect(system("git", "-C", destination, "ls-files", "--error-unmatch", sources_path, + out: File::NULL, err: File::NULL)).to be(true) + end + + it "does NOT warn when the site is not a git repository" do + build_skeleton + write(sources_path, "/* generated locally, no repo */\n") + + output = capture_generator(sync: true) + + expect(output).not_to include("git rm --cached") + end + end + # Version-aware sync: the generator records which docs-kit version a site was # last synced at (a `# docs-kit synced: vX.Y.Z` stamp in the initializer) so a # future `--sync` can run the ORDERED migrations between that version and the From ef2d732a8b9d514e3111d1620c0c9cfea622d474 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sun, 16 Aug 2026 18:04:19 +0200 Subject: [PATCH 2/6] fix(generator): respect a site's explicit ! unignore of tailwind.sources.css MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups (PR #72): - An explicit `!tailwind.sources.css` in .gitignore is the site's deliberate opt-out — appending our entry after it would become the last matching rule and silently defeat the hand-edit. The generator now backs off with a yellow skip, and SyncReport skips the `git rm --cached` nag for the same opt-out (a deliberate commit shouldn't be nagged every sync). - Presence detection now also counts a bare-filename line (`tailwind.sources.css`, optionally `**/`-prefixed) as covering — it matches at any depth per gitignore semantics. Broader globs stay undetected on purpose: the miss costs one harmless redundant line, and `git check-ignore` would conflate a user's global excludes with the repo's committed convention. The covering/negation regexes live beside TAILWIND_SOURCES in SyncReport, shared by both call sites. --- .../docs_kit/install/install_generator.rb | 14 ++++++-- .../docs_kit/install/sync_report.rb | 25 ++++++++++++++ spec/generators/install_generator_spec.rb | 34 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index e436045..a749f62 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -59,9 +59,9 @@ def self.synced_stamp(version = DocsKit::VERSION) = "# docs-kit synced: v#{versi # The generated Tailwind @source globs file bin/build-css rewrites on # every build — gitignored fleet-wide (see ignore_generated_css_sources). + # The covering/negation line regexes live beside the path in SyncReport, + # which shares them for the tracked-file drift check. TAILWIND_SOURCES = SyncReport::TAILWIND_SOURCES - # A non-negated .gitignore line already covering it, leading slash or not. - TAILWIND_SOURCES_IGNORED_RE = %r{^/?#{Regexp.escape(TAILWIND_SOURCES)}\s*$} # The RuboCop wiring docs-kit injects. REQUIRE loads the cops; # INHERIT_GEM/INHERIT_PATH enable + scope them (see config/rubocop/docs_kit.yml). @@ -212,7 +212,15 @@ def ignore_generated_css_sources path = File.join(destination_root, ".gitignore") return create_file(".gitignore", entry) unless File.exist?(path) - if File.read(path).match?(TAILWIND_SOURCES_IGNORED_RE) + content = File.read(path) + # An explicit `!` unignore is the site's deliberate opt-out — appending + # our entry AFTER it would become the last matching rule and silently + # defeat the hand-edit. Back off (and SyncReport skips its nag too). + if content.match?(SyncReport::TAILWIND_SOURCES_NEGATED_RE) + return say_status(:skip, ".gitignore negates tailwind.sources.css (!) — respecting the site's opt-out", + :yellow) + end + if content.match?(SyncReport::TAILWIND_SOURCES_IGNORED_RE) return say_status(:identical, ".gitignore (tailwind.sources.css)", :blue) end diff --git a/lib/generators/docs_kit/install/sync_report.rb b/lib/generators/docs_kit/install/sync_report.rb index 3f6339b..bf52262 100644 --- a/lib/generators/docs_kit/install/sync_report.rb +++ b/lib/generators/docs_kit/install/sync_report.rb @@ -26,6 +26,23 @@ class SyncReport DOCKERFILE = "Dockerfile" TAILWIND_SOURCES = "app/assets/stylesheets/tailwind.sources.css" + # A .gitignore line covering the generated file per gitignore semantics: + # the anchored path (leading slash optional — a slash-containing pattern + # is root-anchored either way) or the bare filename (no slash → matches + # at any depth), each optionally **/-prefixed. Deliberately NOT a full + # gitignore matcher: an exotic broader glob (`app/assets/stylesheets/*`) + # is missed at the cost of one redundant, harmless line — whereas + # `git check-ignore` would conflate a user's global excludes with the + # repo's committed convention. + TAILWIND_SOURCES_COVER = + "(?:(?:/|\\*\\*/)?#{Regexp.escape(File.dirname(TAILWIND_SOURCES))}/|(?:\\*\\*/)?)" \ + "#{Regexp.escape(File.basename(TAILWIND_SOURCES))}[ \t]*".freeze + TAILWIND_SOURCES_IGNORED_RE = /^#{TAILWIND_SOURCES_COVER}$/ + # An explicit `!` unignore of the file — the site's deliberate opt-out + # from the fleet convention (it wants the file committed). Both the + # generator's append and the tracked-file drift warning respect it. + TAILWIND_SOURCES_NEGATED_RE = /^!#{TAILWIND_SOURCES_COVER}$/ + # Matches the version stamp the Dockerfile template writes, e.g. # `# docs-kit Dockerfile v1.0.2`. Absent on a hand-written Dockerfile a site # brought itself — which we deliberately leave alone (no marker → no warning). @@ -89,12 +106,20 @@ def dockerfile_drift # stages a deletion, so we hand the site the exact command instead of # touching its index. def tailwind_sources_drift + return if gitignore_negates_tailwind_sources? return unless tracked_by_git?(TAILWIND_SOURCES) "#{TAILWIND_SOURCES} is generated by bin/build-css but tracked by git — " \ "run `git rm --cached #{TAILWIND_SOURCES}` and commit (the ignore entry is in place)." end + # The site wrote an explicit `!` exception for the file — it has opted to + # commit it on purpose, so nagging `git rm --cached` every sync would + # fight a deliberate hand-edit. + def gitignore_negates_tailwind_sources? + read(".gitignore")&.match?(TAILWIND_SOURCES_NEGATED_RE) || false + end + # True when the site's git index tracks `rel`. Conservative: no git on # PATH, not a repo, or an untracked file all read as "no drift". ls-files # only consults the index (no commit needed), and `git -C` resolves the diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 958dc7b..a2a23da 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -1101,6 +1101,40 @@ def render_page(view) expect(read(".gitignore").scan(sources_path).size).to eq(1) end + it "treats a bare-filename ignore line as covering (matches at any depth — no duplicate)" do + build_skeleton + write(".gitignore", "tailwind.sources.css\n") + + run_generator + + expect(read(".gitignore")).to eq("tailwind.sources.css\n") + end + + it "respects a site's explicit negation (!) — never appends an override" do + # A site that deliberately unignores + commits the file has opted out of + # the fleet convention. Appending our entry would become the LAST matching + # rule and silently defeat the hand-edit — so the generator backs off. + build_skeleton + write(".gitignore", "app/assets/stylesheets/*\n!/#{sources_path}\n") + + output = capture_generator + + expect(read(".gitignore")).to eq("app/assets/stylesheets/*\n!/#{sources_path}\n") + expect(output).to match(/negat|opt-out/i) + end + + it "an explicit negation also silences the git-tracked drift warning (a deliberate commit)" do + build_skeleton + write(sources_path, "/* deliberately committed */\n") + write(".gitignore", "!#{sources_path}\n") + system("git", "-C", destination, "init", "-q") + system("git", "-C", destination, "add", sources_path) + + output = capture_generator(sync: true) + + expect(output).not_to include("git rm --cached") + end + it "adds the entry on --sync (the fleet-wide upgrade path)" do build_skeleton write(".gitignore", "/node_modules\n") From 8c3e29f9a4ab490f0d7125f17fc46b9d2823b308 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sun, 16 Aug 2026 19:03:13 +0200 Subject: [PATCH 3/6] fix(generator): CRLF-tolerant gitignore match + last-match-wins opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups (PR #72, round 2): - `[ \t\r]*` in TAILWIND_SOURCES_COVER: `$` matches before `\n` but never past a `\r`, so on a CRLF-checked-out .gitignore both the covering-entry and the negation detection silently failed (duplicate appends; a missed opt-out). - SyncReport.tailwind_sources_rule reads the recognized lines the way git does — last match wins. A `!` line overridden by a LATER ignore line is dead: the file is effectively ignored, so the generator reports it as covered (no append, no bogus "opt-out" message) and the tracked-file drift warning still fires. Only an EFFECTIVE trailing negation counts as the site's opt-out. --- .../docs_kit/install/install_generator.rb | 24 +++++++------- .../docs_kit/install/sync_report.rb | 32 +++++++++++++++---- spec/generators/install_generator_spec.rb | 25 +++++++++++++++ 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index a749f62..8a9b1fa 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -212,19 +212,19 @@ def ignore_generated_css_sources path = File.join(destination_root, ".gitignore") return create_file(".gitignore", entry) unless File.exist?(path) - content = File.read(path) - # An explicit `!` unignore is the site's deliberate opt-out — appending - # our entry AFTER it would become the last matching rule and silently - # defeat the hand-edit. Back off (and SyncReport skips its nag too). - if content.match?(SyncReport::TAILWIND_SOURCES_NEGATED_RE) - return say_status(:skip, ".gitignore negates tailwind.sources.css (!) — respecting the site's opt-out", - :yellow) - end - if content.match?(SyncReport::TAILWIND_SOURCES_IGNORED_RE) - return say_status(:identical, ".gitignore (tailwind.sources.css)", :blue) + # Last-match-wins, like git reads the file: an EFFECTIVE `!` unignore is + # the site's deliberate opt-out — appending our entry after it would + # become the last matching rule and silently defeat the hand-edit, so + # back off (SyncReport skips its nag too). An effective ignore is done. + case SyncReport.tailwind_sources_rule(File.read(path)) + when :negate + say_status(:skip, ".gitignore negates tailwind.sources.css (!) — respecting the site's opt-out", + :yellow) + when :ignore + say_status(:identical, ".gitignore (tailwind.sources.css)", :blue) + else + append_to_file ".gitignore", "\n#{entry}" end - - append_to_file ".gitignore", "\n#{entry}" end # Install the `docs_kit:og` rake task — gem-owned wiring, refreshed on every diff --git a/lib/generators/docs_kit/install/sync_report.rb b/lib/generators/docs_kit/install/sync_report.rb index bf52262..52aae50 100644 --- a/lib/generators/docs_kit/install/sync_report.rb +++ b/lib/generators/docs_kit/install/sync_report.rb @@ -34,15 +34,35 @@ class SyncReport # is missed at the cost of one redundant, harmless line — whereas # `git check-ignore` would conflate a user's global excludes with the # repo's committed convention. + # `[ \t\r]*` (not `[ \t]*`): `$` matches before `\n` but never past a + # `\r`, so the class must consume the CR of a CRLF-checked-out file. TAILWIND_SOURCES_COVER = "(?:(?:/|\\*\\*/)?#{Regexp.escape(File.dirname(TAILWIND_SOURCES))}/|(?:\\*\\*/)?)" \ - "#{Regexp.escape(File.basename(TAILWIND_SOURCES))}[ \t]*".freeze + "#{Regexp.escape(File.basename(TAILWIND_SOURCES))}[ \t\r]*".freeze TAILWIND_SOURCES_IGNORED_RE = /^#{TAILWIND_SOURCES_COVER}$/ # An explicit `!` unignore of the file — the site's deliberate opt-out # from the fleet convention (it wants the file committed). Both the - # generator's append and the tracked-file drift warning respect it. + # generator's append and the tracked-file drift warning respect it — + # but only when it's the file's EFFECTIVE rule (see .tailwind_sources_rule). TAILWIND_SOURCES_NEGATED_RE = /^!#{TAILWIND_SOURCES_COVER}$/ + # The file's effective disposition among the recognized .gitignore lines, + # honoring git's last-match-wins: `:negate` (the site's genuine opt-out), + # `:ignore` (already covered), or nil (no recognized line). A `!` line + # overridden by a LATER ignore line is dead — git ignores the file, so + # treating it as an opt-out would suppress a warranted drift warning. + def self.tailwind_sources_rule(gitignore_content) + rule = nil + gitignore_content.each_line do |line| + if line.match?(TAILWIND_SOURCES_NEGATED_RE) + rule = :negate + elsif line.match?(TAILWIND_SOURCES_IGNORED_RE) + rule = :ignore + end + end + rule + end + # Matches the version stamp the Dockerfile template writes, e.g. # `# docs-kit Dockerfile v1.0.2`. Absent on a hand-written Dockerfile a site # brought itself — which we deliberately leave alone (no marker → no warning). @@ -113,11 +133,11 @@ def tailwind_sources_drift "run `git rm --cached #{TAILWIND_SOURCES}` and commit (the ignore entry is in place)." end - # The site wrote an explicit `!` exception for the file — it has opted to - # commit it on purpose, so nagging `git rm --cached` every sync would - # fight a deliberate hand-edit. + # The site's EFFECTIVE rule for the file is an explicit `!` exception — + # it has opted to commit it on purpose, so nagging `git rm --cached` + # every sync would fight a deliberate hand-edit. def gitignore_negates_tailwind_sources? - read(".gitignore")&.match?(TAILWIND_SOURCES_NEGATED_RE) || false + self.class.tailwind_sources_rule(read(".gitignore").to_s) == :negate end # True when the site's git index tracks `rel`. Conservative: no git on diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index a2a23da..95291b8 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -1123,6 +1123,31 @@ def render_page(view) expect(output).to match(/negat|opt-out/i) end + it "recognizes an existing entry on a CRLF .gitignore (no duplicate per re-run)" do + build_skeleton + write(".gitignore", "/#{sources_path}\r\n") + + run_generator + + expect(read(".gitignore")).to eq("/#{sources_path}\r\n") + end + + it "honors last-match-wins: a dead negation followed by an ignore line is NOT an opt-out" do + # git reads the LAST matching line — a later ignore rule overrides the + # negation, so the file is effectively ignored: no append needed, and the + # tracked-file drift warning must still fire. + build_skeleton + write(".gitignore", "!#{sources_path}\n/#{sources_path}\n") + write(sources_path, "/* tracked while effectively ignored */\n") + system("git", "-C", destination, "init", "-q") + system("git", "-C", destination, "add", "-f", sources_path) + + output = capture_generator(sync: true) + + expect(read(".gitignore")).to eq("!#{sources_path}\n/#{sources_path}\n") + expect(output).to include("git rm --cached #{sources_path}") + end + it "an explicit negation also silences the git-tracked drift warning (a deliberate commit)" do build_skeleton write(sources_path, "/* deliberately committed */\n") From f0b7855823580d09bb12b77e2d3478d6f6804d24 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sun, 16 Aug 2026 19:11:45 +0200 Subject: [PATCH 4/6] fix(generator): drift check asks git check-ignore, not the line regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up (PR #72, round 3): a dead `!` negation followed by an UNRECOGNIZED broad ignore (`app/assets/stylesheets/*`) read as an opt-out and suppressed the tracked-file drift warning. Rather than reimplement gitignore glob semantics, the drift check now asks git itself: `git check-ignore -q --no-index` resolves the FULL pattern semantics (broad globs, ordering, nested .gitignores). --no-index is essential — without it a tracked path is never reported ignored, which is the exact state the check exists to catch. The recognized-lines regex stays for the generator's append decision, where a user's global excludes must NOT decide repo content. --- .../docs_kit/install/sync_report.rb | 29 ++++++++++++------- spec/generators/install_generator_spec.rb | 15 ++++++++++ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/lib/generators/docs_kit/install/sync_report.rb b/lib/generators/docs_kit/install/sync_report.rb index 52aae50..5bde852 100644 --- a/lib/generators/docs_kit/install/sync_report.rb +++ b/lib/generators/docs_kit/install/sync_report.rb @@ -41,16 +41,18 @@ class SyncReport "#{Regexp.escape(File.basename(TAILWIND_SOURCES))}[ \t\r]*".freeze TAILWIND_SOURCES_IGNORED_RE = /^#{TAILWIND_SOURCES_COVER}$/ # An explicit `!` unignore of the file — the site's deliberate opt-out - # from the fleet convention (it wants the file committed). Both the - # generator's append and the tracked-file drift warning respect it — - # but only when it's the file's EFFECTIVE rule (see .tailwind_sources_rule). + # from the fleet convention (it wants the file committed). The + # generator's append respects it when it's the file's effective rule + # (see .tailwind_sources_rule); the drift warning consults + # `git check-ignore` instead, which resolves full pattern semantics. TAILWIND_SOURCES_NEGATED_RE = /^!#{TAILWIND_SOURCES_COVER}$/ # The file's effective disposition among the recognized .gitignore lines, # honoring git's last-match-wins: `:negate` (the site's genuine opt-out), # `:ignore` (already covered), or nil (no recognized line). A `!` line # overridden by a LATER ignore line is dead — git ignores the file, so - # treating it as an opt-out would suppress a warranted drift warning. + # treating it as an opt-out would misreport the site's intent. Drives the + # generator's append decision only (the drift check asks git itself). def self.tailwind_sources_rule(gitignore_content) rule = nil gitignore_content.each_line do |line| @@ -126,18 +128,25 @@ def dockerfile_drift # stages a deletion, so we hand the site the exact command instead of # touching its index. def tailwind_sources_drift - return if gitignore_negates_tailwind_sources? return unless tracked_by_git?(TAILWIND_SOURCES) + # git's own verdict, not the recognized-lines regex: check-ignore + # resolves the FULL pattern semantics (broad globs, ordering, nested + # .gitignores), so a dead `!` line before a broader ignore still warns, + # while a genuinely effective negation (the site's opt-out) stays quiet. + return unless ignored_by_git?(TAILWIND_SOURCES) "#{TAILWIND_SOURCES} is generated by bin/build-css but tracked by git — " \ "run `git rm --cached #{TAILWIND_SOURCES}` and commit (the ignore entry is in place)." end - # The site's EFFECTIVE rule for the file is an explicit `!` exception — - # it has opted to commit it on purpose, so nagging `git rm --cached` - # every sync would fight a deliberate hand-edit. - def gitignore_negates_tailwind_sources? - self.class.tailwind_sources_rule(read(".gitignore").to_s) == :negate + # True when the repo's effective exclude rules cover `rel`. `--no-index` + # is essential: without it a TRACKED path is never reported ignored (the + # exact state this drift check exists to catch). Only called after + # tracked_by_git? proved git + a repo exist, so a failure here + # conservatively reads as "not ignored" → no drift warning. + def ignored_by_git?(rel) + system("git", "-C", @root, "check-ignore", "-q", "--no-index", rel, + out: File::NULL, err: File::NULL) == true end # True when the site's git index tracks `rel`. Conservative: no git on diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 95291b8..cab2609 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -1148,6 +1148,21 @@ def render_page(view) expect(output).to include("git rm --cached #{sources_path}") end + it "still warns when a dead negation precedes an UNRECOGNIZED broad ignore (git's verdict wins)" do + # The recognized-lines regex can't see `app/assets/stylesheets/*`, but the + # drift check asks `git check-ignore` — git says the file is effectively + # ignored, so the negation is dead and the tracked copy still gets the nag. + build_skeleton + write(".gitignore", "!#{sources_path}\napp/assets/stylesheets/*\n") + write(sources_path, "/* tracked while effectively ignored by a broad glob */\n") + system("git", "-C", destination, "init", "-q") + system("git", "-C", destination, "add", "-f", sources_path) + + output = capture_generator(sync: true) + + expect(output).to include("git rm --cached #{sources_path}") + end + it "an explicit negation also silences the git-tracked drift warning (a deliberate commit)" do build_skeleton write(sources_path, "/* deliberately committed */\n") From b0901ceff6bc7f43ca51bdf8844d8cba6c3cb01c Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 24 Aug 2026 15:24:09 +0200 Subject: [PATCH 5/6] chore: point gemspec URLs at the zoolutions org --- docs-kit.gemspec | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs-kit.gemspec b/docs-kit.gemspec index 6fdee54..44b4922 100644 --- a/docs-kit.gemspec +++ b/docs-kit.gemspec @@ -38,11 +38,11 @@ Gem::Specification.new do |s| s.bindir = "exe" s.executables = s.files.grep(%r{\Aexe/}) { |f| File.basename(f) } - s.homepage = "https://github.com/mhenrixon/docs-kit" + s.homepage = "https://github.com/zoolutions/docs-kit" s.metadata = { - "source_code_uri" => "https://github.com/mhenrixon/docs-kit", - "changelog_uri" => "https://github.com/mhenrixon/docs-kit/blob/main/CHANGELOG.md", - "bug_tracker_uri" => "https://github.com/mhenrixon/docs-kit/issues", + "source_code_uri" => "https://github.com/zoolutions/docs-kit", + "changelog_uri" => "https://github.com/zoolutions/docs-kit/blob/main/CHANGELOG.md", + "bug_tracker_uri" => "https://github.com/zoolutions/docs-kit/issues", "rubygems_mfa_required" => "true" } From 7b6b7c61b7630c27364eaf94c40516b9357c9f18 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 24 Aug 2026 16:36:36 +0200 Subject: [PATCH 6/6] fix(generator): scope the tailwind drift verdict to committed .gitignore rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ignored_by_git?` shelled out to `git check-ignore`, which also consults .git/info/exclude and a global core.excludesFile — so the sync report's drift warning (and its "the ignore entry is in place" guidance) could differ per machine, contradicting the comment above the recognized-lines regex. Replace it with `git ls-files --cached --ignored --exclude-per-directory=.gitignore`: git's full pattern semantics, but only the repo's own .gitignore files, and it reports tracked paths directly (no --no-index dance). Reconcile the comments that still named check-ignore. Spec: a file tracked + ignored only via .git/info/exclude produces no drift warning (machine-local excludes never decide the report). --- .../docs_kit/install/sync_report.rb | 39 +++++++++++-------- spec/generators/install_generator_spec.rb | 23 ++++++++++- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/lib/generators/docs_kit/install/sync_report.rb b/lib/generators/docs_kit/install/sync_report.rb index 5bde852..b125a0c 100644 --- a/lib/generators/docs_kit/install/sync_report.rb +++ b/lib/generators/docs_kit/install/sync_report.rb @@ -31,9 +31,10 @@ class SyncReport # is root-anchored either way) or the bare filename (no slash → matches # at any depth), each optionally **/-prefixed. Deliberately NOT a full # gitignore matcher: an exotic broader glob (`app/assets/stylesheets/*`) - # is missed at the cost of one redundant, harmless line — whereas - # `git check-ignore` would conflate a user's global excludes with the - # repo's committed convention. + # is missed at the cost of one redundant, harmless line. This regex + # drives the generator's APPEND decision only; the drift warning asks + # git itself (see #ignored_by_git?, scoped to the committed .gitignore + # files so a user's personal excludes never decide repo content). # `[ \t\r]*` (not `[ \t]*`): `$` matches before `\n` but never past a # `\r`, so the class must consume the CR of a CRLF-checked-out file. TAILWIND_SOURCES_COVER = @@ -43,8 +44,8 @@ class SyncReport # An explicit `!` unignore of the file — the site's deliberate opt-out # from the fleet convention (it wants the file committed). The # generator's append respects it when it's the file's effective rule - # (see .tailwind_sources_rule); the drift warning consults - # `git check-ignore` instead, which resolves full pattern semantics. + # (see .tailwind_sources_rule); the drift warning asks git instead + # (#ignored_by_git?), which resolves full pattern semantics. TAILWIND_SOURCES_NEGATED_RE = /^!#{TAILWIND_SOURCES_COVER}$/ # The file's effective disposition among the recognized .gitignore lines, @@ -129,24 +130,30 @@ def dockerfile_drift # touching its index. def tailwind_sources_drift return unless tracked_by_git?(TAILWIND_SOURCES) - # git's own verdict, not the recognized-lines regex: check-ignore - # resolves the FULL pattern semantics (broad globs, ordering, nested - # .gitignores), so a dead `!` line before a broader ignore still warns, - # while a genuinely effective negation (the site's opt-out) stays quiet. + # git's own verdict, not the recognized-lines regex: git resolves the + # FULL pattern semantics (broad globs, ordering, nested .gitignores), + # so a dead `!` line before a broader ignore still warns, while a + # genuinely effective negation (the site's opt-out) stays quiet. return unless ignored_by_git?(TAILWIND_SOURCES) "#{TAILWIND_SOURCES} is generated by bin/build-css but tracked by git — " \ "run `git rm --cached #{TAILWIND_SOURCES}` and commit (the ignore entry is in place)." end - # True when the repo's effective exclude rules cover `rel`. `--no-index` - # is essential: without it a TRACKED path is never reported ignored (the - # exact state this drift check exists to catch). Only called after - # tracked_by_git? proved git + a repo exist, so a failure here - # conservatively reads as "not ignored" → no drift warning. + # True when the repo's own .gitignore rules cover `rel` — git's full + # pattern semantics (broad globs, `!` ordering, nested .gitignores), but + # SCOPED to the repo's committed convention: `--exclude-per-directory` + # consults only the .gitignore files, never `.git/info/exclude` or a + # global core.excludesFile, so the verdict (and the "the ignore entry is + # in place" guidance it backs) is identical on every machine. + # `--cached --ignored` reports a TRACKED path matching an exclude — the + # exact state this drift check exists to catch. Only called after + # tracked_by_git? proved git + a repo exist. def ignored_by_git?(rel) - system("git", "-C", @root, "check-ignore", "-q", "--no-index", rel, - out: File::NULL, err: File::NULL) == true + out = IO.popen(["git", "-C", @root, "ls-files", "--cached", "--ignored", + "--exclude-per-directory=.gitignore", "--", rel], + err: File::NULL, &:read) + !out.strip.empty? end # True when the site's git index tracks `rel`. Conservative: no git on diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index cab2609..2e138a3 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -1150,8 +1150,8 @@ def render_page(view) it "still warns when a dead negation precedes an UNRECOGNIZED broad ignore (git's verdict wins)" do # The recognized-lines regex can't see `app/assets/stylesheets/*`, but the - # drift check asks `git check-ignore` — git says the file is effectively - # ignored, so the negation is dead and the tracked copy still gets the nag. + # drift check asks git itself — git says the file is effectively ignored, + # so the negation is dead and the tracked copy still gets the nag. build_skeleton write(".gitignore", "!#{sources_path}\napp/assets/stylesheets/*\n") write(sources_path, "/* tracked while effectively ignored by a broad glob */\n") @@ -1198,6 +1198,25 @@ def render_page(view) out: File::NULL, err: File::NULL)).to be(true) end + it "ignores machine-local excludes (.git/info/exclude) — the sync report is machine-independent" do + # The drift verdict must come from the repo's COMMITTED .gitignore files + # only: a developer's personal excludes (.git/info/exclude or a global + # core.excludesFile) would otherwise flip the warning per machine — and + # its "the ignore entry is in place" guidance would be a lie (the repo + # has no entry). Here only info/exclude ignores the tracked file: the + # report must stay quiet on the tailwind drift. + build_skeleton + write(sources_path, "/* tracked; ignored only by a personal exclude */\n") + system("git", "-C", destination, "init", "-q") + system("git", "-C", destination, "add", sources_path) + FileUtils.mkdir_p(File.join(destination, ".git/info")) + File.write(File.join(destination, ".git/info/exclude"), "#{sources_path}\n") + + report = DocsKit::Generators::SyncReport.new(destination) + + expect(report.items.join).not_to include("git rm --cached") + end + it "does NOT warn when the site is not a git repository" do build_skeleton write(sources_path, "/* generated locally, no repo */\n")