Repository: pypa/hatch

Issue:
hatch fmt fails if [tool.ruff] exists in pyproject.toml
With hatch 1.8.0 (Python 3.11.6, Fedora 39), if there is a `[tool.ruff]` section in `pyproject.toml` then `hatch fmt` will fail:

```sh
$ hatch new foobar && cd foobar && echo '[tool.ruff]' >> pyproject.toml && hatch fmt
foobar
├── src
│   └── foobar
│       ├── __about__.py
│       └── __init__.py
├── tests
│   └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
ruff failed
  Cause: Failed to parse `<snip>/.local/share/hatch/env/.internal/fmt/.config/lC50A0FE/pyproject.toml`: TOML parse error at line 84, column 1
   |
84 | [tool.ruff]
   | ^
invalid table header
duplicate key `ruff` in table `tool`
```

The relevant portion of the auto-generated `pyproject.toml`:

```toml
[tool.ruff]

[tool.ruff]
extend = "<snip>/.local/share/hatch/env/.internal/fmt/.config/lC50A0FE/ruff_defaults.toml"
```

I think I would expect any settings that I have in _my_ `[tool.ruff]` to override Hatch's defaults. For example, if I have a `line-length = 100`, that would override the `line-length = 120` from Hatch's ruff defaults

Discussion:
github-actions[bot]: ![Code Coverage](https://img.shields.io/badge/coverage-98.78%25-ok?style=flat)

Package | Statements
--- | ---
hatch | 96.24% (4897 / 5088)
hatchling | 97.55% (3786 / 3881)
tests | 99.95% (14982 / 14988)
**Summary** | 98.78% (23665 / 23957)

<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/docs/history/hatch.md b/docs/history/hatch.md
index 911b6d61..de570867 100644
--- a/docs/history/hatch.md
+++ b/docs/history/hatch.md
@@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 ***Fixed:***
 
 - Ignore a project's Python requirement for environments where the project is not installed
+- When not persisting config for static analysis, properly manage internal settings when Ruff's top level table already exists
 
 ## [1.8.1](https://github.com/pypa/hatch/releases/tag/hatch-v1.8.1) - 2023-12-14 ## {: #hatch-v1.8.1 }
 
diff --git a/src/hatch/env/internal/fmt.py b/src/hatch/env/internal/fmt.py
index 7f04351e..73ae656c 100644
--- a/src/hatch/env/internal/fmt.py
+++ b/src/hatch/env/internal/fmt.py
@@ -144,11 +144,23 @@ class InternalFormatEnvironment(InternalEnvironment):
 
         old_contents = self.user_config_file.read_text()
         config_path = str(self.internal_config_file).replace('\\', '\\\\')
-        contents = (
-            f'{old_contents}\n[tool.ruff]\nextend = "{config_path}"'
-            if self.user_config_file.name == 'pyproject.toml'
-            else f'extend = "{config_path}"\n{old_contents}'
-        )
+        if self.user_config_file.name == 'pyproject.toml':
+            lines = old_contents.splitlines()
+            try:
+                index = lines.index('[tool.ruff]')
+            except ValueError:
+                lines.extend((
+                    '',
+                    '[tool.ruff]',
+                    f'extend = "{config_path}"',
+                ))
+            else:
+                lines.insert(index + 1, f'extend = "{config_path}"')
+
+            contents = '\n'.join(lines)
+        else:
+            contents = f'extend = "{config_path}"\n{old_contents}'
+
         self.internal_user_config_file.write_text(contents)
 
     @cached_property


Test patch:
diff --git a/tests/cli/fmt/test_fmt.py b/tests/cli/fmt/test_fmt.py
index 9befff09..2a3bccab 100644
--- a/tests/cli/fmt/test_fmt.py
+++ b/tests/cli/fmt/test_fmt.py
@@ -178,6 +178,63 @@ extend = "{config_path}\""""
 extend = "{config_path}\""""
         )
 
+    def test_existing_config(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_stable):
+        config_file.model.template.plugins['default']['tests'] = False
+        config_file.save()
+
+        project_name = 'My.App'
+
+        with temp_dir.as_cwd():
+            result = hatch('new', project_name)
+
+        assert result.exit_code == 0, result.output
+
+        project_path = temp_dir / 'my-app'
+        data_path = temp_dir / 'data'
+        data_path.mkdir()
+
+        project_file = project_path / 'pyproject.toml'
+        old_contents = project_file.read_text()
+        project_file.write_text(f'[tool.ruff]\n{old_contents}')
+
+        run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b''))
+        mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True)
+        mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='')
+        mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context')
+
+        with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
+            result = hatch('fmt', '--check')
+
+        assert result.exit_code == 0, result.output
+        assert not result.output
+
+        root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config'
+        config_dir = next(root_data_path.iterdir())
+        default_config = config_dir / 'ruff_defaults.toml'
+        user_config = config_dir / 'pyproject.toml'
+
+        assert run.call_args_list == [
+            mocker.call(
+                [ruff_on_path, 'check', '--config', str(user_config), '.'],
+                shell=False,
+            ),
+            mocker.call(
+                [ruff_on_path, 'format', '--config', str(user_config), '--check', '--diff', '.'],
+                shell=False,
+            ),
+        ]
+
+        assert default_config.read_text() == defaults_file_stable
+
+        config_path = str(default_config).replace('\\', '\\\\')
+        assert (
+            user_config.read_text()
+            == f"""\
+[tool.ruff]
+extend = "{config_path}\"
+{old_contents.rstrip()}"""
+        )
+
 
 class TestPreview:
     def test_fix_flag(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_preview):
@@ -341,6 +398,7 @@ extend = "{config_path}\""""
             user_config.read_text()
             == f"""\
 {old_contents}
+
 [tool.ruff]
 extend = "{config_path}\""""
         )
@@ -398,6 +456,7 @@ extend = "{config_path}\""""
             user_config.read_text()
             == f"""\
 {old_contents}
+
 [tool.ruff]
 extend = "{config_path}\""""
         )