worktree-shield 0.1.0__py3-none-any.whl

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.
Files changed (56) hide show
  1. worktree_shield/__init__.py +8 -0
  2. worktree_shield/__main__.py +6 -0
  3. worktree_shield/cli.py +377 -0
  4. worktree_shield/collectors/__init__.py +1 -0
  5. worktree_shield/collectors/base.py +63 -0
  6. worktree_shield/collectors/compose.py +315 -0
  7. worktree_shield/collectors/envfiles.py +130 -0
  8. worktree_shield/collectors/layout.py +106 -0
  9. worktree_shield/collectors/node.py +87 -0
  10. worktree_shield/collectors/procfile.py +134 -0
  11. worktree_shield/collectors/python.py +128 -0
  12. worktree_shield/collectors/runtime.py +167 -0
  13. worktree_shield/collectors/static.py +25 -0
  14. worktree_shield/collectors/vite.py +133 -0
  15. worktree_shield/collisions.py +115 -0
  16. worktree_shield/config.py +188 -0
  17. worktree_shield/exit_codes.py +22 -0
  18. worktree_shield/files.py +70 -0
  19. worktree_shield/git/__init__.py +1 -0
  20. worktree_shield/git/discovery.py +85 -0
  21. worktree_shield/git/porcelain.py +48 -0
  22. worktree_shield/models.py +356 -0
  23. worktree_shield/names.py +83 -0
  24. worktree_shield/orchestrator.py +121 -0
  25. worktree_shield/overlap.py +175 -0
  26. worktree_shield/parsers/__init__.py +1 -0
  27. worktree_shield/parsers/commands.py +158 -0
  28. worktree_shield/parsers/compose.py +148 -0
  29. worktree_shield/parsers/dburl.py +156 -0
  30. worktree_shield/parsers/dotenv.py +91 -0
  31. worktree_shield/parsers/interpolate.py +142 -0
  32. worktree_shield/parsers/jsonc.py +48 -0
  33. worktree_shield/parsers/jstext.py +142 -0
  34. worktree_shield/plan.py +175 -0
  35. worktree_shield/privacy.py +237 -0
  36. worktree_shield/reports/__init__.py +1 -0
  37. worktree_shield/reports/findings.py +176 -0
  38. worktree_shield/reports/json_report.py +61 -0
  39. worktree_shield/reports/markdown.py +103 -0
  40. worktree_shield/reports/sarif.py +116 -0
  41. worktree_shield/reports/terminal.py +327 -0
  42. worktree_shield/rules/__init__.py +1 -0
  43. worktree_shield/rules/catalog.py +28 -0
  44. worktree_shield/rules/common.py +61 -0
  45. worktree_shield/rules/context.py +201 -0
  46. worktree_shield/rules/engine.py +60 -0
  47. worktree_shield/rules/explain.py +27 -0
  48. worktree_shield/rules/rules.md +112 -0
  49. worktree_shield/rules/runtime.py +164 -0
  50. worktree_shield/rules/shared.py +216 -0
  51. worktree_shield/runner.py +273 -0
  52. worktree_shield-0.1.0.dist-info/METADATA +151 -0
  53. worktree_shield-0.1.0.dist-info/RECORD +56 -0
  54. worktree_shield-0.1.0.dist-info/WHEEL +4 -0
  55. worktree_shield-0.1.0.dist-info/entry_points.txt +4 -0
  56. worktree_shield-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,115 @@
1
+ """The collision graph: claims of different worktrees on overlapping resources.
2
+
3
+ Pure: claims in, collisions out. Keys of one kind are joined when any two of them
4
+ overlap (:func:`~worktree_shield.overlap.overlap`), so ``0.0.0.0:8000`` links
5
+ ``127.0.0.1:8000`` and ``127.0.0.2:8000`` into one collision even though those two do
6
+ not overlap each other. A group claimed by one worktree only is no collision.
7
+
8
+ Certainty
9
+ *confirmed* when a running server (a runtime claim) of one worktree certainly holds
10
+ what another worktree's configuration needs; *probable* when the configured
11
+ resources certainly overlap; *possible* when the overlap depends on how a name
12
+ resolves or how a path is used, or when every server involved moves to the next free
13
+ port (Vite without ``strictPort``). Static evidence alone never confirms.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections import defaultdict
19
+ from itertools import combinations
20
+
21
+ from worktree_shield.models import (
22
+ Certainty,
23
+ Collision,
24
+ Observation,
25
+ ResourceClaim,
26
+ ResourceKind,
27
+ )
28
+ from worktree_shield.overlap import Overlap, overlap
29
+
30
+ _KIND_ORDER = {kind: index for index, kind in enumerate(ResourceKind)}
31
+
32
+
33
+ def find_collisions(
34
+ claims: tuple[ResourceClaim, ...] | list[ResourceClaim],
35
+ ) -> tuple[Collision, ...]:
36
+ """Every collision among ``claims``, ordered by kind and key."""
37
+ by_kind: dict[ResourceKind, list[ResourceClaim]] = defaultdict(list)
38
+ for claim in claims:
39
+ if claim.kind is not ResourceKind.CLIENT_ENDPOINT:
40
+ by_kind[claim.kind].append(claim)
41
+ found: list[Collision] = []
42
+ for kind, members in by_kind.items():
43
+ for group in _groups(kind, members):
44
+ if len({claim.worktree for claim in group}) > 1:
45
+ found.append(_collision(kind, group))
46
+ return tuple(sorted(found, key=lambda c: (_KIND_ORDER[c.kind], c.key)))
47
+
48
+
49
+ def _groups(kind: ResourceKind, claims: list[ResourceClaim]) -> list[list[ResourceClaim]]:
50
+ """Claims joined by overlapping keys (connected components over the keys)."""
51
+ keys = sorted({claim.key for claim in claims})
52
+ parent = {key: key for key in keys}
53
+
54
+ def root(key: str) -> str:
55
+ while parent[key] != key:
56
+ parent[key] = parent[parent[key]]
57
+ key = parent[key]
58
+ return key
59
+
60
+ for a, b in combinations(keys, 2):
61
+ if overlap(kind, a, b) is not Overlap.NONE:
62
+ parent[root(a)] = root(b)
63
+ groups: dict[str, list[ResourceClaim]] = defaultdict(list)
64
+ for claim in claims:
65
+ groups[root(claim.key)].append(claim)
66
+ return list(groups.values())
67
+
68
+
69
+ def _collision(kind: ResourceKind, claims: list[ResourceClaim]) -> Collision:
70
+ pairs = [
71
+ (a, b, overlap(kind, a.key, b.key))
72
+ for a, b in combinations(claims, 2)
73
+ if a.worktree != b.worktree
74
+ ]
75
+ certain = [(a, b) for a, b, result in pairs if result is Overlap.SAME]
76
+ if any(Observation.RUNTIME in (a.observation, b.observation) for a, b in certain):
77
+ certainty = Certainty.CONFIRMED
78
+ elif not certain or (
79
+ kind is ResourceKind.LISTEN_PORT and all(c.strict is False for c in claims)
80
+ ):
81
+ certainty = Certainty.POSSIBLE
82
+ else:
83
+ certainty = Certainty.PROBABLE
84
+ key = min(claim.key for claim in claims)
85
+ worktrees = len({claim.worktree for claim in claims})
86
+ return Collision(kind, key, certainty, _reason(kind, key, worktrees, claims), tuple(claims))
87
+
88
+
89
+ def _reason(kind: ResourceKind, key: str, worktrees: int, claims: list[ResourceClaim]) -> str:
90
+ name = key.split("://", 1)[-1]
91
+ count = f"{worktrees} worktrees"
92
+ if kind is ResourceKind.LISTEN_PORT:
93
+ port = name.rpartition(":")[2]
94
+ reason = f"{count} start a server on port {port}; only one can listen on it at a time"
95
+ if all(claim.strict is False for claim in claims):
96
+ reason += ", but Vite moves to the next free port unless strictPort is set"
97
+ return reason
98
+ if kind is ResourceKind.DATABASE:
99
+ return (
100
+ f"{count} use the same database; migrations, seed data and tests of one "
101
+ "change the data of the other"
102
+ )
103
+ if kind is ResourceKind.COMPOSE_PROJECT:
104
+ return (
105
+ f"{count} have the Compose project name {name}; docker compose up in one "
106
+ "takes over the containers, networks and volumes of the other"
107
+ )
108
+ if kind is ResourceKind.CONTAINER_NAME:
109
+ return (
110
+ f"{count} name a container {name}; a Docker daemon runs one container of that "
111
+ "name, so starting the second stack replaces or fails on the first"
112
+ )
113
+ if kind is ResourceKind.VOLUME:
114
+ return f"{count} mount the Docker volume {name}, so they read and write the same data"
115
+ return f"{count} write to {name} or a directory inside it"
@@ -0,0 +1,188 @@
1
+ """The repository policy, ``.worktree-shield.toml``: TOML read as data, never run.
2
+
3
+ Precedence is flags, then the file in the current worktree, then the defaults in
4
+ :class:`~worktree_shield.models.Policy`; there is no user-wide policy. Anything the
5
+ parser does not know is an error rather than a silent default, so a misspelt key cannot
6
+ quietly turn a ``deny`` back into a ``warn``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import tomllib
13
+ from pathlib import Path, PurePosixPath, PureWindowsPath
14
+ from typing import Any
15
+
16
+ from worktree_shield.files import RefusedError, read_text_inside
17
+ from worktree_shield.models import Policy, PolicyLevel, Repository
18
+
19
+ POLICY_FILE = ".worktree-shield.toml"
20
+ MAX_POLICY_BYTES = 64 * 1024
21
+ SCHEMA_VERSION = 1
22
+ MAX_FILE_BYTES_LIMIT = 16 * 1024 * 1024
23
+
24
+ _SCAN_FLAGS = ("include_runtime", "include_env_files")
25
+ _LEVELS = (
26
+ "shared_host_ports",
27
+ "shared_database",
28
+ "shared_compose_projects",
29
+ "fixed_container_names",
30
+ "shared_absolute_paths",
31
+ )
32
+ _SECTIONS: dict[str, tuple[str, ...]] = {
33
+ "scan": (*_SCAN_FLAGS, "max_file_bytes"),
34
+ "policy": _LEVELS,
35
+ "allow": ("ports", "paths"),
36
+ }
37
+
38
+
39
+ class PolicyError(Exception):
40
+ """The policy file cannot be used; the message names the file and the problem."""
41
+
42
+
43
+ def parse_policy(text: str, source: str) -> Policy:
44
+ """The policy in ``text``; ``source`` names the file in messages and in the result."""
45
+ try:
46
+ data = tomllib.loads(text)
47
+ except tomllib.TOMLDecodeError as error:
48
+ raise PolicyError(f"{source} is not valid TOML: {error}") from None
49
+ try:
50
+ return _policy(data, source)
51
+ except _Invalid as error:
52
+ raise PolicyError(f"{source}: {error}") from None
53
+
54
+
55
+ def load_policy(worktree: Path) -> Policy:
56
+ """The policy of a worktree, or the defaults when it has no policy file.
57
+
58
+ The file must be a regular file inside the worktree (a link may point elsewhere in
59
+ it) and at most :data:`MAX_POLICY_BYTES` long.
60
+ """
61
+ path = worktree / POLICY_FILE
62
+ try:
63
+ text = read_text_inside(worktree, POLICY_FILE, MAX_POLICY_BYTES)
64
+ except RefusedError as error:
65
+ raise PolicyError(f"{path} {error}") from None
66
+ if text is None:
67
+ return Policy()
68
+ return parse_policy(text, str(path))
69
+
70
+
71
+ def policy_of(repository: Repository) -> Policy:
72
+ """The policy of the current worktree; a bare repository has the defaults only."""
73
+ if repository.current is None:
74
+ return Policy()
75
+ return load_policy(Path(repository.current))
76
+
77
+
78
+ def apply_overrides(
79
+ policy: Policy,
80
+ *,
81
+ include_runtime: bool | None = None,
82
+ include_env_files: bool | None = None,
83
+ ) -> Policy:
84
+ """The policy with the settings given on the command line; ``None`` keeps the file's."""
85
+ changes: dict[str, Any] = {}
86
+ if include_runtime is not None:
87
+ changes["include_runtime"] = include_runtime
88
+ if include_env_files is not None:
89
+ changes["include_env_files"] = include_env_files
90
+ return dataclasses.replace(policy, **changes) if changes else policy
91
+
92
+
93
+ class _Invalid(Exception):
94
+ pass
95
+
96
+
97
+ def _policy(data: dict[str, Any], source: str) -> Policy:
98
+ for key, value in data.items():
99
+ if key == "schema_version":
100
+ continue
101
+ if key not in _SECTIONS:
102
+ unknown = f"section [{key}]" if isinstance(value, dict) else f"key {key}"
103
+ raise _Invalid(f"unknown {unknown}")
104
+ if not isinstance(value, dict):
105
+ raise _Invalid(f"{key} must be a section, written [{key}]")
106
+ for name in value:
107
+ if name not in _SECTIONS[key]:
108
+ raise _Invalid(f"unknown key {key}.{name}")
109
+ _schema_version(data)
110
+ scan = data.get("scan", {})
111
+ levels = data.get("policy", {})
112
+ allow = data.get("allow", {})
113
+ settings: dict[str, Any] = {name: _flag(scan, name) for name in _SCAN_FLAGS if name in scan}
114
+ if "max_file_bytes" in scan:
115
+ settings["max_file_bytes"] = _max_file_bytes(scan["max_file_bytes"])
116
+ settings |= {name: _level(levels, name) for name in _LEVELS if name in levels}
117
+ if "ports" in allow:
118
+ settings["allow_ports"] = _ports(allow["ports"])
119
+ if "paths" in allow:
120
+ settings["allow_paths"] = _paths(allow["paths"])
121
+ return Policy(**settings, source=source)
122
+
123
+
124
+ def _schema_version(data: dict[str, Any]) -> None:
125
+ if "schema_version" not in data:
126
+ raise _Invalid(
127
+ f"schema_version is missing; start the file with schema_version = {SCHEMA_VERSION}"
128
+ )
129
+ version = data["schema_version"]
130
+ if not _is_int(version):
131
+ raise _Invalid(f"schema_version must be the number {SCHEMA_VERSION}, not {_shown(version)}")
132
+ if version != SCHEMA_VERSION:
133
+ raise _Invalid(
134
+ f"schema_version {version} is not supported; this version reads {SCHEMA_VERSION}"
135
+ )
136
+
137
+
138
+ def _flag(section: dict[str, Any], name: str) -> bool:
139
+ value = section[name]
140
+ if not isinstance(value, bool):
141
+ raise _Invalid(f"scan.{name} must be true or false, not {_shown(value)}")
142
+ return value
143
+
144
+
145
+ def _max_file_bytes(value: Any) -> int:
146
+ if not _is_int(value) or not 1 <= value <= MAX_FILE_BYTES_LIMIT:
147
+ raise _Invalid(
148
+ f"scan.max_file_bytes must be a whole number from 1 to {MAX_FILE_BYTES_LIMIT}, "
149
+ f"not {_shown(value)}"
150
+ )
151
+ result: int = value
152
+ return result
153
+
154
+
155
+ def _level(section: dict[str, Any], name: str) -> PolicyLevel:
156
+ value = section[name]
157
+ allowed = ", ".join(level.value for level in PolicyLevel)
158
+ if not isinstance(value, str) or value not in {level.value for level in PolicyLevel}:
159
+ raise _Invalid(f"policy.{name} must be one of {allowed}, not {_shown(value)}")
160
+ return PolicyLevel(value)
161
+
162
+
163
+ def _ports(value: Any) -> tuple[int, ...]:
164
+ if not isinstance(value, list) or not all(_is_int(p) and 1 <= p <= 65535 for p in value):
165
+ raise _Invalid("allow.ports must be port numbers from 1 to 65535 in a list, like [5432]")
166
+ return tuple(sorted(set(value)))
167
+
168
+
169
+ def _paths(value: Any) -> tuple[str, ...]:
170
+ if not isinstance(value, list) or not all(_is_absolute(p) for p in value):
171
+ raise _Invalid('allow.paths must be absolute paths in a list, like ["/srv/cache"]')
172
+ return tuple(dict.fromkeys(value))
173
+
174
+
175
+ def _is_int(value: Any) -> bool:
176
+ return isinstance(value, int) and not isinstance(value, bool)
177
+
178
+
179
+ def _is_absolute(value: Any) -> bool:
180
+ if not isinstance(value, str) or any(ord(c) < 32 or ord(c) == 127 for c in value):
181
+ return False
182
+ return PurePosixPath(value).is_absolute() or PureWindowsPath(value).is_absolute()
183
+
184
+
185
+ def _shown(value: Any) -> str:
186
+ """A value from the file for a message: repr() escapes control characters."""
187
+ shown = repr(value)
188
+ return shown if len(shown) <= 40 else shown[:39] + "…"
@@ -0,0 +1,22 @@
1
+ """Exit codes are a contract: scripts and CI jobs depend on them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+
7
+
8
+ class ExitCode(IntEnum):
9
+ OK = 0
10
+ """The scan ran. Findings, even failing ones, do not change this."""
11
+
12
+ STRICT_FAILURE = 1
13
+ """``--strict`` was given and a finding reached the policy's fail level (``deny``)."""
14
+
15
+ ERROR = 2
16
+ """Wrong usage, or the tool itself failed."""
17
+
18
+ CONTEXT_UNAVAILABLE = 3
19
+ """Something the command needs is missing, e.g. the directory is not in a Git repository."""
20
+
21
+ INTERRUPTED = 130
22
+ """Stopped with Ctrl+C (128 + SIGINT, as shells report it)."""
@@ -0,0 +1,70 @@
1
+ """Reading a configuration file of a worktree, and nothing outside it.
2
+
3
+ Every file worktree-shield reads from a repository goes through :func:`read_inside`:
4
+ links may point elsewhere in the worktree but not out of it, only regular files are
5
+ opened (a named pipe would block, a device could be endless), and a file larger than
6
+ the cap is refused rather than read in part, since half a file can mean something else.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import stat
13
+ from pathlib import Path
14
+
15
+ _FLAGS = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)
16
+ _FLAGS |= getattr(os, "O_BINARY", 0)
17
+
18
+
19
+ class RefusedError(Exception):
20
+ """The file exists but was not read; ``str()`` completes "<path> …" for a report."""
21
+
22
+
23
+ def read_inside(root: Path, relative: str | Path, max_bytes: int) -> bytes | None:
24
+ """The bytes of ``root / relative``, or ``None`` when there is no such file.
25
+
26
+ Raises :class:`RefusedError` when the file must not be read: a link out of ``root``,
27
+ not a regular file, larger than ``max_bytes`` or unreadable.
28
+ """
29
+ path = root / relative
30
+ if not os.path.lexists(path):
31
+ return None
32
+ try:
33
+ target = path.resolve(strict=True)
34
+ if not target.is_relative_to(root.resolve(strict=True)):
35
+ raise RefusedError("is a link to a file outside the worktree")
36
+ if not stat.S_ISREG(target.stat().st_mode): # Windows cannot open() a directory
37
+ raise RefusedError("is not a regular file")
38
+ # Checked again on what was opened: a file swapped for a pipe after the check
39
+ # cannot block (O_NONBLOCK) and is refused by fstat().
40
+ fd = os.open(target, _FLAGS)
41
+ except FileNotFoundError:
42
+ raise RefusedError("could not be read (a link to a file that does not exist)") from None
43
+ except OSError as error:
44
+ raise RefusedError(f"could not be read ({error.strerror or error})") from None
45
+ try:
46
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
47
+ raise RefusedError("is not a regular file")
48
+ chunks: list[bytes] = []
49
+ size = 0
50
+ while size <= max_bytes and (chunk := os.read(fd, max_bytes + 1 - size)):
51
+ chunks.append(chunk)
52
+ size += len(chunk)
53
+ except OSError as error:
54
+ raise RefusedError(f"could not be read ({error.strerror or error})") from None
55
+ finally:
56
+ os.close(fd)
57
+ if size > max_bytes:
58
+ raise RefusedError(f"is larger than {max_bytes} bytes")
59
+ return b"".join(chunks)
60
+
61
+
62
+ def read_text_inside(root: Path, relative: str | Path, max_bytes: int) -> str | None:
63
+ """Like :func:`read_inside`, decoded as UTF-8 (a byte order mark is dropped)."""
64
+ raw = read_inside(root, relative, max_bytes)
65
+ if raw is None:
66
+ return None
67
+ try:
68
+ return raw.decode("utf-8-sig")
69
+ except UnicodeDecodeError:
70
+ raise RefusedError("is not UTF-8 text") from None
@@ -0,0 +1 @@
1
+ """Reading the repository through Git's machine-readable interfaces, never changing it."""
@@ -0,0 +1,85 @@
1
+ """Find the repository and its worktrees, without changing anything in ``.git``.
2
+
3
+ Every git command runs as ``git -c core.fsmonitor=false -C <dir> …`` with
4
+ ``GIT_OPTIONAL_LOCKS=0`` (no index refresh, so no ``index.lock``),
5
+ ``GIT_TERMINAL_PROMPT=0`` (never wait for credentials) and ``LC_ALL=C``. Disabling the
6
+ fsmonitor stops a repository-configured program from being run. Only ``rev-parse``
7
+ and ``worktree list`` are used; nothing is pruned, repaired or locked.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from worktree_shield.git.porcelain import PorcelainError, parse_worktree_list
15
+ from worktree_shield.models import FailureKind, ProbeFailure, Repository
16
+ from worktree_shield.runner import CommandResult, CommandRunner, probe_failure
17
+
18
+ GIT_TIMEOUT = 10.0
19
+ """Seconds; worktree listing reads a few small files."""
20
+
21
+ GIT_ENV = {"GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"}
22
+
23
+
24
+ class NotARepositoryError(Exception):
25
+ """The directory is not inside a Git repository (exit code 3, not a failure)."""
26
+
27
+
28
+ def git_argv(directory: Path, *args: str) -> list[str]:
29
+ return ["git", "-c", "core.fsmonitor=false", "-C", str(directory), *args]
30
+
31
+
32
+ def _git(runner: CommandRunner, directory: Path, *args: str) -> CommandResult:
33
+ return runner.run(git_argv(directory, *args), timeout=GIT_TIMEOUT, env_overrides=GIT_ENV)
34
+
35
+
36
+ def discover(
37
+ runner: CommandRunner, start: Path
38
+ ) -> tuple[Repository | None, tuple[ProbeFailure, ...]]:
39
+ """The repository containing ``start`` and all its worktrees.
40
+
41
+ Raises :class:`NotARepositoryError` when ``start`` is not in a repository; any
42
+ other problem (git missing, a repository owned by another user, a timeout) is a
43
+ ProbeFailure and no repository.
44
+ """
45
+ result = _git(
46
+ runner,
47
+ start,
48
+ "rev-parse",
49
+ "--path-format=absolute",
50
+ "--git-common-dir",
51
+ "--is-bare-repository",
52
+ )
53
+ if not result.ok and "not a git repository" in result.stderr:
54
+ raise NotARepositoryError(result.stderr.strip().splitlines()[0])
55
+ failure = probe_failure("git rev-parse", result)
56
+ if failure is not None:
57
+ return None, (failure,)
58
+ lines = result.stdout.splitlines()
59
+ if len(lines) != 2 or lines[1] not in {"true", "false"}:
60
+ return None, (_malformed("git rev-parse", "expected the common directory and true/false"),)
61
+ common_dir, bare = lines[0], lines[1] == "true"
62
+
63
+ current: str | None = None
64
+ if not bare:
65
+ result = _git(runner, start, "rev-parse", "--path-format=absolute", "--show-toplevel")
66
+ failure = probe_failure("git rev-parse --show-toplevel", result)
67
+ if failure is not None:
68
+ return None, (failure,)
69
+ current = result.stdout.strip() or None
70
+
71
+ result = _git(runner, start, "worktree", "list", "--porcelain", "-z")
72
+ failure = probe_failure("git worktree list", result)
73
+ if failure is not None:
74
+ return None, (failure,)
75
+ if result.stdout_truncated:
76
+ return None, (_malformed("git worktree list", "the list was longer than the output cap"),)
77
+ try:
78
+ worktrees = parse_worktree_list(result.stdout)
79
+ except PorcelainError as exc:
80
+ return None, (_malformed("git worktree list", str(exc)),)
81
+ return Repository(common_dir, current, bare, worktrees), ()
82
+
83
+
84
+ def _malformed(probe: str, detail: str) -> ProbeFailure:
85
+ return ProbeFailure(probe, FailureKind.MALFORMED_OUTPUT, detail)
@@ -0,0 +1,48 @@
1
+ """Parse ``git worktree list --porcelain -z``.
2
+
3
+ With ``-z`` every attribute ends with NUL and every record with one more NUL, so paths
4
+ with spaces, newlines or any other byte except NUL come through intact. This is the
5
+ format Git documents as stable for scripts; the human-readable list is never parsed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from worktree_shield.models import Worktree
11
+
12
+
13
+ class PorcelainError(ValueError):
14
+ """The output is not a worktree list."""
15
+
16
+
17
+ def parse_worktree_list(text: str) -> tuple[Worktree, ...]:
18
+ """The worktrees in Git's order: the main worktree first, then the linked ones.
19
+
20
+ Unknown attributes are skipped, so a newer Git that adds one does not break the
21
+ scan. A record without a ``worktree`` line is an error.
22
+ """
23
+ worktrees: list[Worktree] = []
24
+ for record in text.split("\0\0"):
25
+ attributes = [item for item in record.split("\0") if item]
26
+ if not attributes:
27
+ continue
28
+ name, _, path = attributes[0].partition(" ")
29
+ if name != "worktree" or not path:
30
+ raise PorcelainError(f"a record starts with {name!r}, not 'worktree <path>'")
31
+ head = branch = locked = prunable = None
32
+ bare = detached = False
33
+ for attribute in attributes[1:]:
34
+ key, space, value = attribute.partition(" ")
35
+ if key == "HEAD":
36
+ head = value or None
37
+ elif key == "branch":
38
+ branch = value or None
39
+ elif key == "bare":
40
+ bare = True
41
+ elif key == "detached":
42
+ detached = True
43
+ elif key == "locked":
44
+ locked = value if space else ""
45
+ elif key == "prunable":
46
+ prunable = value if space else ""
47
+ worktrees.append(Worktree(path, head, branch, bare, detached, locked, prunable))
48
+ return tuple(worktrees)