Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Unreleased

- Added `sparse_paths_type` option to select between `"cone"` (default) and `"no-cone"` sparse-checkout modes, enabling file-level and regex patterns. (#360)
- Fixed sparse-path filtering that incorrectly included root-level files after v3.5.3 switched to cone mode. (#363)
- Updated `regex` dependency to resolve an import-time crash in the `sly` lexer. (#363)

# 3.8.1 (2025-03-20)

- Fixed reapplication of sparse paths after install. (@fhamdi-bdai)
Expand Down
33 changes: 31 additions & 2 deletions docs/use-cases/sparse-checkouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ from the reference repository. It is important to note, that this influences onl
checkout. The reference repository, maintained by gitman as a cache, will still be a full clone of the original repo.

Using `sparse_paths` will use git's sparse checkout feature to just materialize the selected paths in the working tree.
As this is a [git feature](https://git-scm.com/docs/git-read-tree#_sparse_checkout), all syntax options are
available and passed unmodified to `$GIT_DIR/info/sparse-checkout`.
The `sparse_paths_type` option selects between git's two sparse-checkout modes:

- `"cone"` (default): only directory paths are supported and git uses a faster hash-based algorithm.
Note that **all files at the repository root are always included** in cone mode; only subdirectories can be filtered.
- `"no-cone"`: patterns are treated as gitignore-style regular expressions and passed unmodified to git.
This enables file-level and glob patterns (e.g. `"*.c"`) that cone mode cannot match.

## Cone mode (default)

The following example configuration will clone the full font-awesome repo into the cache, but the project local
clone will only contain the `fonts` directory and it's children.
Expand All @@ -18,3 +24,26 @@ clone will only contain the `fonts` directory and it's children.
sparse_paths:
- "fonts/*"
```

Since cone mode only accepts directories, the trailing `/*` is stripped automatically so that `fonts` is passed to
`git sparse-checkout set`.

## Non-cone mode

When you need to match individual files or use regular-expression patterns, set `sparse_paths_type: no-cone`.
Patterns are passed unmodified to git's sparse checkout. For example, to check out only a single root-level file
and a specific subdirectory:

```yaml
- repo: "https://github.com/example/repo"
name: example
rev: main
sparse_paths_type: no-cone
sparse_paths:
- "/c"
- "/a/"
```

See the [git sparse-checkout documentation][git-sparse] for the full pattern syntax available in non-cone mode.

[git-sparse]: https://git-scm.com/docs/git-sparse-checkout
21 changes: 11 additions & 10 deletions gitman/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def clone(
*,
cache=settings.CACHE,
sparse_paths=None,
sparse_paths_type="cone",
rev=None,
user_params=None
):
Expand Down Expand Up @@ -61,6 +62,10 @@ def clone(
git("clone", "--mirror", repo, reference, *user_params)

if sparse_paths and sparse_paths[0]:
cone = sparse_paths_type != "no-cone"
mode_flag = "--cone" if cone else "--no-cone"
paths = sanitize_sparse_paths(sparse_paths) if cone else sparse_paths

os.makedirs(normpath)
git("-C", normpath, "init")
git("-C", normpath, "remote", "add", "origin", repo)
Expand All @@ -73,14 +78,7 @@ def clone(
) as fd:
fd.write("%s/objects" % sparse_paths_repo)

git("-C", normpath, "sparse-checkout", "init", "--cone")
git(
"-C",
normpath,
"sparse-checkout",
"set",
*sanitize_sparse_paths(sparse_paths)
)
git("-C", normpath, "sparse-checkout", "set", mode_flag, *paths)
git("-C", normpath, "fetch", "origin")
git("-C", normpath, "checkout", rev)
elif settings.CACHE_DISABLE:
Expand Down Expand Up @@ -256,9 +254,12 @@ def am(patch, _skip=False):
raise ShellError from e


def apply_sparse_checkout(sparse_paths):
def apply_sparse_checkout(sparse_paths, sparse_paths_type="cone"):
"""Re-apply sparse-checkout paths to an existing working tree."""
git("sparse-checkout", "set", *sanitize_sparse_paths(sparse_paths))
cone = sparse_paths_type != "no-cone"
mode_flag = "--cone" if cone else "--no-cone"
paths = sanitize_sparse_paths(sparse_paths) if cone else sparse_paths
git("sparse-checkout", "set", mode_flag, *paths)


def update(
Expand Down
15 changes: 14 additions & 1 deletion gitman/models/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class Source:
| `type` | `"git"` or `"git-svn"` | No | `"git"` |
| `params` | Additional arguments for `clone` | No | `null` |
| `sparse_paths` | Controls partial checkout | No | `[]` |
| `sparse_paths_type` | `"cone"` or `"no-cone"` sparse mode | No | `"cone"` |
| `links` | Creates symlinks within a project | No | `[]` |
| `scripts` | Shell commands to run after checkout | No | `[]` |
| `patches` | patches to be applied after checkout | No | `[]` |
Expand All @@ -50,6 +51,15 @@ class Source:

See [using sparse checkouts][using-sparse-checkouts] for more information.

The `sparse_paths_type` option selects git's sparse-checkout mode:

- `"cone"` (default): only directory patterns are supported; git uses a
faster hash-based algorithm. Note that all root-level files are always
included in cone mode.
- `"no-cone"`: patterns are treated as gitignore-style regular expressions
and passed unmodified. This enables file-level and glob patterns (e.g.
`"*.c"`) that cone mode cannot match.

### Links

See [using multiple links][using-multiple-links] for more information.
Expand Down Expand Up @@ -85,6 +95,7 @@ class Source:
type: str = "git"
params: Optional[str] = None
sparse_paths: List[str] = field(default_factory=list)
sparse_paths_type: str = "cone"
links: List[Link] = field(default_factory=list)

scripts: List[str] = field(default_factory=list)
Expand Down Expand Up @@ -150,6 +161,7 @@ def update_files(
self.repo,
self.name,
sparse_paths=self.sparse_paths,
sparse_paths_type=self.sparse_paths_type,
rev=self.rev,
user_params=self.clone_params_if_any(),
)
Expand Down Expand Up @@ -206,7 +218,7 @@ def update_files(

# Re-apply sparse-checkout paths in case they changed since initial clone
if self.sparse_paths and self.sparse_paths[0]:
git.apply_sparse_checkout(self.sparse_paths)
git.apply_sparse_checkout(self.sparse_paths, self.sparse_paths_type)

# Update the working tree to the desired revision
git.update(
Expand Down Expand Up @@ -379,6 +391,7 @@ def lock(
scripts=self.scripts,
patches=self.patches,
sparse_paths=self.sparse_paths,
sparse_paths_type=self.sparse_paths_type,
)
return source

Expand Down
73 changes: 72 additions & 1 deletion gitman/tests/test_git.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
from unittest.mock import Mock, patch
from unittest.mock import Mock, mock_open, patch

from gitman import git, settings
from gitman.exceptions import ShellError
Expand Down Expand Up @@ -53,6 +53,77 @@ def test_clone_from_reference(self, mock_call):
],
)

@patch("builtins.open", mock_open())
@patch("os.makedirs", Mock(return_value=None))
@patch("os.path.isdir", Mock(return_value=False))
def test_clone_sparse_cone(self, mock_call):
"""Verify sparse checkout uses cone mode and sanitizes paths."""
git.clone(
"git",
"mock.git",
"mock/path",
cache="cache",
sparse_paths=["src/*"],
rev="rev",
)
check_calls(
mock_call,
[
"git clone --mirror mock.git "
+ os.path.normpath("cache/mock.reference"),
"git -C " + os.path.normpath("mock/path") + " init",
"git -C "
+ os.path.normpath("mock/path")
+ " remote add origin mock.git",
"git -C "
+ os.path.normpath("mock/path")
+ " sparse-checkout set --cone src",
"git -C " + os.path.normpath("mock/path") + " fetch origin",
"git -C " + os.path.normpath("mock/path") + " checkout rev",
],
)

@patch("builtins.open", mock_open())
@patch("os.makedirs", Mock(return_value=None))
@patch("os.path.isdir", Mock(return_value=False))
def test_clone_sparse_no_cone(self, mock_call):
"""Verify sparse checkout passes patterns unmodified in no-cone mode."""
git.clone(
"git",
"mock.git",
"mock/path",
cache="cache",
sparse_paths=["src/*"],
sparse_paths_type="no-cone",
rev="rev",
)
check_calls(
mock_call,
[
"git clone --mirror mock.git "
+ os.path.normpath("cache/mock.reference"),
"git -C " + os.path.normpath("mock/path") + " init",
"git -C "
+ os.path.normpath("mock/path")
+ " remote add origin mock.git",
"git -C "
+ os.path.normpath("mock/path")
+ " sparse-checkout set --no-cone src/*",
"git -C " + os.path.normpath("mock/path") + " fetch origin",
"git -C " + os.path.normpath("mock/path") + " checkout rev",
],
)

def test_apply_sparse_checkout_cone(self, mock_call):
"""Verify re-applying sparse paths sanitizes globs in cone mode."""
git.apply_sparse_checkout(["src/*"])
check_calls(mock_call, ["git sparse-checkout set --cone src"])

def test_apply_sparse_checkout_no_cone(self, mock_call):
"""Verify re-applying sparse paths keeps patterns raw in no-cone mode."""
git.apply_sparse_checkout(["src/*"], "no-cone")
check_calls(mock_call, ["git sparse-checkout set --no-cone src/*"])

def test_fetch(self, mock_call):
"""Verify the commands to fetch from a Git repository."""
git.fetch("git", "mock.git", "mock/path")
Expand Down
8 changes: 7 additions & 1 deletion gitman/tests/test_models_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ def test_update_files(
source.update_files()

mock_clone.assert_called_once_with(
"git", "repo", "name", rev="rev", sparse_paths=[], user_params=None
"git",
"repo",
"name",
rev="rev",
sparse_paths=[],
sparse_paths_type="cone",
user_params=None,
)
mock_is_fetch_required.assert_called_once_with("git", "rev")
mock_fetch.assert_called_once_with("git", "repo", "name", rev="rev")
Expand Down
Loading