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.
@@ -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
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .DS_Store
@@ -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.
@@ -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
+ ```
@@ -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,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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', {})))
@@ -0,0 +1,12 @@
1
+ """Problems reported by checks."""
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Finding:
7
+ kind: str
8
+ path: str
9
+ message: str
10
+
11
+ def __str__(self) -> str:
12
+ return f'{self.kind}: {self.path}: {self.message}'