Repository: DavidVujic/python-polylith

Issue:
Discrepency between `poly diff` and `poly create` for `tdd` theme
## Describe the bug

The command `poly diff` assumes the configured `tool.polylith.namespace` is a part of the path to a brick, when trying to determine the changed bricks in `polylith.diff.collect.py::_get_changed_bricks`. However, the namespace is not considered by `poly create` when the theme is `tdd`, and will not be part of the path created.

As such, when using the `poly create` and `poly diff` command together in a polylith workspace with the `tdd` theme, `poly diff` will incorrectly report no changes without the enduser knowing why.

## To Reproduce
Steps to reproduce the behavior:
1. Set theme to `tdd` in your `workspace.toml`
2. Create component with `poetry poly create component --name dummy`
3. Commit changes and add a tag
6. Make changes to component
7. Run `git diff --stat --name-only` and views changes reported
8. Run `poetry poly diff` and view no changes reported

## Expected behavior

That `poly diff` does not consider `namespace` when the theme is `tdd` OR that the `namespace` is also included by `poly create` when the theme is `tdd`.

Optionally raise an exception by `poly diff` if the expected path to bricks does not exist.

**Desktop (please complete the following information):**
 - OS: Ubuntu `22.04 LTS`
 - Python version: `3.10`
 - Poetry version: `1.8.3`


Discussion:
sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=232) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=232&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=232&resolutions=WONTFIX)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DavidVujic_python-polylith&pullRequest=232&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=232&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=232&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=232)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=232) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=232&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=232&resolutions=WONTFIX)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DavidVujic_python-polylith&pullRequest=232&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=232&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=232&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=232)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=232) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=232&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=232&resolutions=WONTFIX)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DavidVujic_python-polylith&pullRequest=232&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=232&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=232&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=232)



Patch:
diff --git a/components/polylith/commands/diff.py b/components/polylith/commands/diff.py
index 478223d..a621592 100644
--- a/components/polylith/commands/diff.py
+++ b/components/polylith/commands/diff.py
@@ -7,8 +7,8 @@ from polylith import configuration, diff, info, repo
 def print_views(root: Path, tag: str, short: bool, only_bricks: bool) -> None:
     ns = configuration.get_namespace_from_config(root)
     files = diff.collect.get_files(tag)
-    bases = diff.collect.get_changed_bases(files, ns)
-    components = diff.collect.get_changed_components(files, ns)
+    bases = diff.collect.get_changed_bases(root, files, ns)
+    components = diff.collect.get_changed_components(root, files, ns)
 
     projects = diff.collect.get_changed_projects(files)
     all_projects_data = info.get_bricks_in_projects(root, components, bases, ns)
diff --git a/components/polylith/diff/collect.py b/components/polylith/diff/collect.py
index 7751ce1..1a5dd86 100644
--- a/components/polylith/diff/collect.py
+++ b/components/polylith/diff/collect.py
@@ -1,3 +1,4 @@
+import re
 import subprocess
 from pathlib import Path
 from typing import List, Set, Union
@@ -5,34 +6,48 @@ from typing import List, Set, Union
 from polylith import configuration, repo
 
 
-def _parse_folder_parts(folder: str, changed_file: Path) -> str:
-    file_path = Path(changed_file.as_posix().replace(folder, ""))
+def _parse_folder_parts(pattern: str, changed_file: Path) -> str:
+    parts = re.split(pattern, changed_file.as_posix())
+    remainder = parts[-1]
+
+    file_path = Path(remainder)
 
     return next(p for p in file_path.parts if p != file_path.root)
 
 
-def _get_changed(folder: str, changed_files: List[Path]) -> set:
+def _get_changed(pattern: str, changed_files: List[Path]) -> set:
     return {
-        _parse_folder_parts(folder, f)
+        _parse_folder_parts(pattern, f)
         for f in changed_files
-        if str.startswith(f.as_posix(), folder)
+        if re.match(pattern, f.as_posix())
     }
 
 
+def _parse_path_pattern(root: Path, top_dir: str, namespace: str) -> str:
+    theme = configuration.get_theme_from_config(root)
+
+    if theme == "loose":
+        return f"{top_dir}/{namespace}/"
+
+    return rf"({top_dir}\/).+(\/src\/)({namespace})"
+
+
 def _get_changed_bricks(
-    top_dir: str, changed_files: List[Path], namespace: str
+    root: Path, top_dir: str, changed_files: List[Path], namespace: str
 ) -> list:
-    d = f"{top_dir}/{namespace}"
+    pattern = _parse_path_pattern(root, top_dir, namespace)
 
-    return sorted(_get_changed(d, changed_files))
+    return sorted(_get_changed(pattern, changed_files))
 
 
-def get_changed_components(changed_files: List[Path], namespace: str) -> list:
-    return _get_changed_bricks(repo.components_dir, changed_files, namespace)
+def get_changed_components(
+    root: Path, changed_files: List[Path], namespace: str
+) -> list:
+    return _get_changed_bricks(root, repo.components_dir, changed_files, namespace)
 
 
-def get_changed_bases(changed_files: List[Path], namespace: str) -> list:
-    return _get_changed_bricks(repo.bases_dir, changed_files, namespace)
+def get_changed_bases(root: Path, changed_files: List[Path], namespace: str) -> list:
+    return _get_changed_bricks(root, repo.bases_dir, changed_files, namespace)
 
 
 def get_changed_projects(changed_files: List[Path]) -> list:
diff --git a/development/david.py b/development/david.py
index fb625d2..4f0d47e 100644
--- a/development/david.py
+++ b/development/david.py
@@ -40,8 +40,8 @@ ns = configuration.get_namespace_from_config(root)
 tag = diff.collect.get_latest_tag(root, "release") or ""
 
 changed_files = diff.collect.get_files(tag)
-changed_components = diff.collect.get_changed_components(changed_files, ns)
-changed_bases = diff.collect.get_changed_bases(changed_files, ns)
+changed_components = diff.collect.get_changed_components(root, changed_files, ns)
+changed_bases = diff.collect.get_changed_bases(root, changed_files, ns)
 changed_projects = diff.collect.get_changed_projects(changed_files)
 
 projects_data = info.get_bricks_in_projects(root, changed_components, changed_bases, ns)
diff --git a/projects/poetry_polylith_plugin/pyproject.toml b/projects/poetry_polylith_plugin/pyproject.toml
index 6897bb6..31b5e52 100644
--- a/projects/poetry_polylith_plugin/pyproject.toml
+++ b/projects/poetry_polylith_plugin/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "poetry-polylith-plugin"
-version = "1.23.2"
+version = "1.24.0"
 description = "A Poetry plugin that adds tooling support for the Polylith Architecture"
 authors = ["David Vujic"]
 homepage = "https://davidvujic.github.io/python-polylith-docs/"
diff --git a/projects/polylith_cli/pyproject.toml b/projects/polylith_cli/pyproject.toml
index f54a7ce..6d9ebd7 100644
--- a/projects/polylith_cli/pyproject.toml
+++ b/projects/polylith_cli/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "polylith-cli"
-version = "1.10.2"
+version = "1.11.0"
 description = "Python tooling support for the Polylith Architecture"
 authors = ['David Vujic']
 homepage = "https://davidvujic.github.io/python-polylith-docs/"


Test patch:
diff --git a/test/components/polylith/diff/__init__.py b/test/components/polylith/diff/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/components/polylith/diff/test_collect.py b/test/components/polylith/diff/test_collect.py
new file mode 100644
index 0000000..0af00eb
--- /dev/null
+++ b/test/components/polylith/diff/test_collect.py
@@ -0,0 +1,66 @@
+from polylith.diff import collect
+from pathlib import Path
+import pytest
+
+root = Path.cwd()
+ns = "my_namespace"
+
+
+changed_files_loose = [
+    Path(f"components/{ns}/a/core.py"),
+    Path(f"some/other/{ns}/file.py"),
+    Path(f"bases/{ns}/b/core.py"),
+    Path(f"components/{ns}/b/core.py"),
+    Path(f"components/{ns}/c/nested/subfolder/core.py"),
+]
+
+changed_files_tdd = [
+    Path(f"some/other/{ns}/file.py"),
+    Path(f"bases/b/src/{ns}/b/core.py"),
+    Path(f"components/a/src/{ns}/a/core.py"),
+    Path(f"components/b/src/{ns}/b/core.py"),
+    Path(f"components/{ns}/x/core.py"),
+    Path(f"components/c/src/{ns}/c/nested/subfolder/core.py"),
+]
+
+
+@pytest.fixture
+def setup(monkeypatch):
+    def set_theme(theme: str):
+        monkeypatch.setattr(
+            collect.configuration, "get_theme_from_config", lambda *args: theme
+        )
+
+    return set_theme
+
+
+def test_get_changed_components(setup):
+    setup(theme="loose")
+
+    res = collect.get_changed_components(root, changed_files_loose, ns)
+
+    assert res == ["a", "b", "c"]
+
+
+def test_get_changed_bases(setup):
+    setup(theme="loose")
+
+    res = collect.get_changed_bases(root, changed_files_loose, ns)
+
+    assert res == ["b"]
+
+
+def test_get_changed_components_with_tdd_theme(setup):
+    setup(theme="tdd")
+
+    res = collect.get_changed_components(root, changed_files_tdd, ns)
+
+    assert res == ["a", "b", "c"]
+
+
+def test_get_changed_bases_with_tdd_theme(setup):
+    setup(theme="tdd")
+
+    res = collect.get_changed_bases(root, changed_files_tdd, ns)
+
+    assert res == ["b"]