Repository: pantsbuild/pants

Issue:
--black-skip breaks fmt goal on isort exception
**Describe the bug**
There are a few related bugs, but the breaking behaviour is the most important I think. I've tested on 2.11, and everything works as expected, so this appears to only be a pre-release/dev bug.

While writing a plugin, I started implementing and testing the `skip` functionality and noticed some strange behaviour on `main`. This behaviour isn't limited to `black`, but it's quick to replicate.

Note below that even after skipping `black` in the `lint` goal, it is in the associated list and marked "succeeded". In reality, it should be failing if it's actually running. In 2.11, black just wouldn't be shown in that list. Personally, I think "black skipped" in yellow text might make more sense, but 🤷🏽 

```bash
> ./pants lint --black-skip ::

✓ autoflake succeeded.
✓ bandit succeeded.
✓ black succeeded.
✕ flake8 failed.
✓ isort succeeded.
✓ pylint succeeded.
✓ pyupgrade succeeded.
✓ shellcheck succeeded.
✓ shfmt succeeded.
```

 The more concerning behaviour is on format.

```bash
> ./pants fmt --black-skip ::

14:28:06.41 [ERROR] 1 Exception encountered:

Engine traceback:
  in select
  in pants.core.goals.fmt.fmt
  in pants.core.goals.fmt.fmt_language
  in pants.backend.python.lint.isort.rules.isort_fmt (isort)
  in pants.engine.process.fallible_to_exec_result_or_raise
Traceback (most recent call last):
  File "/Users/sj/.cache/pants/setup/bootstrap-Darwin-x86_64/pants.NUYNTS/install/lib/python3.9/site-packages/pants/engine/process.py", line 272, in fallible_to_exec_result_or_raise
    raise ProcessExecutionFailure(
pants.engine.process.ProcessExecutionFailure: Process 'Run isort on 7 files.' failed with exit code 1.
```


**Pants version**
2.13.0.dev1

**OS**
MacOS

**Additional info**
Using this repo (https://github.com/sureshjoshi/pants-example-plugin) and I manually set the toml to `2.13.0.dev`.

I also went in and messed with the `main.py` formatting, so I knew `black` would want to run.


Discussion:
I can reproduce :fearful: 
Oh this is just a silly mistake :sweat_smile: I'll get to work on PR and add test we don't goof in the future.

Resolving patch:
diff --git a/src/python/pants/core/goals/fmt.py b/src/python/pants/core/goals/fmt.py
--- a/src/python/pants/core/goals/fmt.py
+++ b/src/python/pants/core/goals/fmt.py
@@ -286,7 +286,8 @@ async def fmt_language(language_fmt_request: _LanguageFmtRequest) -> _LanguageFm
             continue
         result = await Get(FmtResult, FmtRequest, request)
         results.append(result)
-        prior_formatter_result = result.output
+        if not result.skipped:
+            prior_formatter_result = result.output
     return _LanguageFmtResults(
         tuple(results),
         input=original_sources.snapshot.digest,


Test patch:
diff --git a/src/python/pants/core/goals/fmt_test.py b/src/python/pants/core/goals/fmt_test.py
--- a/src/python/pants/core/goals/fmt_test.py
+++ b/src/python/pants/core/goals/fmt_test.py
@@ -15,7 +15,7 @@
 from pants.engine.fs import EMPTY_DIGEST, CreateDigest, Digest, FileContent
 from pants.engine.internals.native_engine import EMPTY_SNAPSHOT, Snapshot
 from pants.engine.rules import Get, collect_rules, rule
-from pants.engine.target import FieldSet, MultipleSourcesField, Target
+from pants.engine.target import FieldSet, SingleSourceField, Target
 from pants.engine.unions import UnionRule
 from pants.testutil.rule_runner import RuleRunner
 from pants.util.logging import LogLevel
@@ -24,20 +24,20 @@
 SMALLTALK_FILE = FileContent("formatted.st", b"y := self size + super size.')\n")
 
 
-class FortranSources(MultipleSourcesField):
+class FortranSource(SingleSourceField):
     pass
 
 
 class FortranTarget(Target):
     alias = "fortran"
-    core_fields = (FortranSources,)
+    core_fields = (FortranSource,)
 
 
 @dataclass(frozen=True)
 class FortranFieldSet(FieldSet):
-    required_fields = (FortranSources,)
+    required_fields = (FortranSource,)
 
-    sources: FortranSources
+    sources: FortranSource
 
 
 class FortranFmtRequest(FmtRequest):
@@ -47,30 +47,28 @@ class FortranFmtRequest(FmtRequest):
 
 @rule
 async def fortran_fmt(request: FortranFmtRequest) -> FmtResult:
-    output = (
-        await Get(Snapshot, CreateDigest([FORTRAN_FILE]))
-        if any(fs.address.target_name == "needs_formatting" for fs in request.field_sets)
-        else EMPTY_SNAPSHOT
-    )
+    if not any(fs.address.target_name == "needs_formatting" for fs in request.field_sets):
+        return FmtResult.skip(formatter_name=request.name)
+    output = await Get(Snapshot, CreateDigest([FORTRAN_FILE]))
     return FmtResult(
-        input=EMPTY_SNAPSHOT, output=output, stdout="", stderr="", formatter_name=request.name
+        input=request.snapshot, output=output, stdout="", stderr="", formatter_name=request.name
     )
 
 
-class SmalltalkSources(MultipleSourcesField):
+class SmalltalkSource(SingleSourceField):
     pass
 
 
 class SmalltalkTarget(Target):
     alias = "smalltalk"
-    core_fields = (SmalltalkSources,)
+    core_fields = (SmalltalkSource,)
 
 
 @dataclass(frozen=True)
 class SmalltalkFieldSet(FieldSet):
-    required_fields = (SmalltalkSources,)
+    required_fields = (SmalltalkSource,)
 
-    sources: SmalltalkSources
+    source: SmalltalkSource
 
 
 class SmalltalkNoopRequest(FmtRequest):
@@ -80,11 +78,10 @@ class SmalltalkNoopRequest(FmtRequest):
 
 @rule
 async def smalltalk_noop(request: SmalltalkNoopRequest) -> FmtResult:
-    result_digest = await Get(Digest, CreateDigest([SMALLTALK_FILE]))
-    result_snapshot = await Get(Snapshot, Digest, result_digest)
+    assert request.snapshot != EMPTY_SNAPSHOT
     return FmtResult(
-        input=result_snapshot,
-        output=result_snapshot,
+        input=request.snapshot,
+        output=request.snapshot,
         stdout="",
         stderr="",
         formatter_name=request.name,
@@ -98,6 +95,7 @@ class SmalltalkSkipRequest(FmtRequest):
 
 @rule
 def smalltalk_skip(request: SmalltalkSkipRequest) -> FmtResult:
+    assert request.snapshot != EMPTY_SNAPSHOT
     return FmtResult.skip(formatter_name=request.name)
 
 
@@ -116,16 +114,6 @@ def fmt_rule_runner(
     )
 
 
-def fortran_digest(rule_runner: RuleRunner) -> Digest:
-    return rule_runner.make_snapshot({FORTRAN_FILE.path: FORTRAN_FILE.content.decode()}).digest
-
-
-def merged_digest(rule_runner: RuleRunner) -> Digest:
-    return rule_runner.make_snapshot(
-        {fc.path: fc.content.decode() for fc in (FORTRAN_FILE, SMALLTALK_FILE)}
-    ).digest
-
-
 def run_fmt(
     rule_runner: RuleRunner, *, target_specs: List[str], only: list[str] | None = None
 ) -> str:
@@ -141,19 +129,26 @@ def run_fmt(
 def test_summary() -> None:
     rule_runner = fmt_rule_runner(
         target_types=[FortranTarget, SmalltalkTarget],
-        fmt_request_types=[FortranFmtRequest, SmalltalkNoopRequest, SmalltalkSkipRequest],
+        # NB: Keep SmalltalkSkipRequest before SmalltalkNoopRequest so it runs first. This helps test
+        # a bug where a formatter run after a skipped formatter was receiving an empty snapshot.
+        # See https://github.com/pantsbuild/pants/issues/15406
+        fmt_request_types=[FortranFmtRequest, SmalltalkSkipRequest, SmalltalkNoopRequest],
     )
 
     rule_runner.write_files(
         {
             "BUILD": dedent(
                 """\
-                fortran(name='f1')
-                fortran(name='needs_formatting')
-                smalltalk(name='s1')
-                smalltalk(name='s2')
+                fortran(name='f1', source="ft1.f98")
+                fortran(name='needs_formatting', source="formatted.f98")
+                smalltalk(name='s1', source="st1.st")
+                smalltalk(name='s2', source="formatted.st")
                 """,
             ),
+            "ft1.f98": "READ INPUT TAPE 5\n",
+            "formatted.f98": "READ INPUT TAPE 5",
+            "st1.st": "y := self size + super size.')",
+            "formatted.st": "y := self size + super size.')\n",
         },
     )
 
@@ -171,7 +166,8 @@ def test_summary() -> None:
     smalltalk_file = Path(rule_runner.build_root, SMALLTALK_FILE.path)
     assert fortran_file.is_file()
     assert fortran_file.read_text() == FORTRAN_FILE.content.decode()
-    assert not smalltalk_file.is_file()
+    assert smalltalk_file.is_file()
+    assert smalltalk_file.read_text() == SMALLTALK_FILE.content.decode()
 
     stderr = run_fmt(
         rule_runner,