Repository: akaihola/darker

Issue:
Darker github action ignores `working-directory`
- [x] Add GitHub Workflow which adds as a functional test for this

<!-- 

    NOTE:

    To ask for help using Darker, please use Discussions (see the top of this page).
    This form is only for reporting bugs.

-->

**Describe the bug**
Darker github action ignores [`working-directory` setting](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#defaultsrunworking-directory).

**To Reproduce**
Steps to reproduce the behavior: take a look at these two commits in my demo repo, [one](https://github.com/dshemetov/darker-test/pull/2) and [two](https://github.com/dshemetov/darker-test/pull/3).

**Expected behavior**
Darker CI action should use the `working-directory` setting.

**Additional context**
Vaguely related to my previous report here #522. Thanks for your help!

Discussion:
@akaihola: Thanks @dshemetov, I'll definitely take a look at this!

Solution patch:
diff --git a/.github/workflows/test-working-directory.yml b/.github/workflows/test-working-directory.yml
new file mode 100644
index 000000000..27630ad6b
--- /dev/null
+++ b/.github/workflows/test-working-directory.yml
@@ -0,0 +1,107 @@
+---
+name: "GH Action `working-directory:` test"
+
+on: push  # yamllint disable-line rule:truthy
+
+jobs:
+  setup:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Set up initial test repository
+        run: |
+          mkdir -p test-repo
+          cd test-repo
+          git init
+          git config user.email "test@example.com"
+          git config user.name "Test User"
+          git commit --allow-empty -m "Initial empty commit"
+          echo 'def hello(): return "Hello, World!"' > test.py
+          git add test.py
+          git commit -m "Add test.py file"
+          echo 'def  hello():    return  "Hello, World!"' > test.py
+
+      - name: Upload test repository
+        uses: actions/upload-artifact@v3
+        with:
+          name: test-repo
+          path: test-repo
+
+  test:
+    needs: setup
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        scenario:
+          - name: no-work-dir
+            working_directory: null  # Consider it omitted
+            src: test.py
+            options: --check --diff
+            expected_exitcode: 2
+          - name: right-workdir
+            working_directory: test-repo
+            src: test.py
+            options: --check --diff
+            expected_exitcode: 1
+          - name: wrong-work-dir
+            working_directory: non-existent-dir
+            src: test.py
+            options: --check --diff
+            expected_exitcode: 21
+          - name: file-not-found
+            working_directory: test-repo
+            src: non_existent_file.py
+            options: --check --diff
+            expected_exitcode: 2
+          - name: invalid-arguments
+            working_directory: test-repo
+            src: test.py
+            options: --invalid-option
+            expected_exitcode: 3
+          - name: missing-deps
+            working_directory: test-repo
+            src: test.py
+            options: --flynt  # not yet supported by the action
+            expected_exitcode: 4
+
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 10
+
+      - name: Download test repository
+        uses: actions/download-artifact@v3
+        with:
+          name: test-repo
+          path: ${{ runner.temp }}/test-repo
+
+      - name: "Run Darker - ${{ matrix.scenario.name }}"
+        uses: ./
+        continue-on-error: true
+        id: darker-run
+        with:
+          version: "@gh-action-working-directory"
+          options: ${{ matrix.scenario.options }}
+          # In the action, '.' is the default value used if `working-directory` omitted
+          working-directory: >-
+            ${{
+              matrix.scenario.working_directory == null && '.'
+              || format('{0}/{1}', runner.temp, matrix.scenario.working_directory)
+            }}
+          src: ${{ matrix.scenario.src }}
+          revision: HEAD
+
+      - name: Check exit code
+        if: >-
+          steps.darker-run.outputs.exitcode != matrix.scenario.expected_exitcode
+        run: |
+          echo "::error::Expected exit code ${{ matrix.scenario.expected_exitcode }}"
+          echo "::error::Darker exited with ${{ steps.darker-run.outputs.exitcode }}"
+          exit 1
+
+      - name: Verify diff output for right-workdir scenario
+        if: >
+          matrix.scenario.name == 'right-workdir' &&
+          !contains(steps.darker-run.outputs.stdout, '@@')
+        run: |
+          echo "::error::Darker did not output a diff as expected"
+          exit 1
diff --git a/CHANGES.rst b/CHANGES.rst
index 3b92b56cd..f62f2a527 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -10,6 +10,8 @@ Added
 - Display exit code in parentheses after error message.
 - Do not reformat renamed files.
 - CI workflow to post recent project activity in a discussion. Triggered manually.
+- CI "future" test now tests against ``main`` of Darkgraylib_ in addition to Black_,
+  Flynt_ and isort_.
 - The ``--preview`` configuration flag is now supported in the configuration files for
   Darker and Black
 - Prevent Pylint from updating beyond version 3.2.7 due to dropped Python 3.8 support.
@@ -45,6 +47,7 @@ Fixed
   The work-around should be removed when Python 3.8 and 3.9 are no longer supported.
 - Add missing configuration flag for Flynt_.
 - Only split source code lines at Python's universal newlines (LF, CRLF, CR).
+- The Darker GitHub action now respects the ``working-directory`` input option.
 
 
 2.1.1_ - 2024-04-16
@@ -687,4 +690,5 @@ Added
 .. _pydocstyle: http://www.pydocstyle.org/
 .. _Ruff: https://astral.sh/ruff
 .. _Black: https://black.readthedocs.io/
+.. _isort: https://pycqa.github.io/isort/
 .. _NixOS: https://nixos.org/
diff --git a/action.yml b/action.yml
index cd1196805..be0b3750e 100644
--- a/action.yml
+++ b/action.yml
@@ -27,6 +27,19 @@ inputs:
       NOTE: Baseline linting has been moved to the Graylint package.
     required: false
     default: ''
+  working-directory:
+    description: >-
+      Directory to run Darker in, either absolute or relative to
+      $GITHUB_WORKSPACE. By default, Darker is run in $GITHUB_WORKSPACE.
+    required: false
+    default: '.'
+outputs:
+  exitcode:
+    description: "Exit code of Darker"
+    value: ${{ steps.darker.outputs.exitcode }}
+  stdout:
+    description: "Standard output of Darker"
+    value: ${{ steps.darker.outputs.stdout }}
 branding:
   color: "black"
   icon: "check-circle"
@@ -35,8 +48,18 @@ runs:
   steps:
     - name: Commit Range
       id: commit-range
-      uses: akaihola/darker/.github/actions/commit-range@1.7.1
+      uses: ./.github/actions/commit-range
+    - name: Set up Python
+      uses: actions/setup-python@v5
+      with:
+        python-version: '3.x'
+        cache: 'pip'
+    - name: Install dependencies
+      run: |
+        pip install pip-requirements-parser
+      shell: bash
     - name: Run Darker
+      id: darker
       run: |
         # Exists since using github.action_path + path to main script doesn't
         # work because bash interprets the backslashes in github.action_path
@@ -68,5 +91,6 @@ runs:
         INPUT_REVISION: ${{ inputs.revision }}
         INPUT_LINT: ${{ inputs.lint }}
         INPUT_COMMIT_RANGE: ${{ steps.commit-range.outputs.commit-range }}
+        INPUT_WORKING_DIRECTORY: ${{ inputs.working-directory }}
         pythonioencoding: utf-8
       shell: bash
diff --git a/action/main.py b/action/main.py
index d4cd0f988..e2a9ceab3 100644
--- a/action/main.py
+++ b/action/main.py
@@ -13,6 +13,7 @@
 SRC = os.getenv("INPUT_SRC", default="")
 VERSION = os.getenv("INPUT_VERSION", default="")
 REVISION = os.getenv("INPUT_REVISION") or os.getenv("INPUT_COMMIT_RANGE") or "HEAD^"
+WORKING_DIRECTORY = os.getenv("INPUT_WORKING_DIRECTORY", ".")
 
 if os.getenv("INPUT_LINT", default=""):
     print(
@@ -20,28 +21,60 @@
         " See https://pypi.org/project/graylint for more information.",
     )
 
-run([sys.executable, "-m", "venv", str(ENV_PATH)], check=True)  # nosec
+
+def set_github_output(key: str, val: str) -> None:
+    """Write a key-value pair to the output file."""
+    with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="UTF-8") as f:
+        if "\n" in val:
+            print(f"{key}<<DARKER_ACTION_EOF", file=f)
+            print(val, file=f, end="" if val.endswith("\n") else "\n")
+            print("DARKER_ACTION_EOF", file=f)
+        else:
+            print(f"{key}={val}", file=f)
+
+
+def exit_with_exitcode(exitcode: int) -> None:
+    """Write the exit code to the output file and exit with it."""
+    set_github_output("exitcode", str(exitcode))
+    sys.exit(exitcode)
+
+
+# Check if the working directory exists
+if not os.path.isdir(WORKING_DIRECTORY):
+    print(f"::error::Working directory does not exist: {WORKING_DIRECTORY}", flush=True)
+    exit_with_exitcode(21)
+
+
+def pip_install(*packages):
+    """Install the specified Python packages using a pip subprocess."""
+    python = str(ENV_BIN / "python")
+    args = [python, "-m", "pip", "install", *packages]
+    pip_proc = run(  # nosec
+        args,
+        check=False,
+        stdout=PIPE,
+        stderr=STDOUT,
+        encoding="utf-8",
+    )
+    print(pip_proc.stdout, end="")
+    if pip_proc.returncode:
+        print(f"::error::Failed to install {' '.join(packages)}.", flush=True)
+        sys.exit(pip_proc.returncode)
+
+
+if not ENV_PATH.exists():
+    run([sys.executable, "-m", "venv", str(ENV_PATH)], check=True)  # nosec
 
 req = ["darker[black,color,isort]"]
 if VERSION:
     if VERSION.startswith("@"):
-        req[0] = f"git+https://github.com/akaihola/darker{VERSION}#egg={req[0]}"
+        req[0] += f"@git+https://github.com/akaihola/darker{VERSION}"
     elif VERSION.startswith(("~", "=", "<", ">")):
         req[0] += VERSION
     else:
         req[0] += f"=={VERSION}"
 
-pip_proc = run(  # nosec
-    [str(ENV_BIN / "python"), "-m", "pip", "install"] + req,
-    check=False,
-    stdout=PIPE,
-    stderr=STDOUT,
-    encoding="utf-8",
-)
-print(pip_proc.stdout, end="")
-if pip_proc.returncode:
-    print(f"::error::Failed to install {' '.join(req)}.", flush=True)
-    sys.exit(pip_proc.returncode)
+pip_install(*req)
 
 
 base_cmd = [str(ENV_BIN / "darker")]
@@ -58,7 +91,9 @@
     stderr=STDOUT,
     env={**os.environ, "PATH": f"{ENV_BIN}:{os.environ['PATH']}"},
     encoding="utf-8",
+    cwd=WORKING_DIRECTORY,
 )
 print(proc.stdout, end="")
 
-sys.exit(proc.returncode)
+set_github_output("stdout", proc.stdout)
+exit_with_exitcode(proc.returncode)
diff --git a/constraints-future.txt b/constraints-future.txt
index 87ed7623d..6c3d500e7 100644
--- a/constraints-future.txt
+++ b/constraints-future.txt
@@ -3,6 +3,7 @@
 # still works against upcaming releases of Python ependencies. Keep this
 # up-to-date by adding entries for any packages we want to get early
 # incompatibility warnings for.
-black @ git+https://github.com/psf/black.git@main 
+black @ git+https://github.com/psf/black.git@main
+darkgraylib @ git+https://github.com/akaihola/darkgraylib.git@main
 flynt @ git+https://github.com/ikamensh/flynt.git@master
 isort @ git+https://github.com/PyCQA/isort.git@main


Test patch:
diff --git a/action/tests/test_main.py b/action/tests/test_main.py
index 9a10e684a..17aad68de 100644
--- a/action/tests/test_main.py
+++ b/action/tests/test_main.py
@@ -49,7 +49,9 @@ def patch_main(
 
     def run(args, **kwargs):
         returncode = pip_returncode if args[1:3] == ["-m", "pip"] else 0
-        return CompletedProcess(args, returncode, stdout="", stderr="")
+        return CompletedProcess(
+            args, returncode, stdout="Output\nfrom\nDarker", stderr=""
+        )
 
     run_mock = Mock(wraps=run)
     exit_ = Mock(side_effect=SysExitCalled)
@@ -78,7 +80,16 @@ def main_patch(
         yield run_main_fixture
 
 
-def test_creates_virtualenv(tmp_path, main_patch):
+@pytest.fixture
+def github_output(tmp_path: Path) -> Generator[Path]:
+    """Fixture to set up a GitHub output file for the action"""
+    gh_output_filepath = tmp_path / "github.output"
+    with patch.dict("os.environ", {"GITHUB_OUTPUT": str(gh_output_filepath)}):
+
+        yield gh_output_filepath
+
+
+def test_creates_virtualenv(tmp_path, main_patch, github_output):
     """The GitHub action creates a virtualenv for Darker"""
     with pytest.raises(SysExitCalled):
 
@@ -99,8 +110,7 @@ def test_creates_virtualenv(tmp_path, main_patch):
     dict(
         run_main_env={"INPUT_VERSION": "@master"},
         expect=[
-            "git+https://github.com/akaihola/darker"
-            "@master#egg=darker[black,color,isort]"
+            "darker[black,color,isort]@git+https://github.com/akaihola/darker@master"
         ],
     ),
     dict(
@@ -112,7 +122,7 @@ def test_creates_virtualenv(tmp_path, main_patch):
         expect=["darker[black,color,isort]"],
     ),
 )
-def test_installs_packages(tmp_path, main_patch, run_main_env, expect):
+def test_installs_packages(tmp_path, main_patch, github_output, run_main_env, expect):
     """Darker, isort and linters are installed in the virtualenv using pip"""
     with pytest.raises(SysExitCalled):
 
@@ -185,7 +195,12 @@ def test_installs_packages(tmp_path, main_patch, run_main_env, expect):
         ],
     ),
 )
-def test_runs_darker(tmp_path: Path, env: dict[str, str], expect: list[str]) -> None:
+def test_runs_darker(
+    tmp_path: Path,
+    github_output: Generator[Path],
+    env: dict[str, str],
+    expect: list[str],
+) -> None:
     """Configuration translates correctly into a Darker command line"""
     with patch_main(tmp_path, env) as main_patch, pytest.raises(SysExitCalled):
 
@@ -218,15 +233,28 @@ def test_error_if_pip_fails(tmp_path, capsys):
     )
     assert (
         capsys.readouterr().out.splitlines()[-1]
-        == "::error::Failed to install darker[black,color,isort]."
+        == "Darker::error::Failed to install darker[black,color,isort]."
     )
     main_patch.sys.exit.assert_called_once_with(42)
 
 
-def test_exits(main_patch):
+def test_exits(main_patch, github_output):
     """A successful run exits with a zero return code"""
     with pytest.raises(SysExitCalled):
 
         run_module("main")
 
     main_patch.sys.exit.assert_called_once_with(0)
+
+
+@pytest.mark.parametrize(
+    "expect_line",
+    ["exitcode=0", "stdout<<DARKER_ACTION_EOF"],
+)
+def test_writes_github_output(main_patch, github_output, expect_line):
+    """A successful run outputs a zero exit code and output from Darker."""
+    with pytest.raises(SysExitCalled):
+
+        run_module("main")
+
+    assert expect_line in github_output.read_text().splitlines()