Skip to content

Commit e02a29f

Browse files
Don’t look at ignore files outside initialized repos (#15941)
Right now, when Oxide is scanning for files, it considers ignore files in the "root" directory it is scanning as well as all parent directories. We honor .gitignore files even when not in a git repo as an optimization in case a project has been created, contains a .gitignore, but no repo has actually been initialized. However, this has an unintended side effect of including ignore files _ouside of a repo_ when there is one. This means that if you have a .gitignore file in your home folder it'll get applied even when you're inside a git repo which is not what you'd expect. This PR addresses this by checking to see the folder being scanned is inside a repo and turns on a flag that ensures .gitignore files from the repo are the only ones used (global ignore files configured in git still work tho). This still needs lots of tests to make sure things work as expected. Fixes #15876 --------- Co-authored-by: Robin Malfait <[email protected]>
1 parent 86264a9 commit e02a29f

File tree

5 files changed

+260
-10
lines changed

5 files changed

+260
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Suggest container query variants ([#15857](https://github.com/tailwindlabs/tailwindcss/pull/15857))
2222
- Disable bare value suggestions when not using the `--spacing` variable ([#15857](https://github.com/tailwindlabs/tailwindcss/pull/15857))
2323
- Ensure suggested classes are properly sorted ([#15857](https://github.com/tailwindlabs/tailwindcss/pull/15857))
24+
- Don’t look at ignore files outside initialized repos ([#15941](https://github.com/tailwindlabs/tailwindcss/pull/15941))
2425
- _Upgrade_: Ensure JavaScript config files on different drives are correctly migrated ([#15927](https://github.com/tailwindlabs/tailwindcss/pull/15927))
2526

2627
## [4.0.0] - 2025-01-21

crates/oxide/src/scanner/allowed_paths.rs

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,11 @@ pub fn resolve_allowed_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
3333

3434
#[tracing::instrument(skip_all)]
3535
pub fn resolve_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
36-
WalkBuilder::new(root)
37-
.hidden(false)
38-
.require_git(false)
39-
.build()
40-
.filter_map(Result::ok)
36+
create_walk_builder(root).build().filter_map(Result::ok)
4137
}
4238

4339
pub fn read_dir(root: &Path, depth: Option<usize>) -> impl Iterator<Item = DirEntry> {
44-
WalkBuilder::new(root)
45-
.hidden(false)
46-
.require_git(false)
40+
create_walk_builder(root)
4741
.max_depth(depth)
4842
.filter_entry(move |entry| match entry.file_type() {
4943
Some(file_type) if file_type.is_dir() => match entry.file_name().to_str() {
@@ -59,6 +53,61 @@ pub fn read_dir(root: &Path, depth: Option<usize>) -> impl Iterator<Item = DirEn
5953
.filter_map(Result::ok)
6054
}
6155

56+
fn create_walk_builder(root: &Path) -> WalkBuilder {
57+
let mut builder = WalkBuilder::new(root);
58+
59+
// Scan hidden files / directories
60+
builder.hidden(false);
61+
62+
// By default, allow .gitignore files to be used regardless of whether or not
63+
// a .git directory is present. This is an optimization for when projects
64+
// are first created and may not be in a git repo yet.
65+
builder.require_git(false);
66+
67+
// Don't descend into .git directories inside the root folder
68+
// This is necessary when `root` contains the `.git` dir.
69+
builder.filter_entry(|entry| entry.file_name() != ".git");
70+
71+
// If we are in a git repo then require it to ensure that only rules within
72+
// the repo are used. For example, we don't want to consider a .gitignore file
73+
// in the user's home folder if we're in a git repo.
74+
//
75+
// The alternative is using a call like `.parents(false)` but that will
76+
// prevent looking at parent directories for .gitignore files from within
77+
// the repo and that's not what we want.
78+
//
79+
// For example, in a project with this structure:
80+
//
81+
// home
82+
// .gitignore
83+
// my-project
84+
// .gitignore
85+
// apps
86+
// .gitignore
87+
// web
88+
// {root}
89+
//
90+
// We do want to consider all .gitignore files listed:
91+
// - home/.gitignore
92+
// - my-project/.gitignore
93+
// - my-project/apps/.gitignore
94+
//
95+
// However, if a repo is initialized inside my-project then only the following
96+
// make sense for consideration:
97+
// - my-project/.gitignore
98+
// - my-project/apps/.gitignore
99+
//
100+
// Setting the require_git(true) flag conditionally allows us to do this.
101+
for parent in root.ancestors() {
102+
if parent.join(".git").exists() {
103+
builder.require_git(true);
104+
break;
105+
}
106+
}
107+
108+
builder
109+
}
110+
62111
pub fn is_allowed_content_path(path: &Path) -> bool {
63112
// Skip known ignored files
64113
if path

crates/oxide/tests/scanner.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,4 +586,121 @@ mod scanner {
586586
]
587587
);
588588
}
589+
590+
#[test]
591+
fn skips_ignore_files_outside_of_a_repo() {
592+
// Create a temporary working directory
593+
let dir = tempdir().unwrap().into_path();
594+
595+
// Create files
596+
create_files_in(
597+
&dir,
598+
&[
599+
// This file should always be picked up
600+
("home/project/apps/web/index.html", "content-['index.html']"),
601+
// Set up various ignore rules
602+
("home/.gitignore", "ignore-home.html"),
603+
("home/project/.gitignore", "ignore-project.html"),
604+
("home/project/apps/.gitignore", "ignore-apps.html"),
605+
("home/project/apps/web/.gitignore", "ignore-web.html"),
606+
// Some of these should be ignored depending on which dir is the repo root
607+
(
608+
"home/project/apps/web/ignore-home.html",
609+
"content-['ignore-home.html']",
610+
),
611+
(
612+
"home/project/apps/web/ignore-project.html",
613+
"content-['ignore-project.html']",
614+
),
615+
(
616+
"home/project/apps/web/ignore-apps.html",
617+
"content-['ignore-apps.html']",
618+
),
619+
(
620+
"home/project/apps/web/ignore-web.html",
621+
"content-['ignore-web.html']",
622+
),
623+
],
624+
);
625+
626+
let sources = vec![GlobEntry {
627+
base: dir
628+
.join("home/project/apps/web")
629+
.to_string_lossy()
630+
.to_string(),
631+
pattern: "**/*".to_owned(),
632+
}];
633+
634+
let candidates = Scanner::new(Some(sources.clone())).scan();
635+
636+
// All ignore files are applied because there's no git repo
637+
assert_eq!(candidates, vec!["content-['index.html']".to_owned(),]);
638+
639+
// Initialize `home` as a git repository and scan again
640+
// The results should be the same as before
641+
_ = Command::new("git")
642+
.arg("init")
643+
.current_dir(dir.join("home"))
644+
.output();
645+
let candidates = Scanner::new(Some(sources.clone())).scan();
646+
647+
assert_eq!(candidates, vec!["content-['index.html']".to_owned(),]);
648+
649+
// Drop the .git folder
650+
fs::remove_dir_all(dir.join("home/.git")).unwrap();
651+
652+
// Initialize `home/project` as a git repository and scan again
653+
_ = Command::new("git")
654+
.arg("init")
655+
.current_dir(dir.join("home/project"))
656+
.output();
657+
let candidates = Scanner::new(Some(sources.clone())).scan();
658+
659+
assert_eq!(
660+
candidates,
661+
vec![
662+
"content-['ignore-home.html']".to_owned(),
663+
"content-['index.html']".to_owned(),
664+
]
665+
);
666+
667+
// Drop the .git folder
668+
fs::remove_dir_all(dir.join("home/project/.git")).unwrap();
669+
670+
// Initialize `home/project/apps` as a git repository and scan again
671+
_ = Command::new("git")
672+
.arg("init")
673+
.current_dir(dir.join("home/project/apps"))
674+
.output();
675+
let candidates = Scanner::new(Some(sources.clone())).scan();
676+
677+
assert_eq!(
678+
candidates,
679+
vec![
680+
"content-['ignore-home.html']".to_owned(),
681+
"content-['ignore-project.html']".to_owned(),
682+
"content-['index.html']".to_owned(),
683+
]
684+
);
685+
686+
// Drop the .git folder
687+
fs::remove_dir_all(dir.join("home/project/apps/.git")).unwrap();
688+
689+
// Initialize `home/project/apps` as a git repository and scan again
690+
_ = Command::new("git")
691+
.arg("init")
692+
.current_dir(dir.join("home/project/apps/web"))
693+
.output();
694+
let candidates = Scanner::new(Some(sources.clone())).scan();
695+
696+
assert_eq!(
697+
candidates,
698+
vec![
699+
"content-['ignore-apps.html']".to_owned(),
700+
"content-['ignore-home.html']".to_owned(),
701+
"content-['ignore-project.html']".to_owned(),
702+
"content-['index.html']".to_owned(),
703+
]
704+
);
705+
}
589706
}

integrations/cli/index.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,83 @@ describe.each([
556556
])
557557
},
558558
)
559+
560+
test(
561+
'git ignore files outside of a repo are not considered',
562+
{
563+
fs: {
564+
// Ignore everything in the "home" directory
565+
'home/.gitignore': '*',
566+
567+
// Only ignore files called ignore-*.html in the actual git repo
568+
'home/project/.gitignore': 'ignore-*.html',
569+
570+
'home/project/package.json': json`
571+
{
572+
"type": "module",
573+
"dependencies": {
574+
"tailwindcss": "workspace:^",
575+
"@tailwindcss/cli": "workspace:^"
576+
}
577+
}
578+
`,
579+
580+
'home/project/src/index.css': css` @import 'tailwindcss'; `,
581+
'home/project/src/index.html': html`
582+
<div
583+
class="content-['index.html']"
584+
></div>
585+
`,
586+
'home/project/src/ignore-1.html': html`
587+
<div
588+
class="content-['ignore-1.html']"
589+
></div>
590+
`,
591+
'home/project/src/ignore-2.html': html`
592+
<div
593+
class="content-['ignore-2.html']"
594+
></div>
595+
`,
596+
},
597+
598+
installDependencies: false,
599+
},
600+
async ({ fs, root, exec }) => {
601+
await exec(`pnpm install --ignore-workspace`, {
602+
cwd: path.join(root, 'home/project'),
603+
})
604+
605+
// No git repo = all ignore files are considered
606+
await exec(`${command} --input src/index.css --output dist/out.css`, {
607+
cwd: path.join(root, 'home/project'),
608+
})
609+
610+
await fs.expectFileNotToContain('./home/project/dist/out.css', [
611+
candidate`content-['index.html']`,
612+
candidate`content-['ignore-1.html']`,
613+
candidate`content-['ignore-2.html']`,
614+
])
615+
616+
// Make home/project a git repo
617+
// Only ignore files within the repo are considered
618+
await exec(`git init`, {
619+
cwd: path.join(root, 'home/project'),
620+
})
621+
622+
await exec(`${command} --input src/index.css --output dist/out.css`, {
623+
cwd: path.join(root, 'home/project'),
624+
})
625+
626+
await fs.expectFileToContain('./home/project/dist/out.css', [
627+
candidate`content-['index.html']`,
628+
])
629+
630+
await fs.expectFileNotToContain('./home/project/dist/out.css', [
631+
candidate`content-['ignore-1.html']`,
632+
candidate`content-['ignore-2.html']`,
633+
])
634+
},
635+
)
559636
})
560637

561638
test(

integrations/utils.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ interface TestConfig {
3232
fs: {
3333
[filePath: string]: string | Uint8Array
3434
}
35+
36+
installDependencies?: boolean
3537
}
3638
interface TestContext {
3739
root: string
@@ -382,14 +384,18 @@ export function test(
382384
await context.fs.write(filename, content)
383385
}
384386

387+
let shouldInstallDependencies = config.installDependencies ?? true
388+
385389
try {
386390
// In debug mode, the directory is going to be inside the pnpm workspace
387391
// of the tailwindcss package. This means that `pnpm install` will run
388392
// pnpm install on the workspace instead (expect if the root dir defines
389393
// a separate workspace). We work around this by using the
390394
// `--ignore-workspace` flag.
391-
let ignoreWorkspace = debug && !config.fs['pnpm-workspace.yaml']
392-
await context.exec(`pnpm install${ignoreWorkspace ? ' --ignore-workspace' : ''}`)
395+
if (shouldInstallDependencies) {
396+
let ignoreWorkspace = debug && !config.fs['pnpm-workspace.yaml']
397+
await context.exec(`pnpm install${ignoreWorkspace ? ' --ignore-workspace' : ''}`)
398+
}
393399
} catch (error: any) {
394400
console.error(error)
395401
console.error(error.stdout?.toString())

0 commit comments

Comments
 (0)