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 <malfait.robin@gmail.com>
This commit is contained in:
Jordan Pittman 2025-01-28 11:13:16 -05:00 • committed by GitHub
parent 86264a9e66
commit e02a29fa94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 260 additions and 10 deletions

View file

@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Suggest container query variants ([#15857](https://github.com/tailwindlabs/tailwindcss/pull/15857))
- Disable bare value suggestions when not using the `--spacing` variable ([#15857](https://github.com/tailwindlabs/tailwindcss/pull/15857))
- Ensure suggested classes are properly sorted ([#15857](https://github.com/tailwindlabs/tailwindcss/pull/15857))
- Don’t look at ignore files outside initialized repos ([#15941](https://github.com/tailwindlabs/tailwindcss/pull/15941))
- _Upgrade_: Ensure JavaScript config files on different drives are correctly migrated ([#15927](https://github.com/tailwindlabs/tailwindcss/pull/15927))
## [4.0.0] - 2025-01-21

View file

@ -33,17 +33,11 @@ pub fn resolve_allowed_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
#[tracing::instrument(skip_all)]
pub fn resolve_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
WalkBuilder::new(root)
.hidden(false)
.require_git(false)
.build()
.filter_map(Result::ok)
create_walk_builder(root).build().filter_map(Result::ok)
}
pub fn read_dir(root: &Path, depth: Option<usize>) -> impl Iterator<Item = DirEntry> {
WalkBuilder::new(root)
.hidden(false)
.require_git(false)
create_walk_builder(root)
.max_depth(depth)
.filter_entry(move |entry| match entry.file_type() {
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
.filter_map(Result::ok)
}
fn create_walk_builder(root: &Path) -> WalkBuilder {
let mut builder = WalkBuilder::new(root);
// Scan hidden files / directories
builder.hidden(false);
// By default, allow .gitignore files to be used regardless of whether or not
// a .git directory is present. This is an optimization for when projects
// are first created and may not be in a git repo yet.
builder.require_git(false);
// Don't descend into .git directories inside the root folder
// This is necessary when `root` contains the `.git` dir.
builder.filter_entry(|entry| entry.file_name() != ".git");
// If we are in a git repo then require it to ensure that only rules within
// the repo are used. For example, we don't want to consider a .gitignore file
// in the user's home folder if we're in a git repo.
//
// The alternative is using a call like `.parents(false)` but that will
// prevent looking at parent directories for .gitignore files from within
// the repo and that's not what we want.
//
// For example, in a project with this structure:
//
// home
// .gitignore
// my-project
// .gitignore
// apps
// .gitignore
// web
// {root}
//
// We do want to consider all .gitignore files listed:
// - home/.gitignore
// - my-project/.gitignore
// - my-project/apps/.gitignore
//
// However, if a repo is initialized inside my-project then only the following
// make sense for consideration:
// - my-project/.gitignore
// - my-project/apps/.gitignore
//
// Setting the require_git(true) flag conditionally allows us to do this.
for parent in root.ancestors() {
if parent.join(".git").exists() {
builder.require_git(true);
break;
}
}
builder
}
pub fn is_allowed_content_path(path: &Path) -> bool {
// Skip known ignored files
if path

View file

@ -586,4 +586,121 @@ mod scanner {
]
);
}
#[test]
fn skips_ignore_files_outside_of_a_repo() {
// Create a temporary working directory
let dir = tempdir().unwrap().into_path();
// Create files
create_files_in(
&dir,
&[
// This file should always be picked up
("home/project/apps/web/index.html", "content-['index.html']"),
// Set up various ignore rules
("home/.gitignore", "ignore-home.html"),
("home/project/.gitignore", "ignore-project.html"),
("home/project/apps/.gitignore", "ignore-apps.html"),
("home/project/apps/web/.gitignore", "ignore-web.html"),
// Some of these should be ignored depending on which dir is the repo root
(
"home/project/apps/web/ignore-home.html",
"content-['ignore-home.html']",
),
(
"home/project/apps/web/ignore-project.html",
"content-['ignore-project.html']",
),
(
"home/project/apps/web/ignore-apps.html",
"content-['ignore-apps.html']",
),
(
"home/project/apps/web/ignore-web.html",
"content-['ignore-web.html']",
),
],
);
let sources = vec![GlobEntry {
base: dir
.join("home/project/apps/web")
.to_string_lossy()
.to_string(),
pattern: "**/*".to_owned(),
}];
let candidates = Scanner::new(Some(sources.clone())).scan();
// All ignore files are applied because there's no git repo
assert_eq!(candidates, vec!["content-['index.html']".to_owned(),]);
// Initialize `home` as a git repository and scan again
// The results should be the same as before
_ = Command::new("git")
.arg("init")
.current_dir(dir.join("home"))
.output();
let candidates = Scanner::new(Some(sources.clone())).scan();
assert_eq!(candidates, vec!["content-['index.html']".to_owned(),]);
// Drop the .git folder
fs::remove_dir_all(dir.join("home/.git")).unwrap();
// Initialize `home/project` as a git repository and scan again
_ = Command::new("git")
.arg("init")
.current_dir(dir.join("home/project"))
.output();
let candidates = Scanner::new(Some(sources.clone())).scan();
assert_eq!(
candidates,
vec![
"content-['ignore-home.html']".to_owned(),
"content-['index.html']".to_owned(),
]
);
// Drop the .git folder
fs::remove_dir_all(dir.join("home/project/.git")).unwrap();
// Initialize `home/project/apps` as a git repository and scan again
_ = Command::new("git")
.arg("init")
.current_dir(dir.join("home/project/apps"))
.output();
let candidates = Scanner::new(Some(sources.clone())).scan();
assert_eq!(
candidates,
vec![
"content-['ignore-home.html']".to_owned(),
"content-['ignore-project.html']".to_owned(),
"content-['index.html']".to_owned(),
]
);
// Drop the .git folder
fs::remove_dir_all(dir.join("home/project/apps/.git")).unwrap();
// Initialize `home/project/apps` as a git repository and scan again
_ = Command::new("git")
.arg("init")
.current_dir(dir.join("home/project/apps/web"))
.output();
let candidates = Scanner::new(Some(sources.clone())).scan();
assert_eq!(
candidates,
vec![
"content-['ignore-apps.html']".to_owned(),
"content-['ignore-home.html']".to_owned(),
"content-['ignore-project.html']".to_owned(),
"content-['index.html']".to_owned(),
]
);
}
}

View file

@ -556,6 +556,83 @@ describe.each([
])
},
)
test(
'git ignore files outside of a repo are not considered',
{
fs: {
// Ignore everything in the "home" directory
'home/.gitignore': '*',
// Only ignore files called ignore-*.html in the actual git repo
'home/project/.gitignore': 'ignore-*.html',
'home/project/package.json': json`
{
"type": "module",
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'home/project/src/index.css': css` @import 'tailwindcss'; `,
'home/project/src/index.html': html`
<div
class="content-['index.html']"
></div>
`,
'home/project/src/ignore-1.html': html`
<div
class="content-['ignore-1.html']"
></div>
`,
'home/project/src/ignore-2.html': html`
<div
class="content-['ignore-2.html']"
></div>
`,
},
installDependencies: false,
},
async ({ fs, root, exec }) => {
await exec(`pnpm install --ignore-workspace`, {
cwd: path.join(root, 'home/project'),
})
// No git repo = all ignore files are considered
await exec(`${command} --input src/index.css --output dist/out.css`, {
cwd: path.join(root, 'home/project'),
})
await fs.expectFileNotToContain('./home/project/dist/out.css', [
candidate`content-['index.html']`,
candidate`content-['ignore-1.html']`,
candidate`content-['ignore-2.html']`,
])
// Make home/project a git repo
// Only ignore files within the repo are considered
await exec(`git init`, {
cwd: path.join(root, 'home/project'),
})
await exec(`${command} --input src/index.css --output dist/out.css`, {
cwd: path.join(root, 'home/project'),
})
await fs.expectFileToContain('./home/project/dist/out.css', [
candidate`content-['index.html']`,
])
await fs.expectFileNotToContain('./home/project/dist/out.css', [
candidate`content-['ignore-1.html']`,
candidate`content-['ignore-2.html']`,
])
},
)
})
test(

View file

@ -32,6 +32,8 @@ interface TestConfig {
fs: {
[filePath: string]: string | Uint8Array
}
installDependencies?: boolean
}
interface TestContext {
root: string
@ -382,14 +384,18 @@ export function test(
await context.fs.write(filename, content)
}
let shouldInstallDependencies = config.installDependencies ?? true
try {
// In debug mode, the directory is going to be inside the pnpm workspace
// of the tailwindcss package. This means that `pnpm install` will run
// pnpm install on the workspace instead (expect if the root dir defines
// a separate workspace). We work around this by using the
// `--ignore-workspace` flag.
let ignoreWorkspace = debug && !config.fs['pnpm-workspace.yaml']
await context.exec(`pnpm install${ignoreWorkspace ? ' --ignore-workspace' : ''}`)
if (shouldInstallDependencies) {
let ignoreWorkspace = debug && !config.fs['pnpm-workspace.yaml']
await context.exec(`pnpm install${ignoreWorkspace ? ' --ignore-workspace' : ''}`)
}
} catch (error: any) {
console.error(error)
console.error(error.stdout?.toString())