gitpro 0.3.1__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.
gitpro/scan.py ADDED
@@ -0,0 +1,358 @@
1
+ """Find the repos under some directories, report the identity git uses in each, remove repo-level overrides.
2
+
3
+ A user.name or user.email in a repo's own .git/config wins over every identity gitpro writes, so a
4
+ forgotten one keeps committing with an old address. scan shows them next to what git would use
5
+ without them; fix removes them.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import glob
11
+ import json
12
+ import os
13
+ import re
14
+ import shlex
15
+ from collections import Counter
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Callable
19
+
20
+ from . import undo
21
+ from .config import HOST_RE, OWNER_RE, ConfigError, Scan
22
+ from .pin import FILE as PIN_FILE, git
23
+
24
+ KEYS = ("user.name", "user.email")
25
+ SKIP = {"node_modules"} # and every hidden directory
26
+ _GLOB = re.compile(r"[*?[]") # where an exclude pattern's literal prefix ends
27
+ _USER = r"^user\.(name|email)$"
28
+ # scp-like (git@host:owner/x), ssh://user@host:port/owner/x, https://host/owner/x
29
+ _URL = re.compile(r"^(?:[a-z][a-z0-9+.-]*://)?(?:[^@/]+@)?([^/:]+)(?::\d+(?=/))?[:/]([^/]+)")
30
+
31
+
32
+ @dataclass
33
+ class Repo:
34
+ path: Path
35
+ remote: str # origin's URL, else the first remote's; "" without remotes
36
+ now: dict[str, tuple[str, str]] # key -> (value, origin) that git resolves
37
+ local: dict[str, str] # key -> value set in the repo's own config file
38
+ beneath: dict[str, str] # key -> value git would resolve without those
39
+
40
+ def source(self, key: str = "user.email") -> str:
41
+ """Where the value git uses comes from: 'repo', 'pin', a file path, or '' when unset."""
42
+ if key not in self.now:
43
+ return ""
44
+ if key in self.local:
45
+ return "repo"
46
+ path = Path(self.now[key][1][len("file:"):])
47
+ if path.is_absolute():
48
+ return str(path)
49
+ return "pin" if path.name == PIN_FILE else str(self.path / path)
50
+
51
+ def after_fix(self) -> dict[str, str]:
52
+ """name and email once fix removed what it can (a setting nothing else provides stays)."""
53
+ return {k: self.beneath.get(k, self.now[k][0]) for k in KEYS if k in self.now}
54
+
55
+ def using(self) -> dict[str, str]:
56
+ return {k: self.now[k][0] for k in KEYS if k in self.now}
57
+
58
+
59
+ def find(roots, *, exclude=(), max_depth: int | None = None, excluded: set | None = None) -> list[Path]:
60
+ """Every directory holding a .git (repo, worktree or submodule), nested ones included.
61
+
62
+ Hidden directories and node_modules are always skipped. exclude: patterns as in [scan] exclude
63
+ (see excluded_by); a directory one matches is not entered, and is added to `excluded` when given.
64
+ A root that is itself excluded is skipped. max_depth: how many levels below each root to look
65
+ (None: no limit)."""
66
+ pats = _patterns(exclude)
67
+ found = set()
68
+ for root in roots:
69
+ root = Path(root)
70
+ if pats and _match(str(root), pats):
71
+ continue
72
+ base = len(root.parts)
73
+ for dirpath, dirnames, filenames in os.walk(root):
74
+ if ".git" in dirnames or ".git" in filenames:
75
+ found.add(Path(dirpath))
76
+ if max_depth is not None and len(Path(dirpath).parts) - base >= max_depth:
77
+ dirnames[:] = []
78
+ continue
79
+ keep = []
80
+ for d in dirnames:
81
+ if d.startswith(".") or d in SKIP:
82
+ continue
83
+ full = os.path.join(dirpath, d)
84
+ if pats and _match(full, pats):
85
+ if excluded is not None:
86
+ excluded.add(Path(full))
87
+ continue
88
+ keep.append(d)
89
+ dirnames[:] = keep
90
+ return sorted(found)
91
+
92
+
93
+ def _normalise(pattern: str, home: str) -> str:
94
+ """A [scan] exclude pattern as an fnmatch pattern over absolute, resolved paths: ~/ is the home
95
+ directory, a relative pattern matches at any depth (**/ in front), ** is fnmatch's * (which
96
+ crosses /). An absolute pattern's part before its first glob character is resolved (roots are,
97
+ so ~/work/old still matches when ~/work is a symlink) and escaped (a [ in a path is literal)."""
98
+ p = pattern.rstrip("/") or "/"
99
+ if p == "~" or p.startswith("~/"):
100
+ lit, p = home, p[1:] # the home directory is literal, whatever it contains
101
+ elif p.startswith("/"):
102
+ lit = ""
103
+ else:
104
+ return ("**/" + p).replace("**", "*")
105
+ m = _GLOB.search(p)
106
+ cut = len(p) if m is None else p.rfind("/", 0, m.start())
107
+ head, tail = os.path.realpath(lit + p[:cut] or "/"), p[cut:]
108
+ if head == "/" and tail:
109
+ head = ""
110
+ return glob.escape(head) + tail.replace("**", "*")
111
+
112
+
113
+ def _patterns(exclude) -> list[tuple[str, str]]:
114
+ home = str(Path.home())
115
+ return [(pat, _normalise(pat, home)) for pat in exclude]
116
+
117
+
118
+ def _match(path: str, pats: list[tuple[str, str]]) -> str | None:
119
+ return next((pat for pat, norm in pats if fnmatch.fnmatchcase(path, norm)), None)
120
+
121
+
122
+ def excluded_by(path: Path, exclude) -> str | None:
123
+ """The first exclude pattern (as written) that matches the absolute directory path, else None."""
124
+ return _match(str(path), _patterns(exclude))
125
+
126
+
127
+ def effective_roots(args_roots: list[str], cfg_scan: Scan | None) -> tuple[list[Path], list[str], str]:
128
+ """-> (roots, exclude, source): the command line's directories ("args"), else [scan] roots from a
129
+ config that loaded ("config"), else the home directory ("default"). exclude comes from the config
130
+ in every case; --repo never consults this. Roots are absolute and resolved, duplicates dropped."""
131
+ if args_roots:
132
+ roots, source = args_roots, "args"
133
+ elif cfg_scan is not None and cfg_scan.roots: # roots = [] counts as unset (scanroots warns)
134
+ roots, source = list(cfg_scan.roots), "config"
135
+ else:
136
+ roots, source = ["~"], "default"
137
+ out: list[Path] = []
138
+ for r in roots:
139
+ p = Path(r).expanduser().resolve()
140
+ if p not in out:
141
+ out.append(p)
142
+ return out, list(cfg_scan.exclude) if cfg_scan is not None else [], source
143
+
144
+
145
+ def _entries(repo: str, *scope: str) -> list[tuple[str, str, str]]:
146
+ """(origin, key, value) for user.name and user.email, in the order git reads them (the last one wins)."""
147
+ toks = git(repo, "config", *scope, "-z", "--show-origin", "--get-regexp", _USER, ok=(0, 1)).split("\0")
148
+ out = []
149
+ for origin, entry in zip(toks[0::2], toks[1::2]):
150
+ key, _, value = entry.partition("\n")
151
+ out.append((origin, key, value))
152
+ return out
153
+
154
+
155
+ def inspect(path: Path) -> Repo:
156
+ r = str(path)
157
+ urls = dict(line.split(" ", 1) for line in
158
+ git(r, "config", "--get-regexp", r"^remote\..*\.url$", ok=(0, 1)).splitlines())
159
+ own = _entries(r, "--local")
160
+ own_origins = {origin for origin, _, _ in own}
161
+ now, beneath = {}, {}
162
+ for origin, key, value in _entries(r):
163
+ now[key] = (value, origin)
164
+ if origin not in own_origins:
165
+ beneath[key] = value
166
+ return Repo(path, urls.get("remote.origin.url") or next(iter(urls.values()), ""), now,
167
+ {key: value for _, key, value in own}, beneath)
168
+
169
+
170
+ def _owner_name(uid: int) -> str:
171
+ try:
172
+ import pwd
173
+ return pwd.getpwuid(uid).pw_name
174
+ except (ImportError, KeyError):
175
+ return f"uid {uid}"
176
+
177
+
178
+ def skip_reason(path: Path, error: str) -> tuple[str, str]:
179
+ """(reason, detail) for a repo git refused to read. The reason groups repos in the summary
180
+ ("owned by root"); the detail says what is wrong with this one. git's own message is often
181
+ misleading here: for another user's repo it says "--local can only be used inside a git repository"."""
182
+ dotgit = path / ".git"
183
+ if dotgit.is_file():
184
+ try:
185
+ line = dotgit.read_text(errors="replace").splitlines()[0]
186
+ except (OSError, IndexError):
187
+ line = ""
188
+ if line.startswith("gitdir:"):
189
+ target = line[len("gitdir:"):].strip()
190
+ if not (path / target).exists():
191
+ return "with a broken .git link", f"broken .git: points to missing {target}"
192
+ getuid = getattr(os, "getuid", None) # not on Windows
193
+ if getuid:
194
+ try:
195
+ uid = (dotgit if dotgit.exists() else path).stat().st_uid
196
+ except OSError:
197
+ uid = getuid()
198
+ if uid != getuid():
199
+ name = _owner_name(uid)
200
+ return (f"owned by {name}",
201
+ f"owned by {name}: git ignores other users' repos unless listed in safe.directory")
202
+ return "git can't read", error
203
+
204
+
205
+ def inspect_all(paths: list[Path]) -> tuple[list[Repo], list[tuple[Path, str, str]]]:
206
+ """(repos, [(path, reason, detail) for the ones git refused to read, e.g. owned by another user])."""
207
+ repos, failed = [], []
208
+ for p in paths:
209
+ try:
210
+ repos.append(inspect(p))
211
+ except ConfigError as e:
212
+ failed.append((p, *skip_reason(p, str(e).splitlines()[0] if str(e) else "git failed")))
213
+ return repos, failed
214
+
215
+
216
+ def skipped(bad: list[dict]) -> str:
217
+ """'30 skipped: 26 owned by root, 4 with a broken .git link' for report()["unreadable"]."""
218
+ if not bad:
219
+ return ""
220
+ counts = Counter(b["reason"] for b in bad).most_common()
221
+ return f"{len(bad)} skipped: " + ", ".join(f"{n} {why}" for why, n in counts)
222
+
223
+
224
+ def host_owner(url: str) -> tuple[str, str] | None:
225
+ """(host, owner) of a remote URL, parsed from its redacted form: a userinfo secret never ends up in either."""
226
+ from .resolve import redact_url
227
+ m = _URL.match(redact_url(url) or url)
228
+ return (m[1], m[2]) if m else None
229
+
230
+
231
+ def owner(url: str) -> str:
232
+ """'host/owner' of a remote URL; the URL itself (redacted) when it has no host (a local path)."""
233
+ if not url:
234
+ return "no remote"
235
+ from .resolve import redact_url
236
+ url = redact_url(url) or url
237
+ ho = host_owner(url)
238
+ return "/".join(ho) if ho else url
239
+
240
+
241
+ def derive_id(owner: str, host: str, taken: set) -> str:
242
+ """The [[identity]] id for an owner: lowercase, other characters as '-', then '-<first host label>'
243
+ until it is not in taken. taken is not changed; the caller adds the id it uses."""
244
+ ident = re.sub(r"[^A-Za-z0-9._-]", "-", owner).lower()
245
+ while ident in taken:
246
+ ident += "-" + host.split(".")[0]
247
+ return ident
248
+
249
+
250
+ def suggest(repos: list[Repo]) -> tuple[list[dict], list[Repo]]:
251
+ """[[identity]] blocks that keep the name and email fix would change, and the repos no identity can match.
252
+
253
+ An identity matches by host and owner, so each block says how many other repos of those owners it changes.
254
+ """
255
+ wanted: dict[tuple[str, str, str], Counter] = {}
256
+ unmatched = []
257
+ for r in repos:
258
+ now = r.using()
259
+ if not r.local or r.after_fix() == now:
260
+ continue
261
+ ho = host_owner(r.remote)
262
+ if not (ho and HOST_RE.match(ho[0]) and OWNER_RE.match(ho[1]) and len(now) == 2):
263
+ unmatched.append(r)
264
+ continue
265
+ wanted.setdefault((ho[0], now["user.name"], now["user.email"]), Counter())[ho[1]] += 1
266
+ claimed = Counter((host, o) for (host, _, _), owners in wanted.items() for o in owners)
267
+ blocks, ids = [], set()
268
+ for (host, name, email), owners in sorted(wanted.items()):
269
+ names = sorted(owners, key=lambda o: (-owners[o], o))
270
+ others = sum(1 for r in repos if host_owner(r.remote) in {(host, o) for o in names}
271
+ and r.after_fix().get("user.email") != email and r.using().get("user.email") != email)
272
+ ident = derive_id(names[0], host, ids)
273
+ ids.add(ident)
274
+ repos_note = ", ".join(f"{o} {owners[o]}" for o in names)
275
+ blocks.append({"id": ident, "name": name, "email": email, "host": host, "owners": names, "changes_others": others,
276
+ "toml": "\n".join([
277
+ "[[identity]]",
278
+ f"id = {json.dumps(ident)}",
279
+ f"name = {json.dumps(name)}",
280
+ f"email = {json.dumps(email)}",
281
+ f"host = {json.dumps(host)}",
282
+ f"owners = {json.dumps(names)} # repos with this setting: {repos_note}",
283
+ *([f"# also changes {others} other repo(s) of these owners to this email"] if others else []),
284
+ *(f"# {o} is in another block too: an identity matches by owner, so keep only one"
285
+ for o in names if claimed[(host, o)] > 1),
286
+ ])})
287
+ return blocks, unmatched
288
+
289
+
290
+ def who(ident: dict[str, str]) -> str:
291
+ """'name <email>' of a {'user.name', 'user.email'} dict; '(no email)' without an email."""
292
+ email = ident.get("user.email")
293
+ return f"{ident.get('user.name', '')} <{email}>".strip() if email else "(no email)"
294
+
295
+
296
+ def short_names(paths: list[Path]) -> dict[Path, str]:
297
+ """The fewest trailing path parts that tell the paths apart: 'mcp/ai-collab', 'LLM/ai-collab'."""
298
+ out = {}
299
+ for p in paths:
300
+ n = 1
301
+ while n < len(p.parts) and sum(q.parts[-n:] == p.parts[-n:] for q in paths) > 1:
302
+ n += 1
303
+ out[p] = "/".join(p.parts[-n:])
304
+ return out
305
+
306
+
307
+ def report(roots: list[Path], repos: list[Repo], failed: list[tuple[Path, str, str]],
308
+ label: Callable[[str], str]) -> dict:
309
+ """Everything scan shows, as plain data (also what --json prints). label names a source() for people."""
310
+ groups: dict[tuple[str, str, str], Counter] = {}
311
+ for r in repos:
312
+ now = r.using()
313
+ groups.setdefault((now.get("user.name", ""), now.get("user.email", ""), label(r.source())),
314
+ Counter())[owner(r.remote)] += 1
315
+ over = [r for r in repos if r.local]
316
+ short = short_names([r.path for r in over])
317
+ blocks, unmatched = suggest(repos)
318
+ pair = lambda d: {"name": d.get("user.name", ""), "email": d.get("user.email", "")} # noqa: E731
319
+ from .resolve import redact_url
320
+ return {
321
+ "roots": [str(p) for p in roots],
322
+ "repos": len(repos),
323
+ "unreadable": [{"path": str(p), "reason": why, "error": detail} for p, why, detail in failed],
324
+ "identities": [
325
+ {"name": n, "email": e, "source": src, "repos": sum(c.values()),
326
+ "owners": dict(sorted(c.items(), key=lambda kv: (-kv[1], kv[0])))}
327
+ for (n, e, src), c in sorted(groups.items(), key=lambda kv: (kv[0][2] == "repo", -sum(kv[1].values())))],
328
+ "overrides": [
329
+ {"path": str(r.path), "short": short[r.path], "remote": redact_url(r.remote) or "", "owner": owner(r.remote),
330
+ "now": pair(r.using()), "after": pair(r.after_fix()),
331
+ "settings": [{"key": k, "value": v, "without": r.beneath.get(k)} for k, v in r.local.items()],
332
+ "fixable": [k for k in r.local if k in r.beneath]}
333
+ for r in over],
334
+ "suggestions": blocks,
335
+ "unmatched": [str(r.path) for r in unmatched],
336
+ }
337
+
338
+
339
+ def fix(repos: list[Repo], state: Path, prog: str = "gitpro") -> tuple[list[tuple[Repo, str]], Path | None]:
340
+ """Remove every override something else can replace; ([(repo, key) removed], script that puts them back)."""
341
+ pairs = [(r, k) for r in repos for k in r.local if k in r.beneath]
342
+ if not pairs:
343
+ return [], None
344
+ # Written first, so an interrupted fix can still be undone.
345
+ script = undo.write("scan --fix", [f"git -C {shlex.quote(str(r.path))} config {k} {shlex.quote(r.local[k])}"
346
+ for r, k in pairs], what="removed repo-local user settings", state=state)
347
+ for r, k in pairs:
348
+ remove(r, k)
349
+ return pairs, script
350
+
351
+
352
+ def remove(repo: Repo, key: str) -> None:
353
+ """Remove key from the repo's own config, and the [user] section if that leaves it empty."""
354
+ r = str(repo.path)
355
+ git(r, "config", "--local", "--unset-all", key)
356
+ if not git(r, "config", "--local", "--get-regexp", r"^user\.", ok=(0, 1)):
357
+ # Older git leaves the emptied [user] header behind; newer git already dropped it (exit 128).
358
+ git(r, "config", "--local", "--remove-section", "user", ok=(0, 128))
gitpro/scanroots.py ADDED
@@ -0,0 +1,146 @@
1
+ """scan -c/--config and --roots: where scan looks, from the command line or the config's [scan] table.
2
+
3
+ Roots: DIR... on the command line, else [scan] roots, else ~. [scan] exclude applies to both, never to
4
+ --repo. gitpro never edits the config: with roots on the command line it prints the line to add.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import config, jsonout, undo
15
+
16
+ _ALWAYS = {"**/node_modules", "node_modules"} # patterns every walk applies anyway
17
+
18
+
19
+ def extend(subs) -> None:
20
+ from .cli import capability, extend_command
21
+ extend_command(subs, "scan", add_flags, handler)
22
+ capability("scan.roots")
23
+
24
+
25
+ def add_flags(p: argparse.ArgumentParser) -> None:
26
+ p.add_argument("-c", "--config", type=Path, metavar="PATH", help="config file (default: see gitpro -h)")
27
+ p.add_argument("--home", type=Path, help=argparse.SUPPRESS) # accepted, ignored (labels use $HOME)
28
+ p.add_argument("--roots", action="store_true", help="print the directories scan looks in, and what it skips")
29
+
30
+
31
+ def _warn(msg: str) -> None:
32
+ print(f"⚠ {msg}", file=sys.stderr)
33
+
34
+
35
+ def scan_config(explicit: Path | None, *, has_args: bool) -> "tuple[config.Scan | None, Path | None]":
36
+ """(Scan or None, config path or None). Lenient: only [scan] matters; a bad [scan] table or TOML
37
+ prints one warning and counts as no config. A config that cannot be found is no config."""
38
+ home = Path.home()
39
+ try:
40
+ path, _ = config.find(explicit, home)
41
+ except config.ConfigError:
42
+ return None, None
43
+ try:
44
+ return config.load_scan(path), path
45
+ except config.ConfigError as e:
46
+ msg = str(e)
47
+ msg = msg[len(f"{path}: "):] if msg.startswith(f"{path}: ") else msg
48
+ _warn(f"{undo.tilde(path)}: {msg}; " + ("its [scan] table is ignored" if has_args else "scanning ~"))
49
+ return None, path
50
+
51
+
52
+ def handler(args) -> int | None:
53
+ """--roots prints and exits; otherwise sets args.roots_effective, exclude, roots_source and
54
+ roots_hint for cmd_scan, or exits 2 when no root is left to scan. --repo: as before."""
55
+ from . import scan
56
+ from .args import fail
57
+ if args.repo and not args.roots:
58
+ return None
59
+ cfg_scan, path = scan_config(args.config, has_args=bool(args.dirs))
60
+ if cfg_scan is not None and not cfg_scan.roots and not args.dirs and path is not None:
61
+ _warn(f"{undo.tilde(path)}: [scan] roots is empty; scanning ~")
62
+ written = [str(d) for d in args.dirs]
63
+ roots, exclude, source = scan.effective_roots(written, cfg_scan)
64
+ if args.roots:
65
+ if source != "args":
66
+ written = list(cfg_scan.roots) if source == "config" and cfg_scan is not None else ["~"]
67
+ return print_roots(args, roots, exclude, source, path if cfg_scan is not None else None,
68
+ as_written(written))
69
+ existing = [r for r in roots if r.is_dir()]
70
+ if not existing:
71
+ return fail(f"no scan root exists (looked at: {', '.join(undo.tilde(r) for r in roots)})", args)
72
+ for r in roots:
73
+ if r not in existing:
74
+ _warn(f"root does not exist: {undo.tilde(r)}")
75
+ usable = []
76
+ for r in existing:
77
+ pat = scan.excluded_by(r, exclude)
78
+ if pat:
79
+ _warn(f"root {undo.tilde(r)} is excluded by '{pat}'; skipped")
80
+ else:
81
+ usable.append(r)
82
+ if not usable:
83
+ return fail("every scan root is excluded by [scan] exclude", args)
84
+ args.roots_effective, args.exclude, args.roots_source = usable, exclude, source
85
+ if source == "args" and path is not None and path.is_file():
86
+ args.roots_hint = roots_hint(usable, cfg_scan, path)
87
+ return None
88
+
89
+
90
+ def roots_hint(usable: list[Path], cfg_scan, path: Path) -> str | None:
91
+ """The footer for roots given on the command line: the [scan] roots line that keeps them. Only
92
+ roots [scan] would accept; none when there are none, or they are what the config has already."""
93
+ from . import scan
94
+ keep = [r for r in usable if config.scan_root_error(undo.tilde(r)) is None]
95
+ if not keep:
96
+ return None
97
+ if cfg_scan is not None and cfg_scan.roots:
98
+ configured, _, _ = scan.effective_roots([], cfg_scan)
99
+ if set(configured) == set(keep):
100
+ return None
101
+ return f"to keep these roots: add to {undo.tilde(path)}: [scan] roots = {toml_list(keep)}"
102
+
103
+
104
+ def as_written(written: list[str]) -> dict:
105
+ """{resolved root: the first way it was written}, to show ~/work rather than where it points."""
106
+ out: dict = {}
107
+ for w in written:
108
+ out.setdefault(Path(w).expanduser().resolve(), w)
109
+ return out
110
+
111
+
112
+ def toml_list(roots) -> str:
113
+ """["~", "/mnt/d/work"]: roots as a TOML array, home shown as ~ (what [scan] roots accepts)."""
114
+ return json.dumps([undo.tilde(r) for r in roots], ensure_ascii=False).replace("\x7f", "\\u007f")
115
+
116
+
117
+ def _label(root: Path, written: str | None) -> str:
118
+ """~/work as written, and where it really is when that differs: ~/work → /mnt/data/work."""
119
+ if written is None:
120
+ return undo.tilde(root)
121
+ plain = Path(written).expanduser()
122
+ same = plain.is_absolute() and Path(os.path.normpath(plain)) == root
123
+ return written if same else f"{written} → {undo.tilde(root)}"
124
+
125
+
126
+ def print_roots(args, roots, exclude, source: str, path: Path | None, written: dict | None = None) -> int:
127
+ """`scan --roots`: the effective roots (as written, resolved when different) and exclude
128
+ patterns, text or --json (roots resolved; written: as given)."""
129
+ written = written or {}
130
+ missing = [r for r in roots if not r.is_dir()]
131
+ if getattr(args, "json", False):
132
+ jsonout.emit("scan", {"roots": [str(r) for r in roots], "exclude": list(exclude), "source": source,
133
+ "config": str(path) if path else None, "missing": [str(r) for r in missing],
134
+ "written": [written.get(r, str(r)) for r in roots]})
135
+ return 0
136
+ where = {"config": f"from {undo.tilde(path)}" if path else "from config", "args": "from command line",
137
+ "default": "default"}[source]
138
+ lines = [f"roots ({where}):"]
139
+ lines += [f" {_label(r, written.get(r))}" + (" (missing)" if r in missing else "") for r in roots]
140
+ lines.append("exclude:")
141
+ lines += [f" {e}" + (" (always)" if e in _ALWAYS else "") for e in exclude]
142
+ if not _ALWAYS & set(exclude):
143
+ lines.append(" **/node_modules (always)")
144
+ lines.append(" **/.* (always: hidden directories)")
145
+ print("\n".join(lines))
146
+ return 0