Repository: dprint/dprint (language: rust)

Issue:
dprint attempts to format files in .git
Version: 0.45.0

```console
$ mkdir wat
$ cd wat
$ git init
$ dprint init
$ git commit -am "init"
$ git switch -c test.d.ts
$ dprint fmt
dprint fmt
Error formatting /home/jabaile/wat/.git/refs/heads/test.d.ts. Message: Identifier cannot follow number at file:///home/jabaile/wat/.git/refs/heads/test.d.ts:1:3

  20ba1a6a14151f3a90d7638555294cfad31f451f
    ~
Error formatting /home/jabaile/wat/.git/logs/refs/heads/test.d.ts. Message: Identifier cannot follow number at file:///home/jabaile/wat/.git/logs/refs/heads/test.d.ts:1:44

  0000000000000000000000000000000000000000 20ba1a6a14151f3a90d7638555294cfad31f451...
                                             ~
Had 2 errors formatting.
```

This just surprised someone on my team; it doesn't seem like formatting in `.git` will ever be a good idea, even worse than `node_modules`; can this directory be ignored by default?


Discussion / hints:

Fix patch:
diff --git a/crates/dprint/src/commands/formatting.rs b/crates/dprint/src/commands/formatting.rs
index 0b4275e72..733806094 100644
--- a/crates/dprint/src/commands/formatting.rs
+++ b/crates/dprint/src/commands/formatting.rs
@@ -41,7 +41,7 @@ pub async fn stdin_fmt<TEnvironment: Environment>(
 
   // if the path is absolute, then apply exclusion rules
   if environment.is_absolute_path(&cmd.file_name_or_path) {
-    let file_matcher = FileMatcher::new(plugins_scope.config.as_ref().unwrap(), &cmd.patterns, &environment.cwd())?;
+    let file_matcher = FileMatcher::new(environment.clone(), plugins_scope.config.as_ref().unwrap(), &cmd.patterns, &environment.cwd())?;
     // canonicalize the file path, then check if it's in the list of file paths.
     let resolved_file_path = environment.canonicalize(&cmd.file_name_or_path)?;
     // log the file text as-is since it's not in the list of files to format
diff --git a/crates/dprint/src/commands/general.rs b/crates/dprint/src/commands/general.rs
index 0b094c35a..fa40f533b 100644
--- a/crates/dprint/src/commands/general.rs
+++ b/crates/dprint/src/commands/general.rs
@@ -387,6 +387,142 @@ mod test {
     }
   }
 
+  #[test]
+  fn should_respect_gitignore() {
+    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
+      .with_default_config(|c| {
+        c.add_includes("**/*.txt");
+      })
+      .write_file("/file1.txt", "")
+      .write_file("/file2.txt", "")
+      .write_file("/file3.txt", "")
+      .write_file(".gitignore", "file2.txt")
+      .build();
+    run_test_cli(vec!["output-file-paths"], &environment).unwrap();
+    let mut logged_messages = environment.take_stdout_messages();
+    logged_messages.sort();
+    assert_eq!(logged_messages, vec!["/file1.txt", "/file3.txt",]);
+  }
+
+  #[test]
+  fn should_respect_gitignore_sub_dir() {
+    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
+      .with_default_config(|c| {
+        c.add_includes("**/*.txt");
+      })
+      .write_file("/file1.txt", "")
+      .write_file("/file2.txt", "")
+      .write_file("/file3.txt", "")
+      .write_file("/sub/.gitignore", "file1.txt")
+      .write_file("/sub/file1.txt", "")
+      .write_file("/sub/file2.txt", "")
+      .build();
+    run_test_cli(vec!["output-file-paths"], &environment).unwrap();
+    let mut logged_messages = environment.take_stdout_messages();
+    logged_messages.sort();
+    assert_eq!(logged_messages, vec!["/file1.txt", "/file2.txt", "/file3.txt", "/sub/file2.txt"]);
+  }
+
+  #[test]
+  fn should_include_gitignored_explicitly_specified_file() {
+    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
+      .with_default_config(|c| {
+        c.add_includes("**/*.txt").add_includes("file1.txt");
+      })
+      .write_file("/file1.txt", "")
+      .write_file("/file2.txt", "")
+      .write_file("/.gitignore", "file1.txt")
+      .build();
+    run_test_cli(vec!["output-file-paths"], &environment).unwrap();
+    let mut logged_messages = environment.take_stdout_messages();
+    logged_messages.sort();
+    assert_eq!(logged_messages, vec!["/file1.txt", "/file2.txt"]);
+  }
+
+  #[test]
+  fn should_include_gitignored_explicitly_specified_dir() {
+    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
+      .with_default_config(|c| {
+        c.add_includes("**/*.txt").add_includes("sub_dir");
+      })
+      .write_file("/file.txt", "")
+      .write_file("/sub_dir/file.txt", "")
+      .write_file("/sub_dir/sub/file.txt", "")
+      .write_file("/sub_dir2/file.txt", "")
+      .write_file("/.gitignore", "sub_dir\nsub_dir2")
+      .build();
+    run_test_cli(vec!["output-file-paths"], &environment).unwrap();
+    let mut logged_messages = environment.take_stdout_messages();
+    logged_messages.sort();
+    assert_eq!(logged_messages, vec!["/file.txt", "/sub_dir/file.txt", "/sub_dir/sub/file.txt"]);
+  }
+
+  #[test]
+  fn unexcluding_gitignored_file() {
+    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
+      .with_default_config(|c| {
+        c.add_excludes("!file1.txt");
+      })
+      .write_file("/file1.txt", "")
+      .write_file("/.gitignore", "file1.txt")
+      .build();
+    run_test_cli(vec!["output-file-paths"], &environment).unwrap();
+    let mut logged_messages = environment.take_stdout_messages();
+    logged_messages.sort();
+    assert_eq!(logged_messages, vec!["/file1.txt"]);
+  }
+
+  #[test]
+  fn unexcluding_gitignored_dir() {
+    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
+      .with_default_config(|c| {
+        c.add_excludes("!sub_dir");
+      })
+      .write_file("/file1.txt", "")
+      .write_file("/sub_dir/sub.txt", "")
+      .write_file("/file2.txt", "")
+      .write_file("/.gitignore", "file1.txt\nsub_dir")
+      .build();
+    run_test_cli(vec!["output-file-paths"], &environment).unwrap();
+    let mut logged_messages = environment.take_stdout_messages();
+    logged_messages.sort();
+    assert_eq!(logged_messages, vec!["/file2.txt", "/sub_dir/sub.txt"]);
+  }
+
+  #[test]
+  fn excluded_include_and_excluded_gitignore() {
+    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
+      .with_default_config(|c| {
+        c.add_includes("**/*.txt").add_includes("!sub/sub_dir/*.txt");
+      })
+      .write_file("/sub/sub_dir/.gitignore", "!not_ignored.txt\n")
+      .write_file("/sub/sub_dir/not_ignored.txt", "")
+      .write_file("/sub/sub_dir/sub.txt", "")
+      .write_file("/data.txt", "")
+      .build();
+    run_test_cli(vec!["output-file-paths"], &environment).unwrap();
+    let mut logged_messages = environment.take_stdout_messages();
+    logged_messages.sort();
+    assert_eq!(logged_messages, vec!["/data.txt"]);
+  }
+
+  #[test]
+  fn include_and_excluded_gitignore_subdir() {
+    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_plugin()
+      .with_default_config(|c| {
+        c.add_includes("**/*.txt");
+      })
+      .write_file("/sub/sub_dir/.gitignore", "ignored.txt\n")
+      .write_file("/sub/sub_dir/ignored.txt", "")
+      .write_file("/sub/sub_dir/sub.txt", "")
+      .write_file("/data.txt", "")
+      .build();
+    run_test_cli(vec!["output-file-paths"], &environment).unwrap();
+    let mut logged_messages = environment.take_stdout_messages();
+    logged_messages.sort();
+    assert_eq!(logged_messages, vec!["/data.txt", "/sub/sub_dir/sub.txt"]);
+  }
+
   #[test]
   fn should_clear_cache_directory() {
     let environment = TestEnvironment::new();
diff --git a/crates/dprint/src/commands/lsp/mod.rs b/crates/dprint/src/commands/lsp/mod.rs
index 35f60999a..e524f9091 100644
--- a/crates/dprint/src/commands/lsp/mod.rs
+++ b/crates/dprint/src/commands/lsp/mod.rs
@@ -482,6 +482,7 @@ mod test {
       })
       .initialize()
       .build();
+    environment.write_file(".gitignore", "gitignored_file.txt\ngitignored_dir").unwrap();
 
     environment.clone().run_in_runtime(async move {
       let (backend, recv_task, test_client) = setup_backend(environment.clone());
@@ -625,6 +626,16 @@ mod test {
           did_open!(file_uri, "testing");
           assert_format!(file_uri, None);
 
+          // ignores gitignored files
+          let file_uri = Url::parse("file:///gitignored_file.txt").unwrap();
+          did_open!(file_uri, "testing");
+          assert_format!(file_uri, None);
+
+          // ignores file in gitignored dir
+          let file_uri = Url::parse("file:///gitignored_dir/file.txt").unwrap();
+          did_open!(file_uri, "testing");
+          assert_format!(file_uri, None);
+
           // ignores excluded directory files
           let file_uri = Url::parse("file:///ignored-dir/file.txt").unwrap();
           did_open!(file_uri, "testing");
diff --git a/crates/dprint/src/configuration/get_init_config_file_text.rs b/crates/dprint/src/configuration/get_init_config_file_text.rs
index e2c408306..eff680857 100644
--- a/crates/dprint/src/configuration/get_init_config_file_text.rs
+++ b/crates/dprint/src/configuration/get_init_config_file_text.rs
@@ -105,7 +105,7 @@ pub async fn get_init_config_file_text(environment: &impl Environment) -> Result
     }
     json_text.push_str("  ]\n}\n");
   } else {
-    json_text.push_str("  \"excludes\": [\n    \"**/node_modules\",\n    \"**/*-lock.json\"\n  ],\n");
+    json_text.push_str("  \"excludes\": [\n    \"**/*-lock.json\"\n  ],\n");
     json_text.push_str("  \"plugins\": [\n");
     json_text.push_str("    // specify plugin urls here\n");
     json_text.push_str("  ]\n}\n");
@@ -303,7 +303,6 @@ mod test {
         text,
         r#"{
   "excludes": [
-    "**/node_modules",
     "**/*-lock.json"
   ],
   "plugins": [
@@ -382,7 +381,6 @@ mod test {
         text,
         r#"{
   "excludes": [
-    "**/node_modules",
     "**/*-lock.json"
   ],
   "plugins": [
diff --git a/crates/dprint/src/patterns.rs b/crates/dprint/src/patterns.rs
index d713bbb0e..3706eb496 100644
--- a/crates/dprint/src/patterns.rs
+++ b/crates/dprint/src/patterns.rs
@@ -5,20 +5,30 @@ use anyhow::Result;
 use crate::arg_parser::FilePatternArgs;
 use crate::configuration::ResolvedConfig;
 use crate::environment::CanonicalizedPathBuf;
+use crate::environment::Environment;
 use crate::utils::is_absolute_pattern;
 use crate::utils::is_negated_glob;
+use crate::utils::ExcludeMatchDetail;
+use crate::utils::GitIgnoreTree;
 use crate::utils::GlobMatcher;
 use crate::utils::GlobMatcherOptions;
+use crate::utils::GlobMatchesDetail;
 use crate::utils::GlobPattern;
 use crate::utils::GlobPatterns;
 
-pub struct FileMatcher {
+pub struct FileMatcher<TEnvironment: Environment> {
   glob_matcher: GlobMatcher,
+  gitignores: GitIgnoreTree<TEnvironment>,
 }
 
-impl FileMatcher {
-  pub fn new(config: &ResolvedConfig, args: &FilePatternArgs, root_dir: &CanonicalizedPathBuf) -> Result<Self> {
+impl<TEnvironment: Environment> FileMatcher<TEnvironment> {
+  pub fn new(environment: TEnvironment, config: &ResolvedConfig, args: &FilePatternArgs, root_dir: &CanonicalizedPathBuf) -> Result<Self> {
     let patterns = get_all_file_patterns(config, args, root_dir);
+    let gitignores = GitIgnoreTree::new(
+      environment,
+      // explicitly specified paths should override what's in the gitignore
+      patterns.include_paths(),
+    );
     let glob_matcher = GlobMatcher::new(
       patterns,
       &GlobMatcherOptions {
@@ -27,7 +37,7 @@ impl FileMatcher {
       },
     )?;
 
-    Ok(FileMatcher { glob_matcher })
+    Ok(FileMatcher { glob_matcher, gitignores })
   }
 
   pub fn matches(&self, file_path: impl AsRef<Path>) -> bool {
@@ -37,8 +47,44 @@ impl FileMatcher {
   /// More expensive check for if the directory is already ignored.
   /// Prefer using `matches` if you already know the parent directory
   /// isn't ignored.
-  pub fn matches_and_dir_not_ignored(&self, file_path: impl AsRef<Path>) -> bool {
-    self.glob_matcher.matches_and_dir_not_ignored(file_path)
+  pub fn matches_and_dir_not_ignored(&mut self, file_path: &Path) -> bool {
+    let match_result = self.glob_matcher.matches_detail(file_path);
+    match match_result {
+      GlobMatchesDetail::Matched => {
+        if self.is_gitignored(file_path, /* is dir */ false) {
+          return false;
+        }
+      }
+      GlobMatchesDetail::MatchedOptedOutExclude => {}
+      GlobMatchesDetail::Excluded | GlobMatchesDetail::NotMatched => return false,
+    };
+    // ensure the parents aren't ignored
+    if !file_path.starts_with(self.glob_matcher.base_dir()) {
+      return false;
+    }
+    for ancestor in file_path.ancestors() {
+      if let Ok(path) = ancestor.strip_prefix(self.glob_matcher.base_dir()) {
+        match self.glob_matcher.check_exclude(path, true) {
+          ExcludeMatchDetail::Excluded => return false,
+          ExcludeMatchDetail::OptedOutExclude => {}
+          ExcludeMatchDetail::NotExcluded => {
+            if self.is_gitignored(path, /* is dir */ true) {
+              return false;
+            }
+          }
+        }
+      } else {
+        break;
+      }
+    }
+    true
+  }
+
+  fn is_gitignored(&mut self, path: &Path, is_dir: bool) -> bool {
+    let Some(gitignore) = self.gitignores.get_resolved_git_ignore_for_file(path) else {
+      return false;
+    };
+    gitignore.is_ignored(path, is_dir)
   }
 }
 
@@ -50,7 +96,10 @@ pub fn get_patterns_as_glob_matcher(patterns: &[String], config_base_path: &Cano
       arg_includes: None,
       config_includes: Some(GlobPattern::new_vec(includes, config_base_path.clone())),
       arg_excludes: None,
-      config_excludes: GlobPattern::new_vec(excludes, config_base_path.clone()),
+      config_excludes: excludes
+        .into_iter()
+        .map(|relative_pattern| GlobPattern::new(relative_pattern, config_base_path.clone()).invert())
+        .collect(),
     },
     &GlobMatcherOptions {
       case_sensitive: !cfg!(windows),
@@ -76,12 +125,10 @@ pub fn get_all_file_patterns(config: &ResolvedConfig, args: &FilePatternArgs, cw
       None
     } else {
       // resolve CLI patterns based on the current working directory
-      Some(
-        GlobPattern::new_vec(args.exclude_patterns.iter().map(|p| process_cli_pattern(p, cwd)).collect(), cwd.clone())
-          .into_iter()
-          .map(|pattern| pattern.into_negated())
-          .collect(),
-      )
+      Some(GlobPattern::new_vec(
+        args.exclude_patterns.iter().map(|p| process_cli_pattern(p, cwd)).collect(),
+        cwd.clone(),
+      ))
     },
   }
 }
@@ -103,25 +150,23 @@ fn get_config_includes_file_patterns(config: &ResolvedConfig, args: &FilePattern
 fn get_config_exclude_file_patterns(config: &ResolvedConfig, args: &FilePatternArgs, cwd: &CanonicalizedPathBuf) -> Vec<GlobPattern> {
   let mut file_patterns = Vec::new();
 
-  file_patterns.extend(
-    match &args.exclude_pattern_overrides {
-      Some(exclude_overrides) => {
-        // resolve CLI patterns based on the current working directory
-        GlobPattern::new_vec(exclude_overrides.iter().map(|p| process_cli_pattern(p, cwd)).collect(), cwd.clone())
-      }
-      None => config
-        .excludes
-        .as_ref()
-        .map(|excludes| GlobPattern::new_vec(process_config_patterns(excludes).collect(), config.base_path.clone()))
-        .unwrap_or_default(),
+  file_patterns.extend(match &args.exclude_pattern_overrides {
+    Some(exclude_overrides) => {
+      // resolve CLI patterns based on the current working directory
+      GlobPattern::new_vec(exclude_overrides.iter().map(|p| process_cli_pattern(p, cwd)).collect(), cwd.clone())
     }
-    .into_iter()
-    .map(|pattern| pattern.into_negated()),
-  );
+    None => config
+      .excludes
+      .as_ref()
+      .map(|excludes| GlobPattern::new_vec(process_config_patterns(excludes).collect(), config.base_path.clone()))
+      .unwrap_or_default(),
+  });
 
+  // todo(THIS PR): document removing this flag in favour of a !**/node_modules pattern
+  // and make this work with that
   if !args.allow_node_modules {
     // glob walker will not search the children of a directory once it's ignored like this
-    let node_modules_exclude = String::from("!**/node_modules");
+    let node_modules_exclude = String::from("**/node_modules");
     let mut exclude_node_module_patterns = vec![GlobPattern::new(node_modules_exclude.clone(), cwd.clone())];
     if !cwd.starts_with(&config.base_path) {
       exclude_node_module_patterns.push(GlobPattern::new(node_modules_exclude, config.base_path.clone()));
@@ -132,6 +177,7 @@ fn get_config_exclude_file_patterns(config: &ResolvedConfig, args: &FilePatternA
       }
     }
   }
+
   file_patterns
 }
 
@@ -190,6 +236,10 @@ fn process_config_pattern(file_pattern: &str) -> String {
 
 #[cfg(test)]
 mod test {
+  use std::path::PathBuf;
+
+  use crate::environment::TestEnvironment;
+
   use super::*;
 
   #[test]
@@ -232,4 +282,65 @@ mod test {
     assert_eq!(process_config_pattern("!test"), "!test");
     assert_eq!(process_config_pattern("!**/test"), "!**/test");
   }
+
+  #[test]
+  fn handles_ignored_dir() {
+    let environment = TestEnvironment::new();
+    let cwd = CanonicalizedPathBuf::new_for_testing("/testing/dir");
+    let glob_matcher = GlobMatcher::new(
+      GlobPatterns {
+        arg_includes: None,
+        config_includes: Some(vec![GlobPattern::new("**/*.ts".to_string(), cwd.clone())]),
+        arg_excludes: None,
+        config_excludes: vec![GlobPattern::new("sub-dir".to_string(), cwd.clone())],
+      },
+      &GlobMatcherOptions {
+        case_sensitive: true,
+        base_dir: cwd,
+      },
+    )
+    .unwrap();
+    let mut file_matcher = FileMatcher {
+      glob_matcher,
+      gitignores: GitIgnoreTree::new(environment, vec![]),
+    };
+    assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/dir/match.ts", true);
+    assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/dir/other/match.ts", true);
+    assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/sub-dir/no-match.ts", false);
+    assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/sub-dir/nested/no-match.ts", false);
+  }
+
+  #[test]
+  fn handles_ignored_dir_while_include_is_sub_dir() {
+    let environment = TestEnvironment::new();
+    let base_dir = CanonicalizedPathBuf::new_for_testing("/");
+    let cwd = CanonicalizedPathBuf::new_for_testing("/sub-dir");
+    let glob_matcher = GlobMatcher::new(
+      GlobPatterns {
+        arg_includes: None,
+        // notice cwd and base_dir are different. This will happen when the config
+        // file is in an ancestor dir and the user has stepped into a folder
+        config_includes: Some(vec![GlobPattern::new("**/*.ts".to_string(), cwd.clone())]),
+        arg_excludes: None,
+        config_excludes: vec![GlobPattern::new("**/dist".to_string(), base_dir.clone())],
+      },
+      &GlobMatcherOptions {
+        case_sensitive: true,
+        base_dir: cwd,
+      },
+    )
+    .unwrap();
+    let mut file_matcher = FileMatcher {
+      glob_matcher,
+      gitignores: GitIgnoreTree::new(environment, vec![]),
+    };
+    assert_matches_dir_and_not_ignored(&mut file_matcher, "/sub-dir/dir/match.ts", true);
+    assert_matches_dir_and_not_ignored(&mut file_matcher, "/sub-dir/dir/other/match.ts", true);
+    assert_matches_dir_and_not_ignored(&mut file_matcher, "/sub-dir/dist/no-match.ts", false);
+  }
+
+  #[track_caller]
+  fn assert_matches_dir_and_not_ignored(matcher: &mut FileMatcher<TestEnvironment>, path: &str, expected: bool) {
+    assert_eq!(matcher.matches_and_dir_not_ignored(&PathBuf::from(path)), expected);
+  }
 }
diff --git a/crates/dprint/src/resolution.rs b/crates/dprint/src/resolution.rs
index 4481ecbf8..22ce3aad4 100644
--- a/crates/dprint/src/resolution.rs
+++ b/crates/dprint/src/resolution.rs
@@ -205,7 +205,7 @@ pub struct PluginsScope<TEnvironment: Environment> {
   pub plugins: IndexMap<String, Rc<PluginWithConfig>>,
   pub plugin_name_maps: PluginNameResolutionMaps,
   global_config_diagnostics: Vec<GlobalConfigDiagnostic>,
-  cached_editor_file_matcher: RefCell<Option<FileMatcher>>,
+  cached_editor_file_matcher: RefCell<Option<FileMatcher<TEnvironment>>>,
 }
 
 impl<TEnvironment: Environment> PluginsScope<TEnvironment> {
@@ -326,7 +326,7 @@ impl<TEnvironment: Environment> PluginsScope<TEnvironment> {
       let Some(config) = &self.config else {
         return false;
       };
-      let matcher = match FileMatcher::new(config, &FilePatternArgs::default(), &config.base_path) {
+      let matcher = match FileMatcher::new(self.environment.clone(), config, &FilePatternArgs::default(), &config.base_path) {
         Ok(matcher) => matcher,
         Err(err) => {
           log_warn!(self.environment, "Error creating file matcher: {}", err);
@@ -335,7 +335,7 @@ impl<TEnvironment: Environment> PluginsScope<TEnvironment> {
       };
       file_matcher_borrow.replace(matcher);
     }
-    match file_matcher_borrow.as_ref() {
+    match file_matcher_borrow.as_mut() {
       Some(file_matcher) => file_matcher.matches_and_dir_not_ignored(file_path),
       None => false, // should never happen
     }
diff --git a/crates/dprint/src/utils/gitignore.rs b/crates/dprint/src/utils/gitignore.rs
new file mode 100644
index 000000000..9c81efb47
--- /dev/null
+++ b/crates/dprint/src/utils/gitignore.rs
@@ -0,0 +1,144 @@
+use std::collections::HashMap;
+use std::path::Path;
+use std::path::PathBuf;
+use std::rc::Rc;
+
+use crate::environment::Environment;
+
+/// Lifted from the Deno code.
+/// Copyright 2018-2024 the Deno authors. MIT license.
+/// Resolved gitignore for a directory.
+pub struct DirGitIgnores {
+  current: Option<Rc<ignore::gitignore::Gitignore>>,
+  parent: Option<Rc<DirGitIgnores>>,
+}
+
+impl DirGitIgnores {
+  pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
+    let mut is_ignored = false;
+    if let Some(parent) = &self.parent {
+      is_ignored = parent.is_ignored(path, is_dir);
+    }
+    if let Some(current) = &self.current {
+      match current.matched(path, is_dir) {
+        ignore::Match::None => {}
+        ignore::Match::Ignore(_) => {
+          is_ignored = true;
+        }
+        ignore::Match::Whitelist(_) => {
+          is_ignored = false;
+        }
+      }
+    }
+    is_ignored
+  }
+}
+
+/// Resolves gitignores in a directory tree taking into account
+/// ancestor gitignores that may be found in a directory.
+pub struct GitIgnoreTree<TEnvironment> {
+  environment: TEnvironment,
+  ignores: HashMap<PathBuf, Option<Rc<DirGitIgnores>>>,
+  include_paths: Vec<PathBuf>,
+}
+
+impl<TEnvironment: Environment> GitIgnoreTree<TEnvironment> {
+  pub fn new(
+    environment: TEnvironment,
+    // paths that should override what's in the gitignore
+    include_paths: Vec<PathBuf>,
+  ) -> Self {
+    Self {
+      environment,
+      ignores: Default::default(),
+      include_paths,
+    }
+  }
+
+  pub fn get_resolved_git_ignore_for_dir_children(&mut self, dir_path: &Path) -> Option<Rc<DirGitIgnores>> {
+    self.get_resolved_git_ignore_inner(dir_path, None)
+  }
+
+  pub fn get_resolved_git_ignore_for_file(&mut self, file_path: &Path) -> Option<Rc<DirGitIgnores>> {
+    let dir_path = file_path.parent()?;
+    self.get_resolved_git_ignore_inner(dir_path, None)
+  }
+
+  fn get_resolved_git_ignore_inner(&mut self, dir_path: &Path, maybe_parent: Option<&Path>) -> Option<Rc<DirGitIgnores>> {
+    let maybe_resolved = self.ignores.get(dir_path).cloned();
+    if let Some(resolved) = maybe_resolved {
+      resolved
+    } else {
+      let resolved = self.resolve_gitignore_in_dir(dir_path, maybe_parent);
+      self.ignores.insert(dir_path.to_owned(), resolved.clone());
+      resolved
+    }
+  }
+
+  fn resolve_gitignore_in_dir(&mut self, dir_path: &Path, maybe_parent: Option<&Path>) -> Option<Rc<DirGitIgnores>> {
+    if let Some(parent) = maybe_parent {
+      // stop searching if the parent dir had a .git directory in it
+      if self.environment.path_exists(parent.join(".git")) {
+        return None;
+      }
+    }
+
+    let parent = dir_path.parent().and_then(|parent| self.get_resolved_git_ignore_inner(parent, Some(dir_path)));
+    let current = self.environment.read_file(dir_path.join(".gitignore")).ok().and_then(|text| {
+      let mut builder = ignore::gitignore::GitignoreBuilder::new(dir_path);
+      for line in text.lines() {
+        builder.add_line(None, line).ok()?;
+      }
+      // override the gitignore contents to include these paths
+      for path in &self.include_paths {
+        if let Ok(suffix) = path.strip_prefix(dir_path) {
+          let suffix = suffix.to_string_lossy().replace('\\', "/");
+          let _ignore = builder.add_line(None, &format!("!/{}", suffix));
+          if !suffix.ends_with('/') {
+            let _ignore = builder.add_line(None, &format!("!/{}/", suffix));
+          }
+        }
+      }
+      let gitignore = builder.build().ok()?;
+      Some(Rc::new(gitignore))
+    });
+    if parent.is_none() && current.is_none() {
+      None
+    } else {
+      Some(Rc::new(DirGitIgnores { current, parent }))
+    }
+  }
+}
+
+#[cfg(test)]
+mod test {
+  use crate::environment::TestEnvironment;
+
+  use super::*;
+
+  #[test]
+  fn git_ignore_tree() {
+    let env = TestEnvironment::new();
+    env.write_file("/.gitignore", "file.txt").unwrap();
+    env.write_file("/sub_dir/.gitignore", "data.txt").unwrap();
+    env.write_file("/sub_dir/sub_dir/.gitignore", "!file.txt\nignore.txt").unwrap();
+    let mut ignore_tree = GitIgnoreTree::new(env, Vec::new());
+    let mut run_test = |path: &str, expected: bool| {
+      let path = PathBuf::from(path);
+      let gitignore = ignore_tree.get_resolved_git_ignore_for_file(&path).unwrap();
+      assert_eq!(gitignore.is_ignored(&path, /* is_dir */ false), expected, "Path: {}", path.display());
+    };
+    run_test("/file.txt", true);
+    run_test("/other.txt", false);
+    run_test("/data.txt", false);
+    run_test("/sub_dir/file.txt", true);
+    run_test("/sub_dir/other.txt", false);
+    run_test("/sub_dir/data.txt", true);
+    run_test("/sub_dir/sub_dir/file.txt", false); // unignored up here
+    run_test("/sub_dir/sub_dir/sub_dir/file.txt", false);
+    run_test("/sub_dir/sub_dir/sub_dir/ignore.txt", true);
+    run_test("/sub_dir/sub_dir/ignore.txt", true);
+    run_test("/sub_dir/ignore.txt", false);
+    run_test("/ignore.txt", false);
+  }
+}
diff --git a/crates/dprint/src/utils/glob/glob.rs b/crates/dprint/src/utils/glob/glob.rs
index 20c8e726c..2257f2333 100644
--- a/crates/dprint/src/utils/glob/glob.rs
+++ b/crates/dprint/src/utils/glob/glob.rs
@@ -10,9 +10,12 @@ use std::sync::Arc;
 use crate::environment::CanonicalizedPathBuf;
 use crate::environment::DirEntry;
 use crate::environment::Environment;
+use crate::utils::gitignore::GitIgnoreTree;
 
+use super::ExcludeMatchDetail;
 use super::GlobMatcher;
 use super::GlobMatcherOptions;
+use super::GlobMatchesDetail;
 use super::GlobPatterns;
 
 #[derive(Debug, Default, Clone)]
@@ -47,6 +50,7 @@ pub fn glob(environment: &impl Environment, opts: GlobOptions) -> Result<GlobOut
   let start_instant = std::time::Instant::now();
   log_debug!(environment, "Globbing: {:?}", opts.file_patterns);
 
+  let git_ignore_tree = GitIgnoreTree::new(environment.clone(), opts.file_patterns.include_paths());
   let glob_matcher = GlobMatcher::new(
     opts.file_patterns,
     &GlobMatcherOptions {
@@ -65,7 +69,7 @@ pub fn glob(environment: &impl Environment, opts: GlobOptions) -> Result<GlobOut
   dprint_core::async_runtime::spawn_blocking(move || read_dir_runner.run());
 
   // run the glob matching on the current thread (the two threads will communicate with each other)
-  let glob_matching_processor = GlobMatchingProcessor::new(shared_state, glob_matcher);
+  let mut glob_matching_processor = GlobMatchingProcessor::new(shared_state, glob_matcher, git_ignore_tree);
   let results = glob_matching_processor.run()?;
 
   log_debug!(environment, "File(s) matched: {:?}", results);
@@ -74,11 +78,15 @@ pub fn glob(environment: &impl Environment, opts: GlobOptions) -> Result<GlobOut
   Ok(results)
 }
 
+struct DirEntries {
+  path: PathBuf,
+  entries: Vec<DirOrConfigEntry>,
+}
+
 enum DirOrConfigEntry {
   Dir(PathBuf),
   File(PathBuf),
-  // todo: finish implementing this later
-  #[allow(dead_code)]
+  // todo: get rid of this from here probably
   Config(PathBuf),
 }
 
@@ -86,8 +94,6 @@ const PUSH_DIR_ENTRIES_BATCH_COUNT: usize = 500;
 
 struct ReadDirRunner<TEnvironment: Environment> {
   environment: TEnvironment,
-  // todo: finish implementing this later
-  #[allow(dead_code)]
   start_dir: PathBuf,
   shared_state: Arc<SharedState>,
 }
@@ -103,54 +109,64 @@ impl<TEnvironment: Environment> ReadDirRunner<TEnvironment> {
 
   pub fn run(&self) {
     while let Some(pending_dirs) = self.get_next_pending_dirs() {
+      let mut pending_count = 0;
       let mut all_entries = Vec::new();
       for current_dir in pending_dirs.into_iter().flatten() {
         let info_result = self.environment.dir_info(&current_dir);
-        match info_result {
+        let entries = match info_result {
           Ok(entries) => {
-            if !entries.is_empty() {
-              let maybe_config_file = if current_dir != self.start_dir {
-                entries
-                  .iter()
-                  .filter_map(|e| match e {
-                    DirEntry::Directory(_) => None,
-                    DirEntry::File { name, path } => {
-                      if matches!(name.to_str(), Some(".dprint.json" | "dprint.json" | ".dprint.jsonc" | "dprint.jsonc")) {
-                        Some(path)
-                      } else {
-                        None
-                      }
+            if entries.is_empty() {
+              continue;
+            }
+            let maybe_config_file = if current_dir != self.start_dir {
+              entries
+                .iter()
+                .filter_map(|e| match e {
+                  DirEntry::Directory(_) => None,
+                  DirEntry::File { name, path } => {
+                    if matches!(name.to_str(), Some(".dprint.json" | "dprint.json" | ".dprint.jsonc" | "dprint.jsonc")) {
+                      Some(path)
+                    } else {
+                      None
                     }
-                  })
-                  .next()
-              } else {
-                None
-              };
-              if let Some(config_file) = maybe_config_file {
-                all_entries.push(DirOrConfigEntry::Config(config_file.clone()));
-              } else {
-                all_entries.extend(entries.into_iter().map(|e| match e {
+                  }
+                })
+                .next()
+            } else {
+              None
+            };
+            if let Some(config_file) = maybe_config_file {
+              vec![DirOrConfigEntry::Config(config_file.clone())]
+            } else {
+              entries
+                .into_iter()
+                .map(|e| match e {
                   DirEntry::Directory(path) => DirOrConfigEntry::Dir(path),
                   DirEntry::File { path, .. } => DirOrConfigEntry::File(path),
-                }));
-                // it is much faster to batch these than to hit the lock every time
-                if all_entries.len() > PUSH_DIR_ENTRIES_BATCH_COUNT {
-                  self.push_entries(std::mem::take(&mut all_entries));
-                }
-              }
+                })
+                .collect::<Vec<_>>()
             }
           }
           Err(err) => {
             let ignore_error = is_system_volume_error(&current_dir, &err);
-            if !ignore_error {
-              if err.kind() == std::io::ErrorKind::PermissionDenied {
-                log_warn!(self.environment, "WARNING: Ignoring directory. Permission denied: {}", current_dir.display());
-              } else {
-                self.set_glob_error(anyhow!("Error reading dir '{}': {:#}", current_dir.display(), err));
-                return;
-              }
+            if ignore_error {
+              continue;
+            }
+            if err.kind() == std::io::ErrorKind::PermissionDenied {
+              log_warn!(self.environment, "WARNING: Ignoring directory. Permission denied: {}", current_dir.display());
+              continue;
+            } else {
+              self.set_glob_error(anyhow!("Error reading dir '{}': {:#}", current_dir.display(), err));
+              return;
             }
           }
+        };
+        pending_count += entries.len();
+        all_entries.push(DirEntries { path: current_dir, entries });
+        // it is much faster to batch these than to hit the lock every time
+        if pending_count > PUSH_DIR_ENTRIES_BATCH_COUNT {
+          self.push_entries(std::mem::take(&mut all_entries));
+          pending_count = 0;
         }
       }
       if !all_entries.is_empty() {
@@ -186,7 +202,7 @@ impl<TEnvironment: Environment> ReadDirRunner<TEnvironment> {
     cvar.notify_one();
   }
 
-  fn push_entries(&self, entries: Vec<DirOrConfigEntry>) {
+  fn push_entries(&self, entries: Vec<DirEntries>) {
     let (ref lock, ref cvar) = &self.shared_state.inner;
     let mut state = lock.lock();
     state.pending_entries.push(entries);
@@ -201,17 +217,22 @@ fn is_system_volume_error(dir_path: &Path, err: &std::io::Error) -> bool {
     && matches!(dir_path.file_name().and_then(|f| f.to_str()), Some("System Volume Information"))
 }
 
-struct GlobMatchingProcessor {
+struct GlobMatchingProcessor<TEnvironment: Environment> {
   shared_state: Arc<SharedState>,
   glob_matcher: GlobMatcher,
+  git_ignore_tree: GitIgnoreTree<TEnvironment>,
 }
 
-impl GlobMatchingProcessor {
-  pub fn new(shared_state: Arc<SharedState>, glob_matcher: GlobMatcher) -> Self {
-    Self { shared_state, glob_matcher }
+impl<TEnvironment: Environment> GlobMatchingProcessor<TEnvironment> {
+  pub fn new(shared_state: Arc<SharedState>, glob_matcher: GlobMatcher, git_ignore_tree: GitIgnoreTree<TEnvironment>) -> Self {
+    Self {
+      shared_state,
+      glob_matcher,
+      git_ignore_tree,
+    }
   }
 
-  pub fn run(&self) -> Result<GlobOutput> {
+  pub fn run(&mut self) -> Result<GlobOutput> {
     let mut output = GlobOutput::default();
 
     loop {
@@ -221,20 +242,44 @@ impl GlobMatchingProcessor {
         Ok(None) => return Ok(output),
         Err(err) => return Err(err), // error
         Ok(Some(entries)) => {
-          for entry in entries.into_iter().flatten() {
-            match entry {
-              DirOrConfigEntry::Dir(path) => {
-                if !self.glob_matcher.is_dir_ignored(&path) {
-                  pending_dirs.push(path);
+          for dir in entries.into_iter().flatten() {
+            let gitignore = self.git_ignore_tree.get_resolved_git_ignore_for_dir_children(&dir.path);
+            for entry in dir.entries {
+              match entry {
+                DirOrConfigEntry::Dir(path) => {
+                  let is_ignored = match self.glob_matcher.is_dir_ignored(&path) {
+                    ExcludeMatchDetail::Excluded => true,
+                    ExcludeMatchDetail::OptedOutExclude => false,
+                    ExcludeMatchDetail::NotExcluded => match &gitignore {
+                      Some(gitignore) => {
+                        gitignore.is_ignored(&path, /* is dir */ true)
+                      }
+                      None => false,
+                    },
+                  } || path.file_name().map(|f| f == ".git").unwrap_or(false);
+                  if !is_ignored {
+                    pending_dirs.push(path);
+                  }
                 }
-              }
-              DirOrConfigEntry::File(path) => {
-                if self.glob_matcher.matches(&path) {
-                  output.file_paths.push(path);
+                DirOrConfigEntry::File(path) => {
+                  let is_matched = match self.glob_matcher.matches_detail(&path) {
+                    GlobMatchesDetail::Excluded => false,
+                    GlobMatchesDetail::Matched => match &gitignore {
+                      Some(gitignore) => {
+                        !gitignore.is_ignored(&path, /* is dir */ false)
+                      }
+                      None => true,
+                    },
+                    GlobMatchesDetail::MatchedOptedOutExclude => true,
+                    GlobMatchesDetail::NotMatched => false,
+                  };
+                  if is_matched {
+                    output.file_paths.push(path);
+                  }
+                }
+                DirOrConfigEntry::Config(path) => {
+                  output.config_files.push(path);
                 }
-              }
-              DirOrConfigEntry::Config(path) => {
-                output.config_files.push(path);
               }
             }
           }
@@ -252,7 +297,7 @@ impl GlobMatchingProcessor {
     cvar.notify_one();
   }
 
-  fn get_next_entries(&self) -> Result<Option<Vec<Vec<DirOrConfigEntry>>>> {
+  fn get_next_entries(&self) -> Result<Option<Vec<Vec<DirEntries>>>> {
     let (ref lock, ref cvar) = &self.shared_state.inner;
     let mut state = lock.lock();
     loop {
@@ -298,7 +343,7 @@ enum ProcessingThreadState {
 
 struct SharedStateInternal {
   pending_dirs: Vec<Vec<PathBuf>>,
-  pending_entries: Vec<Vec<DirOrConfigEntry>>,
+  pending_entries: Vec<Vec<DirEntries>>,
   read_dir_thread_state: ReadDirThreadState,
   processing_thread_state: ProcessingThreadState,
 }
@@ -337,6 +382,8 @@ mod test {
   async fn should_glob() {
     let mut environment_builder = TestEnvironmentBuilder::new();
     let mut expected_matches = Vec::new();
+    // ignores .git folder
+    environment_builder.write_file("/.git/data.txt", "");
     for i in 1..100 {
       environment_builder.write_file(format!("/{}.txt", i), "");
       expected_matches.push(format!("/{}.txt", i));
diff --git a/crates/dprint/src/utils/glob/glob_matcher.rs b/crates/dprint/src/utils/glob/glob_matcher.rs
index a37ee474d..83ff5ac44 100644
--- a/crates/dprint/src/utils/glob/glob_matcher.rs
+++ b/crates/dprint/src/utils/glob/glob_matcher.rs
@@ -2,6 +2,8 @@ use std::borrow::Cow;
 use std::path::Path;
 
 use anyhow::Result;
+use ignore::gitignore::Gitignore;
+use ignore::gitignore::GitignoreBuilder;
 use ignore::overrides::Override;
 use ignore::overrides::OverrideBuilder;
 use ignore::Match;
@@ -20,18 +22,27 @@ pub struct GlobMatcherOptions {
 pub enum GlobMatchesDetail {
   /// Matched an includes pattern.
   Matched,
+  /// Matched and opted out of gitignore exclusion.
+  MatchedOptedOutExclude,
   /// Matched an excludes pattern.
   Excluded,
   /// Matched neither an includes or excludes pattern.
   NotMatched,
 }
 
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum ExcludeMatchDetail {
+  Excluded,
+  OptedOutExclude,
+  NotExcluded,
+}
+
 pub struct GlobMatcher {
   base_dir: CanonicalizedPathBuf,
   config_include_matcher: Option<Override>,
   arg_include_matcher: Option<Override>,
-  config_exclude_matcher: Override,
-  arg_exclude_matcher: Option<Override>,
+  config_exclude_matcher: Gitignore,
+  arg_exclude_matcher: Option<Gitignore>,
 }
 
 impl GlobMatcher {
@@ -46,7 +57,7 @@ impl GlobMatcher {
     let config_excludes = patterns
       .config_excludes
       .into_iter()
-      .filter_map(|pattern| pattern.into_non_negated().into_new_base(base_dir.clone()))
+      .filter_map(|pattern| pattern.into_new_base(base_dir.clone()))
       .collect::<Vec<_>>();
     let config_includes = patterns.config_includes.map(|includes| {
       includes
@@ -63,7 +74,7 @@ impl GlobMatcher {
     let arg_excludes = patterns.arg_excludes.map(|excludes| {
       excludes
         .into_iter()
-        .filter_map(|pattern| pattern.into_non_negated().into_new_base(base_dir.clone()))
+        .filter_map(|pattern| pattern.into_new_base(base_dir.clone()))
         .collect::<Vec<_>>()
     });
 
@@ -76,15 +87,19 @@ impl GlobMatcher {
         Some(includes) => Some(build_override(&includes, opts, &base_dir)?),
         None => None,
       },
-      config_exclude_matcher: build_override(&config_excludes, opts, &base_dir)?,
+      config_exclude_matcher: build_gitignore(&config_excludes, opts, &base_dir)?,
       arg_exclude_matcher: match arg_excludes {
-        Some(excludes) => Some(build_override(&excludes, opts, &base_dir)?),
+        Some(excludes) => Some(build_gitignore(&excludes, opts, &base_dir)?),
         None => None,
       },
       base_dir,
     })
   }
 
+  pub fn base_dir(&self) -> &CanonicalizedPathBuf {
+    &self.base_dir
+  }
+
   /// Gets if the matcher only has excludes patterns.
   pub fn has_only_excludes(&self) -> bool {
     (self.config_include_matcher.as_ref().map(|m| m.is_empty()).unwrap_or(true) && self.arg_include_matcher.as_ref().map(|m| m.is_empty()).unwrap_or(true))
@@ -92,7 +107,10 @@ impl GlobMatcher {
   }
 
   pub fn matches(&self, path: impl AsRef<Path>) -> bool {
-    self.matches_detail(path) == GlobMatchesDetail::Matched
+    matches!(
+      self.matches_detail(path),
+      GlobMatchesDetail::Matched | GlobMatchesDetail::MatchedOptedOutExclude
+    )
   }
 
   pub fn matches_detail(&self, path: impl AsRef<Path>) -> GlobMatchesDetail {
@@ -117,15 +135,13 @@ impl GlobMatcher {
       Cow::Borrowed(path)
     };
 
-    if matches!(self.config_exclude_matcher.matched(&path, false), Match::Whitelist(_))
-      || self
-        .arg_exclude_matcher
-        .as_ref()
-        .map(|m| matches!(m.matched(&path, false), Match::Whitelist(_)))
-        .unwrap_or(false)
-    {
-      GlobMatchesDetail::Excluded
-    } else if self
+    let matched_result = match self.check_exclude(&path, false) {
+      ExcludeMatchDetail::Excluded => return GlobMatchesDetail::Excluded,
+      ExcludeMatchDetail::OptedOutExclude => GlobMatchesDetail::MatchedOptedOutExclude,
+      ExcludeMatchDetail::NotExcluded => GlobMatchesDetail::Matched,
+    };
+
+    if self
       .arg_include_matcher
       .as_ref()
       .map(|m| matches!(m.matched(&path, false), Match::Whitelist(_)))
@@ -136,52 +152,40 @@ impl GlobMatcher {
         .map(|m| matches!(m.matched(&path, false), Match::Whitelist(_)))
         .unwrap_or(true)
     {
-      GlobMatchesDetail::Matched
+      matched_result
     } else {
       GlobMatchesDetail::NotMatched
     }
   }
 
-  pub fn is_dir_ignored(&self, path: impl AsRef<Path>) -> bool {
-    if path.as_ref().starts_with(&self.base_dir) {
-      let path = path.as_ref().strip_prefix(&self.base_dir).unwrap();
-      matches!(self.config_exclude_matcher.matched(path, true), Match::Whitelist(_))
-        || self
-          .arg_exclude_matcher
-          .as_ref()
-          .map(|m| matches!(m.matched(path, true), Match::Whitelist(_)))
-          .unwrap_or(false)
-    } else {
-      true
+  pub fn check_exclude(&self, path: &Path, is_dir: bool) -> ExcludeMatchDetail {
+    let config_match = self.config_exclude_matcher.matched(path, is_dir);
+    let mut result = match config_match {
+      Match::None => ExcludeMatchDetail::NotExcluded,
+      Match::Ignore(_) => ExcludeMatchDetail::Excluded,
+      Match::Whitelist(_) => ExcludeMatchDetail::OptedOutExclude,
+    };
+    if let Some(matcher) = &self.arg_exclude_matcher {
+      let arg_match = matcher.matched(path, is_dir);
+      match arg_match {
+        Match::None => {}
+        Match::Ignore(_) => {
+          result = ExcludeMatchDetail::Excluded;
+        }
+        Match::Whitelist(_) => {
+          result = ExcludeMatchDetail::OptedOutExclude;
+        }
+      }
     }
+    result
   }
 
-  /// More expensive check for if the directory is already ignored.
-  /// Prefer using `matches` if you already know the parent directory
-  /// isn't ignored as it's faster.
-  pub fn matches_and_dir_not_ignored(&self, file_path: impl AsRef<Path>) -> bool {
-    if !self.matches(&file_path) {
-      return false;
-    }
-
-    if file_path.as_ref().starts_with(&self.base_dir) {
-      for ancestor in file_path.as_ref().ancestors() {
-        if let Ok(path) = ancestor.strip_prefix(&self.base_dir) {
-          if matches!(self.config_exclude_matcher.matched(path, true), Match::Whitelist(_)) {
-            return false;
-          }
-          if let Some(arg_exclude_matcher) = &self.arg_exclude_matcher {
-            if matches!(arg_exclude_matcher.matched(path, true), Match::Whitelist(_)) {
-              return false;
-            }
-          }
-        } else {
-          return true;
-        }
-      }
-      true
+  pub fn is_dir_ignored(&self, path: impl AsRef<Path>) -> ExcludeMatchDetail {
+    if path.as_ref().starts_with(&self.base_dir) {
+      let path = path.as_ref().strip_prefix(&self.base_dir).unwrap();
+      self.check_exclude(path, true)
     } else {
-      false
+      ExcludeMatchDetail::Excluded
     }
   }
 }
@@ -191,20 +195,36 @@ fn build_override(patterns: &[GlobPattern], opts: &GlobMatcherOptions, base_dir:
   let builder = builder.case_insensitive(!opts.case_sensitive)?;
 
   for pattern in patterns {
-    // change patterns that start with ./ to be at the "root" of the globbing
-    let pattern = if pattern.relative_pattern.starts_with("!./") {
-      Cow::Owned(format!("!/{}", &pattern.relative_pattern[3..]))
-    } else if pattern.relative_pattern.starts_with("./") {
-      Cow::Owned(format!("/{}", &pattern.relative_pattern[2..]))
-    } else {
-      Cow::Borrowed(&pattern.relative_pattern)
-    };
+    let pattern = normalize_pattern(pattern);
     builder.add(&pattern)?;
   }
 
   Ok(builder.build()?)
 }
 
+fn build_gitignore(patterns: &[GlobPattern], opts: &GlobMatcherOptions, base_dir: &CanonicalizedPathBuf) -> Result<Gitignore> {
+  let mut builder = GitignoreBuilder::new(base_dir);
+  let builder = builder.case_insensitive(!opts.case_sensitive)?;
+
+  for pattern in patterns {
+    let pattern = normalize_pattern(pattern);
+    builder.add_line(None, &pattern)?;
+  }
+
+  Ok(builder.build()?)
+}
+
+fn normalize_pattern(pattern: &GlobPattern) -> Cow<str> {
+  // change patterns that start with ./ to be at the "root" of the globbing
+  if pattern.relative_pattern.starts_with("!./") {
+    Cow::Owned(format!("!/{}", &pattern.relative_pattern[3..]))
+  } else if pattern.relative_pattern.starts_with("./") {
+    Cow::Owned(format!("/{}", &pattern.relative_pattern[2..]))
+  } else {
+    Cow::Borrowed(&pattern.relative_pattern)
+  }
+}
+
 fn get_base_dir<'a>(dirs: impl Iterator<Item = &'a CanonicalizedPathBuf>) -> Option<CanonicalizedPathBuf> {
   let mut base_dir: Option<&'a CanonicalizedPathBuf> = None;
   for dir in dirs {
@@ -311,50 +331,4 @@ mod test {
     assert!(glob_matcher.matches("\\?\\UNC\\wsl$\\Ubuntu\\home\\david\\dir\\other.ts"));
     assert!(!glob_matcher.matches("\\?\\UNC\\wsl$\\Ubuntu\\home\\david\\no-match.ts"));
   }
-
-  #[test]
-  fn handles_ignored_dir() {
-    let cwd = CanonicalizedPathBuf::new_for_testing("/testing/dir");
-    let glob_matcher = GlobMatcher::new(
-      GlobPatterns {
-        arg_includes: None,
-        config_includes: Some(vec![GlobPattern::new("**/*.ts".to_string(), cwd.clone())]),
-        arg_excludes: None,
-        config_excludes: vec![GlobPattern::new("sub-dir".to_string(), cwd.clone())],
-      },
-      &GlobMatcherOptions {
-        case_sensitive: true,
-        base_dir: cwd,
-      },
-    )
-    .unwrap();
-    assert!(glob_matcher.matches_and_dir_not_ignored("/testing/dir/match.ts"));
-    assert!(glob_matcher.matches_and_dir_not_ignored("/testing/dir/other/match.ts"));
-    assert!(!glob_matcher.matches_and_dir_not_ignored("/testing/sub-dir/no-match.ts"));
-    assert!(!glob_matcher.matches_and_dir_not_ignored("/testing/sub-dir/nested/no-match.ts"));
-  }
-
-  #[test]
-  fn handles_ignored_dir_while_include_is_sub_dir() {
-    let base_dir = CanonicalizedPathBuf::new_for_testing("/");
-    let cwd = CanonicalizedPathBuf::new_for_testing("/sub-dir");
-    let glob_matcher = GlobMatcher::new(
-      GlobPatterns {
-        arg_includes: None,
-        // notice cwd and base_dir are different. This will happen when the config
-        // file is in an ancestor dir and the user has stepped into a folder
-        config_includes: Some(vec![GlobPattern::new("**/*.ts".to_string(), cwd.clone())]),
-        arg_excludes: None,
-        config_excludes: vec![GlobPattern::new("**/dist".to_string(), base_dir.clone())],
-      },
-      &GlobMatcherOptions {
-        case_sensitive: true,
-        base_dir: cwd,
-      },
-    )
-    .unwrap();
-    assert!(glob_matcher.matches_and_dir_not_ignored("/sub-dir/dir/match.ts"));
-    assert!(glob_matcher.matches_and_dir_not_ignored("/sub-dir/dir/other/match.ts"));
-    assert!(!glob_matcher.matches_and_dir_not_ignored("/sub-dir/dist/no-match.ts"));
-  }
 }
diff --git a/crates/dprint/src/utils/glob/glob_pattern.rs b/crates/dprint/src/utils/glob/glob_pattern.rs
index 34eb8484e..dd7b42100 100644
--- a/crates/dprint/src/utils/glob/glob_pattern.rs
+++ b/crates/dprint/src/utils/glob/glob_pattern.rs
@@ -1,4 +1,5 @@
 use std::collections::VecDeque;
+use std::path::PathBuf;
 
 use crate::environment::CanonicalizedPathBuf;
 
@@ -13,6 +14,44 @@ pub struct GlobPatterns {
   pub config_excludes: Vec<GlobPattern>,
 }
 
+impl GlobPatterns {
+  /// Resolves the include paths (not patterns).
+  pub fn include_paths(&self) -> Vec<PathBuf> {
+    // we only make the explicitly specified paths override the gitignore
+    // because it starts getting really complicated with globs and some
+    // people may not want globs to not match gitignored files
+    self
+      .arg_includes
+      .iter()
+      .flat_map(|i| i.iter())
+      .chain(self.config_includes.iter().flat_map(|i| i.iter()))
+      .filter_map(|pattern| {
+        if !is_pattern(&pattern.relative_pattern) {
+          Some(pattern.base_dir.join(&pattern.relative_pattern))
+        } else {
+          None
+        }
+      })
+      .collect()
+  }
+}
+
+fn is_pattern(pattern: &str) -> bool {
+  if pattern.starts_with('!') {
+    return true;
+  }
+
+  let mut was_last_escape = false;
+  for c in pattern.chars() {
+    if !was_last_escape && matches!(c, '*' | '{' | '?') {
+      return true;
+    }
+
+    was_last_escape = matches!(c, '\\');
+  }
+  false
+}
+
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct GlobPattern {
   pub relative_pattern: String,
@@ -35,20 +74,12 @@ impl GlobPattern {
     is_negated_glob(&self.relative_pattern)
   }
 
-  pub fn into_non_negated(self) -> GlobPattern {
+  pub fn invert(self) -> Self {
     if self.is_negated() {
       GlobPattern {
         base_dir: self.base_dir,
         relative_pattern: non_negated_glob(&self.relative_pattern).to_string(),
       }
-    } else {
-      self
-    }
-  }
-
-  pub fn into_negated(self) -> GlobPattern {
-    if self.is_negated() {
-      self
     } else {
       GlobPattern {
         base_dir: self.base_dir,
@@ -171,25 +202,14 @@ mod test {
   use super::*;
 
   #[test]
-  fn should_make_negated() {
-    let test_dir = CanonicalizedPathBuf::new_for_testing("/test");
-    let pattern = GlobPattern::new("**/*".to_string(), test_dir.clone()).into_negated();
-    assert_eq!(pattern.relative_pattern, "!**/*");
-
-    // should keep as-is
-    let pattern = GlobPattern::new("!**/*".to_string(), test_dir).into_negated();
-    assert_eq!(pattern.relative_pattern, "!**/*");
-  }
-
-  #[test]
-  fn should_make_non_negated() {
+  fn should_invert() {
     let test_dir = CanonicalizedPathBuf::new_for_testing("/test");
-    let pattern = GlobPattern::new("!**/*".to_string(), test_dir.clone()).into_non_negated();
+    let pattern = GlobPattern::new("!**/*".to_string(), test_dir.clone()).invert();
     assert_eq!(pattern.relative_pattern, "**/*");
 
     // should keep as-is
-    let pattern = GlobPattern::new("**/*".to_string(), test_dir).into_non_negated();
-    assert_eq!(pattern.relative_pattern, "**/*");
+    let pattern = GlobPattern::new("**/*".to_string(), test_dir).invert();
+    assert_eq!(pattern.relative_pattern, "!**/*");
   }
 
   #[test]
diff --git a/crates/dprint/src/utils/mod.rs b/crates/dprint/src/utils/mod.rs
index 648a53b12..cfd9bd475 100644
--- a/crates/dprint/src/utils/mod.rs
+++ b/crates/dprint/src/utils/mod.rs
@@ -6,6 +6,7 @@ mod file_path_utils;
 mod file_text;
 mod get_bytes_hash;
 mod get_difference;
+mod gitignore;
 mod glob;
 mod lax_single_process_fs_flag;
 mod logging;
@@ -30,6 +31,7 @@ pub use file_path_utils::*;
 pub use file_text::*;
 pub use get_bytes_hash::*;
 pub use get_difference::*;
+pub use gitignore::*;
 pub use glob::*;
 pub use lax_single_process_fs_flag::*;
 pub use logging::*;
diff --git a/website/src/config.md b/website/src/config.md
index cedb8edce..c9c005f7a 100644
--- a/website/src/config.md
+++ b/website/src/config.md
@@ -26,7 +26,6 @@ See [Setup](/setup).
     "indentWidth": 2
   },
   "excludes": [
-    "**/node_modules",
     "**/*-lock.json"
   ],
   "plugins": [
@@ -104,15 +103,27 @@ These should be file globs according to [`gitignore`'s extended glob syntax](htt
 {
   // ...omitted...
   "excludes": [
-    "**/node_modules",
     "**/*-lock.json"
   ]
 }
 ```
 
+### Un-excluding gitignored files
+
+Files that are gitignored will be excluded by default, but you can "un-exclude" them by specifying a negated glob:
+
+```json
+{
+  "excludes": [
+    // will format dist.js even though it's gitignored
+    "!dist.js"
+  ]
+}
+```
+
 ## Includes
 
-The `includes` property can be used to limit dprint to only formatting certain files. Generally, you shouldn't bother to provide this.
+The `includes` property can be used to limit dprint to only formatting certain files. Generally, you don't need to bother providing this.
 
 ```json
 {
diff --git a/website/src/plugins/typescript.md b/website/src/plugins/typescript.md
index dd7545c11..e92efebc5 100644
--- a/website/src/plugins/typescript.md
+++ b/website/src/plugins/typescript.md
@@ -35,9 +35,6 @@ This will update your config file to have an entry for the plugin. Then optional
   "typescript": {
     // TypeScript & JavaScript config goes here
   },
-  "excludes": [
-    "**/node_modules" // if necessary
-  ],
   "plugins": [
     // ...etc...
     "https://plugins.dprint.dev/typescript-x.x.x.wasm"


Test patch: