Repository: andreoliwa/nitpick (language: python)

Issue:
nitpick init: Identify styles that are relevant for your project and programming language
## Problem

While setting up Nitpick with `nitpick init`, I want to select only the styles that are relevant for the specific programming languages used in the project.

The default style is opinionated, made for Python only, and probably not a fit for everyone (it's not a fit for my own projects anymore).

Adding only the styles that match the file types present on the project is way more flexible and still auto-configurable.

## Possible solution

~~Each style file should (optionally) have information about the file types it relates to.~~ 
- This is done since https://github.com/andreoliwa/nitpick/issues/448; the built-in style files:

1. ...are separated into directories that represent file types (actually, [identify](https://github.com/pre-commit/identify) tags).
2. ...have a `[nitpick.meta]` section that can be expanded if needed.

- [x] Add a flag to `nitpick init` to trigger this auto-detection, e.g. `nitpick init --identify`
- [x] During `nitpick init --identify`, scan the files on the project root recursively, and collect a set of identify tags from each file.
      E.g.: `{"ini", "text", "python", "json", "rst", "yaml"}`
- [x] On the list of styles suggested by `nitpick init`, display only styles:
  - [x] which match the collected tags above, or
  - [x] styles without any `identify_tags` (in the `any` directory)
- [x] Add the identified styles to the `[tool.nitpick]style` key in Nitpick's config (`pyproject.toml` or `.nitpick.toml`)
- [x] Create an empty `[tool.nitpick]ignore_on_init_identify` key (or some better name) and a comment: "Add here the full path of the styles you want --identify to ignore"
- [x] `nitpick init --identify` can be configured as a pre-commit hook. Every time a new built-in style is added to nitpick, your projects will have this style (if they have any file with a matching type AKA identify tag)
- [x] `nitpick init --identify` should be idempotent. It shouldn't duplicate styles if they already exist in any of the lists (`[tool.nitpick]style` or `[tool.nitpick]ignore_on_init_identify`)
- [x] ~~The identified styles are loaded as styles relative to the `[tools.nitpick]identified_styles_prefix` URL (defaults to `py://nitpick/resources/...`)~~ -> CLI option `--library` to accept the path to the custom library
  - [x] How do we deal with styles that are not built-in and live in another directory? Think about it
- [x] This feature should play nicely with https://github.com/andreoliwa/nitpick/issues/458
  - [x] The styles specified in the CLI have precedence and should be added first to `[tool.nitpick]style`
  - [x] Only then the styles detected by `--identify` should be appended 

---

These ideas below are not needed anymore, because the directory under `resources/` already specifies the file type for the style.

One possible use case: a style that can be used only by multiple specific file types and not `any`.
Right now, I can't think of a real case.
The `any` directory works.
If someone needs to specify precise tags for the styles in `any`, this can be done in a separate issue.

- [x] ~~Each style file should have an optional key `[nitpick]identify_tags`~~
- [x] ~~This key should accept a list of [pre-commit/identify](https://github.com/pre-commit/identify) tags~~
- [x] ~~This list means that the style is related to that `identify` tag/type. E.g.:~~
  - [x] ~~`black.toml` should have `[nitpick]identify_tags = ["python"]`~~
  - [x] ~~`package-json.toml` should have `[nitpick]identify_tags = ["javascript"]`~~
  - [x] ~~`bash.toml` should have `[nitpick]identify_tags = ["shell"]`~~
  - [x] ~~`commitlint.toml` shouldn't have any `identify_tags`~~
    - [x] ~~Or maybe an empty list, meaning explicitly "this style has no identify tags on purpose": `[nitpick]identify_tags = []`~~
- [x] ~~#330 should be done before this issue~~ -> Not necessarily



Discussion / hints:
I like the idea of using file tags as a mapping to style presets.

Can we make the prefix configurable? So, where you state:

> The identified styles should be added as `py://` URLs as usual, since they are resources built-in into Nitpick

I'd prefer that to say:

> The identified styles are loaded as styles relative to the `[tools.nitpick]identified_styles_prefix` URL (defaults to `py://nitpick/resources/...`).

That way we can swap out the styles by providing our own. I guess we'll have to handle missing tags from that (e.g. what to do if there is no `{identified_styles_prefix}/kotlin.toml` style resource).
> Can we make the prefix configurable?

Yeah, this works.

> I guess we'll have to handle missing tags from that (e.g. what to do if there is no `{identified_styles_prefix}/kotlin.toml` style resource).

This implies that you have styles stored somewhere (e.g. in a private repo) that mimics (most of) the directory tree of Nitpick built-ins, right?

---

I'd like somehow to create a "shared Nitpick library" where people could contribute.
Encourage people and projects to add their styles to be shipped as Nitpick built-ins.
Either in individual .toml files or as a ["preset"](https://github.com/andreoliwa/nitpick/tree/develop/src/nitpick/resources/presets).

Right now I'm the only one adding these built-in styles for my own use.
I'd like to also cherry-pick and reuse styles from other people/projects.

---

But maybe I digress, and this whole "managing style files" is getting out of hand and it deserves to be a separate package, as I mentioned in #469.

If only there was some Python package that can handle merging config files like [Renovate does with the "extends" keyword](https://docs.renovatebot.com/configuration-options/#extends), or like ESLint mentioned in #445:

> Nitpick reminds me of ESLint configs (like [AirBnB's](https://github.com/airbnb/javascript)) where there can be a single source that projects can pull from, and any changes can be reflected throughout.


> This implies that you have styles stored somewhere (e.g. in a private repo) that mimics (most of) the directory tree of Nitpick built-ins, right?

Yes, exactly. 

The whole reason I’ve been providing PRs is so we can handle the styles entirely independently ;-) Specifically, because your current Python style is rather far removed from current community best practices (line length 80 with bugbear extensions to 88, as encoded by black), and because I need to guarantee stability for our engineers, I created an internal repository that replaces all of the defaults.

Apart from the exact contents of the default styles, you should definitely consider moving the default styles to a separate repository, just so you can decouple the nitpick release cycle from the default styles.

I’d personally then recommend the styles are then updated to reflect community best practices and consensus a bit more closely, as that would help adoption better.



I will try to keep this ticket shorter because it's already a bit ambitious as it is.

While working on the detection of styles, I'll think about how to deal with styles that are not built-in ones (I added it to the description).

I'll answer your comments above but in #482. 🙂 

The more I talk with stakeholders in our org about nitpick, the clearer it is to me that this feature would be _amazing_ and of huge value. So, consider this my second thumb-up on the ticket, if it helps increase its priority. 😃


Fix patch:
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
index fba8703f..64565a65 100644
--- a/.pre-commit-hooks.yaml
+++ b/.pre-commit-hooks.yaml
@@ -5,6 +5,16 @@
   entry: nitpick fix
   language: python
 
+- id: nitpick-suggest
+  name: "nitpick init --suggest (auto fixing files)"
+  description: "Suggest new Nitpick styles based on the files in the project root (skipping Git ignored files)"
+  entry: nitpick init --fix --suggest
+  language: python
+  # This  hook should NOT be run for changed Git files, otherwise they will be considered style URLS
+  # and will be added to [tool.nitpick]style in pyproject.toml
+  pass_filenames: false
+  always_run: true
+
 # This hook is kept for compatibility (or if one wants to be explicit): "nitpick" does the same as "nitpick-fix" now
 - id: nitpick-fix
   name: "nitpick fix (auto fixing files)"
diff --git a/.tool-versions b/.tool-versions
index 0b835166..e71db6b5 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,2 +1,2 @@
-poetry 1.6.1
+poetry 1.7.1
 pre-commit 3.3.2
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26098936..002f1a2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -125,7 +125,7 @@
 
 - default pre-commit hook now runs "nitpick fix" ([cb4c242](https://github.com/andreoliwa/nitpick/commit/cb4c242607c6810c629dc7f5604920c1b64a070e))
 - **json:** autofix JSON files ([#429](https://github.com/andreoliwa/nitpick/issues/429)) ([4b58a03](https://github.com/andreoliwa/nitpick/commit/4b58a0380f88b01c99945817e7ff9b595ea362aa))
-- nitpick init adds a [tool.nitpick] section ([36f4065](https://github.com/andreoliwa/nitpick/commit/36f4065483b1ec4308bc28b815c82aa0abac104c))
+- nitpick init adds a [tool.nitpick] table ([36f4065](https://github.com/andreoliwa/nitpick/commit/36f4065483b1ec4308bc28b815c82aa0abac104c))
 - **yaml:** autofix .pre-commit-config.yaml (note: style changed!) ([#434](https://github.com/andreoliwa/nitpick/issues/434)) ([352b53d](https://github.com/andreoliwa/nitpick/commit/352b53d574e49ca683666fd40de3462d1396e575))
 - **yaml:** autofix GitHub Workflow files ([#437](https://github.com/andreoliwa/nitpick/issues/437)) ([6af77c4](https://github.com/andreoliwa/nitpick/commit/6af77c4293d8f964ccd249626d47c82440e6412f))
 - **yaml:** autofix YAML files ([#431](https://github.com/andreoliwa/nitpick/issues/431)) ([d8cc4b1](https://github.com/andreoliwa/nitpick/commit/d8cc4b1e80366d475c08316c54dc35393b4430dd))
@@ -289,7 +289,7 @@
 
 ### Features
 
-- **style:** validate the [nitpick.files] section ([96c1c31](https://github.com/andreoliwa/nitpick/commit/96c1c31))
+- **style:** validate the [nitpick.files] table ([96c1c31](https://github.com/andreoliwa/nitpick/commit/96c1c31))
 - show help with the documentation URL on validation errors ([83a8f89](https://github.com/andreoliwa/nitpick/commit/83a8f89))
 - validate [nitpick.files.present] and [nitpick.files.absent] ([ab068b5](https://github.com/andreoliwa/nitpick/commit/ab068b5))
 - validate configuration of JSON files ([e1192a4](https://github.com/andreoliwa/nitpick/commit/e1192a4))
@@ -324,7 +324,7 @@
 
 ### Features
 
-- **pyproject.toml:** validate [tool.nitpick] section ([765562a](https://github.com/andreoliwa/nitpick/commit/765562a))
+- **pyproject.toml:** validate [tool.nitpick] table ([765562a](https://github.com/andreoliwa/nitpick/commit/765562a))
 
 # [0.17.0](https://github.com/andreoliwa/nitpick/compare/v0.16.1...v0.17.0) (2019-08-08)
 
@@ -451,7 +451,7 @@
 ### Bug Fixes
 
 - keep showing other errors when pyproject.toml doesn't exist ([dc7f02f](https://github.com/andreoliwa/nitpick/commit/dc7f02f))
-- move nitpick config to an exclusive section on the style file ([cd64361](https://github.com/andreoliwa/nitpick/commit/cd64361))
+- move nitpick config to an exclusive table on the style file ([cd64361](https://github.com/andreoliwa/nitpick/commit/cd64361))
 - use only yield to return values ([af7d8d2](https://github.com/andreoliwa/nitpick/commit/af7d8d2))
 - use yaml.safe_load() ([b1df589](https://github.com/andreoliwa/nitpick/commit/b1df589))
 
@@ -467,7 +467,7 @@
 
 ### BREAKING CHANGES
 
-- Comma separated values was moved to a different section in the TOML file:
+- Comma separated values was moved to a different table in the TOML file:
 
 Before:
 ["setup.cfg".nitpick]
@@ -567,7 +567,7 @@ Now:
 - First warning, only on the main Python file ([0b30506](https://github.com/andreoliwa/nitpick/commit/0b30506))
 - Read config from pyproject.toml, cache data, run only on one Python ([265daa5](https://github.com/andreoliwa/nitpick/commit/265daa5))
 - Read style from TOML file/URL (or climb directory tree) ([84f19d6](https://github.com/andreoliwa/nitpick/commit/84f19d6))
-- Respect the files section on nitpick.toml ([9e36a02](https://github.com/andreoliwa/nitpick/commit/9e36a02))
+- Respect the files table on nitpick.toml ([9e36a02](https://github.com/andreoliwa/nitpick/commit/9e36a02))
 - Use nitpick's own default style file if none is provided ([4701b86](https://github.com/andreoliwa/nitpick/commit/4701b86))
 
 <a name="0.1.1"></a>
diff --git a/docs/autofix_docs.py b/docs/autofix_docs.py
index 83d498d4..775aa892 100644
--- a/docs/autofix_docs.py
+++ b/docs/autofix_docs.py
@@ -30,7 +30,7 @@
     EmojiEnum,
 )
 from nitpick.core import Nitpick
-from nitpick.style import BuiltinStyle, builtin_styles
+from nitpick.style import BuiltinStyle, builtin_styles, repo_root
 
 RST_DIVIDER_FROM_HERE = ".. auto-generated-from-here"
 RST_DIVIDER_START = ".. auto-generated-start-{}"
@@ -296,10 +296,11 @@ def _build_library(gitref: bool = True) -> list[str]:
         style = BuiltinStyle.from_path(path)
 
         # When run with tox (invoke ci-build), the path starts with "site-packages"
-        clean_root = style.path_from_repo_root.replace("site-packages/", "src/")
+        path_from_repo_root = path.relative_to(repo_root()).as_posix()
+        clean_root = path_from_repo_root.replace("site-packages/", "src/")
 
         row = StyleLibraryRow(
-            style=f"{pre}`{style.py_url_without_ext} <{clean_root}>`{post}",
+            style=f"{pre}`{style.formatted} <{clean_root}>`{post}",
             name=f"`{style.name} <{style.url}>`_" if style.url else style.name,
         )
         library[style.identify_tag].append(attr.astuple(row))
diff --git a/docs/cli.rst b/docs/cli.rst
index 12c3ff41..1e81b0ca 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -23,6 +23,29 @@ If you use Git, you can review the files before committing.
 
 The available commands are described below.
 
+Suggest styles based on project files
+-------------------------------------
+
+You can use the ``nitpick init --suggest`` command to suggest styles based on the files in your project.
+
+Nitpick will scan the files starting from the project root, skipping the ones ignored by Git, and suggest the styles that match the file types.
+
+By default, it will suggest the styles from the built-in library. You can use the ``--library`` option to scan a custom directory with styles.
+
+The directory structure of your custom library should be the same as the built-in library: ``<root_library_dir>/<file_type>/<your_style_name>.toml``.
+
+- ``file_type`` can be any tag reported by the `pre-commit/identify library <https://github.com/pre-commit/identify>`_ (e.g. ``python``, ``yaml``, ``markdown``, etc.);
+- use the special type ``any`` for styles that apply to all file types (e.g. trim trailing whitespace).
+
+The ``--suggest` command will display what would be changed. To apply these changes and autofix the config, run with the ``--fix`` option.
+
+To ignore styles, add them to the ``[tool.nitpick] ignore_styles`` key on the config file.
+
+Nitpick will consider the following Git ignore files:
+
+- ``.gitignore`` on the project root;
+- a global ignore file configured by ``git config --get core.excludesFile``.
+
 .. auto-generated-from-here
 
 .. _cli_cmd:
@@ -47,7 +70,7 @@ Main options
     Commands:
       check  Don't modify files, just print the differences.
       fix    Fix files, modifying them directly.
-      init   Create a [tool.nitpick] section in the configuration file if it...
+      init   Create or update the [tool.nitpick] table in the configuration...
       ls     List of files configured in the Nitpick style.
 
 .. _cli_cmd_fix:
@@ -121,8 +144,14 @@ At the end of execution, this command displays:
 
     Usage: nitpick init [OPTIONS] [STYLE_URLS]...
 
-      Create a [tool.nitpick] section in the configuration file if it doesn't
-      exist already.
+      Create or update the [tool.nitpick] table in the configuration file.
 
     Options:
-      --help  Show this message and exit.
+      -f, --fix                Autofix the files changed by the command;
+                               otherwise, just print what would be done
+      -s, --suggest            Suggest styles based on the extension of project
+                               files (skipping Git ignored files)
+      -l, --library DIRECTORY  Library dir to scan for style files (implies
+                               --suggest); if not provided, uses the built-in
+                               style library
+      --help                   Show this message and exit.
diff --git a/docs/configuration.rst b/docs/configuration.rst
index ce2d29f2..50ca72dd 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -5,7 +5,7 @@
 Configuration
 =============
 
-:ref:`the-style-file` for your project should be configured in the ``[tool.nitpick]`` section of the configuration file.
+:ref:`the-style-file` for your project should be configured in the ``[tool.nitpick]`` table of the configuration file.
 
 Possible configuration files (in order of precedence):
 
diff --git a/docs/nitpick_section.rst b/docs/nitpick_section.rst
index 08063b37..768db54b 100644
--- a/docs/nitpick_section.rst
+++ b/docs/nitpick_section.rst
@@ -1,9 +1,9 @@
 .. _nitpick_section:
 
-The [nitpick] section
-=====================
+The [nitpick] table
+===================
 
-The [nitpick] section in :ref:`the style file <the-style-file>` contains global settings for the style.
+The [nitpick] table in :ref:`the style file <the-style-file>` contains global settings for the style.
 
 Those are settings that either don't belong to any specific config file, or can be applied to all config files.
 
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
index cce7e2ce..b47889bd 100644
--- a/docs/quickstart.rst
+++ b/docs/quickstart.rst
@@ -66,14 +66,18 @@ If you use pre-commit_ on your project, add this to the ``.pre-commit-config.yam
       - repo: https://github.com/andreoliwa/nitpick
         rev: v0.34.0
         hooks:
+          - id: nitpick-suggest
           - id: nitpick
 
-There are 3 available hook IDs:
+There are 4 available hook IDs:
 
 - ``nitpick`` and ``nitpick-fix`` both run the ``nitpick fix`` command;
-- ``nitpick-check`` runs ``nitpick check``.
+- ``nitpick-check`` runs ``nitpick check``;
+- ``nitpick-suggest`` runs ``nitpick init --fix --suggest``;
 
-If you want to run Nitpick_ as a flake8_ plugin instead::
+If you want to run Nitpick_ as a flake8_ plugin instead:
+
+.. code-block:: yaml
 
     repos:
       - repo: https://github.com/PyCQA/flake8
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 3383dcc2..3e15ab53 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,10 +1,10 @@
 alabaster==0.7.13 ; python_version >= "3.8" and python_version < "4.0"
 attrs==23.1.0 ; python_version >= "3.8" and python_version < "4.0"
 autorepr==0.3.0 ; python_version >= "3.8" and python_version < "4.0"
-babel==2.13.1 ; python_version >= "3.8" and python_version < "4.0"
+babel==2.13.0 ; python_version >= "3.8" and python_version < "4.0"
 cattrs==23.1.2 ; python_version >= "3.8" and python_version < "4.0"
 certifi==2023.7.22 ; python_version >= "3.8" and python_version < "4.0"
-charset-normalizer==3.3.2 ; python_version >= "3.8" and python_version < "4.0"
+charset-normalizer==3.3.0 ; python_version >= "3.8" and python_version < "4.0"
 click==8.1.7 ; python_version >= "3.8" and python_version < "4.0"
 colorama==0.4.6 ; python_version >= "3.8" and python_version < "4.0" and (platform_system == "Windows" or sys_platform == "win32")
 configupdater==3.1.1 ; python_version >= "3.8" and python_version < "4.0"
@@ -15,7 +15,8 @@ exceptiongroup==1.1.3 ; python_version >= "3.8" and python_version < "3.11"
 flake8==5.0.4 ; python_version >= "3.8" and python_version < "4.0"
 flatten-dict==0.4.2 ; python_version >= "3.8" and python_version < "4.0"
 furl==2.1.3 ; python_version >= "3.8" and python_version < "4.0"
-identify==2.5.31 ; python_version >= "3.8" and python_version < "4.0"
+gitignore-parser==0.1.9 ; python_version >= "3.8" and python_version < "4.0"
+identify==2.5.30 ; python_version >= "3.8" and python_version < "4.0"
 idna==3.4 ; python_version >= "3.8" and python_version < "4.0"
 imagesize==1.4.1 ; python_version >= "3.8" and python_version < "4.0"
 importlib-metadata==6.8.0 ; python_version >= "3.8" and python_version < "3.10"
@@ -24,7 +25,7 @@ iniconfig==2.0.0 ; python_version >= "3.8" and python_version < "4.0"
 jinja2==3.1.2 ; python_version >= "3.8" and python_version < "4.0"
 jmespath==1.0.1 ; python_version >= "3.8" and python_version < "4.0"
 jsonschema-specifications==2023.7.1 ; python_version >= "3.8" and python_version < "4.0"
-jsonschema==4.19.2 ; python_version >= "3.8" and python_version < "4.0"
+jsonschema==4.19.1 ; python_version >= "3.8" and python_version < "4.0"
 loguru==0.7.2 ; python_version >= "3.8" and python_version < "4.0"
 markupsafe==2.1.3 ; python_version >= "3.8" and python_version < "4.0"
 marshmallow-polyfield==5.11 ; python_version >= "3.8" and python_version < "4.0"
@@ -40,7 +41,7 @@ pycodestyle==2.9.1 ; python_version >= "3.8" and python_version < "4.0"
 pyflakes==2.5.0 ; python_version >= "3.8" and python_version < "4.0"
 pygments==2.16.1 ; python_version >= "3.8" and python_version < "4.0"
 pytest-socket==0.6.0 ; python_version >= "3.8" and python_version < "4.0"
-pytest==7.4.3 ; python_version >= "3.8" and python_version < "4.0"
+pytest==7.4.2 ; python_version >= "3.8" and python_version < "4.0"
 python-slugify==8.0.1 ; python_version >= "3.8" and python_version < "4.0"
 pytz==2023.3.post1 ; python_version >= "3.8" and python_version < "3.9"
 referencing==0.30.2 ; python_version >= "3.8" and python_version < "4.0"
diff --git a/docs/source/nitpick.rst b/docs/source/nitpick.rst
index 2ee6f017..1cac3626 100644
--- a/docs/source/nitpick.rst
+++ b/docs/source/nitpick.rst
@@ -33,5 +33,6 @@ Submodules
    nitpick.generic
    nitpick.schemas
    nitpick.style
+   nitpick.tomlkit_ext
    nitpick.typedefs
    nitpick.violations
diff --git a/docs/source/nitpick.tomlkit_ext.rst b/docs/source/nitpick.tomlkit_ext.rst
new file mode 100644
index 00000000..bcacacfb
--- /dev/null
+++ b/docs/source/nitpick.tomlkit_ext.rst
@@ -0,0 +1,7 @@
+nitpick.tomlkit\_ext module
+===========================
+
+.. automodule:: nitpick.tomlkit_ext
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/styles.rst b/docs/styles.rst
index 44308d65..a6678dc1 100644
--- a/docs/styles.rst
+++ b/docs/styles.rst
@@ -114,7 +114,7 @@ On YAML files, a list (called a "sequence" in the YAML spec) can contain differe
 - Other lists (sequences).
 
 The default behaviour for all lists: when applying a style, Nitpick_ searches
-if the element already exists in the list; if not found, the element is appended at the end of list.
+if the element already exists in the list; if not found, the element is appended to the end of list.
 
 1. With scalar values, Nitpick_ compares the elements by their exact value.
    Strings are compared in a case-sensitive manner, and spaces are considered.
@@ -124,7 +124,7 @@ On lists containing dicts, it is possible to define a custom "list key" used to
 overriding the default compare mechanism above.
 
 If an element is found by its key, the other key/values are compared and Nitpick_ applies the difference.
-If the key doesn't exist, the new dict is appended at the end of the list.
+If the key doesn't exist, the new dict is appended to the end of the list.
 
 Some files have a predefined configuration:
 
diff --git a/poetry.lock b/poetry.lock
index 4e245fa1..22fb655b 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
 
 [[package]]
 name = "alabaster"
@@ -516,6 +516,16 @@ files = [
 orderedmultidict = ">=1.0.1"
 six = ">=1.8.0"
 
+[[package]]
+name = "gitignore-parser"
+version = "0.1.9"
+description = "A spec-compliant gitignore parser for Python 3.5+"
+optional = false
+python-versions = "*"
+files = [
+    {file = "gitignore_parser-0.1.9.tar.gz", hash = "sha256:270cb8cd09de410b8805c5f4183fd404c28f910dcbb94e1efc08226144fdff7d"},
+]
+
 [[package]]
 name = "icecream"
 version = "2.1.3"
@@ -1606,6 +1616,7 @@ files = [
     {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"},
     {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"},
     {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412"},
+    {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f"},
     {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334"},
     {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d"},
     {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win32.whl", hash = "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d"},
@@ -1613,6 +1624,7 @@ files = [
     {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069"},
     {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248"},
     {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b"},
+    {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe"},
     {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899"},
     {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9"},
     {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win32.whl", hash = "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7"},
@@ -1620,6 +1632,7 @@ files = [
     {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1"},
     {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2"},
     {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92"},
+    {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62"},
     {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9"},
     {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d"},
     {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win32.whl", hash = "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa"},
@@ -1627,6 +1640,7 @@ files = [
     {file = "ruamel.yaml.clib-0.2.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed"},
     {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942"},
     {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875"},
+    {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6"},
     {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3"},
     {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7"},
     {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3"},
@@ -1634,6 +1648,7 @@ files = [
     {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win_amd64.whl", hash = "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675"},
     {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615"},
     {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337"},
+    {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1"},
     {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91"},
     {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28"},
     {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d"},
@@ -1641,6 +1656,7 @@ files = [
     {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win_amd64.whl", hash = "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312"},
     {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001"},
     {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf"},
+    {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c"},
     {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5"},
     {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b"},
     {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880"},
@@ -2289,4 +2305,4 @@ test = ["freezegun", "pytest", "pytest-cov", "pytest-datadir", "pytest-socket",
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "2675e5893f59c1b4f3f1b8696159a4413392cfe2987dbe9f269927791a264de8"
+content-hash = "d76e87860c24701ebd8408aa6102ab15f488e7eb835ecfd14e66594fe2acdec5"
diff --git a/pyproject.toml b/pyproject.toml
index cb986c9c..ef67fd7a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -48,6 +48,7 @@ flake8 = ">=3.0.0"
 flatten-dict = "*"
 freezegun = {optional = true, version = "*"}
 furl = "*"
+gitignore_parser = "*"
 identify = "*"
 importlib-resources = {python = ">=3.8, <3.9", version = "*"}
 jmespath = "*"
diff --git a/src/nitpick/__main__.py b/src/nitpick/__main__.py
index 0772c8c6..4b444d19 100644
--- a/src/nitpick/__main__.py
+++ b/src/nitpick/__main__.py
@@ -5,15 +5,14 @@
 - https://www.python.org/dev/peps/pep-0338/
 - https://docs.python.org/3/using/cmdline.html#cmdoption-m
 """
-# pragma: no cover
-from nitpick.cli import nitpick_cli
-from nitpick.constants import PROJECT_NAME
+from nitpick.cli import nitpick_cli  # pragma: no cover
+from nitpick.constants import PROJECT_NAME  # pragma: no cover
 
 
-def main() -> None:
+def main() -> None:  # pragma: no cover
     """Entry point for the application script."""
     nitpick_cli(auto_envvar_prefix=PROJECT_NAME)
 
 
-if __name__ == "__main__":
+if __name__ == "__main__":  # pragma: no cover
     main()
diff --git a/src/nitpick/cli.py b/src/nitpick/cli.py
index b539b681..b84f920c 100644
--- a/src/nitpick/cli.py
+++ b/src/nitpick/cli.py
@@ -19,11 +19,19 @@
 from pathlib import Path
 
 import click
+import tomlkit
 from click.exceptions import Exit
 from loguru import logger
 
-from nitpick.blender import TomlDoc
-from nitpick.constants import CONFIG_TOOL_KEY, CONFIG_TOOL_NITPICK_KEY, PROJECT_NAME, Flake8OptionEnum
+from nitpick import tomlkit_ext
+from nitpick.constants import (
+    CONFIG_KEY_IGNORE_STYLES,
+    CONFIG_KEY_STYLE,
+    CONFIG_TOOL_NITPICK_KEY,
+    PROJECT_NAME,
+    EmojiEnum,
+    Flake8OptionEnum,
+)
 from nitpick.core import Nitpick
 from nitpick.exceptions import QuitComplainingError
 from nitpick.generic import relative_to_current_dir
@@ -143,17 +151,101 @@ def ls(context, files):  # pylint: disable=invalid-name
 
 @nitpick_cli.command()
 @click.pass_context
+@click.option(
+    "--fix",
+    "-f",
+    is_flag=True,
+    default=False,
+    help="Autofix the files changed by the command; otherwise, just print what would be done",
+)
+@click.option(
+    "--suggest",
+    "-s",
+    is_flag=True,
+    default=False,
+    help="Suggest styles based on the extension of project files (skipping Git ignored files)",
+)
+@click.option(
+    "--library",
+    "-l",
+    type=click.Path(exists=True, dir_okay=True, file_okay=False, resolve_path=True),
+    help="Library dir to scan for style files (implies --suggest); if not provided, uses the built-in style library",
+)
 @click.argument("style_urls", nargs=-1)
-def init(context, style_urls):
-    """Create a [tool.nitpick] section in the configuration file if it doesn't exist already."""
+def init(
+    context,
+    fix: bool,  # pylint: disable=redefined-outer-name
+    suggest: bool,
+    library: str | None,
+    style_urls: list[str],
+) -> None:
+    """Create or update the [tool.nitpick] table in the configuration file."""
+    # If a library is provided, it implies --suggest
+    if library:
+        suggest = True
+
+    if not style_urls and not suggest:
+        click.secho(
+            f"Nothing to do. {EmojiEnum.SLEEPY_FACE.value} Either pass at least one style URL"
+            " or use --suggest to add styles based on the files in the project root"
+            " (you can do both at the same time).",
+            fg="yellow",
+        )
+        return
+
     nit = get_nitpick(context)
     config = nit.project.read_configuration()
 
-    if config.file and PROJECT_NAME in TomlDoc(path=config.file).as_object.get(CONFIG_TOOL_KEY, {}):
-        click.secho(
-            f"The config file {config.file.name} already has a [{CONFIG_TOOL_NITPICK_KEY}] section.", fg="yellow"
+    # Convert tuple to list, so we can add styles to it
+    style_urls = list(style_urls)
+    if suggest:
+        from nitpick import __version__  # pylint: disable=import-outside-toplevel
+
+        # Create the ignored styles array only when suggesting styles
+        if CONFIG_KEY_IGNORE_STYLES not in config.table:
+            config.table.add(CONFIG_KEY_IGNORE_STYLES, config.dont_suggest)
+
+        style_urls.extend(nit.project.suggest_styles(library))
+        tomlkit_ext.update_comment_before(
+            config.table,
+            CONFIG_KEY_STYLE,
+            PROJECT_NAME,
+            f"""
+            (auto-generated by "nitpick init --suggest" {__version__})
+            Styles added to the Nitpick Style Library will be appended to the end of the {CONFIG_KEY_STYLE!r} key.
+            If you don't want a style, move it to the {CONFIG_KEY_IGNORE_STYLES!r} key.
+            """,
         )
-        raise Exit(1)
 
-    nit.project.create_configuration(config, *style_urls)
-    click.secho(f"A [{CONFIG_TOOL_NITPICK_KEY}] section was created in the config file: {config.file.name}", fg="green")
+    new_styles = []
+    for style_url in style_urls:
+        if style_url in config.styles or style_url in config.dont_suggest:
+            continue
+        new_styles.append(style_url)
+        config.styles.add_line(style_url, indent="  ")
+    if not new_styles:
+        click.echo(
+            f"All done! {EmojiEnum.STAR_CAKE.value} [{CONFIG_TOOL_NITPICK_KEY}] table left unchanged in {config.file.name!r}"
+        )
+        return
+
+    click.echo("New styles:")
+    for style in new_styles:
+        click.echo(f"- {style}")
+    count = len(new_styles)
+    message = f"{count} style{'s' if count > 1 else ''}"
+
+    if not fix:
+        click.secho(
+            f"Use --fix to append {message} to the [{CONFIG_TOOL_NITPICK_KEY}]"
+            f" table in the config file {config.file.name!r}.",
+            fg="yellow",
+        )
+        return
+
+    config.file.write_text(tomlkit.dumps(config.doc), encoding="UTF-8")
+    click.echo(
+        f"The [{CONFIG_TOOL_NITPICK_KEY}] table was updated in {config.file.name!r}:"
+        f" {message} appended. {EmojiEnum.STAR_CAKE.value}"
+    )
+    raise Exit(1)  # A non-zero exit code is needed when executed as a pre-commit hook
diff --git a/src/nitpick/constants.py b/src/nitpick/constants.py
index 5e7b06e4..48aac637 100644
--- a/src/nitpick/constants.py
+++ b/src/nitpick/constants.py
@@ -10,8 +10,14 @@
 from requests_cache import DO_NOT_CACHE, NEVER_EXPIRE
 
 # keep-sorted start
+ANY_BUILTIN_STYLE = "any"
 CACHE_DIR_NAME = ".cache"
+COMMENT_MARKER_END = "-end"
+COMMENT_MARKER_START = "-start"
 CONFIG_DUNDER_LIST_KEYS = "__list_keys"
+CONFIG_KEY_IGNORE_STYLES = "ignore_styles"
+CONFIG_KEY_STYLE = "style"
+CONFIG_KEY_TOOL = "tool"
 CONFIG_TOOL_KEY = "tool"
 DOT = "."
 DOT_NITPICK_TOML = ".nitpick.toml"
@@ -22,6 +28,9 @@
 GITHUB_COM_QUERY_STRING_TOKEN = "token"  # nosec # noqa: S105
 GITHUB_COM_RAW = "raw.githubusercontent.com"
 GIT_AT_REFERENCE = "@"
+GIT_CORE_EXCLUDES_FILE = "core.excludesFile"
+GIT_DIR = ".git"
+GIT_IGNORE = ".gitignore"
 GOLANG_MOD = "go.mod"
 GOLANG_SUM = "go.sum"
 JAVASCRIPT_PACKAGE_JSON = "package.json"
@@ -86,6 +95,7 @@ class EmojiEnum(Enum):
     CONSTRUCTION = "🚧"
     GREEN_CHECK = "✅"
     QUESTION_MARK = "❓"
+    SLEEPY_FACE = "😴"
     STAR_CAKE = "✨ 🍰 ✨"
     X_RED_CROSS = "❌"
     # keep-sorted end
@@ -129,7 +139,7 @@ class CachingEnum(IntEnum):
     EXPIRES = auto()
 
 
-# TODO(AA): move this to the enum above
+# TODO: move this to the enum above
 CACHE_EXPIRATION_DEFAULTS = {
     CachingEnum.NEVER: DO_NOT_CACHE,
     CachingEnum.FOREVER: NEVER_EXPIRE,
diff --git a/src/nitpick/core.py b/src/nitpick/core.py
index 9c2b6b77..7a45735d 100644
--- a/src/nitpick/core.py
+++ b/src/nitpick/core.py
@@ -7,38 +7,45 @@
 from functools import lru_cache
 from itertools import chain
 from pathlib import Path
-from typing import TYPE_CHECKING, Iterator
+from typing import TYPE_CHECKING, Iterable, Iterator
 
 import click
 import tomlkit
 from autorepr import autorepr
+from identify import identify
 from loguru import logger
 from marshmallow_polyfield import PolyField
 from more_itertools import always_iterable
 from packaging.version import parse as parse_version
 from pluggy import PluginManager
-from tomlkit import TOMLDocument
-from tomlkit.items import KeyType, SingleKey
+from tomlkit import items
 
-from nitpick import PROJECT_NAME, fields, plugins
-from nitpick.blender import TomlDoc, search_json
+from nitpick import fields, plugins, tomlkit_ext
+from nitpick.blender import search_json
 from nitpick.constants import (
+    ANY_BUILTIN_STYLE,
     CONFIG_FILES,
+    CONFIG_KEY_IGNORE_STYLES,
+    CONFIG_KEY_STYLE,
+    CONFIG_KEY_TOOL,
     CONFIG_TOOL_NITPICK_KEY,
     DOT_NITPICK_TOML,
     JMEX_NITPICK_MINIMUM_VERSION,
-    JMEX_TOOL_NITPICK,
+    PROJECT_NAME,
     PYTHON_MANAGE_PY,
     PYTHON_PYPROJECT_TOML,
-    READ_THE_DOCS_URL,
     ROOT_FILES,
     ROOT_PYTHON_FILES,
 )
 from nitpick.exceptions import QuitComplainingError
-from nitpick.generic import filter_names, glob_files, relative_to_current_dir
+from nitpick.generic import filter_names, glob_files, glob_non_ignored_files, relative_to_current_dir
 from nitpick.plugins.info import FileInfo
 from nitpick.schemas import BaseNitpickSchema, flatten_marshmallow_errors, help_message
-from nitpick.style import StyleManager
+from nitpick.style import (
+    BuiltinStyle,
+    StyleManager,
+    builtin_styles,
+)
 from nitpick.violations import Fuss, ProjectViolations, Reporter, StyleViolations
 
 if TYPE_CHECKING:
@@ -198,20 +205,24 @@ def find_main_python_file(root_dir: Path) -> Path:
 
 
 class ToolNitpickSectionSchema(BaseNitpickSchema):
-    """Validation schema for the ``[tool.nitpick]`` section on ``pyproject.toml``."""
+    """Validation schema for the ``[tool.nitpick]`` table on ``pyproject.toml``."""
 
     error_messages = {"unknown": help_message("Unknown configuration", "configuration.html")}  # noqa: RUF012
 
     style = PolyField(deserialization_schema_selector=fields.string_or_list_field)
     cache = fields.NonEmptyString()
+    ignore_styles = fields.List(fields.NonEmptyString())
 
 
 @dataclass
 class Configuration:
-    """Configuration read from one of the ``CONFIG_FILES``."""
+    """Configuration read from the ``[tool.nitpick]`` table from one of the ``CONFIG_FILES``."""
 
-    file: Path | None
-    styles: str | list[str]
+    file: Path
+    doc: tomlkit.TOMLDocument
+    table: items.Table
+    styles: items.Array
+    dont_suggest: items.Array
     cache: str
 
 
@@ -250,44 +261,74 @@ def plugin_manager(self) -> PluginManager:
             manager.load_setuptools_entrypoints(PROJECT_NAME)
         return manager
 
-    def read_configuration(self) -> Configuration:
-        """Search for a configuration file and validate it against a Marshmallow schema."""
-        config_file: Path | None = None
-        for possible_file in CONFIG_FILES:
-            path: Path = self.root / possible_file
-            if not path.exists():
+    def config_file_or_default(self) -> Path:
+        """Return a config file if found, or the default one."""
+        config_file = self.config_file()
+        if config_file:
+            return config_file
+        return self.root / DOT_NITPICK_TOML
+
+    def config_file(self) -> Path | None:
+        """Determine which config file to use."""
+        found: Path | None = None
+        for possible in CONFIG_FILES:
+            existing: Path = self.root / possible
+            if not existing.exists():
                 continue
 
-            if not config_file:
-                logger.info(f"Config file: reading from {path}")
-                config_file = path
+            if not found:
+                logger.info(f"Config file: reading from {existing}")
+                found = existing
             else:
-                logger.warning(f"Config file: ignoring existing {path}")
+                logger.warning(f"Config file: ignoring existing {existing}")
 
-        if not config_file:
+        if not found:
             logger.warning("Config file: none found")
-            return Configuration(None, [], "")
-
-        toml_doc = TomlDoc(path=config_file)
-        config_dict = search_json(toml_doc.as_object, JMEX_TOOL_NITPICK, {})
-        validation_errors = ToolNitpickSectionSchema().validate(config_dict)
-        if not validation_errors:
-            return Configuration(config_file, config_dict.get("style", []), config_dict.get("cache", ""))
-
-        raise QuitComplainingError(
-            Reporter(FileInfo(self, PYTHON_PYPROJECT_TOML)).make_fuss(
-                StyleViolations.INVALID_DATA_TOOL_NITPICK,
-                flatten_marshmallow_errors(validation_errors),
-                section=CONFIG_TOOL_NITPICK_KEY,
+
+        return found
+
+    def read_configuration(self) -> Configuration:
+        """Return the ``[tool.nitpick]`` table from the configuration file.
+
+        Optionally, validate it against a Marshmallow schema.
+        """
+        config_file = self.config_file_or_default()
+        doc = tomlkit_ext.load(config_file)
+        table: items.Table | None = doc.get(CONFIG_TOOL_NITPICK_KEY)
+        if not table:
+            super_table = tomlkit.table(True)
+            nitpick_table = tomlkit.table()
+            nitpick_table.update({CONFIG_KEY_STYLE: []})
+            super_table.append(PROJECT_NAME, nitpick_table)
+            doc.append(CONFIG_KEY_TOOL, super_table)
+            table = doc.get(CONFIG_TOOL_NITPICK_KEY)
+
+        validation_errors = ToolNitpickSectionSchema().validate(table)
+        if validation_errors:
+            raise QuitComplainingError(
+                Reporter(FileInfo(self, PYTHON_PYPROJECT_TOML)).make_fuss(
+                    StyleViolations.INVALID_DATA_TOOL_NITPICK,
+                    flatten_marshmallow_errors(validation_errors),
+                    section=CONFIG_TOOL_NITPICK_KEY,
+                )
             )
-        )
+
+        existing_styles: items.Array = table.get(CONFIG_KEY_STYLE)
+        if existing_styles is None:
+            existing_styles = tomlkit.array()
+            table.add(CONFIG_KEY_STYLE, existing_styles)
+
+        ignored_styles: items.Array = table.get(CONFIG_KEY_IGNORE_STYLES)
+        if ignored_styles is None:
+            ignored_styles = tomlkit.array()
+
+        return Configuration(config_file, doc, table, existing_styles, ignored_styles, table.get("cache", ""))
 
     def merge_styles(self, offline: bool) -> Iterator[Fuss]:
         """Merge one or multiple style files."""
         config = self.read_configuration()
-
         style = StyleManager(self, offline, config.cache)
-        base = config.file.expanduser().resolve().as_uri() if config.file else None
+        base = config.file.expanduser().resolve().as_uri()
         style_errors = list(style.find_initial_styles(list(always_iterable(config.styles)), base))
         if style_errors:
             raise QuitComplainingError(style_errors)
@@ -309,22 +350,22 @@ def merge_styles(self, offline: bool) -> Iterator[Fuss]:
         self.nitpick_section = self.style_dict.get("nitpick", {})
         self.nitpick_files_section = self.nitpick_section.get("files", {})
 
-    def create_configuration(self, config: Configuration, *style_urls: str) -> None:
-        """Create a configuration file."""
-        if config.file:
-            doc: TOMLDocument = tomlkit.parse(config.file.read_text())
-        else:
-            doc = tomlkit.document()
-            config.file = self.root / DOT_NITPICK_TOML
+    def suggest_styles(self, library_path_str: PathOrStr | None) -> list[str]:
+        """Suggest styles based on the files in the project root (skipping Git ignored files)."""
+        all_tags: set[str] = {ANY_BUILTIN_STYLE}
+        for project_file_path in glob_non_ignored_files(self.root):
+            all_tags.update(identify.tags_from_path(str(project_file_path)))
 
-        if not style_urls:
-            style_urls = (str(StyleManager.get_default_style_url()),)
-
-        tool_nitpick = tomlkit.table()
-        tool_nitpick.add(tomlkit.comment("Generated by the 'nitpick init' command"))
-        tool_nitpick.add(tomlkit.comment(f"More info at {READ_THE_DOCS_URL}configuration.html"))
-        tool_nitpick.add("style", tomlkit.array([tomlkit.string(url) for url in style_urls]))
-        doc.add(SingleKey(CONFIG_TOOL_NITPICK_KEY, KeyType.Bare), tool_nitpick)
-
-        # config.file will always have a value at this point, but mypy can't see it.
-        config.file.write_text(tomlkit.dumps(doc, sort_keys=True))
+        if library_path_str:
+            library_dir = Path(library_path_str)
+            all_styles: Iterable[Path] = library_dir.glob("**/*.toml")
+        else:
+            library_dir = None
+            all_styles = builtin_styles()
+
+        suggested_styles: set[str] = set()
+        for style_path in all_styles:
+            style = BuiltinStyle.from_path(style_path, library_dir)
+            if style.identify_tag in all_tags:
+                suggested_styles.add(style.formatted)
+        return sorted(suggested_styles)
diff --git a/src/nitpick/generic.py b/src/nitpick/generic.py
index 0c63538f..b10f0f3f 100644
--- a/src/nitpick/generic.py
+++ b/src/nitpick/generic.py
@@ -6,11 +6,15 @@
 """
 from __future__ import annotations
 
+import subprocess
 import sys
 from pathlib import Path, PosixPath, WindowsPath
 from typing import TYPE_CHECKING, Iterable
 
-from nitpick.constants import DOT, PROJECT_NAME
+import click
+from gitignore_parser import parse_gitignore
+
+from nitpick.constants import DOT, GIT_CORE_EXCLUDES_FILE, GIT_DIR, GIT_IGNORE, PROJECT_NAME
 
 if TYPE_CHECKING:
     from furl import furl
@@ -96,3 +100,45 @@ def glob_files(dir_: Path, file_patterns: Iterable[str]) -> set[Path]:
         if found_files:
             return found_files
     return set()
+
+
+def get_global_gitignore_path() -> Path | None:
+    """Get the path to the global Git ignore file."""
+    try:
+        output = subprocess.check_output(
+            # TODO: fix: this command might not work on Windows; maybe read ~/.gitconfig directly instead?
+            ["git", "config", "--get", GIT_CORE_EXCLUDES_FILE],  # noqa: S603,S607
+            universal_newlines=True,
+        ).strip()
+        return Path(output) if output else None
+    except subprocess.CalledProcessError:
+        click.secho(
+            f"The '{GIT_CORE_EXCLUDES_FILE}' configuration is not set or does not exist.", err=True, fg="yellow"
+        )
+    except FileNotFoundError:
+        click.secho("Git command not found. Please make sure Git is installed and on the PATH.", err=True, fg="red")
+    return None
+
+
+def glob_non_ignored_files(root_dir: Path, pattern: str = "**/*") -> Iterable[Path]:
+    """Glob all files in the root dir that are not ignored by Git."""
+
+    git_dir = root_dir / GIT_DIR
+    is_locally_ignored = None
+    is_globally_ignored = None
+    if git_dir.is_dir():
+        global_gitignore = get_global_gitignore_path()
+        if global_gitignore and global_gitignore.is_file():
+            is_globally_ignored = parse_gitignore(global_gitignore)
+        local_gitignore_path = root_dir / GIT_IGNORE
+        if local_gitignore_path.is_file():
+            is_locally_ignored = parse_gitignore(local_gitignore_path)
+
+    for project_file in root_dir.glob(pattern):
+        if (
+            not project_file.is_file()
+            or (is_globally_ignored and is_globally_ignored(project_file))
+            or (is_locally_ignored and is_locally_ignored(project_file))
+        ):
+            continue
+        yield project_file
diff --git a/src/nitpick/resources/any/codeclimate.toml b/src/nitpick/resources/any/codeclimate.toml
index f7614dbf..abe7fb04 100644
--- a/src/nitpick/resources/any/codeclimate.toml
+++ b/src/nitpick/resources/any/codeclimate.toml
@@ -8,7 +8,8 @@ url = "https://codeclimate.com/"
 version = "2"
 
 [".codeclimate.yml".checks.file-lines.config]
-# Pylint's default is also 1000: https://github.com/PyCQA/pylint/blob/master/pylint/checkers/format.py#L294-L300
+# Pylint's default is also 1000:
+# https://pylint.readthedocs.io/en/latest/user_guide/configuration/all-options.html#max-module-lines
 threshold = 1000
 
 [".codeclimate.yml".checks.method-complexity.config]
diff --git a/src/nitpick/style.py b/src/nitpick/style.py
index 69b45218..807119fe 100644
--- a/src/nitpick/style.py
+++ b/src/nitpick/style.py
@@ -58,7 +58,7 @@
     # DeprecationWarning: The dpath.util package is being deprecated.
     # All util functions have been moved to dpath package top level.
     from dpath import merge as dpath_merge
-except ImportError:
+except ImportError:  # pragma: no cover
     from dpath.util import merge as dpath_merge
 
 GITHUB_API_SESSION = Session()  # Dedicated session to reuse connections
@@ -787,39 +787,37 @@ def fetch(self, url: furl) -> str:
 class BuiltinStyle:  # pylint: disable=too-few-public-methods
     """A built-in style file in TOML format."""
 
-    py_url: furl
-    py_url_without_ext: furl
-    path_from_repo_root: str
+    formatted: str
     path_from_resources_root: str
 
-    pypackage_url: PythonPackageURL = attr.field(init=False)
     identify_tag: str = attr.field(init=False)
     name: str = attr.field(init=False)
     url: str = attr.field(init=False)
     files: list[str] = attr.field(init=False)
 
     @classmethod
-    def from_path(cls, resource_path: Path) -> BuiltinStyle:
-        """Create a built-in style from a resource path."""
-
+    def from_path(cls, resource_path: Path, library_dir: Path | None = None) -> BuiltinStyle:
+        """Create a style from its path."""
         without_suffix = resource_path.with_suffix("")
-        src_path = builtin_resources_root().parent.parent
-        package_path = resource_path.relative_to(src_path)
-        from_resources_root = without_suffix.relative_to(builtin_resources_root())
-
-        root, *path_remainder = package_path.parts
-        path_remainder_without_suffix = (*path_remainder[:-1], without_suffix.parts[-1])
-
-        bis = BuiltinStyle(
-            py_url=furl(scheme=Scheme.PY, host=root, path=path_remainder),
-            py_url_without_ext=furl(scheme=Scheme.PY, host=root, path=path_remainder_without_suffix),
-            path_from_repo_root=resource_path.relative_to(repo_root()).as_posix(),
-            path_from_resources_root=from_resources_root.as_posix(),
-        )
-        bis.pypackage_url = PythonPackageURL.from_furl(bis.py_url)
+        if library_dir:
+            # Style in a directory
+            from_resources_root = without_suffix.relative_to(library_dir)
+            bis = BuiltinStyle(
+                formatted=str(without_suffix),
+                path_from_resources_root=from_resources_root.as_posix(),
+            )
+        else:
+            # Style from the built-in library
+            package_path = resource_path.relative_to(builtin_resources_root().parent.parent)
+            from_resources_root = without_suffix.relative_to(builtin_resources_root())
+            root, *path_remainder = package_path.parts
+            path_remainder_without_suffix = (*path_remainder[:-1], without_suffix.parts[-1])
+            bis = BuiltinStyle(
+                formatted=furl(scheme=Scheme.PY, host=root, path=path_remainder_without_suffix).url,
+                path_from_resources_root=from_resources_root.as_posix(),
+            )
         bis.identify_tag = from_resources_root.parts[0]
-
-        toml_dict = tomlkit.loads(bis.pypackage_url.content_path.read_text(encoding="UTF-8"))
+        toml_dict = tomlkit.loads(resource_path.read_text(encoding="UTF-8"))
 
         keys = list(toml_dict.keys())
         keys.remove(PROJECT_NAME)
@@ -827,7 +825,7 @@ def from_path(cls, resource_path: Path) -> BuiltinStyle:
 
         try:
             # Intentionally break the doc generation when styles don't have [nitpick.meta]name
-            meta = toml_dict["nitpick"]["meta"]
+            meta = toml_dict["nitpick"]["meta"]  # pylint: disable=invalid-sequence-index
             bis.name = meta["name"]
             bis.url = meta.get("url")
         except KeyError as err:
diff --git a/src/nitpick/tomlkit_ext.py b/src/nitpick/tomlkit_ext.py
new file mode 100644
index 00000000..56fb44ce
--- /dev/null
+++ b/src/nitpick/tomlkit_ext.py
@@ -0,0 +1,139 @@
+"""Extensions for the tomlkit package, with drop-in replacement functions and classes.
+
+Eventually, some of the code here should/could be proposed as pull requests to the original package.
+"""
+from __future__ import annotations
+
+from functools import wraps
+from pathlib import Path
+from textwrap import dedent
+from typing import IO, TYPE_CHECKING, Callable, Iterable
+
+import tomlkit
+from tomlkit import TOMLDocument
+from tomlkit.exceptions import NonExistentKey
+from tomlkit.items import Comment, Key, Table, Whitespace
+
+from nitpick.constants import COMMENT_MARKER_END, COMMENT_MARKER_START
+
+if TYPE_CHECKING:
+    from tomlkit.container import Container
+
+# keep-sorted start
+TOMLKIT_COMMENT = "# "
+TOMLKIT_DOT = "."
+# keep-sorted end
+
+
+def _replace_toml_document_getitem(original_method: Callable) -> Callable:
+    """Replace the ::py:meth:`tomlkit.Container.__getitem__` method to allow dotted keys."""
+
+    @wraps(original_method)
+    def inner_getitem(self, key: str | Iterable[str]) -> Container | None:
+        """If the string key has a dot, recursively get the subkey.
+
+        This is a case that is not handled by tomlkit, it fails with an error.
+        """
+        if isinstance(key, str) and TOMLKIT_DOT in key:
+            current = self
+            for subkey in key.split(TOMLKIT_DOT):
+                current = current.get(subkey)
+                if current is None:
+                    raise NonExistentKey(subkey)
+            return current
+        return original_method(self, key)
+
+    return inner_getitem
+
+
+TOMLDocument.__getitem__ = _replace_toml_document_getitem(TOMLDocument.__getitem__)
+
+
+def load(file_pointer: IO[str] | IO[bytes] | Path) -> TOMLDocument:
+    """Load a TOML file from a file-like object or path.
+
+    Return an empty document if the file doesn't exist.
+
+    Drop-in replacement for :py:meth:`tomlkit.api.load`.
+    """
+    if isinstance(file_pointer, Path):
+        if not file_pointer.exists():
+            return tomlkit.document()
+        return tomlkit.loads(file_pointer.read_text(encoding="UTF-8"))
+    return tomlkit.load(file_pointer)
+
+
+def _find_key(container: Container, key: str) -> int | None:
+    """Find the index of a key in a container."""
+    for index, (pair_key, _) in enumerate(container.body):
+        if pair_key and isinstance(pair_key, Key) and pair_key.key == key:
+            return index
+    return None
+
+
+def _find_markers_before(container: Container, marker: str, start_index: int) -> tuple[int, int | None, int | None]:
+    """Find comment markers before an index; only search comments and whitespace."""
+    previous_object_index: int = -1
+    marker_start: int | None = None
+    marker_end: int | None = None
+    current_index = start_index - 1
+    while current_index >= 0:
+        _, pair_item = container.body[current_index]
+
+        if isinstance(pair_item, Whitespace):
+            pass
+        elif isinstance(pair_item, Comment):
+            stripped = pair_item.trivia.comment.strip(TOMLKIT_COMMENT)
+            if stripped.startswith(f"{marker}{COMMENT_MARKER_START}"):
+                # If we have multiple (wrong) start markers, continue until the first one
+                marker_start = current_index
+            elif stripped.startswith(f"{marker}{COMMENT_MARKER_END}") and not marker_end:
+                # If we have multiple (wrong) end markers, stop on the last one
+                marker_end = current_index
+        else:
+            previous_object_index = current_index
+            break
+
+        current_index -= 1
+
+    return previous_object_index, marker_start, marker_end
+
+
+def multiline_comment_with_markers(marker: str, text: str) -> list[str]:
+    """Add markers to a comment with multiple lines."""
+    lines: list[str] = []
+    for raw_line in dedent(text).strip().splitlines():
+        line = raw_line.lstrip(TOMLKIT_COMMENT)
+        if not lines:
+            line = f"{marker}{COMMENT_MARKER_START} {line}"
+        lines.append(f"{line}")
+    lines.append(f"{marker}{COMMENT_MARKER_END}")
+    return lines
+
+
+def update_comment_before(table: Table, key: str, marker: str, comment: str) -> None:
+    """Update a comment before a key, between markers.
+
+    Either replace an existing block or create a new one.
+    """
+    if not comment.strip():
+        return
+
+    container: Container = table.value
+    key_index = _find_key(container, key)
+    hashed_lines = multiline_comment_with_markers(marker, comment)
+    new_comment = tomlkit.comment(f"\n{TOMLKIT_COMMENT}".join(hashed_lines))
+    if key_index is None:
+        table.add(new_comment)
+        return
+
+    previous_object_index, marker_start, marker_end = _find_markers_before(container, marker, key_index)
+    if marker_end is None:
+        marker_end = key_index - 1
+    if marker_start is None:
+        marker_start = previous_object_index + 1
+
+    # Remove the old comment
+    del table.value.body[marker_start : marker_end + 1]
+    insert_point = marker_start
+    table.value.body.insert(insert_point, (None, new_comment))


Test patch:
diff --git a/tests/data/typed-style-dir/any/editorconfig.toml b/tests/data/typed-style-dir/any/editorconfig.toml
new file mode 100644
index 00000000..baff3df3
--- /dev/null
+++ b/tests/data/typed-style-dir/any/editorconfig.toml
@@ -0,0 +1,15 @@
+[nitpick.meta]
+name = "EditorConfig"
+url = "https://editorconfig.org/"
+
+[".editorconfig"]
+# top-most EditorConfig file
+root = true
+
+[".editorconfig"."*"]
+# Unix-style newlines with a newline ending every file
+end_of_line = "lf"
+insert_final_newline = true
+indent_style = "space"
+indent_size = 4
+trim_trailing_whitespace = true
diff --git a/tests/data/typed-style-dir/markdown/markdownlint.toml b/tests/data/typed-style-dir/markdown/markdownlint.toml
new file mode 100644
index 00000000..aca1f393
--- /dev/null
+++ b/tests/data/typed-style-dir/markdown/markdownlint.toml
@@ -0,0 +1,6 @@
+[nitpick.meta]
+name = "Markdown lint"
+url = "https://github.com/markdownlint/markdownlint"
+
+[".codeclimate.yml".plugins.markdownlint]  # https://docs.codeclimate.com/docs/markdownlint # TODO: style: enable markdownlint after configuring it
+enabled = false
diff --git a/tests/data/typed-style-dir/python/black.toml b/tests/data/typed-style-dir/python/black.toml
new file mode 100644
index 00000000..fe65005a
--- /dev/null
+++ b/tests/data/typed-style-dir/python/black.toml
@@ -0,0 +1,6 @@
+[nitpick.meta]
+name = "Black"
+url = "https://github.com/psf/black"
+
+["pyproject.toml".tool.black]
+line-length = 120
diff --git a/tests/helpers.py b/tests/helpers.py
index 36e25c0a..28ac415f 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -2,6 +2,7 @@
 from __future__ import annotations
 
 import os
+import sys
 from pathlib import Path
 from pprint import pprint
 from textwrap import dedent
@@ -40,6 +41,7 @@
 
 # Non-breaking space
 NBSP = "\xc2\xa0"
+BLANK_LINE = f"{NBSP}###$$${NBSP}"  # something fairly unique
 
 SUGGESTION_BEGIN = "\x1b[32m"
 SUGGESTION_END = "\x1b[0m"
@@ -75,7 +77,7 @@ def tomlstring(value: Any) -> str:
 class ProjectMock:
     """A mocked Python project to help on tests."""
 
-    def __init__(self, tmp_path: Path, **kwargs) -> None:
+    def __init__(self, tmp_path: Path, setup_py: bool = True, pyproject_toml: bool = False) -> None:
         """Create the root dir and make it the current dir (needed by NitpickChecker)."""
         self._actual_violations: set[Fuss] = set()
         self._flake8_errors: list[Flake8Error] = []
@@ -88,8 +90,10 @@ def __init__(self, tmp_path: Path, **kwargs) -> None:
         self._remote_url: str | None = None
         self.files_to_lint: list[Path] = []
 
-        if kwargs.get("setup_py", True):
+        if setup_py:
             self.save_file("setup.py", "x = 1")
+        if pyproject_toml:
+            self.pyproject_toml("")
 
     def create_symlink(self, link_name: str, target_dir: Path, target_file: str | None = None) -> ProjectMock:
         """Create a symlink to a target file.
@@ -344,14 +348,18 @@ def assert_violations(self, *expected_violations: Fuss, disclaimer="") -> Projec
         return self
 
     def _simulate_cli(
-        self, command: str, expected_str_or_lines: StrOrList | None = None, *args: str, exit_code: int | None = None
+        self,
+        command: str,
+        expected_str_or_lines: StrOrList | None = None,
+        *command_args: str,
+        exit_code: int | None = None,
     ):
         """Simulate the CLI by invoking a click command.
 
         1. If the command raised an exception that was not a common "system exit",
             this method will re-raise it, so the test can be fixed.
         """
-        result = CliRunner().invoke(nitpick_cli, ["--project", str(self.root_dir), command, *args])
+        result = CliRunner().invoke(nitpick_cli, ["--project", str(self.root_dir), command, *command_args])
 
         # 1.
         if result.exception and not isinstance(result.exception, SystemExit):
@@ -410,13 +418,33 @@ def cli_ls(self, str_or_lines: StrOrList, *, exit_code: int | None = None) -> Pr
         compare(actual=actual, expected=expected, prefix=f"Result: {result}")
         return self
 
-    def cli_init(self, str_or_lines: StrOrList, *args, exit_code: int | None = None) -> ProjectMock:
+    def cli_init(
+        self,
+        expected_output: StrOrList,
+        *,
+        fix: bool = False,
+        suggest: bool = False,
+        library: str | Path | None = None,
+        style_urls: list[str] | None = None,
+        exit_code: int | None = None,
+    ) -> ProjectMock:
         """Run the init command and assert the output."""
-        result, actual, expected = self._simulate_cli("init", str_or_lines, *args, exit_code=exit_code)
+        command_args = []
+        if fix:
+            command_args.append("--fix")
+        if suggest:
+            command_args.append("--suggest")
+        if library:
+            command_args.extend(["--library", str(library)])
+        if style_urls:
+            command_args.extend(style_urls)
+        if exit_code is None:
+            exit_code = 1 if fix else 0
+        result, actual, expected = self._simulate_cli("init", expected_output, *command_args, exit_code=exit_code)
         compare(actual=actual, expected=expected, prefix=f"Result: {result}")
         return self
 
-    def assert_file_contents(self, *name_contents: PathOrStr | str | None, lstrip=True) -> ProjectMock:
+    def assert_file_contents(self, *name_contents: PathOrStr | str | None, lstrip: bool = True) -> ProjectMock:
         """Assert the file has the expected contents.
 
         Use `None` to indicate that the file doesn't exist.
@@ -430,6 +458,7 @@ def assert_file_contents(self, *name_contents: PathOrStr | str | None, lstrip=Tr
                 expected = dedent(from_path_or_str(file_contents))
                 if lstrip:
                     expected = expected.lstrip()
+                expected = expected.replace(BLANK_LINE, "\n")
             compare(actual=actual, expected=expected, prefix=f"Filename: {filename}")
         return self
 
@@ -455,3 +484,36 @@ def filter_desired_warning(captured: list[warnings.WarningMessage], desired_mess
     Once this package is actually deprecated, then I'll either pin dpath or handle ImportError.
     """
     return [item for item in captured if desired_message in str(item.message)]
+
+
+class OSAgnosticPaths:
+    """A list of paths that can be used for tests both in Windows and Linux."""
+
+    def __init__(self, root_dir: Path, *partial_path: str):
+        self._files: list[Path] = []
+        for path in partial_path:
+            self._files.append(root_dir / path)
+
+    @staticmethod
+    def _normalize(file: Path, double_backslash: bool) -> str:
+        """Double the backslash on Windows."""
+        formatted = str(file)
+        if not (sys.platform == "win32" and double_backslash):
+            return formatted
+        return formatted.replace(os.sep, os.sep + os.sep)
+
+    def _join(self, tabs: int, before: str, after: str = "", double_backslash: bool = False) -> str:
+        """Return a line break with the desired indentation."""
+        prefix = "\n" + (" " * 4 * tabs)
+        return prefix.join(f"{before}{self._normalize(file, double_backslash)}{after}" for file in self._files)
+
+    def as_bullets(self, tabs: int) -> str:
+        """Return a list of files as bullets."""
+        return self._join(tabs, "- ")
+
+    def as_toml_items(self, tabs: int) -> str:
+        """Return a list of files as TOML items.
+
+        On Windows, a double backslash is needed on the TOML file.
+        """
+        return self._join(tabs, '  "', '",', True)
diff --git a/tests/test_builtin.py b/tests/test_builtin.py
index 31e8b41e..74f129bc 100644
--- a/tests/test_builtin.py
+++ b/tests/test_builtin.py
@@ -92,7 +92,7 @@ def test_each_builtin_style(tmp_path, datadir, relative_path):
         DOT_NITPICK_TOML,
         f"""
         [tool.nitpick]
-        style = "{style.py_url_without_ext}"
+        style = "{style.formatted}"
         """,
     )
 
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 8408ab4b..31dd9308 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,13 +1,25 @@
 """CLI tests."""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Generator
+from unittest import mock
+
 import pytest
-import tomlkit
 
-from nitpick.constants import CONFIG_TOOL_NITPICK_KEY, DOT_NITPICK_TOML, PYTHON_PYPROJECT_TOML, READ_THE_DOCS_URL
-from nitpick.style import Scheme, StyleManager
-from tests.helpers import ProjectMock
+from nitpick import __version__
+from nitpick.constants import (
+    CONFIG_KEY_IGNORE_STYLES,
+    CONFIG_KEY_STYLE,
+    CONFIG_TOOL_NITPICK_KEY,
+    DOT_NITPICK_TOML,
+    PYTHON_PYPROJECT_TOML,
+    EmojiEnum,
+)
+from tests.helpers import BLANK_LINE, OSAgnosticPaths, ProjectMock
 
 
-def test_simple_error(tmp_path):
+def test_simple_error(tmp_path: Path) -> None:
     """A simple error on the CLI."""
     project = (
         ProjectMock(tmp_path)
@@ -34,84 +46,222 @@ def test_simple_error(tmp_path):
     )
 
 
-@pytest.mark.parametrize("config_file", [DOT_NITPICK_TOML, PYTHON_PYPROJECT_TOML])
-def test_config_file_already_has_tool_nitpick_section(tmp_path, config_file):
-    """Test if both config files already exist."""
-    project = ProjectMock(tmp_path, pyproject_toml=False, setup_py=True).save_file(
-        config_file,
+def test_missing_style_and_suggest_option(tmp_path: Path) -> None:
+    """Print error if both style and --suggest options are missing."""
+    ProjectMock(tmp_path).cli_init(
+        f"Nothing to do. {EmojiEnum.SLEEPY_FACE.value} Either pass at least one style URL"
+        " or use --suggest to add styles based on the files in the project root"
+        " (you can do both at the same time)."
+    )
+
+
+@pytest.fixture()
+def style_dir_with_types(shared_datadir: Path) -> Generator[Path, None, None]:
+    """A mocked directory with style files organised into "identify" subdirs."""
+    with mock.patch("nitpick.style.builtin_resources_root") as mock_builtin_resources_root:
+        mock_builtin_resources_root.return_value = Path(shared_datadir / "typed-style-dir")
+        yield mock_builtin_resources_root
+
+
+@pytest.mark.parametrize(
+    ("pyproject_toml", "fix", "before", "after"),
+    [
+        (None, True, "", ""),
+        # tomlkit keeps the blank line at the top when the file exists and is empty...
+        ("", True, BLANK_LINE, ""),
+        # Existing table with other keys but missing the "style" key
+        (f'[{CONFIG_TOOL_NITPICK_KEY}]\ncache = "never"', True, "", f'{BLANK_LINE}cache = "never"'),
+    ],
+)
+def test_create_update_tool_nitpick_table_on_config_files(
+    style_dir_with_types: Path,
+    tmp_path: Path,
+    pyproject_toml: str | None,
+    fix: bool,
+    before: str,
+    after: str,
+) -> None:
+    """If no config file is found, create a basic .nitpick.toml."""
+    assert style_dir_with_types
+    project = ProjectMock(tmp_path)
+    if pyproject_toml is None:
+        config_file = DOT_NITPICK_TOML
+    else:
+        config_file = PYTHON_PYPROJECT_TOML
+        project.pyproject_toml(pyproject_toml)
+    project.cli_init(
         f"""
-        [{CONFIG_TOOL_NITPICK_KEY}]
-        style = ['/this/should/not/be/validated-yet.toml']
+        New styles:
+        - py://data/typed-style-dir/any/editorconfig
+        - py://data/typed-style-dir/python/black
+        The [{CONFIG_TOOL_NITPICK_KEY}] table was updated in {config_file!r}: 2 styles appended. {EmojiEnum.STAR_CAKE.value}
         """,
+        fix=fix,
+        suggest=True,
     )
-    project.cli_init(f"The config file {config_file} already has a [{CONFIG_TOOL_NITPICK_KEY}] section.", exit_code=1)
+    if fix:
+        project.assert_file_contents(
+            config_file,
+            f"""
+            {before}[{CONFIG_TOOL_NITPICK_KEY}]{after}
+            # nitpick-start (auto-generated by "nitpick init --suggest" {__version__})
+            # Styles added to the Nitpick Style Library will be appended to the end of the 'style' key.
+            # If you don't want a style, move it to the '{CONFIG_KEY_IGNORE_STYLES}' key.
+            # nitpick-end
+            style = [
+              "py://data/typed-style-dir/any/editorconfig",
+              "py://data/typed-style-dir/python/black",]
+            {CONFIG_KEY_IGNORE_STYLES} = []
+            """,
+        )
+    else:
+        assert not (tmp_path / DOT_NITPICK_TOML).exists()
 
 
-def test_create_basic_dot_nitpick_toml(tmp_path):
-    """If no config file is found, create a basic .nitpick.toml."""
-    project = ProjectMock(tmp_path, pyproject_toml=False, setup_py=True)
-    url = StyleManager.get_default_style_url()
+@pytest.mark.parametrize(
+    ("fix", "footer"),
+    [
+        (
+            True,
+            f"The [{CONFIG_TOOL_NITPICK_KEY}] table was updated in {DOT_NITPICK_TOML!r}: 1 style appended. {EmojiEnum.STAR_CAKE.value}",
+        ),
+        (
+            False,
+            f"Use --fix to append 1 style to the [{CONFIG_TOOL_NITPICK_KEY}] table in the config file '{DOT_NITPICK_TOML}'.",
+        ),
+    ],
+)
+def test_create_the_ignored_styles_array_only_when_suggesting_styles(tmp_path: Path, fix: bool, footer: str) -> None:
+    """Create the ignored styles array only when suggesting styles."""
+    project = ProjectMock(tmp_path)
     project.cli_init(
-        f"A [{CONFIG_TOOL_NITPICK_KEY}] section was created in the config file: {DOT_NITPICK_TOML}"
-    ).assert_file_contents(
-        DOT_NITPICK_TOML,
         f"""
-        [{CONFIG_TOOL_NITPICK_KEY}]
-        # Generated by the 'nitpick init' command
-        # More info at {READ_THE_DOCS_URL}configuration.html
-        style = ['{url}']
+        New styles:
+        - my-style.toml
+        {footer}
         """,
+        fix=fix,
+        style_urls=["my-style.toml"],
     )
-    assert url.scheme == Scheme.PY
+    if fix:
+        project.assert_file_contents(
+            DOT_NITPICK_TOML,
+            f"""
+            [{CONFIG_TOOL_NITPICK_KEY}]
+            style = [
+              "my-style.toml",]
+            """,
+        )
+    else:
+        assert not (tmp_path / DOT_NITPICK_TOML).exists()
 
 
-def test_init_empty_pyproject_toml(tmp_path):
-    """If no config file is found, create a basic .nitpick.toml."""
-    project = ProjectMock(tmp_path, pyproject_toml=False, setup_py=True)
-    url = StyleManager.get_default_style_url()
-    project.pyproject_toml("").cli_init(
-        f"A [{CONFIG_TOOL_NITPICK_KEY}] section was created in the config file: {PYTHON_PYPROJECT_TOML}"
+@pytest.mark.parametrize(
+    ("fix", "key"),
+    [
+        (True, CONFIG_KEY_STYLE),
+        (False, CONFIG_KEY_IGNORE_STYLES),
+    ],
+)
+def test_dont_add_again_an_existing_nor_ignored_style(
+    tmp_path: Path,
+    fix: bool,
+    key: str,
+) -> None:
+    """Don't add again an existing nor ignored style."""
+    unchanged = f"""
+        [{CONFIG_TOOL_NITPICK_KEY}]
+        {key} = ["my-style.toml"]
+    """
+    ProjectMock(tmp_path).pyproject_toml(unchanged).cli_init(
+        f"All done! {EmojiEnum.STAR_CAKE.value} [{CONFIG_TOOL_NITPICK_KEY}]"
+        f" table left unchanged in '{PYTHON_PYPROJECT_TOML}'",
+        fix=fix,
+        style_urls=["my-style.toml"],
+        exit_code=0,
     ).assert_file_contents(
         PYTHON_PYPROJECT_TOML,
+        unchanged,
+    )
+
+
+def test_added_style_should_be_the_first(
+    style_dir_with_types: Path,
+    tmp_path: Path,
+) -> None:
+    """The style added from the CLI should be the first, before the suggested styles."""
+    assert style_dir_with_types
+    project = ProjectMock(tmp_path)
+    project.cli_init(
+        f"""
+        New styles:
+        - my-style.toml
+        - py://data/typed-style-dir/any/editorconfig
+        - py://data/typed-style-dir/python/black
+        The [{CONFIG_TOOL_NITPICK_KEY}] table was updated in {DOT_NITPICK_TOML!r}: 3 styles appended. {EmojiEnum.STAR_CAKE.value}
+        """,
+        fix=True,
+        suggest=True,
+        style_urls=["my-style.toml"],
+    )
+    project.assert_file_contents(
+        DOT_NITPICK_TOML,
         f"""
         [{CONFIG_TOOL_NITPICK_KEY}]
-        # Generated by the 'nitpick init' command
-        # More info at {READ_THE_DOCS_URL}configuration.html
-        style = ['{url}']
+        # nitpick-start (auto-generated by "nitpick init --suggest" {__version__})
+        # Styles added to the Nitpick Style Library will be appended to the end of the 'style' key.
+        # If you don't want a style, move it to the '{CONFIG_KEY_IGNORE_STYLES}' key.
+        # nitpick-end
+        style = [
+          "my-style.toml",
+          "py://data/typed-style-dir/any/editorconfig",
+          "py://data/typed-style-dir/python/black",]
+        {CONFIG_KEY_IGNORE_STYLES} = []
         """,
-        lstrip=False,
     )
-    assert url.scheme == Scheme.PY
 
 
 @pytest.mark.parametrize(
-    "styles",
+    ("fix", "footer"),
     [
-        (),  # no arguments, default style
-        ("https://github.com/andreoliwa/nitpick/blob/develop/initial.toml", "./local.toml"),
+        (
+            True,
+            f"The [{CONFIG_TOOL_NITPICK_KEY}] table was updated in {DOT_NITPICK_TOML!r}: 3 styles appended. {EmojiEnum.STAR_CAKE.value}",
+        ),
+        (
+            False,
+            f"Use --fix to append 3 styles to the [{CONFIG_TOOL_NITPICK_KEY}] table in the config file '{DOT_NITPICK_TOML}'.",
+        ),
     ],
 )
-def test_add_tool_nitpick_section_to_pyproject_toml(tmp_path, styles):
-    """Add a [tool.nitpick] section to pyproject.toml."""
-    project = ProjectMock(tmp_path).pyproject_toml(
-        """
-        [tool.black]
-        line-length = 120
-        """
+def test_use_custom_library_dir(shared_datadir: Path, tmp_path: Path, fix: bool, footer: str) -> None:
+    """Use a custom library directory."""
+    project = ProjectMock(tmp_path).save_file("README.md", "Hello")
+    agnostic_paths = OSAgnosticPaths(
+        shared_datadir / "typed-style-dir", "any/editorconfig", "markdown/markdownlint", "python/black"
     )
-    expected = styles or [StyleManager.get_default_style_url()]
-
     project.cli_init(
-        f"A [{CONFIG_TOOL_NITPICK_KEY}] section was created in the config file: {PYTHON_PYPROJECT_TOML}", *styles
-    ).assert_file_contents(
-        PYTHON_PYPROJECT_TOML,
         f"""
-        [tool.black]
-        line-length = 120
-
-        [{CONFIG_TOOL_NITPICK_KEY}]
-        # Generated by the 'nitpick init' command
-        # More info at {READ_THE_DOCS_URL}configuration.html
-        style = {tomlkit.array([tomlkit.string(str(url)) for url in expected]).as_string()}
+        New styles:
+        {agnostic_paths.as_bullets(2)}
+        {footer}
         """,
+        fix=fix,
+        library=shared_datadir / "typed-style-dir",
     )
+    if fix:
+        project.assert_file_contents(
+            DOT_NITPICK_TOML,
+            f"""
+            [{CONFIG_TOOL_NITPICK_KEY}]
+            # nitpick-start (auto-generated by "nitpick init --suggest" {__version__})
+            # Styles added to the Nitpick Style Library will be appended to the end of the 'style' key.
+            # If you don't want a style, move it to the '{CONFIG_KEY_IGNORE_STYLES}' key.
+            # nitpick-end
+            style = [
+            {agnostic_paths.as_toml_items(3)}]
+            {CONFIG_KEY_IGNORE_STYLES} = []
+            """,
+        )
+    else:
+        assert not (tmp_path / DOT_NITPICK_TOML).exists()
diff --git a/tests/test_generic.py b/tests/test_generic.py
index a95ffa97..562676ff 100644
--- a/tests/test_generic.py
+++ b/tests/test_generic.py
@@ -1,15 +1,25 @@
 """Generic functions tests."""
+from __future__ import annotations
+
 import os
+import subprocess
 import sys
 from pathlib import Path, PosixPath, WindowsPath
+from typing import Generator
 from unittest import mock
 
 import pytest
 from furl import furl
 from testfixtures import compare
 
-from nitpick.constants import EDITOR_CONFIG, PYTHON_TOX_INI
-from nitpick.generic import _url_to_posix_path, _url_to_windows_path, relative_to_current_dir
+from nitpick.constants import EDITOR_CONFIG, GIT_DIR, GIT_IGNORE, PYTHON_TOX_INI
+from nitpick.generic import (
+    _url_to_posix_path,
+    _url_to_windows_path,
+    get_global_gitignore_path,
+    glob_non_ignored_files,
+    relative_to_current_dir,
+)
 
 
 @mock.patch.object(Path, "cwd")
@@ -91,3 +101,89 @@ def test_url_to_posix_path(test_path):
     path = PosixPath(test_path)
     url = furl(path.as_uri())
     assert _url_to_posix_path(url) == path
+
+
+@pytest.fixture()
+def some_directory(tmp_path: Path, request) -> Generator[Path, None, None]:
+    """Create some directory with some files."""
+    project_dir = tmp_path / "project"
+    project_dir.mkdir()
+    (project_dir / "file1.txt").touch()
+    (project_dir / "README.md").touch()
+    src_dir = project_dir / "src"
+    src_dir.mkdir()
+    (src_dir / "module.py").touch()
+
+    if "local" in request.param:
+        (project_dir / GIT_DIR).mkdir()
+        (project_dir / GIT_IGNORE).write_text("*.txt")
+
+    output = ""
+    if "global" in request.param:
+        (project_dir / GIT_DIR).mkdir()
+        global_file = tmp_path / "some-global-gitignore-file"
+        global_file.write_text("*.md")
+        output = str(global_file) + os.linesep
+
+    with mock.patch("subprocess.check_output", return_value=output):
+        yield project_dir
+
+
+@pytest.mark.parametrize("some_directory", ["local"], indirect=True)
+def test_glob_local_gitignore(some_directory: Path) -> None:
+    """Test globbing non-ignored files."""
+    assert set(glob_non_ignored_files(some_directory)) == {
+        some_directory / GIT_IGNORE,
+        some_directory / "README.md",
+        some_directory / "src" / "module.py",
+    }
+    assert set(glob_non_ignored_files(some_directory, "*.md")) == {
+        some_directory / "README.md",
+    }
+    assert set(glob_non_ignored_files(some_directory, "*.txt")) == set()
+    assert set(glob_non_ignored_files(some_directory, "**/*.py")) == {
+        some_directory / "src" / "module.py",
+    }
+
+
+@pytest.mark.parametrize("some_directory", ["no_git_dir"], indirect=True)
+def test_glob_no_git_dir(some_directory: Path) -> None:
+    """Test globbing non-ignored files."""
+    assert set(glob_non_ignored_files(some_directory)) == {
+        some_directory / "file1.txt",
+        some_directory / "README.md",
+        some_directory / "src" / "module.py",
+    }
+
+
+@pytest.mark.parametrize("some_directory", ["global"], indirect=True)
+def test_glob_global_gitignore(some_directory: Path) -> None:
+    """Test globbing non-ignored files."""
+    assert set(glob_non_ignored_files(some_directory)) == {
+        some_directory / "file1.txt",
+        some_directory / "src" / "module.py",
+    }
+
+
+@pytest.mark.parametrize(
+    ("exception", "message"),
+    [
+        (
+            subprocess.CalledProcessError(1, "git"),
+            "The 'core.excludesFile' configuration is not set or does not exist.",
+        ),
+        (FileNotFoundError, "Git command not found. Please make sure Git is installed and on the PATH."),
+    ],
+)
+@mock.patch("subprocess.check_output")
+def test_error_when_calling_git_config(
+    mock_check_output,
+    capsys,
+    exception: Exception,
+    message: str,
+) -> None:
+    """Test error when calling git config."""
+    mock_check_output.side_effect = exception
+    assert get_global_gitignore_path() is None
+    captured = capsys.readouterr()
+    assert captured.err.strip().casefold() == message.strip().casefold()
diff --git a/tests/test_project.py b/tests/test_project.py
index 3bb59499..740d8265 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -121,12 +121,15 @@ def test_django_project_structure(tmp_path):
     ).api_check_then_fix()
 
 
-def test_when_no_config_file_the_default_style_is_requested(tmp_path, caplog):
-    """There is a root dir (setup.py), but no style file. The user should explicitly set the style, no default will be used."""
+def test_when_no_config_file_no_default_style_is_used(tmp_path, caplog):
+    """There is a root dir (setup.py), but no style file.
+
+    The user should explicitly set the style, no default will be used.
+    """
     project = ProjectMock(tmp_path, pyproject_toml=False, setup_py=True)
     error = f"NIP004 No style file configured.{CONFIG_RUN_NITPICK_INIT_OR_CONFIGURE_STYLE_MANUALLY}"
     project.flake8().assert_single_error(error).cli_run(error, exit_code=1)
-    assert project.nitpick_instance.project.read_configuration() == Configuration(None, [], "")
+    assert project.nitpick_instance.project.read_configuration()
     assert "Config file: none found {}" in caplog.messages
 
 
@@ -143,7 +146,14 @@ def test_has_one_config_file(tmp_path, config_file, caplog):
         """,
     ).api_check(offline=True)
     path = project.root_dir / config_file
-    assert project.nitpick_instance.project.read_configuration() == Configuration(path, ["local.toml"], "forever")
+    assert project.nitpick_instance.project.read_configuration() == Configuration(
+        path,
+        {"tool": {"nitpick": {"style": ["local.toml"], "cache": "forever"}}},
+        {"style": ["local.toml"], "cache": "forever"},
+        ["local.toml"],
+        [],
+        "forever",
+    )
     assert f"Config file: reading from {path} {{}}" in caplog.messages
     assert f"Using styles configured in {config_file}: local.toml {{}}" in caplog.messages
 
@@ -169,7 +179,12 @@ def test_has_multiple_config_files(tmp_path, caplog):
         offline=True
     )
     assert project.nitpick_instance.project.read_configuration() == Configuration(
-        project.root_dir / DOT_NITPICK_TOML, ["local_nit.toml"], "never"
+        project.root_dir / DOT_NITPICK_TOML,
+        {"tool": {"nitpick": {"style": ["local_nit.toml"], "cache": "never"}}},
+        {"style": ["local_nit.toml"], "cache": "never"},
+        ["local_nit.toml"],
+        [],
+        "never",
     )
     assert f"Config file: reading from {project.root_dir / DOT_NITPICK_TOML} {{}}" in caplog.messages
     assert f"Config file: ignoring existing {project.root_dir / PYTHON_PYPROJECT_TOML} {{}}" in caplog.messages
diff --git a/tests/test_tomlkit_ext.py b/tests/test_tomlkit_ext.py
new file mode 100644
index 00000000..c977dc53
--- /dev/null
+++ b/tests/test_tomlkit_ext.py
@@ -0,0 +1,307 @@
+"""Test the tomlkit extensions."""
+from pathlib import Path
+from textwrap import dedent
+
+import tomlkit
+
+from nitpick import tomlkit_ext
+from nitpick.tomlkit_ext import update_comment_before
+
+
+def test_load_toml_from_io_or_path(shared_datadir: Path) -> None:
+    """Load TOML from IO or path."""
+    file = shared_datadir / "typed-style-dir/any/editorconfig.toml"
+    from_io = tomlkit_ext.load(file.open("r"))
+    from_path = tomlkit_ext.load(file)
+    assert from_io == from_path
+
+
+def assert_comment(
+    before: str,
+    after: str,
+    *,
+    table: str = "my.table",
+    key: str = "simple-key",
+    marker: str = "here",
+    comment: str = "header\nfirst line",
+) -> None:
+    """Assert that the comment was updated as expected."""
+    doc = tomlkit.parse(dedent(before))
+    update_comment_before(doc[table], key, marker, comment)
+    assert doc.as_string() == dedent(after)
+
+
+def test_comment_between_existing_valid_markers() -> None:
+    """Comment between existing valid markers."""
+    assert_comment(
+        """
+        [my.table]
+        # here-start
+        # previous comment
+        # here-end
+        simple-key = "value"
+
+        [another]
+        x = 1
+        """,
+        """
+        [my.table]
+        # here-start one-line comment
+        # here-end
+        simple-key = "value"
+
+        [another]
+        x = 1
+        """,
+        comment="one-line comment",
+    )
+
+
+def test_missing_start_marker_but_valid_end_marker() -> None:
+    """Missing start marker but valid end marker."""
+    assert_comment(
+        """
+        [my.table]
+        # previous comment
+        # here-end
+        simple-key = [1,2,
+        3]
+
+
+        [another]
+        x = 2
+        """,
+        """
+        [my.table]
+        # here-start header
+        # first line
+        # here-end
+        simple-key = [1,2,
+        3]
+
+
+        [another]
+        x = 2
+        """,
+    )
+
+
+def test_key_between_start_end_marker():
+    """Another key between start/end marker."""
+    assert_comment(
+        """
+        [my.table]
+        # here-start header
+        # open ended comment
+        simple-key = [1,2,
+        3]
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+        """
+        [my.table]
+        # here-start header
+        # first line
+        # here-end
+        simple-key = [1,2,
+        3]
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+    )
+
+
+def test_missing_start_missing_end() -> None:
+    """Missing start and missing end markers."""
+    assert_comment(
+        """
+        [my.table]
+        key-before = 3
+        simple-key = "x"
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+        """
+        [my.table]
+        key-before = 3
+        # here-start header
+        # first line
+        # here-end
+        simple-key = "x"
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+    )
+
+
+def test_valid_start_marker_but_missing_end():
+    """Valid start marker but missing end."""
+    assert_comment(
+        """
+        [my.table]
+        # comment before
+        # here-start header
+        # open ended comment
+        simple-key = 1
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+        """
+        [my.table]
+        # comment before
+        # here-start header
+        # first line
+        # here-end
+        simple-key = 1
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+    )
+
+
+def test_multiple_end_markers() -> None:
+    """Multiple end markers."""
+    assert_comment(
+        """
+        [my.table]
+        # before
+        # here-start yeah
+        # here-end one
+        # here-end two
+        # after
+        simple-key = 1
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+        """
+        [my.table]
+        # before
+        # here-start header
+        # first line
+        # here-end
+        # after
+        simple-key = 1
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+    )
+
+
+def test_multiple_start_markers_first_one_is_used() -> None:
+    """Multiple start markers, first one is used."""
+    assert_comment(
+        """
+        [my.table]
+
+        # before
+
+        # here-start one
+        # here-start two
+        # here-end
+
+        # after
+
+        simple-key = 1
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+        """
+        [my.table]
+
+        # before
+
+        # here-start header
+        # first line
+        # here-end
+
+        # after
+
+        simple-key = 1
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+    )
+
+
+def test_searched_key_between_start_end_markers() -> None:
+    """Searched key between start/end markers."""
+    assert_comment(
+        """
+        [my.table]
+        # before
+        # here-start yeah
+        # open ended comment
+        # after
+        simple-key = 123
+        # here-end
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+        """
+        [my.table]
+        # before
+        # here-start header
+        # first line
+        # here-end
+        simple-key = 123
+        # here-end
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+    )
+
+
+def test_updating_a_comment_before_a_key_that_doesnt_exist() -> None:
+    """Updating a comment before a key that doesn't exist."""
+    assert_comment(
+        """
+        [my.table]
+        # before
+        # open ended comment
+        # after
+        another-key = "value"
+        [another.table]
+        x = 2
+        """,
+        """
+        [my.table]
+        # before
+        # open ended comment
+        # after
+        another-key = "value"
+        # here-start header
+        # first line
+        # here-end
+        [another.table]
+        x = 2
+        """,
+        key="some-other-key",
+    )
+
+
+def test_empty_comment_nothing_changes() -> None:
+    """Empty comment."""
+    content = """
+        [my.table]
+        # before
+        # here-start header
+        # existing comment
+        # here-end
+        # after
+        another-key = "value"
+        [another.table]
+        x = 2
+        """
+    assert_comment(content, content, comment="")
diff --git a/tests/test_yaml_pre_commit.py b/tests/test_yaml_pre_commit.py
index b88931c0..456f92ec 100644
--- a/tests/test_yaml_pre_commit.py
+++ b/tests/test_yaml_pre_commit.py
@@ -36,7 +36,7 @@ def test_repo_should_be_added_not_replaced(tmp_path, datadir):
 def test_overriding_list_key_with_empty_string_restores_default_behaviour(tmp_path, datadir):
     """Test overriding the default list key with nothing.
 
-    The default behaviour will be restored, and the new element will be appended at the end of the list.
+    The default behaviour will be restored, and the new element will be appended to the end of the list.
     """
     ProjectMock(tmp_path).save_file(PRE_COMMIT_CONFIG_YAML, datadir / "uk-actual.yaml").style(
         datadir / "uk-empty.toml"