Skip to content

Commit 1bf4291

Browse files
authored
Fix @source with folders that are ignored (#20214)
This PR fixes an issue where a `@source` that's pointing to a folder that is git ignored, is also ignored by the `@source` even if it's explicitly added. Internally, we convert `@source` directives from `PublicSourceEntry`s to `SourceEntry`s where we have dedicated enum branches for `Auto`, `Pattern`, `Ignored` and `External`. The `Auto` one accepts a `base` path, and will be used for auto content detection. However, these paths will make use of all the default auto content detection rules, which includes git ignore rules. We also have `External` where we link to something that's "external" to the current repo. We can probably improve this name, but it's external in the sense that it won't show up on GitHub for example, aka ignored. We have some content dirs that we ignore by default, such as the `node_modules` folder. When you do use `@source` with `node_modules` in the path, then we will mark it as an `external` resource which does not look at the `gitignore` related rules and allowing it to be included this way. The idea with this is that, even though the folder is ignored by default, you can still include files from the folder by explicitly using the `@source` directive. The issue as seen in #19844 is using `vendor/` instead of `node_modules/` which is _not_ ignored by default. While we can add `vendor/` to this same ignored dirs list, it will result in a breaking change because this folder is often used by the Laravel community to store some resources in. This PR fixes this problem by not only looking at the content dirs we ignore by default, but also looking at the actual git ignore state of this folder. If it turns out that this is ignored, then we promote the `Auto` source to an `External` source. Fixes: #19844 Closes: #20057 ## Test plan 1. Added integration tests for this situation 2. Ran the fix on the reproduction from #19844. If we run the CLI with the `DEBUG=*` environment variable, the log file produces these results: ```diff diff --git a/./tailwindcss-29207.log b/./tailwindcss-30381.log index bc3017c..921af0e 100644 --- a/./tailwindcss-29207.log +++ b/./tailwindcss-30381.log @@ -6,8 +6,9 @@ INFO tailwindcss_oxide::scanner: Source: PublicSourceEntry { base: "/Users/robin INFO tailwindcss_oxide::scanner: Optimized sources: INFO tailwindcss_oxide::scanner: Source: Pattern { base: "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/app/design/frontend/theme", pattern: "/**/*.phtml" } INFO tailwindcss_oxide::scanner: Source: Pattern { base: "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/app/design/frontend/theme", pattern: "/**/*.xml" } -INFO tailwindcss_oxide::scanner: Source: Auto { base: "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/vendor/acme/theme" } +INFO tailwindcss_oxide::scanner: Source: External { base: "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/vendor/acme/theme" } INFO tailwindcss_oxide::scanner: Source: Ignored { base: "/Users/robin/.fnm/node-versions/v26.1.0/installation/bin", pattern: "/node" } INFO discover_sources: tailwindcss_oxide::scanner: enter -INFO discover_sources: tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/app/design/frontend/theme/index.phtml" +INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/app/design/frontend/theme/index.phtml" +INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/vendor/acme/theme/module/templates/component.phtml" INFO discover_sources: tailwindcss_oxide::scanner: exit ``` We're checking some `.gitignore` related files, so let's check on each OS [ci-all]
1 parent 1d5e15e commit 1bf4291

6 files changed

Lines changed: 256 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
- Ensure later `@source` rules can re-include files excluded by earlier `@source not` rules ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203))
3232
- Upgrade: don't migrate empty class rules to invalid `@utility` rules ([#20205](https://github.com/tailwindlabs/tailwindcss/pull/20205))
3333
- Ensure transitions between `inset-shadow-none` and other inset shadows work correctly ([#20208](https://github.com/tailwindlabs/tailwindcss/pull/20208))
34+
- Ensure explicitly referenced `@source` directories are scanned even when ignored by git ([#20214](https://github.com/tailwindlabs/tailwindcss/pull/20214))
3435

3536
### Changed
3637

crates/oxide/src/scanner/mod.rs

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
688688
}
689689

690690
// External sources should take precedence even over git-ignored files:
691-
emit(base, format!("!{}", "/**/*"));
691+
emit(base, "!/**/*".to_owned());
692692

693693
// External sources should still disallow binary extensions:
694694
emit(base, BINARY_EXTENSIONS_GLOB.clone());
@@ -780,14 +780,7 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
780780
let pattern_sources: Vec<(PathBuf, String)> = sources
781781
.iter()
782782
.filter_map(|source| match source {
783-
SourceEntry::Pattern { base, pattern } => {
784-
let normalized = if pattern.starts_with("/") {
785-
pattern.to_string()
786-
} else {
787-
format!("/{pattern}")
788-
};
789-
Some((base.clone(), normalized))
790-
}
783+
SourceEntry::Pattern { base, pattern } => Some((base.into(), pattern.into())),
791784
_ => None,
792785
})
793786
.collect();

crates/oxide/src/scanner/sources.rs

Lines changed: 156 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use crate::GlobEntry;
22
use bexpand::Expression;
3+
use fxhash::FxHashMap;
4+
use ignore::gitignore::Gitignore;
35
use std::path::{Component, Path, PathBuf};
46
use tracing::{event, Level};
57

@@ -48,13 +50,14 @@ pub enum SourceEntry {
4850
/// ```
4951
Ignored { base: PathBuf, pattern: String },
5052

51-
/// External sources are sources outside of your git root which should not
52-
/// follow gitignore rules.
53+
/// External sources are directories that are ignored (by us or .gitignore rules), but should be
54+
/// included bypassing the default ignore rules.
5355
///
5456
/// Represented by:
5557
///
5658
/// ```css
5759
/// @source "../node_modules/my-lib";`
60+
/// @source "../node_modules/my-lib/**/*";`
5861
/// ```
5962
External { base: PathBuf },
6063
}
@@ -311,6 +314,80 @@ mod tests {
311314
);
312315
assert_eq!(source.pattern, "/**/*.html");
313316
}
317+
318+
/// Run the public-to-private conversion for an auto-detected source pointing at `base` and
319+
/// return the resulting entry.
320+
fn auto_source_entry(base: &Path) -> SourceEntry {
321+
public_source_entries_to_private_source_entries(vec![PublicSourceEntry {
322+
base: base.to_string_lossy().to_string(),
323+
pattern: "**/*".to_string(),
324+
negated: false,
325+
}])
326+
.into_iter()
327+
.next()
328+
.unwrap()
329+
}
330+
331+
#[test]
332+
fn auto_detected_folders_become_auto_sources() {
333+
let dir = tempdir().unwrap();
334+
let base = dir.path().join("src");
335+
fs::create_dir_all(&base).unwrap();
336+
let base = dunce::canonicalize(&base).unwrap();
337+
338+
assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base });
339+
}
340+
341+
#[test]
342+
fn folders_ignored_by_default_become_external_sources() {
343+
let dir = tempdir().unwrap();
344+
let base = dir.path().join("node_modules").join("my-lib");
345+
fs::create_dir_all(&base).unwrap();
346+
let base = dunce::canonicalize(&base).unwrap();
347+
348+
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
349+
}
350+
351+
#[test]
352+
fn folders_ignored_by_gitignore_become_external_sources() {
353+
let dir = tempdir().unwrap();
354+
// Pretend this is a git repository so the `.gitignore` search is bounded to it.
355+
fs::create_dir_all(dir.path().join(".git")).unwrap();
356+
fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap();
357+
358+
let base = dir.path().join("dist");
359+
fs::create_dir_all(&base).unwrap();
360+
let base = dunce::canonicalize(&base).unwrap();
361+
362+
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
363+
}
364+
365+
#[test]
366+
fn folders_ignored_by_a_parent_gitignore_become_external_sources() {
367+
let dir = tempdir().unwrap();
368+
fs::create_dir_all(dir.path().join(".git")).unwrap();
369+
// A `.gitignore` higher up in the tree should still apply to nested directories.
370+
fs::write(dir.path().join(".gitignore"), "generated/\n").unwrap();
371+
372+
let base = dir.path().join("packages").join("app").join("generated");
373+
fs::create_dir_all(&base).unwrap();
374+
let base = dunce::canonicalize(&base).unwrap();
375+
376+
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
377+
}
378+
379+
#[test]
380+
fn folders_not_ignored_by_gitignore_stay_auto_sources() {
381+
let dir = tempdir().unwrap();
382+
fs::create_dir_all(dir.path().join(".git")).unwrap();
383+
fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap();
384+
385+
let base = dir.path().join("src");
386+
fs::create_dir_all(&base).unwrap();
387+
let base = dunce::canonicalize(&base).unwrap();
388+
389+
assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base });
390+
}
314391
}
315392

316393
/// For each public source entry:
@@ -360,19 +437,91 @@ pub fn public_source_entries_to_private_source_entries(
360437
})
361438
.collect::<Vec<_>>();
362439

440+
// Compiled `.gitignore` matchers are cached per directory so we read and parse each
441+
// `.gitignore` file at most once, even though entries commonly share ancestor directories
442+
// (e.g. the repository root). A cached `None` means the directory has no `.gitignore` file.
443+
let mut gitignores: FxHashMap<PathBuf, Option<Gitignore>> = FxHashMap::default();
444+
445+
// Boundary for the `.gitignore` walk when a source is not inside a git repository (see
446+
// below).
447+
let cwd = std::env::current_dir()
448+
.map(|cwd| dunce::canonicalize(&cwd).unwrap_or(cwd))
449+
.ok();
450+
363451
// Convert from public SourceEntry to private SourceEntry
364452
expanded_globs
365453
.into_iter()
366-
.map(Into::into)
367-
.collect::<Vec<_>>()
454+
.map(|public_source| {
455+
let mut source: SourceEntry = public_source.into();
456+
457+
// Promote auto-sources to external sources if they were gitignored
458+
if let SourceEntry::Auto { ref base } = source {
459+
let inside_git_repo = base.ancestors().any(|dir| dir.join(".git").exists());
460+
461+
// Walk up from the folder, applying each `.gitignore` relative to the directory
462+
// that contains it (matching git), and stop at the git repository root so
463+
// `.gitignore` files outside of the repo are not considered.
464+
for dir in base.ancestors() {
465+
let gitignore = gitignores.entry(dir.to_path_buf()).or_insert_with(|| {
466+
let path = dir.join(".gitignore");
467+
468+
// `Gitignore::new` roots the matcher at the directory
469+
// containing the file, so patterns match relative to it.
470+
path.is_file().then(|| Gitignore::new(&path).0)
471+
});
472+
473+
if let Some(gitignore) = gitignore {
474+
if gitignore
475+
.matched_path_or_any_parents(&base, true)
476+
.is_ignore()
477+
{
478+
source = SourceEntry::External { base: base.into() };
479+
break;
480+
}
481+
}
482+
483+
// Stop at the git repository root.
484+
if dir.join(".git").exists() {
485+
break;
486+
}
487+
488+
// Without a git repository there is no repository root to stop at. Stop
489+
// once the directory contains the current working directory instead, so
490+
// `.gitignore` files outside of the project (e.g. in the user's home
491+
// directory) can never promote a source to an external source. Note that
492+
// the file walker still applies those `.gitignore` files when deciding
493+
// which files to scan.
494+
if !inside_git_repo && cwd.as_ref().is_some_and(|cwd| cwd.starts_with(dir)) {
495+
break;
496+
}
497+
}
498+
}
499+
500+
source
501+
})
502+
.collect::<Vec<SourceEntry>>()
368503
}
369504

370505
/// Convert a public source entry to a source entry
371506
impl From<PublicSourceEntry> for SourceEntry {
372507
fn from(value: PublicSourceEntry) -> Self {
508+
if value.negated {
509+
return SourceEntry::Ignored {
510+
base: value.base.into(),
511+
pattern: value.pattern,
512+
};
513+
}
514+
373515
let auto = value.pattern.ends_with("**/*")
374516
|| PathBuf::from(&value.base).join(&value.pattern).is_dir();
375517

518+
if !auto {
519+
return SourceEntry::Pattern {
520+
base: value.base.into(),
521+
pattern: value.pattern,
522+
};
523+
}
524+
376525
let inside_ignored_content_dir = IGNORED_CONTENT_DIRS.iter().any(|dir| {
377526
value.base.contains(&format!(
378527
"{}{}{}",
@@ -384,20 +533,12 @@ impl From<PublicSourceEntry> for SourceEntry {
384533
.ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir,))
385534
});
386535

387-
match (value.negated, auto, inside_ignored_content_dir) {
388-
(false, true, false) => SourceEntry::Auto {
389-
base: value.base.into(),
390-
},
391-
(false, true, true) => SourceEntry::External {
536+
match inside_ignored_content_dir {
537+
false => SourceEntry::Auto {
392538
base: value.base.into(),
393539
},
394-
(false, false, _) => SourceEntry::Pattern {
540+
true => SourceEntry::External {
395541
base: value.base.into(),
396-
pattern: value.pattern,
397-
},
398-
(true, _, _) => SourceEntry::Ignored {
399-
base: value.base.into(),
400-
pattern: value.pattern,
401542
},
402543
}
403544
}

crates/oxide/tests/scanner.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,30 @@ mod scanner {
15141514
);
15151515
}
15161516

1517+
// https://github.com/tailwindlabs/tailwindcss/issues/19844
1518+
#[test]
1519+
fn test_allow_explicit_sources_ignored_by_allow_list_gitignore() {
1520+
let ScanResult { candidates, .. } = scan_with_globs(
1521+
&[
1522+
(".gitignore", "*\n!/app\n!/app/design\n!/app/design/**\n"),
1523+
(
1524+
"app/design/frontend/theme/templates/component.phtml",
1525+
"content-['app/design/frontend/theme/templates/component.phtml']",
1526+
),
1527+
(
1528+
"vendor/acme/theme/module/templates/component.phtml",
1529+
"content-['vendor/acme/theme/module/templates/component.phtml']",
1530+
),
1531+
],
1532+
vec!["@source 'vendor/acme/theme'"],
1533+
);
1534+
1535+
assert_eq!(
1536+
candidates,
1537+
vec!["content-['vendor/acme/theme/module/templates/component.phtml']"]
1538+
);
1539+
}
1540+
15171541
#[test]
15181542
fn test_ignore_node_modules_without_gitignore() {
15191543
let ScanResult {

integrations/cli/index.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2083,6 +2083,76 @@ test(
20832083
},
20842084
)
20852085

2086+
// https://github.com/tailwindlabs/tailwindcss/issues/19844
2087+
test(
2088+
'@source scans directories ignored by allow-list .gitignore files',
2089+
{
2090+
fs: {
2091+
'package.json': json`
2092+
{
2093+
"dependencies": {
2094+
"tailwindcss": "workspace:^",
2095+
"@tailwindcss/cli": "workspace:^"
2096+
}
2097+
}
2098+
`,
2099+
'.gitignore': txt`
2100+
*
2101+
!/app
2102+
!/app/design
2103+
!/app/design/**
2104+
`,
2105+
'src/index.css': css`
2106+
@import 'tailwindcss/utilities' source(none);
2107+
@source '../vendor/acme/theme';
2108+
`,
2109+
// 1. Ignored by the `*` in `.gitignore`
2110+
// 2. Included by the `!` pattern in `.gitignore`
2111+
// 3. Ignored by `source(none)`
2112+
//
2113+
// → Should be ignored
2114+
'app/design/frontend/theme/templates/component.phtml': html`
2115+
<div
2116+
class="content-['app/design/frontend/theme/templates/component.phtml']"
2117+
></div>
2118+
`,
2119+
// 1. Ignored by the `*` in `.gitignore`
2120+
// 2. Included by the `!` pattern in `.gitignore`
2121+
// 3. Ignored by `source(none)`
2122+
// 4. Included by the `@source` directive
2123+
//
2124+
// → Should be included
2125+
'vendor/acme/theme/module/templates/component.phtml': html`
2126+
<div
2127+
class="content-['vendor/acme/theme/module/templates/component.phtml']"
2128+
></div>
2129+
`,
2130+
},
2131+
},
2132+
async ({ fs, exec }) => {
2133+
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
2134+
2135+
// 1. Ignored by the `*` in `.gitignore`
2136+
// 2. Included by the `!` pattern in `.gitignore`
2137+
// 3. Ignored by `source(none)`
2138+
//
2139+
// → Should be ignored
2140+
await fs.expectFileNotToContain('dist/out.css', [
2141+
candidate`content-['app/design/frontend/theme/templates/component.phtml']`,
2142+
])
2143+
2144+
// 1. Ignored by the `*` in `.gitignore`
2145+
// 2. Included by the `!` pattern in `.gitignore`
2146+
// 3. Ignored by `source(none)`
2147+
// 4. Included by the `@source` directive
2148+
//
2149+
// → Should be included
2150+
await fs.expectFileToContain('dist/out.css', [
2151+
candidate`content-['vendor/acme/theme/module/templates/component.phtml']`,
2152+
])
2153+
},
2154+
)
2155+
20862156
test(
20872157
'@source works with symlinks (referencing folder in current folder)',
20882158
{

integrations/utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ interface TestContext {
5959
filePath: string,
6060
contents: string | RegExp | (string | RegExp)[],
6161
): Promise<void>
62-
expectFileNotToContain(filePath: string, contents: string | string[]): Promise<void>
62+
expectFileNotToContain(filePath: string, contents: string[]): Promise<void>
6363
}
6464
}
6565
type TestCallback = (context: TestContext) => Promise<void> | void
@@ -481,7 +481,7 @@ export function test(
481481
try {
482482
await context.exec('git init', { cwd: root })
483483
await context.exec('git add --all', { cwd: root })
484-
await context.exec('git commit -m "before migration"', { cwd: root })
484+
await context.exec('git commit -m "before migration" --allow-empty', { cwd: root })
485485
} catch (error: any) {
486486
console.error(error)
487487
console.error(error.stdout?.toString())
@@ -597,6 +597,7 @@ export async function retryAssertion<T>(
597597
try {
598598
return await fn()
599599
} catch (err) {
600+
Error.captureStackTrace(err, retryAssertion)
600601
error = err
601602
await new Promise((resolve) => setTimeout(resolve, delay))
602603
}

0 commit comments

Comments
 (0)