Repository: tianocore/edk2-pytool-extensions

Issue:
[Feature]: Respect git worktrees
### What does the feature solve?

Git worktrees are a standard, well-defined git feature (see [git-worktree](https://git-scm.com/docs/git-worktree)).

The Self-Describing Environment (SDE) currently does not recognize git worktrees which means it fails with multiple descriptors present since the worktree is a copy/variation of the base repo.

The SDE should recognize git worktrees and not load descriptors from the worktrees if they are not the active workspace.

### Describe the solution

Detect git worktrees. If a git worktree is not the active workspace, then do not allow the Self-Describing Environment to process it.

### Have you considered any alternatives?

_No response_

### Additional context

_No response_

Comments:

Patch:
diff --git a/.cspell.json b/.cspell.json
index 08d3448..28c3cdd 100644
--- a/.cspell.json
+++ b/.cspell.json
@@ -129,6 +129,7 @@
         "rtags",
         "Pdbs",
         "uncrustify",
-        "mkdocs"
+        "mkdocs",
+        "pygit"
     ]
 }
diff --git a/edk2toolext/environment/self_describing_environment.py b/edk2toolext/environment/self_describing_environment.py
index 35ecd91..f5d3158 100644
--- a/edk2toolext/environment/self_describing_environment.py
+++ b/edk2toolext/environment/self_describing_environment.py
@@ -13,11 +13,12 @@
 """
 import os
 import logging
-import pathlib
+import pygit2
 from edk2toolext.environment import shell_environment
 from edk2toolext.environment import environment_descriptor_files as EDF
 from edk2toolext.environment import external_dependency
 from multiprocessing import dummy
+from pathlib import Path
 import time
 
 
@@ -44,7 +45,19 @@ def __init__(self, workspace_path, scopes=(), skipped_dirs=()):
         self.scopes = scopes
 
         # Allow certain directories to be skipped
-        self.skipped_dirs = tuple(map(pathlib.Path, (os.path.join(self.workspace, d) for d in skipped_dirs)))
+        self.skipped_dirs = tuple(map(Path, (os.path.join(self.workspace, d) for d in skipped_dirs)))
+
+        # Respect git worktrees
+        repo_path = pygit2.discover_repository(self.workspace)
+        if repo_path:
+            repo = pygit2.Repository(repo_path)
+            worktrees = repo.list_worktrees()
+            for worktree in worktrees:
+                worktree_path = Path(repo.lookup_worktree(worktree).path)
+                if (worktree_path.is_dir()
+                        and Path(self.workspace) != worktree_path
+                        and worktree_path not in skipped_dirs):
+                    self.skipped_dirs += (worktree_path,)
 
         # Validate that all scopes are unique.
         if len(self.scopes) != len(set(self.scopes)):
@@ -67,8 +80,8 @@ def _gather_env_files(self, ext_strings, base_path):
             dirs[:] = [d
                        for d
                        in dirs
-                       if pathlib.Path(root, d) not in self.skipped_dirs
-                       and pathlib.Path(root, d).name != '.git']
+                       if Path(root, d) not in self.skipped_dirs
+                       and Path(root, d).name != '.git']
 
             # Check for any files that match the extensions we're looking for.
             for file in files:
diff --git a/requirements.txt b/requirements.txt
index 9c75c19..65e5d72 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,3 +7,4 @@ coverage == 6.5.0
 pyopenssl == 22.1.0
 pefile == 2022.5.30
 semantic_version == 2.10.0
+pygit2 == 1.11.1
diff --git a/setup.py b/setup.py
index abe879a..c250e36 100644
--- a/setup.py
+++ b/setup.py
@@ -83,7 +83,8 @@ def run(self): # noqa
         'pyyaml>=5.3.1',
         'edk2-pytool-library>=0.12.1',
         'pefile>=2019.4.18',
-        'semantic_version>=2.10.0'
+        'semantic_version>=2.10.0',
+        'pygit2>=1.11.1'
     ],
     extras_require={
         'openssl': ['pyopenssl']


Test patch:
diff --git a/edk2toolext/tests/test_self_describing_environment.py b/edk2toolext/tests/test_self_describing_environment.py
index 6900c8f..2277791 100644
--- a/edk2toolext/tests/test_self_describing_environment.py
+++ b/edk2toolext/tests/test_self_describing_environment.py
@@ -7,6 +7,7 @@
 # SPDX-License-Identifier: BSD-2-Clause-Patent
 ##
 import os
+import pygit2
 import unittest
 import tempfile
 from edk2toolext.environment import self_describing_environment
@@ -114,6 +115,57 @@ def test_duplicate_id_path_env_2(self):
             self_describing_environment.BootstrapEnvironment(self.workspace, scopes)
             self.fail()
 
+    def test_git_worktree(self):
+        """Check that the SDE will recognize a git worktree.
+
+        Specifically verifies duplicate external dependencies in the git
+        worktree are ignored that are discovered during SDE initialization.
+        """
+        # The workspace should not contain a git repo yet
+        repo_path = pygit2.discover_repository(self.workspace)
+        self.assertIsNone(repo_path)
+
+        # Init a git repo in the workspace
+        pygit2.init_repository(self.workspace, initial_head='master')
+        repo_path = pygit2.discover_repository(self.workspace)
+        self.assertIsNotNone(repo_path)
+
+        repo = pygit2.Repository(self.workspace)
+
+        # Create a UEFI tree
+        repo_tree = uefi_tree(self.workspace, create_platform=True)
+        self.assertIsNotNone(repo_tree)
+
+        # Add ext deps to the tree
+        repo_tree.create_ext_dep("nuget", "NuGet.CommandLine", "5.2.0")
+        repo_tree.create_ext_dep("nuget", "NuGet.LibraryModel", "5.6.0")
+
+        # Commit the UEFI tree to the master branch
+        self.assertNotIn('master', repo.branches)
+        index = repo.index
+        index.add_all()
+        index.write()
+        author = pygit2.Signature('SDE Unit Test', 'uefibot@microsoft.com')
+        message = "Add initial platform UEFI worktree"
+        tree = index.write_tree()
+        parents = []
+        repo.create_commit('HEAD', author, author, message, tree, parents)
+        self.assertIn('master', repo.branches)
+
+        # Create the worktree branch
+        worktree_branch = repo.branches.local.create('worktree_branch', commit=repo[repo.head.target])
+        self.assertIn('worktree_branch', repo.branches)
+
+        # Create a worktree on the worktree branch in the git repo
+        self.assertFalse(repo.list_worktrees())
+        repo.add_worktree('test_workspace', os.path.join(self.workspace, '.trees'), worktree_branch)
+        worktrees = repo.list_worktrees()
+        self.assertIn('test_workspace', worktrees)
+
+        # Because this is a subtree, the duplicate ext_deps should be ignored
+        # that are present in the worktree
+        self_describing_environment.BootstrapEnvironment(self.workspace, ('global',))
+
 
 if __name__ == '__main__':
     unittest.main()