Actually disable docstring prefix normalization with -S + fix instability
diff --git a/CHANGES.md b/CHANGES.md
index 09954f2b73..3a4d8fdf17 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -19,6 +19,8 @@
 
 - Single-character closing docstring quotes are no longer moved to their own line as
   this is invalid. This was a bug introduced in version 22.6.0. (#3166)
+- `--skip-string-normalization` / `-S` now prevents docstring prefixes from being
+  normalized as expected (#3168)
 
 ### _Blackd_
 
diff --git a/src/black/linegen.py b/src/black/linegen.py
index 20f3ac6fff..1f132b7888 100644
--- a/src/black/linegen.py
+++ b/src/black/linegen.py
@@ -293,7 +293,24 @@ def visit_STRING(self, leaf: Leaf) -> Iterator[Line]:
         if is_docstring(leaf) and "\\\n" not in leaf.value:
             # We're ignoring docstrings with backslash newline escapes because changing
             # indentation of those changes the AST representation of the code.
-            docstring = normalize_string_prefix(leaf.value)
+            if Preview.normalize_docstring_quotes_and_prefixes_properly in self.mode:
+                # There was a bug where --skip-string-normalization wouldn't stop us
+                # from normalizing docstring prefixes. To maintain stability, we can
+                # only address this buggy behaviour while the preview style is enabled.
+                if self.mode.string_normalization:
+                    docstring = normalize_string_prefix(leaf.value)
+                    # visit_default() does handle string normalization for us, but
+                    # since this method acts differently depending on quote style (ex.
+                    # see padding logic below), there's a possibility for unstable
+                    # formatting as visit_default() is called *after*. To avoid a
+                    # situation where this function formats a docstring differently on
+                    # the second pass, normalize it early.
+                    docstring = normalize_string_quotes(docstring)
+                else:
+                    docstring = leaf.value
+            else:
+                # ... otherwise, we'll keep the buggy behaviour >.<
+                docstring = normalize_string_prefix(leaf.value)
             prefix = get_string_prefix(docstring)
             docstring = docstring[len(prefix) :]  # Remove the prefix
             quote_char = docstring[0]
diff --git a/src/black/mode.py b/src/black/mode.py
index 896c516df7..b7359fab21 100644
--- a/src/black/mode.py
+++ b/src/black/mode.py
@@ -145,12 +145,13 @@ def supports_feature(target_versions: Set[TargetVersion], feature: Feature) -> b
 class Preview(Enum):
     """Individual preview style features."""
 
-    string_processing = auto()
-    remove_redundant_parens = auto()
-    one_element_subscript = auto()
     annotation_parens = auto()
     long_docstring_quotes_on_newline = auto()
+    normalize_docstring_quotes_and_prefixes_properly = auto()
+    one_element_subscript = auto()
     remove_block_trailing_newline = auto()
+    remove_redundant_parens = auto()
+    string_processing = auto()
 
 
 class Deprecated(UserWarning):
diff --git a/tests/data/miscellaneous/docstring_preview_no_string_normalization.py b/tests/data/miscellaneous/docstring_preview_no_string_normalization.py
new file mode 100644
index 0000000000..0957231eb9
--- /dev/null
+++ b/tests/data/miscellaneous/docstring_preview_no_string_normalization.py
@@ -0,0 +1,10 @@
+def do_not_touch_this_prefix():
+    R"""There was a bug where docstring prefixes would be normalized even with -S."""
+
+
+def do_not_touch_this_prefix2():
+    F'There was a bug where docstring prefixes would be normalized even with -S.'
+
+
+def do_not_touch_this_prefix3():
+    uR'''There was a bug where docstring prefixes would be normalized even with -S.'''
diff --git a/tests/data/simple_cases/docstring.py b/tests/data/simple_cases/docstring.py
index 7153be468c..f08bba575f 100644
--- a/tests/data/simple_cases/docstring.py
+++ b/tests/data/simple_cases/docstring.py
@@ -209,6 +209,13 @@ def multiline_docstring_at_line_limit():
     second line----------------------------------------------------------------------"""
 
 
+def stable_quote_normalization_with_immediate_inner_single_quote(self):
+    ''''<text here>
+
+    <text here, since without another non-empty line black is stable>
+    '''
+
+
 # output
 
 class MyClass:
@@ -417,3 +424,10 @@ def multiline_docstring_at_line_limit():
     """first line-----------------------------------------------------------------------
 
     second line----------------------------------------------------------------------"""
+
+
+def stable_quote_normalization_with_immediate_inner_single_quote(self):
+    """'<text here>
+
+    <text here, since without another non-empty line black is stable>
+    """
diff --git a/tests/test_format.py b/tests/test_format.py
index 0e1059c61e..86339f24b8 100644
--- a/tests/test_format.py
+++ b/tests/test_format.py
@@ -139,6 +139,18 @@ def test_docstring_no_string_normalization() -> None:
     assert_format(source, expected, mode)
 
 
+def test_preview_docstring_no_string_normalization() -> None:
+    """
+    Like test_docstring but with string normalization off *and* the preview style
+    enabled.
+    """
+    source, expected = read_data(
+        "miscellaneous", "docstring_preview_no_string_normalization"
+    )
+    mode = replace(DEFAULT_MODE, string_normalization=False, preview=True)
+    assert_format(source, expected, mode)
+
+
 def test_long_strings_flag_disabled() -> None:
     """Tests for turning off the string processing logic."""
     source, expected = read_data("miscellaneous", "long_strings_flag_disabled")

### Description
    
The former was a regression I introduced a long time ago. To avoid changing the stable style too much, the regression is only fixed if `--preview` is enabled.
   
*annoying enough, as we currently always enforce a second format pass if changes were made, there's no good way to prove the existence of the docstring quote normalization instability issue. For posterity, here's one failing example:

```diff
--- source
+++ first pass
@@ -1,7 +1,7 @@
 def some_function(self):
-    ''''<text here>
+    """ '<text here>
 
     <text here, since without another non-empty line black is stable>
 
-    '''
+    """
     pass
--- first pass
+++ second pass
@@ -1,7 +1,7 @@
 def some_function(self):
-    """ '<text here>
+    """'<text here>
 
     <text here, since without another non-empty line black is stable>
 
     """
     pass
```

### Checklist - did you ...

- [x] Add a CHANGELOG entry if necessary?
- [x] Add / update tests if necessary?
- [x] Add new / update outdated documentation?

<!-- Just as a reminder, everyone in all psf/black spaces including PRs
     must follow the PSF Code of Conduct (link below).

     Finally, once again thanks for your time and effort. If you have any
     feedback in regards to your experience contributing here, please
     let us know!

     Helpful links:

      PSF COC: https://www.python.org/psf/conduct/
      Contributing docs: https://black.readthedocs.io/en/latest/contributing/index.html
      Chat on Python Discord: https://discord.gg/RtVdv86PrH -->
