Commit Diff


commit - 3a0128c1eb5a32eb952a949f06cd55f120858e20
commit + 8fa8c678122e2da16b5a37496ded5605b67cbd81
blob - 0a3226a3feaf808cf7bcf5044ccfec1fa1330d1f
blob + 3d71e24bed4cdbbd1dd1e17fc3703ac4e227d678
--- NEWS
+++ NEWS
@@ -1,4 +1,8 @@
 0.20.47	UNRELEASED
+
+ * On Windows, provide a hint about developer mode
+   when creating symlinks fails due to a permission
+   error. (Jelmer Vernooij, #1005)
 
  * Support repository format version 1.
    (Jelmer Vernooij, #1056)
blob - f442e4370923f8142c668ec39667ab0128e9ac59
blob + 3a33d3f4cc7d716df3bc38350fad8a8a3c7bc882
--- dulwich/index.py
+++ dulwich/index.py
@@ -579,10 +579,38 @@ def index_entry_from_stat(
         flags,
         extended_flags
     )
+
+
+if sys.platform == 'win32':
+    # On Windows, creating symlinks either requires administrator privileges
+    # or developer mode. Raise a more helpful error when we're unable to
+    # create symlinks
+
+    # https://github.com/jelmer/dulwich/issues/1005
+
+    class WindowsSymlinkPermissionError(PermissionError):
+
+        def __init__(self, errno, msg, filename):
+            super(PermissionError, self).__init__(
+                errno, "Unable to create symlink; "
+                "do you have developer mode enabled? %s" % msg,
+                filename)
+
+    def symlink(src, dst, target_is_directory=False, *, dir_fd=None):
+        try:
+            return os.symlink(
+                src, dst, target_is_directory=target_is_directory,
+                dir_fd=dir_fd)
+        except PermissionError as e:
+            raise WindowsSymlinkPermissionError(
+                e.errno, e.strerror, e.filename) from e
+else:
+    symlink = os.symlink
 
 
 def build_file_from_blob(
-    blob, mode, target_path, honor_filemode=True, tree_encoding="utf-8"
+    blob, mode, target_path, *, honor_filemode=True, tree_encoding="utf-8",
+    symlink_fn=None
 ):
     """Build a file or symlink on disk based on a Git object.
 
@@ -592,6 +620,7 @@ def build_file_from_blob(
       target_path: Path to write to
       honor_filemode: An optional flag to honor core.filemode setting in
         config file, default is core.filemode=True, change executable bit
+      symlink: Function to use for creating symlinks
     Returns: stat object for the file
     """
     try:
@@ -607,7 +636,7 @@ def build_file_from_blob(
             # os.readlink on Python3 on Windows requires a unicode string.
             contents = contents.decode(tree_encoding)
             target_path = target_path.decode(tree_encoding)
-        os.symlink(contents, target_path)
+        (symlink_fn or symlink)(contents, target_path)
     else:
         if oldstat is not None and oldstat.st_size == len(contents):
             with open(target_path, "rb") as f:
@@ -657,6 +686,7 @@ def build_index_from_tree(
     tree_id,
     honor_filemode=True,
     validate_path_element=validate_path_element_default,
+    symlink_fn=None
 ):
     """Generate and materialize index from a tree
 
@@ -695,7 +725,9 @@ def build_index_from_tree(
         else:
             obj = object_store[entry.sha]
             st = build_file_from_blob(
-                obj, entry.mode, full_path, honor_filemode=honor_filemode
+                obj, entry.mode, full_path,
+                honor_filemode=honor_filemode,
+                symlink_fn=symlink_fn,
             )
 
         # Add file to index
blob - 850a859b476711d16accda12819b23bfa4bd479f
blob + 2a397a75c225d04b76d94183dc20829ddd6d475d
--- dulwich/porcelain.py
+++ dulwich/porcelain.py
@@ -1875,7 +1875,8 @@ def update_head(repo, target, detached=False, new_bran
             r.refs.set_symbolic_ref(b"HEAD", to_set)
 
 
-def reset_file(repo, file_path: str, target: bytes = b'HEAD'):
+def reset_file(repo, file_path: str, target: bytes = b'HEAD',
+               symlink_fn=None):
     """Reset the file to specific commit or branch.
 
     Args:
@@ -1890,7 +1891,7 @@ def reset_file(repo, file_path: str, target: bytes = b
     full_path = os.path.join(repo.path.encode(), file_path)
     blob = repo.object_store[file_entry[1]]
     mode = file_entry[0]
-    build_file_from_blob(blob, mode, full_path)
+    build_file_from_blob(blob, mode, full_path, symlink_fn=symlink_fn)
 
 
 def check_mailmap(repo, contact):
blob - 2ee88e6034819d94cc4c9d1f833c6b8f1585ef3d
blob + 0fa4b1fd483b2f59fa583f53a414f04a50323967
--- dulwich/repo.py
+++ dulwich/repo.py
@@ -337,6 +337,9 @@ class ParentsProvider(object):
 class BaseRepo(object):
     """Base class for a git repository.
 
+    This base class is meant to be used for Repository implementations that e.g.
+    work on top of a different transport than a standard filesystem path.
+
     Attributes:
       object_store: Dictionary-like object for accessing
         the objects
@@ -1095,6 +1098,7 @@ class Repo(BaseRepo):
         object_store: Optional[BaseObjectStore] = None,
         bare: Optional[bool] = None
     ) -> None:
+        self.symlink_fn = None
         hidden_path = os.path.join(root, CONTROLDIR)
         if bare is None:
             if (os.path.isfile(hidden_path)
@@ -1568,6 +1572,7 @@ class Repo(BaseRepo):
             tree,
             honor_filemode=honor_filemode,
             validate_path_element=validate_path_element,
+            symlink_fn=self.symlink_fn,
         )
 
     def get_config(self) -> "ConfigFile":
blob - 7d3a784c21ef83ef16dea99da7ab608c060ca8a7
blob + 920f344f426e676cf6547400a3e9c8e07a612339
--- dulwich/tests/test_index.py
+++ dulwich/tests/test_index.py
@@ -70,10 +70,6 @@ def can_symlink():
     if sys.platform != "win32":
         # Platforms other than Windows should allow symlinks without issues.
         return True
-
-    if not hasattr(os, "symlink"):
-        # Older Python versions do not have `os.symlink` on Windows.
-        return False
 
     test_source = tempfile.mkdtemp()
     test_target = test_source + "can_symlink"