blinkered 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blinkered-0.0.1/.github/workflows/release.yml +61 -0
- blinkered-0.0.1/.gitignore +8 -0
- blinkered-0.0.1/LICENSE +21 -0
- blinkered-0.0.1/PKG-INFO +79 -0
- blinkered-0.0.1/README.md +57 -0
- blinkered-0.0.1/SPEC.md +90 -0
- blinkered-0.0.1/pyproject.toml +34 -0
- blinkered-0.0.1/src/blinkered/__init__.py +1 -0
- blinkered-0.0.1/src/blinkered/__main__.py +3 -0
- blinkered-0.0.1/src/blinkered/checks.py +91 -0
- blinkered-0.0.1/src/blinkered/cli.py +174 -0
- blinkered-0.0.1/src/blinkered/config.py +31 -0
- blinkered-0.0.1/src/blinkered/findings.py +12 -0
- blinkered-0.0.1/src/blinkered/git.py +99 -0
- blinkered-0.0.1/src/blinkered/graph.py +49 -0
- blinkered-0.0.1/src/blinkered/manifest.py +61 -0
- blinkered-0.0.1/src/blinkered/plugins/__init__.py +1 -0
- blinkered-0.0.1/src/blinkered/plugins/python_imports.py +48 -0
- blinkered-0.0.1/src/blinkered/state.py +23 -0
- blinkered-0.0.1/tests/conftest.py +70 -0
- blinkered-0.0.1/tests/test_cli.py +37 -0
- blinkered-0.0.1/tests/test_commands.py +258 -0
- blinkered-0.0.1/tests/test_graph.py +58 -0
- blinkered-0.0.1/tests/test_manifest.py +35 -0
- blinkered-0.0.1/tests/test_view.py +29 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch: # manual run publishes to TestPyPI
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
strategy:
|
|
11
|
+
matrix:
|
|
12
|
+
os: [ubuntu-latest, macos-latest]
|
|
13
|
+
python: ['3.11', '3.12', '3.13']
|
|
14
|
+
runs-on: ${{ matrix.os }}
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python }}
|
|
20
|
+
- run: pip install -e '.[dev]'
|
|
21
|
+
- run: pytest -q
|
|
22
|
+
|
|
23
|
+
build:
|
|
24
|
+
needs: test
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: '3.13'
|
|
31
|
+
- name: Check tag matches version
|
|
32
|
+
if: github.event_name == 'release'
|
|
33
|
+
run: |
|
|
34
|
+
version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
|
35
|
+
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
|
|
36
|
+
echo "tag $GITHUB_REF_NAME does not match version $version"
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
- run: pip install build twine
|
|
40
|
+
- run: python -m build
|
|
41
|
+
- run: twine check --strict dist/*
|
|
42
|
+
- uses: actions/upload-artifact@v4
|
|
43
|
+
with:
|
|
44
|
+
name: dist
|
|
45
|
+
path: dist/
|
|
46
|
+
|
|
47
|
+
publish:
|
|
48
|
+
needs: build
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
environment: ${{ github.event_name == 'release' && 'pypi' || 'testpypi' }}
|
|
51
|
+
permissions:
|
|
52
|
+
id-token: write # trusted publishing
|
|
53
|
+
steps:
|
|
54
|
+
- uses: actions/download-artifact@v4
|
|
55
|
+
with:
|
|
56
|
+
name: dist
|
|
57
|
+
path: dist/
|
|
58
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
59
|
+
with:
|
|
60
|
+
repository-url: ${{ github.event_name == 'release' && 'https://upload.pypi.org/legacy/' || 'https://test.pypi.org/legacy/' }}
|
|
61
|
+
skip-existing: ${{ github.event_name != 'release' }} # TestPyPI reruns of the same version
|
blinkered-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Louis Antonini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
blinkered-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: blinkered
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Work on one node of a repository while the working tree shows only its dependency closure.
|
|
5
|
+
Project-URL: Homepage, https://github.com/louisantonini/blinkered
|
|
6
|
+
Project-URL: Repository, https://github.com/louisantonini/blinkered
|
|
7
|
+
Project-URL: Issues, https://github.com/louisantonini/blinkered/issues
|
|
8
|
+
Author: Louis Antonini
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: dependency-graph,git,monorepo,sparse-checkout,workspace
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# blinkered
|
|
24
|
+
|
|
25
|
+
Work on one node of a repository while the working tree shows only its dependency closure.
|
|
26
|
+
|
|
27
|
+
Each node is a directory with a `blinkered.toml` manifest declaring free-form tags and edges to other nodes. `blinkered workspace <node>` narrows the working tree, through git's cone-mode sparse checkout, to that node and everything it depends on. The graph can grow arbitrarily large and deep while each piece of work stays small.
|
|
28
|
+
|
|
29
|
+
## Requirements
|
|
30
|
+
|
|
31
|
+
Python 3.11 or later and git 2.35 or later. Tested with git 2.50.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```toml
|
|
36
|
+
# blinkered.toml at the repository root: configuration
|
|
37
|
+
always = ["lib"]
|
|
38
|
+
|
|
39
|
+
[plugins]
|
|
40
|
+
python_imports = true
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```toml
|
|
44
|
+
# src/nodes/report/blinkered.toml: a node
|
|
45
|
+
tags = ["transform"]
|
|
46
|
+
|
|
47
|
+
[edges]
|
|
48
|
+
reads = ["selection"]
|
|
49
|
+
joins = ["lookup"]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
blinkered check # validate manifests, graph and layout
|
|
54
|
+
blinkered closure report # report and its transitive dependencies
|
|
55
|
+
blinkered workspace report # narrow the working tree to that closure
|
|
56
|
+
blinkered status # compare the view with the closure after pulls or edits
|
|
57
|
+
blinkered workspace # resync the stored focus
|
|
58
|
+
blinkered workspace --all # restore the full working tree
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
See [SPEC.md](https://github.com/louisantonini/blinkered/blob/main/SPEC.md) for the model, layout rules and full behaviour.
|
|
62
|
+
|
|
63
|
+
## Scope
|
|
64
|
+
|
|
65
|
+
blinkered manages what the working tree shows. It does not run tests, install git hooks, use worktrees, cache data or execute nodes.
|
|
66
|
+
|
|
67
|
+
## Caveats
|
|
68
|
+
|
|
69
|
+
- `workspace --force` deletes ignored files inside nodes leaving the view; git removes them and they cannot be recovered. Without `--force`, blinkered refuses and lists them.
|
|
70
|
+
- Merge conflicts in files outside the view appear on disk; resolve them with `git add --sparse`, commit, then `blinkered workspace`.
|
|
71
|
+
- Package `__init__.py` files must not import sibling nodes, or every import fails in a partial tree.
|
|
72
|
+
- Nodes cannot nest, and directories that group nodes may only hold a `README.md` by default (`grouping_allow`); other files there would be visible in every view.
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
pip install -e '.[dev]'
|
|
78
|
+
pytest -q
|
|
79
|
+
```
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# blinkered
|
|
2
|
+
|
|
3
|
+
Work on one node of a repository while the working tree shows only its dependency closure.
|
|
4
|
+
|
|
5
|
+
Each node is a directory with a `blinkered.toml` manifest declaring free-form tags and edges to other nodes. `blinkered workspace <node>` narrows the working tree, through git's cone-mode sparse checkout, to that node and everything it depends on. The graph can grow arbitrarily large and deep while each piece of work stays small.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
Python 3.11 or later and git 2.35 or later. Tested with git 2.50.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```toml
|
|
14
|
+
# blinkered.toml at the repository root: configuration
|
|
15
|
+
always = ["lib"]
|
|
16
|
+
|
|
17
|
+
[plugins]
|
|
18
|
+
python_imports = true
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```toml
|
|
22
|
+
# src/nodes/report/blinkered.toml: a node
|
|
23
|
+
tags = ["transform"]
|
|
24
|
+
|
|
25
|
+
[edges]
|
|
26
|
+
reads = ["selection"]
|
|
27
|
+
joins = ["lookup"]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
blinkered check # validate manifests, graph and layout
|
|
32
|
+
blinkered closure report # report and its transitive dependencies
|
|
33
|
+
blinkered workspace report # narrow the working tree to that closure
|
|
34
|
+
blinkered status # compare the view with the closure after pulls or edits
|
|
35
|
+
blinkered workspace # resync the stored focus
|
|
36
|
+
blinkered workspace --all # restore the full working tree
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
See [SPEC.md](https://github.com/louisantonini/blinkered/blob/main/SPEC.md) for the model, layout rules and full behaviour.
|
|
40
|
+
|
|
41
|
+
## Scope
|
|
42
|
+
|
|
43
|
+
blinkered manages what the working tree shows. It does not run tests, install git hooks, use worktrees, cache data or execute nodes.
|
|
44
|
+
|
|
45
|
+
## Caveats
|
|
46
|
+
|
|
47
|
+
- `workspace --force` deletes ignored files inside nodes leaving the view; git removes them and they cannot be recovered. Without `--force`, blinkered refuses and lists them.
|
|
48
|
+
- Merge conflicts in files outside the view appear on disk; resolve them with `git add --sparse`, commit, then `blinkered workspace`.
|
|
49
|
+
- Package `__init__.py` files must not import sibling nodes, or every import fails in a partial tree.
|
|
50
|
+
- Nodes cannot nest, and directories that group nodes may only hold a `README.md` by default (`grouping_allow`); other files there would be visible in every view.
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
pip install -e '.[dev]'
|
|
56
|
+
pytest -q
|
|
57
|
+
```
|
blinkered-0.0.1/SPEC.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# blinkered
|
|
2
|
+
|
|
3
|
+
Work on one node of a repository while the working tree shows only its dependency closure. The graph may grow arbitrarily large and deep.
|
|
4
|
+
|
|
5
|
+
Standalone command-line tool, installed outside the repositories it manages (`pipx install blinkered`, `uv tool install blinkered`). Language-neutral core; language-specific checks are optional plugins.
|
|
6
|
+
|
|
7
|
+
## Model
|
|
8
|
+
|
|
9
|
+
- **Node**: a directory other than the repository root containing `blinkered.toml`. Name = directory name, unique across the repository.
|
|
10
|
+
- **Tags**: free labels on a node.
|
|
11
|
+
- **Edges**: directed links to other nodes, with free kinds.
|
|
12
|
+
- No built-in semantics. Tags and edge kinds may appear or disappear as needed; their meaning belongs to the managed repository.
|
|
13
|
+
|
|
14
|
+
```toml
|
|
15
|
+
# blinkered.toml (in a node directory)
|
|
16
|
+
tags = ["transform"]
|
|
17
|
+
|
|
18
|
+
[edges]
|
|
19
|
+
reads = ["selection"]
|
|
20
|
+
joins = ["lookup"]
|
|
21
|
+
uses = ["utils"]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- Manifests are read from the working tree when present, otherwise from the index, so nodes outside the view remain part of the graph.
|
|
25
|
+
- Uncommitted manifest edits take effect immediately.
|
|
26
|
+
|
|
27
|
+
## Layout rules
|
|
28
|
+
|
|
29
|
+
- **No nesting**: no `blinkered.toml` below another node's directory. Cone mode includes directories recursively, so a parent would pull in its children, and a child would expose its parent's files.
|
|
30
|
+
- **Grouping directories**: strict ancestors of node directories that are not nodes, excluding the repository root. They hold no files except an allowlist (default `README.md`); cone mode checks out files directly inside every ancestor of an included directory, so such files are visible in every view below them.
|
|
31
|
+
- **Root**: files at the repository root are always visible. It holds globally visible files (configuration, README) and should stay small.
|
|
32
|
+
- **Non-node directories**: tracked directories containing no nodes disappear from every view unless they become nodes or are listed in `always`.
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
`blinkered.toml` at the repository root: repository configuration, not a node manifest. Its presence marks the repository as managed; it is always visible.
|
|
37
|
+
|
|
38
|
+
```toml
|
|
39
|
+
always = ["lib"] # directories included in every view
|
|
40
|
+
grouping_allow = ["README.md"] # files allowed in grouping directories
|
|
41
|
+
|
|
42
|
+
[plugins]
|
|
43
|
+
python_imports = true # code imports must be covered by declared edges
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
| Command | Behaviour |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `blinkered closure <node> [--via kinds] [--tag tags]` | Node and its transitive dependencies. |
|
|
51
|
+
| `blinkered workspace [<node>]` | Store focus; set cone-mode sparse checkout to the closure's directories plus `always`. Without `<node>`, resync the stored focus. |
|
|
52
|
+
| `blinkered workspace --all` | Disable sparse checkout; clear focus. |
|
|
53
|
+
| `blinkered status` | Compare view with closure of the stored focus: missing, extra, leaks. Read-only; non-zero exit if out of sync. |
|
|
54
|
+
| `blinkered new <node>` | Create directory and `blinkered.toml`; add it to the current view. Refuses inside a node. |
|
|
55
|
+
| `blinkered tags` | List tags and edge kinds in use, with counts. |
|
|
56
|
+
| `blinkered check` | Report manifest errors, unknown targets, cycles, layout-rule violations and plugin findings. |
|
|
57
|
+
|
|
58
|
+
## Behaviour
|
|
59
|
+
|
|
60
|
+
- **Closure**: start at the node, follow outgoing edges to their targets, repeat until no new node is reached. Manifests come from the working tree or the index, so the result is independent of the current view. Output: one node per line. `--via` follows only the listed edge kinds; `--tag` filters the output to nodes with those tags without stopping traversal. Read-only; `workspace` uses the same computation.
|
|
61
|
+
- **State**: the focus node name only, stored under `.git/blinkered/`. Patterns are always recomputed from manifests.
|
|
62
|
+
- **Guard**: `workspace` and `new` run `check` first and refuse on layout violations.
|
|
63
|
+
- **Clean-state rule**: before narrowing, list modified, staged, untracked or ignored paths leaving the view; refuse unless resolved, or `--force` for ignored-only paths.
|
|
64
|
+
- **Leaks**: files git keeps on disk outside the cone (modifications, untracked, conflicts) are reported by `status`, not hidden.
|
|
65
|
+
- **Remote**: pulls update content within the view but not the pattern set; `status` detects drift, `workspace` resyncs.
|
|
66
|
+
|
|
67
|
+
## Git behaviour (git 2.50.1, Python 3.12, pytest 9)
|
|
68
|
+
|
|
69
|
+
Observed behaviour the guards are built on.
|
|
70
|
+
|
|
71
|
+
Narrowing (`git sparse-checkout set`) always exits 0; the tool must do its own checks.
|
|
72
|
+
|
|
73
|
+
| Path leaving the view | Git behaviour |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| Tracked, clean | Removed from disk. |
|
|
76
|
+
| Tracked, modified | Kept on disk with a warning; remains visible. |
|
|
77
|
+
| New file, staged | Removed from disk; content kept in the index; returns when widened. |
|
|
78
|
+
| Untracked | Kept on disk with a warning; remains visible. |
|
|
79
|
+
| Ignored, in a directory with no untracked files | Deleted without warning; not recoverable. |
|
|
80
|
+
| Ignored, next to untracked files | Kept. |
|
|
81
|
+
| Ignored, in a directory that never had tracked files | Kept. |
|
|
82
|
+
|
|
83
|
+
- Manifests outside the view are readable via `git ls-files` and `git show :path` (also `HEAD:path`), with or without sparse index. With sparse index, `ls-files` expands the index and prints hints.
|
|
84
|
+
- Pull: new files in included directories appear; new node directories do not; patterns unchanged.
|
|
85
|
+
- Conflict outside the view: file materialises; plain `git add` refuses; resolve with `git add --sparse`, commit, `git sparse-checkout reapply`.
|
|
86
|
+
- Python: nodes in view import normally; importing a node outside the view raises `ModuleNotFoundError`, including undeclared transitive imports.
|
|
87
|
+
- A package `__init__.py` importing its sibling nodes breaks every import in a partial tree. Node `__init__.py` files must not import siblings.
|
|
88
|
+
- pytest: whole-tree runs collect only tests in view; an explicit path outside the view errors with "file or directory not found".
|
|
89
|
+
|
|
90
|
+
Consequences for the clean-state rule: refuse narrowing when any modified, staged, untracked or ignored path would leave the view; ignored paths are the only case of silent loss.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "blinkered"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Work on one node of a repository while the working tree shows only its dependency closure."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Louis Antonini" }]
|
|
14
|
+
keywords = ["git", "sparse-checkout", "monorepo", "dependency-graph", "workspace"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/louisantonini/blinkered"
|
|
27
|
+
Repository = "https://github.com/louisantonini/blinkered"
|
|
28
|
+
Issues = "https://github.com/louisantonini/blinkered/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
blinkered = "blinkered.cli:main"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = ["pytest"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Work on one node of a repository while the working tree shows only its dependency closure."""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Validation of manifests, layout and working-tree state."""
|
|
2
|
+
import posixpath
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from . import git
|
|
6
|
+
from .config import Config
|
|
7
|
+
from .findings import Finding
|
|
8
|
+
from .graph import cycles
|
|
9
|
+
from .manifest import Node
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ancestors(path: str) -> list[str]:
|
|
13
|
+
"""Strict ancestor directories of `path`, excluding the repository root."""
|
|
14
|
+
parts = path.strip('/').split('/')
|
|
15
|
+
return ['/'.join(parts[:i]) for i in range(1, len(parts))]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def in_view(path: str, keep: list[str]) -> bool:
|
|
19
|
+
"""Whether cone mode checks out `path` (directories end with `/`) when the view is `keep`."""
|
|
20
|
+
is_directory = path.endswith('/')
|
|
21
|
+
path = path.rstrip('/')
|
|
22
|
+
if any(path == d or path.startswith(d + '/') for d in keep):
|
|
23
|
+
return True
|
|
24
|
+
parents = {a for d in keep for a in ancestors(d)}
|
|
25
|
+
if is_directory:
|
|
26
|
+
return path in parents
|
|
27
|
+
parent = posixpath.dirname(path)
|
|
28
|
+
return parent == '' or parent in parents
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check(root: Path, config: Config, nodes: dict[str, Node],
|
|
32
|
+
errors: list[Finding]) -> list[Finding]:
|
|
33
|
+
"""Manifest errors, unknown targets, cycles, layout-rule violations and plugin findings."""
|
|
34
|
+
findings = list(errors)
|
|
35
|
+
for node in nodes.values():
|
|
36
|
+
for kind, targets in node.edges.items():
|
|
37
|
+
findings += [Finding('edge', node.directory, f'{kind} → unknown node {target}')
|
|
38
|
+
for target in targets if target not in nodes]
|
|
39
|
+
findings += [Finding('cycle', nodes[cycle[0]].directory, ' → '.join(cycle))
|
|
40
|
+
for cycle in cycles(nodes)]
|
|
41
|
+
findings += layout(root, config, nodes)
|
|
42
|
+
if config.plugins.get('python_imports'):
|
|
43
|
+
from .plugins import python_imports
|
|
44
|
+
findings += python_imports.check(root, nodes)
|
|
45
|
+
return findings
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def layout(root: Path, config: Config, nodes: dict[str, Node]) -> list[Finding]:
|
|
49
|
+
"""Nested nodes and files in grouping directories outside `grouping_allow`."""
|
|
50
|
+
directories = {node.directory for node in nodes.values()}
|
|
51
|
+
findings = [Finding('layout', node.directory, f'nested inside node {a}')
|
|
52
|
+
for node in nodes.values() for a in ancestors(node.directory)
|
|
53
|
+
if a in directories]
|
|
54
|
+
grouping = {a for d in directories for a in ancestors(d)} - directories
|
|
55
|
+
files = set(git.tracked_paths(root)) | set(git.untracked_paths(root))
|
|
56
|
+
findings += [Finding('layout', path, 'file in grouping directory')
|
|
57
|
+
for path in sorted(files)
|
|
58
|
+
if posixpath.dirname(path) in grouping
|
|
59
|
+
and posixpath.basename(path) not in config.grouping_allow]
|
|
60
|
+
return findings
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def leaving(root: Path, keep: list[str]) -> tuple[list[Finding], list[Finding]]:
|
|
64
|
+
"""Paths that would leave a view limited to `keep`: (work at risk, ignored files git may delete).
|
|
65
|
+
|
|
66
|
+
Ignored files are at risk only inside a directory outside the view that contains tracked files.
|
|
67
|
+
"""
|
|
68
|
+
tracked = git.tracked_paths(root)
|
|
69
|
+
work, ignored = [], []
|
|
70
|
+
for code, path in git.status(root):
|
|
71
|
+
if in_view(path, keep):
|
|
72
|
+
continue
|
|
73
|
+
if code == '!!':
|
|
74
|
+
outside = [a for a in ancestors(path.rstrip('/') + '/x') if not in_view(a + '/', keep)]
|
|
75
|
+
if any(t.startswith(a + '/') for a in outside for t in tracked):
|
|
76
|
+
ignored.append(Finding('ignored', path, 'would be deleted'))
|
|
77
|
+
elif code == '??':
|
|
78
|
+
work.append(Finding('untracked', path, 'would stay on disk outside the view'))
|
|
79
|
+
elif 'U' in code or code in ('AA', 'DD'):
|
|
80
|
+
work.append(Finding('conflict', path, 'unresolved'))
|
|
81
|
+
elif code[0] != ' ':
|
|
82
|
+
work.append(Finding('staged', path, 'staged change outside the view'))
|
|
83
|
+
else:
|
|
84
|
+
work.append(Finding('modified', path, 'would stay on disk outside the view'))
|
|
85
|
+
return work, ignored
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def leaks(root: Path, keep: list[str]) -> list[Finding]:
|
|
89
|
+
"""Non-ignored paths on disk outside the current view."""
|
|
90
|
+
return [Finding('leak', path, code.strip() or code) for code, path in git.status(root)
|
|
91
|
+
if code != '!!' and not in_view(path, keep) and (root / path).exists()]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Command-line entry point."""
|
|
2
|
+
import argparse
|
|
3
|
+
import posixpath
|
|
4
|
+
import sys
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import blinkered
|
|
9
|
+
from . import checks, config as configuration, git, state
|
|
10
|
+
from .config import FILENAME
|
|
11
|
+
from .graph import closure
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def csv(value: str) -> list[str]:
|
|
15
|
+
return [item for item in value.split(',') if item]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parser() -> argparse.ArgumentParser:
|
|
19
|
+
root = argparse.ArgumentParser(prog='blinkered', description=blinkered.__doc__)
|
|
20
|
+
commands = root.add_subparsers(dest='command', required=True)
|
|
21
|
+
|
|
22
|
+
closure = commands.add_parser('closure', help='node and its transitive dependencies')
|
|
23
|
+
closure.add_argument('node')
|
|
24
|
+
closure.add_argument('--via', type=csv, help='edge kinds to follow, comma-separated')
|
|
25
|
+
closure.add_argument('--tag', type=csv, help='output only nodes with these tags')
|
|
26
|
+
|
|
27
|
+
workspace = commands.add_parser('workspace', help='narrow the working tree to a closure')
|
|
28
|
+
workspace.add_argument('node', nargs='?', help='new focus; omit to resync the stored focus')
|
|
29
|
+
workspace.add_argument('--all', action='store_true', help='restore the full working tree')
|
|
30
|
+
workspace.add_argument('--force', action='store_true', help='allow ignored-only paths to leave the view')
|
|
31
|
+
|
|
32
|
+
commands.add_parser('status', help='compare the view with the closure of the focus')
|
|
33
|
+
|
|
34
|
+
new = commands.add_parser('new', help='create a node and add it to the view')
|
|
35
|
+
new.add_argument('directory')
|
|
36
|
+
|
|
37
|
+
commands.add_parser('tags', help='tags and edge kinds in use, with counts')
|
|
38
|
+
commands.add_parser('check', help='manifest, graph and layout validation')
|
|
39
|
+
return root
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Failure(Exception):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def report(title, findings):
|
|
47
|
+
print(title, file=sys.stderr)
|
|
48
|
+
for finding in findings:
|
|
49
|
+
print(f' {finding}', file=sys.stderr)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def view(nodes, config, focus):
|
|
53
|
+
if focus not in nodes:
|
|
54
|
+
raise Failure(f'unknown node: {focus}')
|
|
55
|
+
return sorted({nodes[name].directory for name in closure(nodes, focus)} | set(config.always))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_closure(root, config, nodes, errors, args):
|
|
59
|
+
if args.node not in nodes:
|
|
60
|
+
raise Failure(f'unknown node: {args.node}')
|
|
61
|
+
print('\n'.join(closure(nodes, args.node, via=args.via, tags=args.tag)))
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_workspace(root, config, nodes, errors, args):
|
|
66
|
+
if args.all:
|
|
67
|
+
if git.sparse_patterns(root) is not None:
|
|
68
|
+
git.sparse_disable(root)
|
|
69
|
+
state.write_focus(root, None)
|
|
70
|
+
print('full working tree')
|
|
71
|
+
return 0
|
|
72
|
+
focus = args.node or state.read_focus(root)
|
|
73
|
+
if focus is None:
|
|
74
|
+
raise Failure('no focus: give a node')
|
|
75
|
+
blocking = list(errors) + checks.layout(root, config, nodes)
|
|
76
|
+
if blocking:
|
|
77
|
+
report('refusing: fix these first (blinkered check)', blocking)
|
|
78
|
+
return 1
|
|
79
|
+
keep = view(nodes, config, focus)
|
|
80
|
+
work, ignored = checks.leaving(root, keep)
|
|
81
|
+
if work or (ignored and not args.force):
|
|
82
|
+
report('refusing: these paths would leave the view', work + ignored)
|
|
83
|
+
if ignored and not work:
|
|
84
|
+
print('ignored files only: rerun with --force to delete them', file=sys.stderr)
|
|
85
|
+
return 1
|
|
86
|
+
git.sparse_set(root, keep)
|
|
87
|
+
state.write_focus(root, focus)
|
|
88
|
+
print(f'focus: {focus}')
|
|
89
|
+
print('\n'.join(f' {d}' for d in keep))
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_status(root, config, nodes, errors, args):
|
|
94
|
+
focus = state.read_focus(root)
|
|
95
|
+
current = git.sparse_patterns(root)
|
|
96
|
+
if focus is None:
|
|
97
|
+
print('focus: none')
|
|
98
|
+
print('view: full' if current is None else f'view: sparse, {len(current)} directories')
|
|
99
|
+
return 0 if current is None else 1
|
|
100
|
+
expected = set(view(nodes, config, focus))
|
|
101
|
+
current = set(current or [])
|
|
102
|
+
missing, extra = sorted(expected - current), sorted(current - expected)
|
|
103
|
+
leaked = checks.leaks(root, sorted(current)) if current else []
|
|
104
|
+
print(f'focus: {focus}')
|
|
105
|
+
for label, items in (('missing', missing), ('extra', extra)):
|
|
106
|
+
for item in items:
|
|
107
|
+
print(f'{label}: {item}')
|
|
108
|
+
for finding in leaked:
|
|
109
|
+
print(f'leak: {finding.path} ({finding.message})')
|
|
110
|
+
if missing or extra or leaked:
|
|
111
|
+
print('→ run `blinkered workspace` to resync' if missing or extra else '→ resolve leaks')
|
|
112
|
+
return 1
|
|
113
|
+
print('in sync')
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run_new(root, config, nodes, errors, args):
|
|
118
|
+
directory = (Path.cwd() / args.directory).resolve().relative_to(root.resolve()).as_posix()
|
|
119
|
+
name = posixpath.basename(directory)
|
|
120
|
+
if directory in ('', '.'):
|
|
121
|
+
raise Failure('the repository root cannot be a node')
|
|
122
|
+
if name in nodes:
|
|
123
|
+
raise Failure(f'node {name} already exists at {nodes[name].directory}')
|
|
124
|
+
for node in nodes.values():
|
|
125
|
+
if directory.startswith(node.directory + '/'):
|
|
126
|
+
raise Failure(f'inside node {node.name}')
|
|
127
|
+
if node.directory.startswith(directory + '/'):
|
|
128
|
+
raise Failure(f'would contain node {node.name}')
|
|
129
|
+
path = root / directory
|
|
130
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
(path / FILENAME).write_text('tags = []\n\n[edges]\n')
|
|
132
|
+
if git.sparse_patterns(root) is not None:
|
|
133
|
+
git.sparse_add(root, [directory])
|
|
134
|
+
print(f'created {directory}/{FILENAME}')
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def run_tags(root, config, nodes, errors, args):
|
|
139
|
+
tags = Counter(tag for node in nodes.values() for tag in node.tags)
|
|
140
|
+
kinds = Counter(kind for node in nodes.values() for kind, targets in node.edges.items()
|
|
141
|
+
for _ in targets)
|
|
142
|
+
for title, counts in (('tags', tags), ('edge kinds', kinds)):
|
|
143
|
+
print(f'{title}:')
|
|
144
|
+
for item, count in counts.most_common():
|
|
145
|
+
print(f' {count:>4} {item}')
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run_check(root, config, nodes, errors, args):
|
|
150
|
+
findings = checks.check(root, config, nodes, errors)
|
|
151
|
+
for finding in findings:
|
|
152
|
+
print(finding)
|
|
153
|
+
print(f'{len(nodes)} nodes, {len(findings)} findings')
|
|
154
|
+
return 1 if findings else 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
COMMANDS = {'closure': run_closure, 'workspace': run_workspace, 'status': run_status,
|
|
158
|
+
'new': run_new, 'tags': run_tags, 'check': run_check}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main(argv: list[str] | None = None) -> int:
|
|
162
|
+
args = parser().parse_args(argv)
|
|
163
|
+
if args.command == 'workspace' and args.all and args.node:
|
|
164
|
+
parser().error('workspace: give a node or --all, not both')
|
|
165
|
+
try:
|
|
166
|
+
git.require_version(Path.cwd())
|
|
167
|
+
root = git.repo_root(Path.cwd())
|
|
168
|
+
config = configuration.load(root)
|
|
169
|
+
from .manifest import discover
|
|
170
|
+
nodes, errors = discover(root)
|
|
171
|
+
return COMMANDS[args.command](root, config, nodes, errors, args)
|
|
172
|
+
except (Failure, git.GitError, configuration.NotManaged, ValueError) as error:
|
|
173
|
+
print(f'blinkered: {error}', file=sys.stderr)
|
|
174
|
+
return 2
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Repository configuration: `blinkered.toml` at the repository root."""
|
|
2
|
+
import tomllib
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
FILENAME = 'blinkered.toml'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NotManaged(RuntimeError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Config:
|
|
15
|
+
always: tuple[str, ...] = ()
|
|
16
|
+
grouping_allow: tuple[str, ...] = ('README.md',)
|
|
17
|
+
plugins: dict[str, bool] = field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load(root: Path) -> Config:
|
|
21
|
+
"""Read the root configuration; its absence means the repository is not managed."""
|
|
22
|
+
path = root / FILENAME
|
|
23
|
+
if not path.is_file():
|
|
24
|
+
raise NotManaged(f'no {FILENAME} at {root}')
|
|
25
|
+
data = tomllib.loads(path.read_text())
|
|
26
|
+
unknown = set(data) - {'always', 'grouping_allow', 'plugins'}
|
|
27
|
+
if unknown:
|
|
28
|
+
raise ValueError(f'{FILENAME}: unknown keys {sorted(unknown)}')
|
|
29
|
+
return Config(always=tuple(item.strip('/') for item in data.get('always', ())),
|
|
30
|
+
grouping_allow=tuple(data.get('grouping_allow', Config.grouping_allow)),
|
|
31
|
+
plugins=dict(data.get('plugins', {})))
|