Repository: akaihola/darker

Issue:
`--isort` sorts imports even if they were not edited
The problem with applying isort modifications "surgically" is in the fact that isort may (and often does) change the order of lines.

Could we consider all changes by isort as one contiguous diff chunk, and apply it if any line in `edited_linenums` falls inside that chunk? Is there an edge case where isort only modifies part of the import lines, and a user edit happens to an import outside that part? I can't see a problem there. The important thing is that either all or none of the isort modifications are applied.

Discussion:
sourcery-ai[bot]: # Sourcery Code Quality Report


✅ &nbsp;Merging this PR will **increase** code quality in the affected files by **1.49%**.

| Quality metrics | Before | After | Change |
| --- | :---: | :---: | :---: |
| [Complexity](https://docs.sourcery.ai/GitHub/Quality-Report/#complexity) | 7.27 ⭐ | 6.67 ⭐ | -0.60 👍 |
| [Method Length](https://docs.sourcery.ai/GitHub/Quality-Report/#method-length) | 79.25 🙂 | 83.91 🙂 | 4.66 👎 |
| [Working memory](https://docs.sourcery.ai/GitHub/Quality-Report/#working-memory) | 10.73 😞 | 10.41 😞 | -0.32 👍 |
| **[Quality](https://docs.sourcery.ai/GitHub/Quality-Report/#quality-score)** | **61.32%** 🙂| **62.81%** 🙂| **1.49%** 👍 |



| Other metrics | Before | After | Change |
| --- | :---: | :---: | :---: |
| Lines | 1494 | 1713 | 219 |



| Changed files | Quality Before | Quality After | Quality Change |
| --- | :---: | :---: | :---: |
| src/darker/__main__.py | 41.03% 😞 | 41.50% 😞 | 0.47% 👍|
| src/darker/diff.py | 79.87% ⭐ | 81.69% ⭐ | 1.82% 👍|
| src/darker/import_sorting.py | 84.42% ⭐ | 83.18% ⭐ | -1.24% 👎|
| src/darker/tests/test_import_sorting.py | 85.49% ⭐ | 74.91% 🙂 | -10.58% 👎|
| src/darker/tests/test_main.py | 67.85% 🙂 | 67.83% 🙂 | -0.02% 👎|
| src/darker/tests/test_main_reformat_single_file.py | 75.08% ⭐ | 74.15% 🙂 | -0.93% 👎|



Here are some functions in these files that still need a tune-up:

| File | Function | Complexity | Length | Working Memory | Quality | Recommendation |
| --- | --- | :---: | :---: | :---: | :---: | --- |
| src/darker/__main__.py | main | 32 😞 | 421 ⛔ | 15 😞 | 18.12% ⛔ | Refactor to reduce nesting. Try splitting into smaller methods. Extract out complex expressions |
| src/darker/tests/test_main.py | test\_main | 6 ⭐ | 438 ⛔ | 25 ⛔ | 30.22% 😞 | Try splitting into smaller methods. Extract out complex expressions |
| src/darker/__main__.py | \_reformat\_single\_file | 11 🙂 | 197 😞 | 19 ⛔ | 36.40% 😞 | Try splitting into smaller methods. Extract out complex expressions |
| src/darker/tests/test_main.py | test\_format\_edited\_parts | 0 ⭐ | 197 😞 | 15 😞 | 50.76% 🙂 | Try splitting into smaller methods. Extract out complex expressions |
| src/darker/tests/test_main.py | test\_reformat\_single\_file | 0 ⭐ | 120 😞 | 18 ⛔ | 55.53% 🙂 | Try splitting into smaller methods. Extract out complex expressions |


## Legend and Explanation

The emojis denote the absolute quality of the code:

 - ⭐ excellent
 - 🙂 good
 - 😞 poor
 - ⛔ very poor


The 👍 and 👎 indicate whether the quality has improved or gotten worse with this pull request.

***

Please see our documentation [here](https://docs.sourcery.ai/GitHub/Quality-Report/) for details on how these metrics are calculated.

We are actively working on this report - lots more documentation and extra metrics to come!

Help us [improve](https://research.typeform.com/to/j06Spdfr?type=quality_report&github_login=akaihola&base_repo=https%3A%2F%2Fgithub.com%2Fakaihola%2Fdarker.git&base_remote_ref=refs%2Fpull%2F238%2Fhead&base_ref=isort-if-imports-edited&base_sha=a7c5a323748e0675dfd1d604a9f91242eefbfd19&head_repo=https%3A%2F%2Fgithub.com%2Fakaihola%2Fdarker.git&head_ref=sourcery%2Fisort-if-imports-edited&base_pr_number=238&base_pr_state=open) this quality report!
sourcery-ai[bot]: # Sourcery Code Quality Report


✅ &nbsp;Merging this PR will **increase** code quality in the affected files by **1.49%**.

| Quality metrics | Before | After | Change |
| --- | :---: | :---: | :---: |
| [Complexity](https://docs.sourcery.ai/GitHub/Quality-Report/#complexity) | 7.27 ⭐ | 6.67 ⭐ | -0.60 👍 |
| [Method Length](https://docs.sourcery.ai/GitHub/Quality-Report/#method-length) | 79.25 🙂 | 83.91 🙂 | 4.66 👎 |
| [Working memory](https://docs.sourcery.ai/GitHub/Quality-Report/#working-memory) | 10.73 😞 | 10.41 😞 | -0.32 👍 |
| **[Quality](https://docs.sourcery.ai/GitHub/Quality-Report/#quality-score)** | **61.32%** 🙂| **62.81%** 🙂| **1.49%** 👍 |



| Other metrics | Before | After | Change |
| --- | :---: | :---: | :---: |
| Lines | 1494 | 1711 | 217 |



| Changed files | Quality Before | Quality After | Quality Change |
| --- | :---: | :---: | :---: |
| src/darker/__main__.py | 41.03% 😞 | 41.50% 😞 | 0.47% 👍|
| src/darker/diff.py | 79.87% ⭐ | 81.69% ⭐ | 1.82% 👍|
| src/darker/import_sorting.py | 84.42% ⭐ | 83.18% ⭐ | -1.24% 👎|
| src/darker/tests/test_import_sorting.py | 85.49% ⭐ | 74.91% 🙂 | -10.58% 👎|
| src/darker/tests/test_main.py | 67.85% 🙂 | 67.83% 🙂 | -0.02% 👎|
| src/darker/tests/test_main_reformat_single_file.py | 75.08% ⭐ | 74.15% 🙂 | -0.93% 👎|



Here are some functions in these files that still need a tune-up:

| File | Function | Complexity | Length | Working Memory | Quality | Recommendation |
| --- | --- | :---: | :---: | :---: | :---: | --- |
| src/darker/__main__.py | main | 32 😞 | 421 ⛔ | 15 😞 | 18.12% ⛔ | Refactor to reduce nesting. Try splitting into smaller methods. Extract out complex expressions |
| src/darker/tests/test_main.py | test\_main | 6 ⭐ | 438 ⛔ | 25 ⛔ | 30.22% 😞 | Try splitting into smaller methods. Extract out complex expressions |
| src/darker/__main__.py | \_reformat\_single\_file | 11 🙂 | 197 😞 | 19 ⛔ | 36.40% 😞 | Try splitting into smaller methods. Extract out complex expressions |
| src/darker/tests/test_main.py | test\_format\_edited\_parts | 0 ⭐ | 197 😞 | 15 😞 | 50.76% 🙂 | Try splitting into smaller methods. Extract out complex expressions |
| src/darker/tests/test_main.py | test\_reformat\_single\_file | 0 ⭐ | 120 😞 | 18 ⛔ | 55.53% 🙂 | Try splitting into smaller methods. Extract out complex expressions |


## Legend and Explanation

The emojis denote the absolute quality of the code:

 - ⭐ excellent
 - 🙂 good
 - 😞 poor
 - ⛔ very poor


The 👍 and 👎 indicate whether the quality has improved or gotten worse with this pull request.

***

Please see our documentation [here](https://docs.sourcery.ai/GitHub/Quality-Report/) for details on how these metrics are calculated.

We are actively working on this report - lots more documentation and extra metrics to come!

Help us [improve](https://research.typeform.com/to/j06Spdfr?type=quality_report&github_login=akaihola&base_repo=https%3A%2F%2Fgithub.com%2Fakaihola%2Fdarker.git&base_remote_ref=refs%2Fpull%2F238%2Fhead&base_ref=isort-if-imports-edited&base_sha=1506c0b70f1b7e4d0669b66b307fc2a7f60b9e80&head_repo=https%3A%2F%2Fgithub.com%2Fakaihola%2Fdarker.git&head_ref=sourcery%2Fisort-if-imports-edited&base_pr_number=238&base_pr_state=open) this quality report!
sourcery-ai[bot]: # Sourcery Code Quality Report

✅ &nbsp;Merging this PR will **increase** code quality in the affected files by **1.50%**.

| Quality metrics | Before | After | Change |
| --- | :---: | :---: | :---: |
| [Complexity](https://docs.sourcery.ai/GitHub/Quality-Report/#complexity) | 7.30 ⭐ | 6.70 ⭐ | -0.60 👍 |
| [Method Length](https://docs.sourcery.ai/GitHub/Quality-Report/#method-length) | 78.50 🙂 | 83.24 🙂 | 4.74 👎 |
| [Working memory](https://docs.sourcery.ai/GitHub/Quality-Report/#working-memory) | 10.76 😞 | 10.44 😞 | -0.32 👍 |
| **[Quality](https://docs.sourcery.ai/GitHub/Quality-Report/#quality-score)** | **61.31%** 🙂| **62.81%** 🙂| **1.50%** 👍 |

| Other metrics | Before | After | Change |
| --- | :---: | :---: | :---: |
| Lines | 1503 | 1718 | 215 |

| Changed files | Quality Before | Quality After | Quality Change |
| --- | :---: | :---: | :---: |
| src/darker/__main__.py | 41.05% 😞 | 41.52% 😞 | 0.47% 👍|
| src/darker/diff.py | 79.87% ⭐ | 81.69% ⭐ | 1.82% 👍|
| src/darker/import_sorting.py | 84.58% ⭐ | 83.23% ⭐ | -1.35% 👎|
| src/darker/tests/test_import_sorting.py | 85.74% ⭐ | 74.80% 🙂 | -10.94% 👎|
| src/darker/tests/test_main.py | 67.95% 🙂 | 67.93% 🙂 | -0.02% 👎|
| src/darker/tests/test_main_reformat_single_file.py | 75.08% ⭐ | 74.15% 🙂 | -0.93% 👎|

Here are some functions in these files that still need a tune-up:

| File | Function | Complexity | Length | Working Memory | Quality | Recommendation |
| --- | --- | :---: | :---: | :---: | :---: | --- |
| src/darker/__main__.py | main | 32 😞 | 416.97 ⛔ | 15 😞 | 18.17% ⛔ | Refactor to reduce nesting. Try splitting into smaller methods. Extract out complex expressions |
| src/darker/tests/test_main.py | test\_main | 6 ⭐ | 433.96 ⛔ | 25 ⛔ | 30.26% 😞 | Try splitting into smaller methods. Extract out complex expressions |
| src/darker/__main__.py | \_reformat\_single\_file | 11 🙂 | 197 😞 | 19 ⛔ | 36.40% 😞 | Try splitting into smaller methods. Extract out complex expressions |
| src/darker/tests/test_main.py | test\_format\_edited\_parts | 0 ⭐ | 195.99 😞 | 15 😞 | 50.84% 🙂 | Try splitting into smaller methods. Extract out complex expressions |
| src/darker/tests/test_main.py | test\_reformat\_single\_file | 0 ⭐ | 120 😞 | 18 ⛔ | 55.53% 🙂 | Try splitting into smaller methods. Extract out complex expressions |

## Legend and Explanation

The emojis denote the absolute quality of the code:

 - ⭐ excellent
 - 🙂 good
 - 😞 poor
 - ⛔ very poor


The 👍 and 👎 indicate whether the quality has improved or gotten worse with this pull request.

***

Please see our documentation [here](https://docs.sourcery.ai/GitHub/Quality-Report/) for details on how these metrics are calculated.

We are actively working on this report - lots more documentation and extra metrics to come!

Help us [improve](https://research.typeform.com/to/j06Spdfr?type=quality_report&github_login=akaihola&base_repo=https%3A%2F%2Fgithub.com%2Fakaihola%2Fdarker.git&base_remote_ref=refs%2Fpull%2F238%2Fhead&base_ref=isort-if-imports-edited&base_sha=52d841a6b0daa873fc7caae4f57fc86a489ba221&head_repo=https%3A%2F%2Fgithub.com%2Fakaihola%2Fdarker.git&head_ref=sourcery%2Fisort-if-imports-edited&base_pr_number=238&base_pr_state=open) this quality report!
akaihola: @Yajo, I noticed you gave a thumbs up for this idea in #72. I could include this change in [release 1.5.0](https://github.com/akaihola/darker/milestone/13) – would you be able to do a code review of my pull request here?
akaihola: I hopefully managed to clarify the changes a bit in the description of this PR to make reviewing easier. @roniemartinez would you happen to have a bit of time to do a sanity check on this patch?

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index bb19e24..9bd0616 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -8,7 +8,9 @@ Added
 - The ``--workers``/``-W`` option now specifies how many Darker jobs are used to
   process files in parallel to complete reformatting/linting faster.
 - Linters can now be installed and run in the GitHub Action using the ``lint:`` option.
-  
+- Sort imports only if the range of modified lines overlaps with changes resulting from
+  sorting the imports.
+
 Fixed
 -----
 - Avoid memory leak from using ``@lru_cache`` on a method.
diff --git a/src/darker/__main__.py b/src/darker/__main__.py
index e70c459..8a152d0 100644
--- a/src/darker/__main__.py
+++ b/src/darker/__main__.py
@@ -20,7 +20,7 @@ from darker.chooser import choose_lines
 from darker.command_line import parse_command_line
 from darker.concurrency import get_executor
 from darker.config import OutputMode, dump_config
-from darker.diff import diff_and_get_opcodes, opcodes_to_chunks
+from darker.diff import diff_chunks
 from darker.exceptions import DependencyError, MissingPackageError
 from darker.git import (
     PRE_COMMIT_FROM_TO_REFS,
@@ -28,7 +28,7 @@ from darker.git import (
     EditedLinenumsDiffer,
     RevisionRange,
     get_missing_at_revision,
-    get_rev1_path,
+    get_path_in_repo,
     git_get_content_at_revision,
     git_get_modified_python_files,
     git_is_repository,
@@ -80,63 +80,77 @@ def format_edited_parts(
     with get_executor(max_workers=workers) as executor:
         # pylint: disable=unsubscriptable-object
         futures: List[concurrent.futures.Future[ProcessedDocument]] = []
-        for path_in_repo in sorted(changed_files):
+        edited_linenums_differ = EditedLinenumsDiffer(root, revrange)
+        for relative_path_in_rev2 in sorted(changed_files):
             future = executor.submit(
                 _isort_and_blacken_single_file,
                 root,
-                path_in_repo,
+                relative_path_in_rev2,
+                edited_linenums_differ,
+                black_exclude,
                 revrange,
-                black_config,
                 enable_isort,
-                enable_black=path_in_repo not in black_exclude,
+                black_config,
             )
             futures.append(future)
 
         for future in concurrent.futures.as_completed(futures):
-            src, rev2_content, content_after_reformatting = future.result()
+            (
+                absolute_path_in_rev2,
+                rev2_content,
+                content_after_reformatting,
+            ) = future.result()
             if report_unmodified or content_after_reformatting != rev2_content:
-                yield (src, rev2_content, content_after_reformatting)
+                yield (absolute_path_in_rev2, rev2_content, content_after_reformatting)
 
 
 def _isort_and_blacken_single_file(  # pylint: disable=too-many-arguments
     root: Path,
-    relative_path: Path,
+    relative_path_in_rev2: Path,
+    edited_linenums_differ: EditedLinenumsDiffer,
+    black_exclude: Collection[Path],  # pylint: disable=unsubscriptable-object
     revrange: RevisionRange,
-    black_config: BlackConfig,
     enable_isort: bool,
-    enable_black: bool,
+    black_config: BlackConfig,
 ) -> ProcessedDocument:
     """Black and/or isort formatting for modified chunks in a single file
 
     :param root: Root directory for the relative path
-    :param relative_path: Relative path to a Python source code file
+    :param relative_path_in_rev2: Relative path to a Python source code file
+    :param black_exclude: Python files to not reformat using Black, according to Black
+                          configuration
     :param revrange: The Git revisions to compare
-    :param black_config: Configuration to use for running Black
     :param enable_isort: ``True`` to run ``isort`` first on the file contents
-    :param enable_black: ``True`` to also run ``black`` on the file contents
+    :param black_config: Configuration to use for running Black
     :return: Details about changes for the file
 
     """
-    src = root / relative_path
-    rev2_content = git_get_content_at_revision(relative_path, revrange.rev2, root)
+    # With VSCode, `relative_path_in_rev2` may be a `.py.<HASH>.tmp` file in the
+    # working tree instead of a `.py` file.
+    absolute_path_in_rev2 = root / relative_path_in_rev2
+    rev2_content = git_get_content_at_revision(
+        relative_path_in_rev2, revrange.rev2, root
+    )
     # 1. run isort
     if enable_isort:
         rev2_isorted = apply_isort(
             rev2_content,
-            src,
+            relative_path_in_rev2,
+            edited_linenums_differ,
             black_config.get("config"),
             black_config.get("line_length"),
         )
     else:
         rev2_isorted = rev2_content
-    if enable_black:
+    if relative_path_in_rev2 not in black_exclude:
         # 9. A re-formatted Python file which produces an identical AST was
         #    created successfully - write an updated file or print the diff if
         #    there were any changes to the original
         content_after_reformatting = _blacken_single_file(
             root,
-            relative_path,
-            revrange,
+            relative_path_in_rev2,
+            get_path_in_repo(relative_path_in_rev2),
+            edited_linenums_differ,
             rev2_content,
             rev2_isorted,
             enable_isort,
@@ -145,13 +159,14 @@ def _isort_and_blacken_single_file(  # pylint: disable=too-many-arguments
     else:
         # File was excluded by Black configuration, don't reformat
         content_after_reformatting = rev2_isorted
-    return src, rev2_content, content_after_reformatting
+    return absolute_path_in_rev2, rev2_content, content_after_reformatting
 
 
 def _blacken_single_file(  # pylint: disable=too-many-arguments,too-many-locals
     root: Path,
-    relative_path: Path,
-    revrange: RevisionRange,
+    relative_path_in_rev2: Path,
+    relative_path_in_repo: Path,
+    edited_linenums_differ: EditedLinenumsDiffer,
     rev2_content: TextDocument,
     rev2_isorted: TextDocument,
     enable_isort: bool,
@@ -159,9 +174,12 @@ def _blacken_single_file(  # pylint: disable=too-many-arguments,too-many-locals
 ) -> TextDocument:
     """In a Python file, reformat chunks with edits since the last commit using Black
 
-    :param root: Root directory for the relative path
-    :param relative_path: Relative path to a Python source code file
-    :param revrange: The Git revisions to compare
+    :param root: The common root of all files to reformat
+    :param relative_path_in_rev2: Relative path to a Python source code file. Possibly a
+                                  VSCode ``.py.<HASH>.tmp`` file in the working tree.
+    :param relative_path_in_repo: Relative path to source in the Git repository. Same as
+                                  ``relative_path_in_rev2`` save for VSCode temp files.
+    :param edited_linenums_differ: Helper for finding out which lines were edited
     :param rev2_content: Contents of the file at ``revrange.rev2``
     :param rev2_isorted: Contents of the file after optional import sorting
     :param enable_isort: ``True`` if ``isort`` was already run for the file
@@ -170,20 +188,20 @@ def _blacken_single_file(  # pylint: disable=too-many-arguments,too-many-locals
     :raise: NotEquivalentError
 
     """
-    src = root / relative_path
-    rev1_relative_path = get_rev1_path(relative_path)
-    edited_linenums_differ = EditedLinenumsDiffer(root, revrange)
+    absolute_path_in_rev2 = root / relative_path_in_rev2
 
     # 4. run black
     formatted = run_black(rev2_isorted, black_config)
-    logger.debug("Read %s lines from edited file %s", len(rev2_isorted.lines), src)
+    logger.debug(
+        "Read %s lines from edited file %s",
+        len(rev2_isorted.lines),
+        absolute_path_in_rev2,
+    )
     logger.debug("Black reformat resulted in %s lines", len(formatted.lines))
 
     # 5. get the diff between the edited and reformatted file
-    opcodes = diff_and_get_opcodes(rev2_isorted, formatted)
-
     # 6. convert the diff into chunks
-    black_chunks = list(opcodes_to_chunks(opcodes, rev2_isorted, formatted))
+    black_chunks = diff_chunks(rev2_isorted, formatted)
 
     # Exit early if nothing to do
     if not black_chunks:
@@ -201,15 +219,15 @@ def _blacken_single_file(  # pylint: disable=too-many-arguments,too-many-locals
             logger.debug(
                 "Trying with %s lines of context for `git diff -U %s`",
                 context_lines,
-                src,
+                absolute_path_in_rev2,
             )
         # 2. diff the given revision and worktree for the file
         # 3. extract line numbers in the edited to-file for changed lines
         edited_linenums = edited_linenums_differ.revision_vs_lines(
-            rev1_relative_path, rev2_isorted, context_lines
+            relative_path_in_repo, rev2_isorted, context_lines
         )
         if enable_isort and not edited_linenums and rev2_isorted == rev2_content:
-            logger.debug("No changes in %s after isort", src)
+            logger.debug("No changes in %s after isort", absolute_path_in_rev2)
             last_successful_reformat = rev2_isorted
             break
 
@@ -232,7 +250,7 @@ def _blacken_single_file(  # pylint: disable=too-many-arguments,too-many-locals
             debug_dump(black_chunks, edited_linenums)
             logger.debug(
                 "AST verification of %s with %s lines of context failed",
-                src,
+                absolute_path_in_rev2,
                 context_lines,
             )
             minimum_context_lines.respond(False)
@@ -240,7 +258,7 @@ def _blacken_single_file(  # pylint: disable=too-many-arguments,too-many-locals
             minimum_context_lines.respond(True)
             last_successful_reformat = chosen
     if not last_successful_reformat:
-        raise NotEquivalentError(relative_path)
+        raise NotEquivalentError(relative_path_in_rev2)
     return last_successful_reformat
 
 
diff --git a/src/darker/diff.py b/src/darker/diff.py
index 646ae27..2445af4 100644
--- a/src/darker/diff.py
+++ b/src/darker/diff.py
@@ -177,3 +177,29 @@ def opcodes_to_chunks(
     _validate_opcodes(opcodes)
     for _tag, src_start, src_end, dst_start, dst_end in opcodes:
         yield src_start + 1, src.lines[src_start:src_end], dst.lines[dst_start:dst_end]
+
+
+def diff_chunks(src: TextDocument, dst: TextDocument) -> List[DiffChunk]:
+    """Diff two documents and return the list of chunks in the diff
+
+    Each chunk is a 3-tuple::
+
+        (
+            linenum: int,
+            old_lines: List[str],
+            new_lines: List[str],
+        )
+
+    ``old_lines`` and ``new_lines`` may be
+
+    - identical to indicate a chunk with no changes,
+    - of the same length but different items to indicate some modified lines, or
+    - of different lengths to indicate removed or inserted lines.
+
+    For the return value ``retval``, the following always holds::
+
+        retval[n + 1][0] == retval[n][0] + len(retval[n][old_lines])
+
+    """
+    opcodes = diff_and_get_opcodes(src, dst)
+    return list(opcodes_to_chunks(opcodes, src, dst))
diff --git a/src/darker/git.py b/src/darker/git.py
index 75ff318..0e294c8 100644
--- a/src/darker/git.py
+++ b/src/darker/git.py
@@ -170,7 +170,7 @@ class RevisionRange:
         return cls(rev1 if common_ancestor == rev1_hash else common_ancestor, rev2)
 
 
-def get_rev1_path(path: Path) -> Path:
+def get_path_in_repo(path: Path) -> Path:
     """Return the relative path to the file in the old revision
 
     This is usually the same as the relative path on the command line. But in the
@@ -188,7 +188,7 @@ def get_rev1_path(path: Path) -> Path:
 
 
 def should_reformat_file(path: Path) -> bool:
-    return path.exists() and get_rev1_path(path).suffix == ".py"
+    return path.exists() and get_path_in_repo(path).suffix == ".py"
 
 
 @lru_cache(maxsize=1)
diff --git a/src/darker/import_sorting.py b/src/darker/import_sorting.py
index f226d53..1174d96 100644
--- a/src/darker/import_sorting.py
+++ b/src/darker/import_sorting.py
@@ -1,11 +1,13 @@
 import logging
 import sys
 from pathlib import Path
-from typing import Any, Optional
+from typing import Any, List, Optional
 
 from darker.black_compat import find_project_root
+from darker.diff import diff_chunks
 from darker.exceptions import IncompatiblePackageError, MissingPackageError
-from darker.utils import TextDocument
+from darker.git import EditedLinenumsDiffer
+from darker.utils import DiffChunk, TextDocument
 
 if sys.version_info >= (3, 8):
     from typing import TypedDict
@@ -55,31 +57,113 @@ class IsortArgs(TypedDict, total=False):
 def apply_isort(
     content: TextDocument,
     src: Path,
+    edited_linenums_differ: EditedLinenumsDiffer,
     config: Optional[str] = None,
     line_length: Optional[int] = None,
 ) -> TextDocument:
-    isort_args = IsortArgs()
+    """Run isort on the given Python source file content
+
+    :param content: The contents of the Python source code file to sort imports in
+    :param src: The relative path to the file. This must be the actual path in the
+                repository, which may differ from the path given on the command line in
+                case of VSCode temporary files.
+    :param edited_linenums_differ: Helper for finding out which lines were edited
+    :param config: Path to configuration file
+    :param line_length: Maximum line length to use
+
+    """
+    edited_linenums = edited_linenums_differ.revision_vs_lines(
+        src,
+        content,
+        context_lines=0,
+    )
+    if not edited_linenums:
+        return content
+    isort_args = _build_isort_args(src, config, line_length)
+    rev2_isorted = _call_isort_code(content, isort_args)
+    # Get the chunks in the diff between the edited and import-sorted file
+    isort_chunks = diff_chunks(content, rev2_isorted)
+    if not isort_chunks:
+        # No imports were sorted. Return original content.
+        return content
+    if not _diff_overlaps_with_edits(edited_linenums, isort_chunks):
+        # No lines had been modified in the range of modified import lines. Return
+        # original content.
+        return content
+    # The range lines modified by sorted imports overlaps with user modifications in the
+    # code. Return the import-sorted file.
+    return rev2_isorted
+
+
+def _build_isort_args(
+    src: Path,
+    config: Optional[str] = None,
+    line_length: Optional[int] = None,
+) -> IsortArgs:
+    """Build ``isort.code()`` keyword arguments
+
+    :param src: The relative path to the file. This must be the actual path in the
+                repository, which may differ from the path given on the command line in
+                case of VSCode temporary files.
+    :param config: Path to configuration file
+    :param line_length: Maximum line length to use
+
+    """
+    isort_args: IsortArgs = {}
     if config:
         isort_args["settings_file"] = config
     else:
         isort_args["settings_path"] = str(find_project_root((str(src),)))
     if line_length:
         isort_args["line_length"] = line_length
+    return isort_args
 
-    logger.debug(
-        "isort.code(code=..., {})".format(
-            ", ".join(f"{k}={v!r}" for k, v in isort_args.items())
-        )
-    )
 
+def _call_isort_code(content: TextDocument, isort_args: IsortArgs) -> TextDocument:
+    """Call ``isort.code()`` and return the result as a `TextDocument` object
+
+    :param content: The contents of the Python source code file to sort imports in
+    :param isort_args: Keyword arguments for ``isort.code()``
+
+    """
     code = content.string
+    logger.debug(
+        "isort.code(code=..., %s)",
+        ", ".join(f"{k}={v!r}" for k, v in isort_args.items()),
+    )
     try:
         code = isort_code(code=code, **isort_args)
     except isort.exceptions.FileSkipComment:
         pass
-
     return TextDocument.from_str(
         code,
         encoding=content.encoding,
         mtime=content.mtime,
     )
+
+
+def _diff_overlaps_with_edits(
+    edited_linenums: List[int], isort_chunks: List[DiffChunk]
+) -> bool:
+    """Return ``True`` if the complete diff overlaps the range of edited lines
+
+    :param edited_linenums: The line numbers of all edited lines
+    :param isort_chunks: The diff chunks
+    :return: ``True`` if the two overlap
+
+    """
+    if not edited_linenums:
+        return False
+    first_edited_linenum, last_edited_linenum = edited_linenums[0], edited_linenums[-1]
+    modified_chunks = [
+        (linenum, old, new) for linenum, old, new in isort_chunks if old != new
+    ]
+    if not modified_chunks:
+        return False
+    (first_isort_line, _, _) = modified_chunks[0]
+    (last_isort_chunk_start, last_isort_chunk_original_lines, _) = modified_chunks[-1]
+    last_isort_line = last_isort_chunk_start + len(last_isort_chunk_original_lines)
+    return (
+        first_edited_linenum < last_isort_line
+        and last_edited_linenum >= first_isort_line
+    )


Test patch:
diff --git a/src/darker/tests/test_git.py b/src/darker/tests/test_git.py
index 610b75b..d0ad478 100644
--- a/src/darker/tests/test_git.py
+++ b/src/darker/tests/test_git.py
@@ -250,9 +250,9 @@ def test_revisionrange_parse_with_common_ancestor(git_repo, revrange, expect):
     dict(path="file.12345.tmp", expect="file.12345.tmp"),
     dict(path="subdir/file.12345.tmp", expect="subdir/file.12345.tmp"),
 )
-def test_get_rev1_path(path, expect):
-    """``get_rev1_path`` drops two suffixes from ``.py.<HASH>.tmp``"""
-    result = git.get_rev1_path(Path(path))
+def test_get_path_in_repo(path, expect):
+    """``get_path_in_repo`` drops two suffixes from ``.py.<HASH>.tmp``"""
+    result = git.get_path_in_repo(Path(path))
 
     assert result == Path(expect)
 
diff --git a/src/darker/tests/test_import_sorting.py b/src/darker/tests/test_import_sorting.py
index 7f5b3ca..8e00fa6 100644
--- a/src/darker/tests/test_import_sorting.py
+++ b/src/darker/tests/test_import_sorting.py
@@ -1,6 +1,6 @@
 """Tests for :mod:`darker.import_sorting`"""
 
-# pylint: disable=unused-argument
+# pylint: disable=unused-argument,protected-access
 
 from importlib import reload
 from pathlib import Path
@@ -9,11 +9,12 @@ from textwrap import dedent
 import pytest
 
 import darker.import_sorting
+from darker.git import EditedLinenumsDiffer, RevisionRange
 from darker.tests.helpers import isort_present
-from darker.utils import TextDocument
+from darker.utils import TextDocument, joinlines
 
-ORIGINAL_SOURCE = ("import sys", "import os")
-ISORTED_SOURCE = ("import os", "import sys")
+ORIGINAL_SOURCE = ("import sys", "import os", "", "print(42)")
+ISORTED_SOURCE = ("import os", "import sys", "", "print(42)")
 
 
 @pytest.mark.parametrize("present", [True, False])
@@ -31,14 +32,32 @@ def test_import_sorting_importable_with_and_without_isort(present):
 
 @pytest.mark.parametrize("encoding", ["utf-8", "iso-8859-1"])
 @pytest.mark.parametrize("newline", ["\n", "\r\n"])
-def test_apply_isort(encoding, newline):
-    """Import sorting is applied correctly, with encoding and newline intact"""
-    result = darker.import_sorting.apply_isort(
-        TextDocument.from_lines(ORIGINAL_SOURCE, encoding=encoding, newline=newline),
-        Path("test1.py"),
+@pytest.mark.kwparametrize(
+    dict(content=ORIGINAL_SOURCE, expect=ORIGINAL_SOURCE),
+    dict(content=("import sys", "import os"), expect=("import sys", "import os")),
+    dict(
+        content=("import sys", "import os", "# foo", "print(42)"),
+        expect=("import sys", "import os", "# foo", "print(42)"),
+    ),
+    dict(
+        content=("import sys", "import os", "", "print(43)"),
+        expect=("import sys", "import os", "", "print(43)"),
+    ),
+    dict(content=("import   sys", "import os", "", "print(42)"), expect=ISORTED_SOURCE),
+    dict(content=("import sys", "import   os", "", "print(42)"), expect=ISORTED_SOURCE),
+)
+def test_apply_isort(git_repo, encoding, newline, content, expect):
+    """Imports are sorted if edits overlap them, with encoding and newline intact"""
+    git_repo.add({"test1.py": joinlines(ORIGINAL_SOURCE, newline)}, commit="Initial")
+    edited_linenums_differ = EditedLinenumsDiffer(
+        git_repo.root, RevisionRange("HEAD", ":WORKTREE:")
     )
+    src = Path("test1.py")
+    content_ = TextDocument.from_lines(content, encoding=encoding, newline=newline)
+
+    result = darker.import_sorting.apply_isort(content_, src, edited_linenums_differ)
 
-    assert result.lines == ISORTED_SOURCE
+    assert result.lines == expect
     assert result.encoding == encoding
     assert result.newline == newline
 
@@ -100,18 +119,100 @@ def test_isort_config(
     config = str(tmpdir / settings_file) if settings_file else None
 
     actual = darker.import_sorting.apply_isort(
-        TextDocument.from_str(content), Path("test1.py"), config
+        TextDocument.from_str(content),
+        Path("test1.py"),
+        EditedLinenumsDiffer(Path("."), RevisionRange("master", "HEAD")),
+        config,
     )
     assert actual.string == expect
 
 
+@pytest.mark.kwparametrize(
+    dict(src=Path("file.py"), expect={"settings_path": "{cwd}"}),
+    dict(
+        config="myconfig.toml",
+        expect={"settings_file": "myconfig.toml"},
+    ),
+    dict(line_length=42, expect={"settings_path": "{cwd}", "line_length": 42}),
+    src=Path("file.py"),
+    config=None,
+    line_length=None,
+)
+def test_build_isort_args(src, config, line_length, expect):
+    """``_build_isort_args`` returns correct arguments for isort"""
+    result = darker.import_sorting._build_isort_args(src, config, line_length)
+
+    if "settings_path" in expect:
+        expect["settings_path"] = str(expect["settings_path"].format(cwd=Path.cwd()))
+    assert result == expect
+
+
 def test_isort_file_skip_comment():
     """``apply_isort()`` handles ``FileSkipComment`` exception correctly"""
     # Avoid https://github.com/PyCQA/isort/pull/1833 by splitting the skip string
     content = "# iso" + "rt:skip_file"
 
     actual = darker.import_sorting.apply_isort(
-        TextDocument.from_str(content), Path("test1.py")
+        TextDocument.from_str(content),
+        Path("test1.py"),
+        EditedLinenumsDiffer(Path("."), RevisionRange("master", "HEAD")),
     )
 
     assert actual.string == content
+
+
+@pytest.mark.kwparametrize(
+    dict(edited_linenums=[], isort_chunks=[], expect=False),
+    dict(edited_linenums=[1, 2, 3, 4, 5, 6, 7, 8, 9], isort_chunks=[], expect=False),
+    dict(edited_linenums=[], isort_chunks=[(1, ("a", "b"), ("A", "B"))], expect=False),
+    dict(edited_linenums=[1], isort_chunks=[(1, ("a", "b"), ("A", "B"))], expect=True),
+    dict(edited_linenums=[2], isort_chunks=[(1, ("a", "b"), ("A", "B"))], expect=True),
+    dict(edited_linenums=[3], isort_chunks=[(1, ("a", "b"), ("A", "B"))], expect=False),
+    dict(edited_linenums=[], isort_chunks=[(1, ("A", "B"), ("A", "B"))], expect=False),
+    dict(edited_linenums=[1], isort_chunks=[(1, ("A", "B"), ("A", "B"))], expect=False),
+    dict(edited_linenums=[2], isort_chunks=[(1, ("A", "B"), ("A", "B"))], expect=False),
+    dict(edited_linenums=[3], isort_chunks=[(1, ("A", "B"), ("A", "B"))], expect=False),
+    dict(
+        edited_linenums=[3, 9],
+        isort_chunks=[
+            (1, ("a", "b"), ("A", "B")),
+            (3, ("c", "d", "e", "f", "g"), ("c", "d", "e", "f", "g")),
+            (8, ("h", "i", "j"), ("h", "i", "j")),
+        ],
+        expect=False,
+    ),
+    dict(
+        edited_linenums=[3, 9],
+        isort_chunks=[
+            (1, ("a", "b", "c"), ("A", "B", "C")),
+            (4, ("d", "e", "f", "g"), ("d", "e", "f", "g")),
+            (8, ("h", "i", "j"), ("h", "i", "j")),
+        ],
+        expect=True,
+    ),
+    dict(
+        edited_linenums=[3, 9],
+        isort_chunks=[
+            (1, ("a", "b", "c"), ("a", "b", "c")),
+            (4, ("d", "e", "f", "g"), ("d", "e", "f", "g")),
+            (8, ("h", "i", "j"), ("H", "I", "J")),
+        ],
+        expect=True,
+    ),
+    dict(
+        edited_linenums=[3, 9],
+        isort_chunks=[
+            (1, ("a", "b", "c", "d"), ("a", "b", "c", "d")),
+            (5, ("e", "f", "g", "h", "i"), ("e", "f", "g", "h", "i")),
+            (10, ("j"), ("J")),
+        ],
+        expect=False,
+    ),
+)
+def test_diff_overlaps_with_edits(edited_linenums, isort_chunks, expect):
+    """Overlapping edits and sorting of imports are detected correctly"""
+    result = darker.import_sorting._diff_overlaps_with_edits(
+        edited_linenums, isort_chunks
+    )
+
+    assert result == expect
diff --git a/src/darker/tests/test_main.py b/src/darker/tests/test_main.py
index b1bca07..613e56a 100644
--- a/src/darker/tests/test_main.py
+++ b/src/darker/tests/test_main.py
@@ -16,7 +16,7 @@ import darker.__main__
 import darker.import_sorting
 import darker.linting
 from darker.exceptions import MissingPackageError
-from darker.git import WORKTREE, RevisionRange
+from darker.git import WORKTREE, EditedLinenumsDiffer, RevisionRange
 from darker.tests.helpers import isort_present
 from darker.utils import TextDocument, joinlines
 from darker.verification import NotEquivalentError
@@ -391,7 +391,8 @@ def test_blacken_single_file(
     result = darker.__main__._blacken_single_file(
         git_repo.root,
         Path(relative_path),
-        RevisionRange(rev1, rev2),
+        Path("file.py"),
+        EditedLinenumsDiffer(git_repo.root, RevisionRange(rev1, rev2)),
         TextDocument(rev2_content),
         TextDocument(rev2_isorted),
         enable_isort,
diff --git a/src/darker/tests/test_main_blacken_single_file.py b/src/darker/tests/test_main_blacken_single_file.py
index ab38b1e..e89c1ca 100644
--- a/src/darker/tests/test_main_blacken_single_file.py
+++ b/src/darker/tests/test_main_blacken_single_file.py
@@ -6,7 +6,7 @@ from pathlib import Path
 from textwrap import dedent
 
 import darker.__main__
-from darker.git import RevisionRange
+from darker.git import EditedLinenumsDiffer, RevisionRange
 from darker.utils import TextDocument
 
 
@@ -58,7 +58,11 @@ def test_blacken_single_file_common_ancestor(git_repo):
     result = darker.__main__._blacken_single_file(
         git_repo.root,
         Path("a.py"),
-        RevisionRange.parse_with_common_ancestor("master...", git_repo.root),
+        Path("a.py"),
+        EditedLinenumsDiffer(
+            git_repo.root,
+            RevisionRange.parse_with_common_ancestor("master...", git_repo.root),
+        ),
         rev2_content=worktree,
         rev2_isorted=worktree,
         enable_isort=False,
@@ -112,7 +116,11 @@ def test_reformat_single_file_docstring(git_repo):
     result = darker.__main__._blacken_single_file(
         git_repo.root,
         Path("a.py"),
-        RevisionRange("HEAD", ":WORKTREE:"),
+        Path("a.py"),
+        EditedLinenumsDiffer(
+            git_repo.root,
+            RevisionRange.parse_with_common_ancestor("HEAD..", git_repo.root),
+        ),
         rev2_content=TextDocument.from_str(modified),
         rev2_isorted=TextDocument.from_str(modified),
         enable_isort=False,