فهرست منبع

Implement core.sharedRepository configuration support (#1804)

Jelmer Vernooij 3 ماه پیش
والد
کامیت
25b1152326
5فایلهای تغییر یافته به همراه610 افزوده شده و 36 حذف شده
  1. 8 2
      NEWS
  2. 6 1
      dulwich/index.py
  3. 67 21
      dulwich/object_store.py
  4. 188 12
      dulwich/repo.py
  5. 341 0
      tests/test_repository.py

+ 8 - 2
NEWS

@@ -1,5 +1,11 @@
 0.25.0	UNRELEASED
 0.25.0	UNRELEASED
 
 
+ * Implement support for ``core.sharedRepository`` configuration option.
+   Repository files and directories now respect shared repository permissions
+   for group-writable or world-writable repositories. Affects loose objects,
+   pack files, pack indexes, index files, and other git metadata files.
+   (Jelmer Vernooij, #1804)
+
  * Drop support for Python 3.9. (Jelmer Vernooij)
  * Drop support for Python 3.9. (Jelmer Vernooij)
 
 
  * Add support for ``git rerere`` (reuse recorded resolution) with CLI
  * Add support for ``git rerere`` (reuse recorded resolution) with CLI
@@ -131,7 +137,7 @@
    commit metadata. Supports automatic upstream detection from tracking branches
    commit metadata. Supports automatic upstream detection from tracking branches
    and verbose mode to display commit messages. (Jelmer Vernooij, #1782)
    and verbose mode to display commit messages. (Jelmer Vernooij, #1782)
 
 
- * Add support for ``git mailsplit`` command to split mbox files and Maildir
+ * Add support for ``dulwich mailsplit`` command to split mbox files and Maildir
    into individual message files. Supports mboxrd format, custom precision,
    into individual message files. Supports mboxrd format, custom precision,
    and all standard git mailsplit options. (Jelmer Vernooij, #1840)
    and all standard git mailsplit options. (Jelmer Vernooij, #1840)
 
 
@@ -487,7 +493,7 @@
 
 
  * Add ``merge-tree`` plumbing command to ``dulwich.porcelain`` and CLI.
  * Add ``merge-tree`` plumbing command to ``dulwich.porcelain`` and CLI.
    This command performs three-way tree merges without touching the working
    This command performs three-way tree merges without touching the working
-   directory or creating commits, similar to ``git merge-tree``. It outputs
+   directory or creating commits, similar to ``dulwich merge-tree``. It outputs
    the merged tree SHA and lists any conflicted paths. (Jelmer Vernooij)
    the merged tree SHA and lists any conflicted paths. (Jelmer Vernooij)
 
 
  * Add ``porcelain.count_objects()`` function to count unpacked objects and
  * Add ``porcelain.count_objects()`` function to count unpacked objects and

+ 6 - 1
dulwich/index.py

@@ -1060,6 +1060,8 @@ class Index:
         read: bool = True,
         read: bool = True,
         skip_hash: bool = False,
         skip_hash: bool = False,
         version: int | None = None,
         version: int | None = None,
+        *,
+        file_mode: int | None = None,
     ) -> None:
     ) -> None:
         """Create an index object associated with the given filename.
         """Create an index object associated with the given filename.
 
 
@@ -1068,11 +1070,13 @@ class Index:
           read: Whether to initialize the index from the given file, should it exist.
           read: Whether to initialize the index from the given file, should it exist.
           skip_hash: Whether to skip SHA1 hash when writing (for manyfiles feature)
           skip_hash: Whether to skip SHA1 hash when writing (for manyfiles feature)
           version: Index format version to use (None = auto-detect from file or use default)
           version: Index format version to use (None = auto-detect from file or use default)
+          file_mode: Optional file permission mask for shared repository
         """
         """
         self._filename = os.fspath(filename)
         self._filename = os.fspath(filename)
         # TODO(jelmer): Store the version returned by read_index
         # TODO(jelmer): Store the version returned by read_index
         self._version = version
         self._version = version
         self._skip_hash = skip_hash
         self._skip_hash = skip_hash
+        self._file_mode = file_mode
         self._extensions: list[IndexExtension] = []
         self._extensions: list[IndexExtension] = []
         self.clear()
         self.clear()
         if read:
         if read:
@@ -1093,7 +1097,8 @@ class Index:
 
 
     def write(self) -> None:
     def write(self) -> None:
         """Write current contents of index to disk."""
         """Write current contents of index to disk."""
-        f = GitFile(self._filename, "wb")
+        mask = self._file_mode if self._file_mode is not None else 0o644
+        f = GitFile(self._filename, "wb", mask=mask)
         try:
         try:
             # Filter out extensions with no meaningful data
             # Filter out extensions with no meaningful data
             meaningful_extensions = []
             meaningful_extensions = []

+ 67 - 21
dulwich/object_store.py

@@ -1258,6 +1258,7 @@ class DiskObjectStore(PackBasedObjectStore):
     def __init__(
     def __init__(
         self,
         self,
         path: str | os.PathLike[str],
         path: str | os.PathLike[str],
+        *,
         loose_compression_level: int = -1,
         loose_compression_level: int = -1,
         pack_compression_level: int = -1,
         pack_compression_level: int = -1,
         pack_index_version: int | None = None,
         pack_index_version: int | None = None,
@@ -1268,6 +1269,8 @@ class DiskObjectStore(PackBasedObjectStore):
         pack_threads: int | None = None,
         pack_threads: int | None = None,
         pack_big_file_threshold: int | None = None,
         pack_big_file_threshold: int | None = None,
         fsync_object_files: bool = False,
         fsync_object_files: bool = False,
+        file_mode: int | None = None,
+        dir_mode: int | None = None,
     ) -> None:
     ) -> None:
         """Open an object store.
         """Open an object store.
 
 
@@ -1283,6 +1286,8 @@ class DiskObjectStore(PackBasedObjectStore):
           pack_threads: number of threads for pack operations
           pack_threads: number of threads for pack operations
           pack_big_file_threshold: threshold for treating files as big
           pack_big_file_threshold: threshold for treating files as big
           fsync_object_files: whether to fsync object files for durability
           fsync_object_files: whether to fsync object files for durability
+          file_mode: File permission mask for shared repository
+          dir_mode: Directory permission mask for shared repository
         """
         """
         super().__init__(
         super().__init__(
             pack_compression_level=pack_compression_level,
             pack_compression_level=pack_compression_level,
@@ -1301,6 +1306,8 @@ class DiskObjectStore(PackBasedObjectStore):
         self.pack_compression_level = pack_compression_level
         self.pack_compression_level = pack_compression_level
         self.pack_index_version = pack_index_version
         self.pack_index_version = pack_index_version
         self.fsync_object_files = fsync_object_files
         self.fsync_object_files = fsync_object_files
+        self.file_mode = file_mode
+        self.dir_mode = dir_mode
 
 
         # Commit graph support - lazy loaded
         # Commit graph support - lazy loaded
         self._commit_graph = None
         self._commit_graph = None
@@ -1316,13 +1323,20 @@ class DiskObjectStore(PackBasedObjectStore):
 
 
     @classmethod
     @classmethod
     def from_config(
     def from_config(
-        cls, path: str | os.PathLike[str], config: "Config"
+        cls,
+        path: str | os.PathLike[str],
+        config: "Config",
+        *,
+        file_mode: int | None = None,
+        dir_mode: int | None = None,
     ) -> "DiskObjectStore":
     ) -> "DiskObjectStore":
         """Create a DiskObjectStore from a configuration object.
         """Create a DiskObjectStore from a configuration object.
 
 
         Args:
         Args:
           path: Path to the object store directory
           path: Path to the object store directory
           config: Configuration object to read settings from
           config: Configuration object to read settings from
+          file_mode: Optional file permission mask for shared repository
+          dir_mode: Optional directory permission mask for shared repository
 
 
         Returns:
         Returns:
           New DiskObjectStore instance configured according to config
           New DiskObjectStore instance configured according to config
@@ -1390,16 +1404,18 @@ class DiskObjectStore(PackBasedObjectStore):
 
 
         instance = cls(
         instance = cls(
             path,
             path,
-            loose_compression_level,
-            pack_compression_level,
-            pack_index_version,
-            pack_delta_window_size,
-            pack_window_memory,
-            pack_delta_cache_size,
-            pack_depth,
-            pack_threads,
-            pack_big_file_threshold,
-            fsync_object_files,
+            loose_compression_level=loose_compression_level,
+            pack_compression_level=pack_compression_level,
+            pack_index_version=pack_index_version,
+            pack_delta_window_size=pack_delta_window_size,
+            pack_window_memory=pack_window_memory,
+            pack_delta_cache_size=pack_delta_cache_size,
+            pack_depth=pack_depth,
+            pack_threads=pack_threads,
+            pack_big_file_threshold=pack_big_file_threshold,
+            fsync_object_files=fsync_object_files,
+            file_mode=file_mode,
+            dir_mode=dir_mode,
         )
         )
         instance._use_commit_graph = use_commit_graph
         instance._use_commit_graph = use_commit_graph
         return instance
         return instance
@@ -1437,12 +1453,16 @@ class DiskObjectStore(PackBasedObjectStore):
 
 
     def add_alternate_path(self, path: str | os.PathLike[str]) -> None:
     def add_alternate_path(self, path: str | os.PathLike[str]) -> None:
         """Add an alternate path to this object store."""
         """Add an alternate path to this object store."""
+        info_dir = os.path.join(self.path, INFODIR)
         try:
         try:
-            os.mkdir(os.path.join(self.path, INFODIR))
+            os.mkdir(info_dir)
+            if self.dir_mode is not None:
+                os.chmod(info_dir, self.dir_mode)
         except FileExistsError:
         except FileExistsError:
             pass
             pass
         alternates_path = os.path.join(self.path, INFODIR, "alternates")
         alternates_path = os.path.join(self.path, INFODIR, "alternates")
-        with GitFile(alternates_path, "wb") as f:
+        mask = self.file_mode if self.file_mode is not None else 0o644
+        with GitFile(alternates_path, "wb", mask=mask) as f:
             try:
             try:
                 orig_f = open(alternates_path, "rb")
                 orig_f = open(alternates_path, "rb")
             except FileNotFoundError:
             except FileNotFoundError:
@@ -1667,8 +1687,12 @@ class DiskObjectStore(PackBasedObjectStore):
         os.rename(path, target_pack_path)
         os.rename(path, target_pack_path)
 
 
         # Write the index.
         # Write the index.
+        mask = self.file_mode if self.file_mode is not None else PACK_MODE
         with GitFile(
         with GitFile(
-            target_index_path, "wb", mask=PACK_MODE, fsync=self.fsync_object_files
+            target_index_path,
+            "wb",
+            mask=mask,
+            fsync=self.fsync_object_files,
         ) as index_file:
         ) as index_file:
             write_pack_index(
             write_pack_index(
                 index_file, entries, pack_sha, version=self.pack_index_version
                 index_file, entries, pack_sha, version=self.pack_index_version
@@ -1732,7 +1756,8 @@ class DiskObjectStore(PackBasedObjectStore):
 
 
         fd, path = tempfile.mkstemp(dir=self.pack_dir, suffix=".pack")
         fd, path = tempfile.mkstemp(dir=self.pack_dir, suffix=".pack")
         f = os.fdopen(fd, "w+b")
         f = os.fdopen(fd, "w+b")
-        os.chmod(path, PACK_MODE)
+        mask = self.file_mode if self.file_mode is not None else PACK_MODE
+        os.chmod(path, mask)
 
 
         def commit() -> "Pack | None":
         def commit() -> "Pack | None":
             if f.tell() > 0:
             if f.tell() > 0:
@@ -1765,34 +1790,52 @@ class DiskObjectStore(PackBasedObjectStore):
         dir = os.path.dirname(path)
         dir = os.path.dirname(path)
         try:
         try:
             os.mkdir(dir)
             os.mkdir(dir)
+            if self.dir_mode is not None:
+                os.chmod(dir, self.dir_mode)
         except FileExistsError:
         except FileExistsError:
             pass
             pass
         if os.path.exists(path):
         if os.path.exists(path):
             return  # Already there, no need to write again
             return  # Already there, no need to write again
-        with GitFile(path, "wb", mask=PACK_MODE, fsync=self.fsync_object_files) as f:
+        mask = self.file_mode if self.file_mode is not None else PACK_MODE
+        with GitFile(path, "wb", mask=mask, fsync=self.fsync_object_files) as f:
             f.write(
             f.write(
                 obj.as_legacy_object(compression_level=self.loose_compression_level)
                 obj.as_legacy_object(compression_level=self.loose_compression_level)
             )
             )
 
 
     @classmethod
     @classmethod
-    def init(cls, path: str | os.PathLike[str]) -> "DiskObjectStore":
+    def init(
+        cls,
+        path: str | os.PathLike[str],
+        *,
+        file_mode: int | None = None,
+        dir_mode: int | None = None,
+    ) -> "DiskObjectStore":
         """Initialize a new disk object store.
         """Initialize a new disk object store.
 
 
         Creates the necessary directory structure for a Git object store.
         Creates the necessary directory structure for a Git object store.
 
 
         Args:
         Args:
           path: Path where the object store should be created
           path: Path where the object store should be created
+          file_mode: Optional file permission mask for shared repository
+          dir_mode: Optional directory permission mask for shared repository
 
 
         Returns:
         Returns:
           New DiskObjectStore instance
           New DiskObjectStore instance
         """
         """
         try:
         try:
             os.mkdir(path)
             os.mkdir(path)
+            if dir_mode is not None:
+                os.chmod(path, dir_mode)
         except FileExistsError:
         except FileExistsError:
             pass
             pass
-        os.mkdir(os.path.join(path, "info"))
-        os.mkdir(os.path.join(path, PACKDIR))
-        return cls(path)
+        info_path = os.path.join(path, "info")
+        pack_path = os.path.join(path, PACKDIR)
+        os.mkdir(info_path)
+        os.mkdir(pack_path)
+        if dir_mode is not None:
+            os.chmod(info_path, dir_mode)
+            os.chmod(pack_path, dir_mode)
+        return cls(path, file_mode=file_mode, dir_mode=dir_mode)
 
 
     def iter_prefix(self, prefix: bytes) -> Iterator[bytes]:
     def iter_prefix(self, prefix: bytes) -> Iterator[bytes]:
         """Iterate over all object SHAs with the given prefix.
         """Iterate over all object SHAs with the given prefix.
@@ -1914,10 +1957,13 @@ class DiskObjectStore(PackBasedObjectStore):
                 # Ensure the info directory exists
                 # Ensure the info directory exists
                 info_dir = os.path.join(self.path, "info")
                 info_dir = os.path.join(self.path, "info")
                 os.makedirs(info_dir, exist_ok=True)
                 os.makedirs(info_dir, exist_ok=True)
+                if self.dir_mode is not None:
+                    os.chmod(info_dir, self.dir_mode)
 
 
                 # Write using GitFile for atomic operation
                 # Write using GitFile for atomic operation
                 graph_path = os.path.join(info_dir, "commit-graph")
                 graph_path = os.path.join(info_dir, "commit-graph")
-                with GitFile(graph_path, "wb") as f:
+                mask = self.file_mode if self.file_mode is not None else 0o644
+                with GitFile(graph_path, "wb", mask=mask) as f:
                     assert isinstance(
                     assert isinstance(
                         f, _GitFile
                         f, _GitFile
                     )  # GitFile in write mode always returns _GitFile
                     )  # GitFile in write mode always returns _GitFile

+ 188 - 12
dulwich/repo.py

@@ -343,6 +343,71 @@ def _set_filesystem_hidden(path: str) -> None:
     # Could implement other platform specific filesystem hiding here
     # Could implement other platform specific filesystem hiding here
 
 
 
 
+def parse_shared_repository(
+    value: str | bytes | bool,
+) -> tuple[int | None, int | None]:
+    """Parse core.sharedRepository configuration value.
+
+    Args:
+      value: Configuration value (string, bytes, or boolean)
+
+    Returns:
+      tuple of (file_mask, directory_mask) or (None, None) if not shared
+
+    The masks are permission bits to apply via chmod.
+    """
+    if isinstance(value, bytes):
+        value = value.decode("utf-8", errors="replace")
+
+    # Handle boolean values
+    if isinstance(value, bool):
+        if value:
+            # true = group (same as "group")
+            return (0o664, 0o2775)
+        else:
+            # false = umask (use system umask, no adjustment)
+            return (None, None)
+
+    # Handle string values
+    value_lower = value.lower()
+
+    if value_lower in ("false", "0", ""):
+        # Use umask (no adjustment)
+        return (None, None)
+
+    if value_lower in ("true", "1", "group"):
+        # Group writable (with setgid bit)
+        return (0o664, 0o2775)
+
+    if value_lower in ("all", "world", "everybody", "2"):
+        # World readable/writable (with setgid bit)
+        return (0o666, 0o2777)
+
+    if value_lower == "umask":
+        # Explicitly use umask
+        return (None, None)
+
+    # Try to parse as octal
+    if value.startswith("0"):
+        try:
+            mode = int(value, 8)
+            # For directories, add execute bits where read bits are set
+            # and add setgid bit for shared repositories
+            dir_mode = mode | 0o2000  # Add setgid bit
+            if mode & 0o004:
+                dir_mode |= 0o001
+            if mode & 0o040:
+                dir_mode |= 0o010
+            if mode & 0o400:
+                dir_mode |= 0o100
+            return (mode, dir_mode)
+        except ValueError:
+            pass
+
+    # Default to umask for unrecognized values
+    return (None, None)
+
+
 class ParentsProvider:
 class ParentsProvider:
     """Provider for commit parent information."""
     """Provider for commit parent information."""
 
 
@@ -440,7 +505,11 @@ class BaseRepo:
         return sys.platform != "win32"
         return sys.platform != "win32"
 
 
     def _init_files(
     def _init_files(
-        self, bare: bool, symlinks: bool | None = None, format: int | None = None
+        self,
+        bare: bool,
+        symlinks: bool | None = None,
+        format: int | None = None,
+        shared_repository: str | bool | None = None,
     ) -> None:
     ) -> None:
         """Initialize a default set of named files."""
         """Initialize a default set of named files."""
         from .config import ConfigFile
         from .config import ConfigFile
@@ -466,6 +535,14 @@ class BaseRepo:
 
 
         cf.set("core", "bare", bare)
         cf.set("core", "bare", bare)
         cf.set("core", "logallrefupdates", True)
         cf.set("core", "logallrefupdates", True)
+
+        # Set shared repository if specified
+        if shared_repository is not None:
+            if isinstance(shared_repository, bool):
+                cf.set("core", "sharedRepository", shared_repository)
+            else:
+                cf.set("core", "sharedRepository", shared_repository)
+
         cf.write_to_file(f)
         cf.write_to_file(f)
         self._put_named_file("config", f.getvalue())
         self._put_named_file("config", f.getvalue())
         self._put_named_file(os.path.join("info", "exclude"), b"")
         self._put_named_file(os.path.join("info", "exclude"), b"")
@@ -1298,8 +1375,18 @@ class Repo(BaseRepo):
                 raise UnsupportedExtension(extension.decode("utf-8"))
                 raise UnsupportedExtension(extension.decode("utf-8"))
 
 
         if object_store is None:
         if object_store is None:
+            # Get shared repository permissions from config
+            try:
+                shared_value = config.get(("core",), "sharedRepository")
+                file_mode, dir_mode = parse_shared_repository(shared_value)
+            except KeyError:
+                file_mode, dir_mode = None, None
+
             object_store = DiskObjectStore.from_config(
             object_store = DiskObjectStore.from_config(
-                os.path.join(self.commondir(), OBJECTDIR), config
+                os.path.join(self.commondir(), OBJECTDIR),
+                config,
+                file_mode=file_mode,
+                dir_mode=dir_mode,
             )
             )
 
 
         # Use reftable if extension is configured
         # Use reftable if extension is configured
@@ -1354,10 +1441,23 @@ class Repo(BaseRepo):
         from .reflog import format_reflog_line
         from .reflog import format_reflog_line
 
 
         path = self._reflog_path(ref)
         path = self._reflog_path(ref)
-        try:
-            os.makedirs(os.path.dirname(path))
-        except FileExistsError:
-            pass
+
+        # Get shared repository permissions
+        file_mode, dir_mode = self._get_shared_repository_permissions()
+
+        # Create directory with appropriate permissions
+        parent_dir = os.path.dirname(path)
+        # Create directory tree, setting permissions on each level if needed
+        parts = []
+        current = parent_dir
+        while current and not os.path.exists(current):
+            parts.append(current)
+            current = os.path.dirname(current)
+        parts.reverse()
+        for part in parts:
+            os.mkdir(part)
+            if dir_mode is not None:
+                os.chmod(part, dir_mode)
         if committer is None:
         if committer is None:
             config = self.get_config_stack()
             config = self.get_config_stack()
             committer = get_user_identity(config)
             committer = get_user_identity(config)
@@ -1374,6 +1474,11 @@ class Repo(BaseRepo):
                 + b"\n"
                 + b"\n"
             )
             )
 
 
+        # Set file permissions (open() respects umask, so we need chmod to set the actual mode)
+        # Always chmod to ensure correct permissions even if file already existed
+        if file_mode is not None:
+            os.chmod(path, file_mode)
+
     def _reflog_path(self, ref: bytes) -> str:
     def _reflog_path(self, ref: bytes) -> str:
         if ref.startswith((b"main-worktree/", b"worktrees/")):
         if ref.startswith((b"main-worktree/", b"worktrees/")):
             raise NotImplementedError(f"refs {ref.decode()} are not supported")
             raise NotImplementedError(f"refs {ref.decode()} are not supported")
@@ -1468,6 +1573,21 @@ class Repo(BaseRepo):
         # TODO(jelmer): Actually probe disk / look at filesystem
         # TODO(jelmer): Actually probe disk / look at filesystem
         return sys.platform != "win32"
         return sys.platform != "win32"
 
 
+    def _get_shared_repository_permissions(
+        self,
+    ) -> tuple[int | None, int | None]:
+        """Get shared repository file and directory permissions from config.
+
+        Returns:
+            tuple of (file_mask, directory_mask) or (None, None) if not shared
+        """
+        try:
+            config = self.get_config()
+            value = config.get(("core",), "sharedRepository")
+            return parse_shared_repository(value)
+        except KeyError:
+            return (None, None)
+
     def _put_named_file(self, path: str, contents: bytes) -> None:
     def _put_named_file(self, path: str, contents: bytes) -> None:
         """Write a file to the control dir with the given name and contents.
         """Write a file to the control dir with the given name and contents.
 
 
@@ -1476,8 +1596,19 @@ class Repo(BaseRepo):
           contents: A string to write to the file.
           contents: A string to write to the file.
         """
         """
         path = path.lstrip(os.path.sep)
         path = path.lstrip(os.path.sep)
-        with GitFile(os.path.join(self.controldir(), path), "wb") as f:
-            f.write(contents)
+
+        # Get shared repository permissions
+        file_mode, _ = self._get_shared_repository_permissions()
+
+        # Create file with appropriate permissions
+        if file_mode is not None:
+            with GitFile(
+                os.path.join(self.controldir(), path), "wb", mask=file_mode
+            ) as f:
+                f.write(contents)
+        else:
+            with GitFile(os.path.join(self.controldir(), path), "wb") as f:
+                f.write(contents)
 
 
     def _del_named_file(self, path: str) -> None:
     def _del_named_file(self, path: str) -> None:
         try:
         try:
@@ -1553,7 +1684,15 @@ class Repo(BaseRepo):
                 index_version = None
                 index_version = None
             skip_hash = config.get_boolean(b"index", b"skipHash", False)
             skip_hash = config.get_boolean(b"index", b"skipHash", False)
 
 
-        return Index(self.index_path(), skip_hash=skip_hash, version=index_version)
+        # Get shared repository permissions for index file
+        file_mode, _ = self._get_shared_repository_permissions()
+
+        return Index(
+            self.index_path(),
+            skip_hash=skip_hash,
+            version=index_version,
+            file_mode=file_mode,
+        )
 
 
     def has_index(self) -> bool:
     def has_index(self) -> bool:
         """Check if an index is present."""
         """Check if an index is present."""
@@ -1860,6 +1999,7 @@ class Repo(BaseRepo):
         default_branch: bytes | None = None,
         default_branch: bytes | None = None,
         symlinks: bool | None = None,
         symlinks: bool | None = None,
         format: int | None = None,
         format: int | None = None,
+        shared_repository: str | bool | None = None,
     ) -> "Repo":
     ) -> "Repo":
         path = os.fspath(path)
         path = os.fspath(path)
         if isinstance(path, bytes):
         if isinstance(path, bytes):
@@ -1867,10 +2007,26 @@ class Repo(BaseRepo):
         controldir = os.fspath(controldir)
         controldir = os.fspath(controldir)
         if isinstance(controldir, bytes):
         if isinstance(controldir, bytes):
             controldir = os.fsdecode(controldir)
             controldir = os.fsdecode(controldir)
+
+        # Determine shared repository permissions early
+        file_mode: int | None = None
+        dir_mode: int | None = None
+        if shared_repository is not None:
+            file_mode, dir_mode = parse_shared_repository(shared_repository)
+
+        # Create base directories with appropriate permissions
         for d in BASE_DIRECTORIES:
         for d in BASE_DIRECTORIES:
-            os.mkdir(os.path.join(controldir, *d))
+            dir_path = os.path.join(controldir, *d)
+            os.mkdir(dir_path)
+            if dir_mode is not None:
+                os.chmod(dir_path, dir_mode)
+
         if object_store is None:
         if object_store is None:
-            object_store = DiskObjectStore.init(os.path.join(controldir, OBJECTDIR))
+            object_store = DiskObjectStore.init(
+                os.path.join(controldir, OBJECTDIR),
+                file_mode=file_mode,
+                dir_mode=dir_mode,
+            )
         ret = cls(path, bare=bare, object_store=object_store)
         ret = cls(path, bare=bare, object_store=object_store)
         if default_branch is None:
         if default_branch is None:
             if config is None:
             if config is None:
@@ -1882,7 +2038,12 @@ class Repo(BaseRepo):
             except KeyError:
             except KeyError:
                 default_branch = DEFAULT_BRANCH
                 default_branch = DEFAULT_BRANCH
         ret.refs.set_symbolic_ref(b"HEAD", local_branch_name(default_branch))
         ret.refs.set_symbolic_ref(b"HEAD", local_branch_name(default_branch))
-        ret._init_files(bare=bare, symlinks=symlinks, format=format)
+        ret._init_files(
+            bare=bare,
+            symlinks=symlinks,
+            format=format,
+            shared_repository=shared_repository,
+        )
         return ret
         return ret
 
 
     @classmethod
     @classmethod
@@ -1895,6 +2056,7 @@ class Repo(BaseRepo):
         default_branch: bytes | None = None,
         default_branch: bytes | None = None,
         symlinks: bool | None = None,
         symlinks: bool | None = None,
         format: int | None = None,
         format: int | None = None,
+        shared_repository: str | bool | None = None,
     ) -> "Repo":
     ) -> "Repo":
         """Create a new repository.
         """Create a new repository.
 
 
@@ -1905,6 +2067,7 @@ class Repo(BaseRepo):
           default_branch: Default branch name
           default_branch: Default branch name
           symlinks: Whether to support symlinks
           symlinks: Whether to support symlinks
           format: Repository format version (defaults to 0)
           format: Repository format version (defaults to 0)
+          shared_repository: Shared repository setting (group, all, umask, or octal)
         Returns: `Repo` instance
         Returns: `Repo` instance
         """
         """
         path = os.fspath(path)
         path = os.fspath(path)
@@ -1923,6 +2086,7 @@ class Repo(BaseRepo):
             default_branch=default_branch,
             default_branch=default_branch,
             symlinks=symlinks,
             symlinks=symlinks,
             format=format,
             format=format,
+            shared_repository=shared_repository,
         )
         )
 
 
     @classmethod
     @classmethod
@@ -1956,12 +2120,21 @@ class Repo(BaseRepo):
         gitdirfile = os.path.join(path, CONTROLDIR)
         gitdirfile = os.path.join(path, CONTROLDIR)
         with open(gitdirfile, "wb") as f:
         with open(gitdirfile, "wb") as f:
             f.write(b"gitdir: " + os.fsencode(worktree_controldir) + b"\n")
             f.write(b"gitdir: " + os.fsencode(worktree_controldir) + b"\n")
+
+        # Get shared repository permissions from main repository
+        _, dir_mode = main_repo._get_shared_repository_permissions()
+
+        # Create directories with appropriate permissions
         try:
         try:
             os.mkdir(main_worktreesdir)
             os.mkdir(main_worktreesdir)
+            if dir_mode is not None:
+                os.chmod(main_worktreesdir, dir_mode)
         except FileExistsError:
         except FileExistsError:
             pass
             pass
         try:
         try:
             os.mkdir(worktree_controldir)
             os.mkdir(worktree_controldir)
+            if dir_mode is not None:
+                os.chmod(worktree_controldir, dir_mode)
         except FileExistsError:
         except FileExistsError:
             pass
             pass
         with open(os.path.join(worktree_controldir, GITDIR), "wb") as f:
         with open(os.path.join(worktree_controldir, GITDIR), "wb") as f:
@@ -1984,6 +2157,7 @@ class Repo(BaseRepo):
         config: "StackedConfig | None" = None,
         config: "StackedConfig | None" = None,
         default_branch: bytes | None = None,
         default_branch: bytes | None = None,
         format: int | None = None,
         format: int | None = None,
+        shared_repository: str | bool | None = None,
     ) -> "Repo":
     ) -> "Repo":
         """Create a new bare repository.
         """Create a new bare repository.
 
 
@@ -1996,6 +2170,7 @@ class Repo(BaseRepo):
           config: Configuration object
           config: Configuration object
           default_branch: Default branch name
           default_branch: Default branch name
           format: Repository format version (defaults to 0)
           format: Repository format version (defaults to 0)
+          shared_repository: Shared repository setting (group, all, umask, or octal)
         Returns: a `Repo` instance
         Returns: a `Repo` instance
         """
         """
         path = os.fspath(path)
         path = os.fspath(path)
@@ -2011,6 +2186,7 @@ class Repo(BaseRepo):
             config=config,
             config=config,
             default_branch=default_branch,
             default_branch=default_branch,
             format=format,
             format=format,
+            shared_repository=shared_repository,
         )
         )
 
 
     create = init_bare
     create = init_bare

+ 341 - 0
tests/test_repository.py

@@ -28,6 +28,7 @@ import shutil
 import stat
 import stat
 import sys
 import sys
 import tempfile
 import tempfile
+import time
 import warnings
 import warnings
 
 
 from dulwich import errors, objects, porcelain
 from dulwich import errors, objects, porcelain
@@ -2080,3 +2081,343 @@ class RepoConfigIncludeIfTests(TestCase):
             config = r.get_config()
             config = r.get_config()
             self.assertEqual(b"true", config.get((b"core",), b"autocrlf"))
             self.assertEqual(b"true", config.get((b"core",), b"autocrlf"))
             r.close()
             r.close()
+
+
+class SharedRepositoryTests(TestCase):
+    """Tests for core.sharedRepository functionality."""
+
+    def setUp(self):
+        super().setUp()
+        self._orig_umask = os.umask(0o022)
+
+    def tearDown(self):
+        os.umask(self._orig_umask)
+        super().tearDown()
+
+    def _get_file_mode(self, path):
+        """Get the file mode bits (without file type bits)."""
+        return stat.S_IMODE(os.stat(path).st_mode)
+
+    def _check_permissions(self, repo, expected_file_mode, expected_dir_mode):
+        """Check that repository files and directories have expected permissions."""
+        objects_dir = os.path.join(repo.commondir(), "objects")
+
+        # Check objects directory
+        actual_dir_mode = self._get_file_mode(objects_dir)
+        self.assertEqual(
+            expected_dir_mode,
+            actual_dir_mode,
+            f"objects dir mode: expected {oct(expected_dir_mode)}, got {oct(actual_dir_mode)}",
+        )
+
+        # Check pack directory
+        pack_dir = os.path.join(objects_dir, "pack")
+        actual_dir_mode = self._get_file_mode(pack_dir)
+        self.assertEqual(
+            expected_dir_mode,
+            actual_dir_mode,
+            f"pack dir mode: expected {oct(expected_dir_mode)}, got {oct(actual_dir_mode)}",
+        )
+
+        # Check info directory
+        info_dir = os.path.join(objects_dir, "info")
+        actual_dir_mode = self._get_file_mode(info_dir)
+        self.assertEqual(
+            expected_dir_mode,
+            actual_dir_mode,
+            f"info dir mode: expected {oct(expected_dir_mode)}, got {oct(actual_dir_mode)}",
+        )
+
+    def test_init_bare_shared_group(self):
+        """Test initializing bare repo with sharedRepository=group."""
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+
+        # Set umask to 0 to see what permissions are actually set
+        os.umask(0)
+
+        repo = Repo.init_bare(tmp_dir, shared_repository="group")
+        self.addCleanup(repo.close)
+
+        # Expected permissions for group sharing
+        expected_dir_mode = 0o2775  # setgid + rwxrwxr-x
+        expected_file_mode = 0o664  # rw-rw-r--
+
+        self._check_permissions(repo, expected_file_mode, expected_dir_mode)
+
+    def test_init_bare_shared_all(self):
+        """Test initializing bare repo with sharedRepository=all."""
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+
+        # Set umask to 0 to see what permissions are actually set
+        os.umask(0)
+
+        repo = Repo.init_bare(tmp_dir, shared_repository="all")
+        self.addCleanup(repo.close)
+
+        # Expected permissions for world sharing
+        expected_dir_mode = 0o2777  # setgid + rwxrwxrwx
+        expected_file_mode = 0o666  # rw-rw-rw-
+
+        self._check_permissions(repo, expected_file_mode, expected_dir_mode)
+
+    def test_init_bare_shared_umask(self):
+        """Test initializing bare repo with sharedRepository=umask (default)."""
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+
+        repo = Repo.init_bare(tmp_dir, shared_repository="umask")
+        self.addCleanup(repo.close)
+
+        # With umask, no special permissions should be set
+        # The actual permissions will depend on the umask, but we can
+        # at least verify that setgid is NOT set
+        objects_dir = os.path.join(repo.commondir(), "objects")
+        actual_mode = os.stat(objects_dir).st_mode
+
+        # Verify setgid bit is NOT set
+        self.assertEqual(0, actual_mode & stat.S_ISGID)
+
+    def test_loose_object_permissions_group(self):
+        """Test that loose objects get correct permissions with sharedRepository=group."""
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+
+        # Set umask to 0 to see what permissions are actually set
+        os.umask(0)
+
+        repo = Repo.init_bare(tmp_dir, shared_repository="group")
+        self.addCleanup(repo.close)
+
+        # Create a blob object
+        blob = objects.Blob.from_string(b"test content")
+        repo.object_store.add_object(blob)
+
+        # Find the object file
+        obj_path = repo.object_store._get_shafile_path(blob.id)
+
+        # Check file permissions
+        actual_mode = self._get_file_mode(obj_path)
+        expected_mode = 0o664  # rw-rw-r--
+        self.assertEqual(
+            expected_mode,
+            actual_mode,
+            f"loose object mode: expected {oct(expected_mode)}, got {oct(actual_mode)}",
+        )
+
+        # Check directory permissions
+        obj_dir = os.path.dirname(obj_path)
+        actual_dir_mode = self._get_file_mode(obj_dir)
+        expected_dir_mode = 0o2775  # setgid + rwxrwxr-x
+        self.assertEqual(
+            expected_dir_mode,
+            actual_dir_mode,
+            f"object dir mode: expected {oct(expected_dir_mode)}, got {oct(actual_dir_mode)}",
+        )
+
+    def test_loose_object_permissions_all(self):
+        """Test that loose objects get correct permissions with sharedRepository=all."""
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+
+        # Set umask to 0 to see what permissions are actually set
+        os.umask(0)
+
+        repo = Repo.init_bare(tmp_dir, shared_repository="all")
+        self.addCleanup(repo.close)
+
+        # Create a blob object
+        blob = objects.Blob.from_string(b"test content")
+        repo.object_store.add_object(blob)
+
+        # Find the object file
+        obj_path = repo.object_store._get_shafile_path(blob.id)
+
+        # Check file permissions
+        actual_mode = self._get_file_mode(obj_path)
+        expected_mode = 0o666  # rw-rw-rw-
+        self.assertEqual(
+            expected_mode,
+            actual_mode,
+            f"loose object mode: expected {oct(expected_mode)}, got {oct(actual_mode)}",
+        )
+
+    def test_pack_file_permissions_group(self):
+        """Test that pack files get correct permissions with sharedRepository=group."""
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+
+        # Set umask to 0 to see what permissions are actually set
+        os.umask(0)
+
+        repo = Repo.init_bare(tmp_dir, shared_repository="group")
+        self.addCleanup(repo.close)
+
+        # Create some objects
+        blobs = [
+            objects.Blob.from_string(f"test content {i}".encode()) for i in range(5)
+        ]
+        repo.object_store.add_objects([(blob, None) for blob in blobs])
+
+        # Find the pack files
+        pack_dir = os.path.join(repo.commondir(), "objects", "pack")
+        pack_files = [f for f in os.listdir(pack_dir) if f.endswith(".pack")]
+        self.assertGreater(len(pack_files), 0, "No pack files created")
+
+        # Check pack file permissions
+        pack_path = os.path.join(pack_dir, pack_files[0])
+        actual_mode = self._get_file_mode(pack_path)
+        expected_mode = 0o664  # rw-rw-r--
+        self.assertEqual(
+            expected_mode,
+            actual_mode,
+            f"pack file mode: expected {oct(expected_mode)}, got {oct(actual_mode)}",
+        )
+
+    def test_pack_index_permissions_group(self):
+        """Test that pack index files get correct permissions with sharedRepository=group."""
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+
+        # Set umask to 0 to see what permissions are actually set
+        os.umask(0)
+
+        repo = Repo.init_bare(tmp_dir, shared_repository="group")
+        self.addCleanup(repo.close)
+
+        # Create some objects
+        blobs = [
+            objects.Blob.from_string(f"test content {i}".encode()) for i in range(5)
+        ]
+        repo.object_store.add_objects([(blob, None) for blob in blobs])
+
+        # Find the pack index files
+        pack_dir = os.path.join(repo.commondir(), "objects", "pack")
+        idx_files = [f for f in os.listdir(pack_dir) if f.endswith(".idx")]
+        self.assertGreater(len(idx_files), 0, "No pack index files created")
+
+        # Check pack index file permissions
+        idx_path = os.path.join(pack_dir, idx_files[0])
+        actual_mode = self._get_file_mode(idx_path)
+        expected_mode = 0o664  # rw-rw-r--
+        self.assertEqual(
+            expected_mode,
+            actual_mode,
+            f"pack index mode: expected {oct(expected_mode)}, got {oct(actual_mode)}",
+        )
+
+    def test_index_file_permissions_group(self):
+        """Test that index file gets correct permissions with sharedRepository=group."""
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+
+        # Set umask to 0 to see what permissions are actually set
+        os.umask(0)
+
+        # Create non-bare repo (index only exists in non-bare repos)
+        repo = Repo.init(tmp_dir, shared_repository="group")
+        self.addCleanup(repo.close)
+
+        # Make a change to trigger index write
+        blob = objects.Blob.from_string(b"test content")
+        repo.object_store.add_object(blob)
+        test_file = os.path.join(tmp_dir, "test.txt")
+        with open(test_file, "wb") as f:
+            f.write(b"test content")
+        # Stage the file
+        porcelain.add(repo, [test_file])
+
+        # Check index file permissions
+        index_path = repo.index_path()
+        actual_mode = self._get_file_mode(index_path)
+        expected_mode = 0o664  # rw-rw-r--
+        self.assertEqual(
+            expected_mode,
+            actual_mode,
+            f"index file mode: expected {oct(expected_mode)}, got {oct(actual_mode)}",
+        )
+
+    def test_existing_repo_respects_config(self):
+        """Test that opening an existing repo respects core.sharedRepository config."""
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+
+        # Set umask to 0 to see what permissions are actually set
+        os.umask(0)
+
+        # Create repo with shared=group
+        repo = Repo.init_bare(tmp_dir, shared_repository="group")
+        repo.close()
+
+        # Reopen the repo
+        repo = Repo(tmp_dir)
+        self.addCleanup(repo.close)
+
+        # Add an object and check permissions
+        blob = objects.Blob.from_string(b"test content after reopen")
+        repo.object_store.add_object(blob)
+
+        obj_path = repo.object_store._get_shafile_path(blob.id)
+        actual_mode = self._get_file_mode(obj_path)
+        expected_mode = 0o664  # rw-rw-r--
+        self.assertEqual(
+            expected_mode,
+            actual_mode,
+            f"loose object mode after reopen: expected {oct(expected_mode)}, got {oct(actual_mode)}",
+        )
+
+    def test_reflog_permissions_group(self):
+        """Test that reflog files get correct permissions with sharedRepository=group."""
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+
+        # Set umask to 0 to see what permissions are actually set
+        os.umask(0)
+
+        repo = Repo.init(tmp_dir, shared_repository="group")
+        self.addCleanup(repo.close)
+
+        # Create a commit to trigger reflog creation
+        blob = objects.Blob.from_string(b"test content")
+        tree = objects.Tree()
+        tree.add(b"test.txt", 0o100644, blob.id)
+
+        c = objects.Commit()
+        c.tree = tree.id
+        c.author = c.committer = b"Test <test@example.com>"
+        c.author_time = c.commit_time = int(time.time())
+        c.author_timezone = c.commit_timezone = 0
+        c.encoding = b"UTF-8"
+        c.message = b"Test commit"
+
+        repo.object_store.add_object(blob)
+        repo.object_store.add_object(tree)
+        repo.object_store.add_object(c)
+
+        # Update ref to trigger reflog creation
+        repo.refs.set_if_equals(
+            b"refs/heads/master", None, c.id, message=b"commit: initial commit"
+        )
+
+        # Check reflog file permissions
+        reflog_path = os.path.join(repo.controldir(), "logs", "refs", "heads", "master")
+        self.assertTrue(os.path.exists(reflog_path), "Reflog file should exist")
+
+        actual_mode = self._get_file_mode(reflog_path)
+        expected_mode = 0o664  # rw-rw-r--
+        self.assertEqual(
+            expected_mode,
+            actual_mode,
+            f"reflog file mode: expected {oct(expected_mode)}, got {oct(actual_mode)}",
+        )
+
+        # Check reflog directory permissions
+        reflog_dir = os.path.dirname(reflog_path)
+        actual_dir_mode = self._get_file_mode(reflog_dir)
+        expected_dir_mode = 0o2775  # setgid + rwxrwxr-x
+        self.assertEqual(
+            expected_dir_mode,
+            actual_dir_mode,
+            f"reflog dir mode: expected {oct(expected_dir_mode)}, got {oct(actual_dir_mode)}",
+        )