pre-commit-bigfix 2.0.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.
@@ -0,0 +1,1203 @@
1
+ #!/usr/bin/env python3
2
+ """Check a BigFix Git External Custom Site repo's layout, file names and paths.
3
+
4
+ A Git External Custom Site ("repository site", BigFix 11.0.7+) is gathered by
5
+ the root server from a git repo, and one bad file can fail the whole sync. This
6
+ hook checks the site as a whole, so pre-commit runs it once per commit
7
+ (pass_filenames: false, always_run: true). The files it sees are those
8
+ `git ls-files` lists: untracked and ignored files do not count.
9
+
10
+ Each site root (--site-root, repeatable; default the repo root) may hold a
11
+ siteConfig.xml naming its folders (BesFileDir, NonClientFilesDir,
12
+ OtherFilesDir); the defaults are Fixlets, NonClientFiles and OtherFiles.
13
+
14
+ A root server may run Windows or RHEL, and one repo site may serve both, so
15
+ names and paths must be valid on both: the stricter limit always applies.
16
+ Folder names, the .bes extension and digest.xml are matched ignoring case,
17
+ as on Windows, so nothing goes unchecked, but a name that matches only that
18
+ way is a warning (W808), since RHEL is case-sensitive.
19
+
20
+ Checks (E-codes fail the hook; W-codes are advisory unless --strict):
21
+
22
+ E800 a .bes file in the .bes folder whose name does not start with a
23
+ numeric ID followed by a space or hyphen (`42- Name.bes`,
24
+ `42 Name.bes`); the server skips it
25
+ E801 two or more .bes files in one site share an ID, compared as
26
+ integers (0042 and 42 collide); each names the others
27
+ E802 a .bes file's server path is over the limit and the rename cannot
28
+ fix it: even `<ID> X.bes` is too long (the folders are), it has no
29
+ ID, or E805 is disabled. Renaming a folder changes its .fxf or
30
+ digest, so that is not done
31
+ E803 fix: zero padding in the ID (0042- X.bes -> 42- X.bes); done even
32
+ when the ID is a duplicate (E801), since the ID does not change
33
+ E804 fix: non-ASCII characters in the name, folded to ASCII
34
+ E805 fix: the server path is over the limit, or the name is over 255
35
+ bytes; renamed `<ID> <Title>.bes`
36
+ E806 a rename was skipped: the new name is taken, ignoring case (by a
37
+ tracked file, another planned rename or an untracked file), the
38
+ file has unstaged changes pre-commit set aside, the commit uses a
39
+ temporary index (git commit -a, git commit <paths>), or the
40
+ rename failed
41
+ E807 any other tracked file whose server path is over the limit, in or
42
+ outside the site roots (the server clones the whole repo); it is
43
+ not renamed, since actions may refer to it by name
44
+ E808 a file or folder name over 255 bytes in UTF-8 that the rename
45
+ cannot fix; a folder is reported once
46
+ W800 a .bes file outside the .bes folder; it is not gathered
47
+ W801 a .bes file directly in the .bes folder, or deeper than
48
+ <BesFileDir>/<fxf>/<digest>/ (server behavior undocumented)
49
+ W802 a file in the .bes folder other than .bes or digest.xml (an .xsd
50
+ too: bes-schema-validate already has the schemas)
51
+ W803 a folder siteConfig.xml renames does not exist (has no files); a
52
+ default name spelled out, as the template does, is not checked,
53
+ since those folders are optional
54
+ W804 a site with no content: no .bes files in the .bes folder and no
55
+ files in the NonClient or Other folders (server files alone pass)
56
+ W806 an ID outside 1..2147483647 (the range is undocumented)
57
+ W807 siteConfig.xml is not readable XML; the default folders are used
58
+ W808 a folder, .bes extension or digest.xml that matches the expected
59
+ name only when case is ignored
60
+
61
+ Path limit (Windows): the server clones the whole repo to
62
+ `<server-root>\\RepositorySiteGather\\Sites\\<repo>_<branch>_<8 hex>\\Source\\`
63
+ and each file's path relative to the REPO root (not the site root) follows
64
+ that. It must be at most --max-path (259: MAX_PATH 260 includes the NUL),
65
+ counted in UTF-16 units. <repo> is the git repo's name (--repo-name, default
66
+ the origin remote's, else the repo folder's), not the BigFix site name.
67
+ Over-long paths fail on 11.0.7 even with Windows long paths enabled. Only
68
+ this Windows layout is confirmed; the RHEL clone path is not, so it is
69
+ the one measured.
70
+
71
+ Name limit (Linux): each file and folder name must be at most NAME_MAX, 255
72
+ bytes in UTF-8. Linux's PATH_MAX (4096 bytes) cannot be reached once the
73
+ Windows limit holds, so it has no check of its own.
74
+
75
+ Auto-fix (the default; --check is a dry run that reports and changes
76
+ nothing): each .bes file in the .bes folder is renamed at most once, combining
77
+ E803, E804 and, if the path or name is still too long, E805. E805 builds
78
+ `<ID> <Title><extension>` (no hyphen) from the first content object's
79
+ <Title> (or the current name without its ID, if the Title has no letter or
80
+ digit), made ASCII and Windows-safe, cut to fit both limits, with trailing
81
+ spaces, dots and hyphens dropped. A name that already fits is left alone,
82
+ even when it differs from <Title>. The ID never changes and no file moves
83
+ between folders. Files without an ID are not given one: --assign-ids is
84
+ deferred (assigning IDs is a policy decision; CommunityContent uses a
85
+ counter file, and max+1 would reuse the IDs of deleted content).
86
+
87
+ How files are renamed: a tracked file with `git mv`, anything else (only a
88
+ file list a caller passes in) with a plain rename. git mv stages the
89
+ rename, so the next commit carries both the new name and the removal of the
90
+ old one; a plain rename would leave an unstaged deletion plus an untracked
91
+ file, and `git commit -a` (or `git add -u`) would then commit the deletion
92
+ alone, dropping the content from the repo. Three cases rename nothing
93
+ (E806), because the rename would be lost:
94
+ - git commit -a and git commit <paths> give the hook a temporary index
95
+ that git discards when the hook fails (see uses_real_index);
96
+ - inside a pre-commit run, pre-commit has stashed unstaged changes in a
97
+ patch it re-applies after the hooks, and cannot re-apply it to a
98
+ renamed file, so a file named in the patch is left alone (see
99
+ pre_commit_stashed_paths; `pre-commit try-repo` uses a temporary cache,
100
+ where the patch is not found);
101
+ - --check.
102
+ Any rename makes the hook exit 1, so the commit stops and the renames can
103
+ be reviewed.
104
+
105
+ Not in scope: more than one object per .bes file and Baselines (repository
106
+ sites are thought to handle both); case conflicts and names Windows forbids
107
+ (use check-case-conflict and check-illegal-windows-names from
108
+ pre-commit-hooks >= 5.0.0); schemas, GroupRelevance and `//` comments (the
109
+ bes-schema-validate, bes-relevance-convert-group and bes-conventions-check
110
+ hooks); writing a siteConfig.xml for a blank repo.
111
+ """
112
+
113
+ import argparse
114
+ import collections
115
+ import glob
116
+ import os
117
+ import posixpath
118
+ import re
119
+ import subprocess
120
+ import sys
121
+ import unicodedata
122
+
123
+ from lxml import etree
124
+
125
+ if __package__ in (None, ""): # run directly as a script, not as a module
126
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
127
+
128
+ from pre_commit_bigfix import bes_common
129
+
130
+ DEFAULT_SERVER_ROOT = "C:\\Program Files (x86)\\BigFix Enterprise\\BES Server"
131
+ DEFAULT_BRANCH = "main"
132
+ # MAX_PATH is 260 including the terminating NUL
133
+ DEFAULT_MAX_PATH = 259
134
+ # a root server may run Linux (RHEL) instead, and one repo site may serve
135
+ # both, so the stricter limit always applies. Linux limits each file or
136
+ # folder name to NAME_MAX bytes (UTF-8). Its PATH_MAX (4096 bytes) needs no
137
+ # check of its own: 259 UTF-16 units are at most 777 bytes in UTF-8.
138
+ NAME_MAX = 255
139
+ # the server names the clone <repo>_<branch>_<8 hex>; the hash is unknown here
140
+ # and only its length matters
141
+ HASH_PLACEHOLDER = "0" * 8
142
+
143
+ DEFAULT_DIRS = ("Fixlets", "NonClientFiles", "OtherFiles")
144
+ SITE_CONFIG = "siteConfig.xml"
145
+ SITE_CONFIG_ELEMENTS = ("BesFileDir", "NonClientFilesDir", "OtherFilesDir")
146
+
147
+ KNOWN_CODES = frozenset(
148
+ {
149
+ "E800",
150
+ "E801",
151
+ "E802",
152
+ "E803",
153
+ "E804",
154
+ "E805",
155
+ "E806",
156
+ "E807",
157
+ "E808",
158
+ "W800",
159
+ "W801",
160
+ "W802",
161
+ "W803",
162
+ "W804",
163
+ "W806",
164
+ "W807",
165
+ "W808",
166
+ }
167
+ )
168
+
169
+ # a numeric ID, then a space or a hyphen: `42- Name.bes`, `42 Name.bes`
170
+ ID_PREFIX_RE = re.compile(r"^([0-9]+)[ -]")
171
+ # undocumented; the largest signed 32-bit integer is the likely ceiling
172
+ MAX_ID = 2147483647
173
+
174
+ # non-ASCII characters NFKD does not fold, or folds to something unwanted:
175
+ # currency from #32, typographic quotes and dashes (the one non-ASCII name
176
+ # found in the corpora was Docker\u2019s). Double quotes become ' because
177
+ # Windows forbids " in file names.
178
+ ASCII_TABLE = {
179
+ "\u20ac": "EUR",
180
+ "\u00a3": "GBP",
181
+ "\u00df": "ss",
182
+ "\u2018": "'",
183
+ "\u2019": "'",
184
+ "\u201a": "'",
185
+ "\u201b": "'",
186
+ "\u201c": "'",
187
+ "\u201d": "'",
188
+ "\u201e": "'",
189
+ "\u201f": "'",
190
+ "\u2012": "-",
191
+ "\u2013": "-",
192
+ "\u2014": "-",
193
+ "\u2015": "-",
194
+ }
195
+ # characters Windows forbids in a file name (control characters too)
196
+ WINDOWS_FORBIDDEN = frozenset('<>:"/\\|?*')
197
+ FIX_CODES = ("E803", "E804", "E805")
198
+
199
+ SiteConfig = collections.namedtuple(
200
+ "SiteConfig", "bes_dir nonclient_dir other_dir named issue"
201
+ )
202
+
203
+
204
+ def server_prefix(server_root, repo_name, branch, commit_hash=HASH_PLACEHOLDER):
205
+ r"""Return the server folder a repo file's relative path is appended to.
206
+
207
+ That is `<server_root>\RepositorySiteGather\Sites\<repo>_<branch>_<hash>\Source\`,
208
+ joined with `/` instead when `server_root` is a POSIX path. Only the
209
+ Windows layout is confirmed; a separator is one character either way,
210
+ so the length is the same.
211
+ """
212
+ sep = "/" if server_root.startswith("/") else "\\"
213
+ root = server_root.rstrip("\\/")
214
+ return sep.join(
215
+ [root, "RepositorySiteGather", "Sites", f"{repo_name}_{branch}_{commit_hash}"]
216
+ + ["Source", ""]
217
+ )
218
+
219
+
220
+ def server_path_length(prefix, rel_path):
221
+ """Length of `rel_path` on the server, in the UTF-16 units MAX_PATH counts.
222
+
223
+ A name git listed as bytes that are not UTF-8 holds lone surrogates
224
+ (os.fsdecode); surrogatepass counts each as one unit instead of failing.
225
+ """
226
+ full = prefix + rel_path.replace("/", "\\")
227
+ return len(full.encode("utf-16-le", "surrogatepass")) // 2
228
+
229
+
230
+ def name_bytes(name):
231
+ """Bytes `name` takes in UTF-8, as a Linux file system counts NAME_MAX.
232
+
233
+ A name git listed as bytes that are not UTF-8 holds surrogate escapes
234
+ (os.fsdecode); surrogateescape turns those back into their bytes.
235
+ """
236
+ try:
237
+ return len(name.encode("utf-8", "surrogateescape"))
238
+ except UnicodeEncodeError: # a surrogate that is not an escape
239
+ return len(name.encode("utf-8", "surrogatepass"))
240
+
241
+
242
+ def repo_name_from_url(url):
243
+ """The last part of a git remote URL, without `.git`.
244
+
245
+ Handles https://host/owner/name(.git), ssh://host/owner/name.git, the
246
+ scp-like git@host:owner/name.git, and local paths, Windows ones too
247
+ (C:\\repos\\name, \\\\server\\share\\name.git). `.git` matches in any case.
248
+ """
249
+ name = url.strip().replace("\\", "/").rstrip("/")
250
+ name = name.replace(":", "/").rsplit("/", 1)[-1]
251
+ # not str.removesuffix(): that needs Python 3.9, and this supports 3.8
252
+ stem, extension = os.path.splitext(name)
253
+ return stem if extension.lower() == ".git" else name
254
+
255
+
256
+ def _git(repo_root, *args):
257
+ """Run git in `repo_root`; return its stdout bytes, or None if it failed."""
258
+ try:
259
+ result = subprocess.run(
260
+ ["git", *args], cwd=repo_root, capture_output=True, check=False
261
+ )
262
+ except OSError: # git not installed
263
+ return None
264
+ return result.stdout if result.returncode == 0 else None
265
+
266
+
267
+ def repo_root():
268
+ """The top folder of the git repo the current folder is in, or None."""
269
+ out = _git(".", "rev-parse", "--show-toplevel")
270
+ return os.fsdecode(out).strip() if out else None
271
+
272
+
273
+ def default_repo_name(root):
274
+ """The origin remote's repo name, or the repo folder's name if no origin."""
275
+ url = _git(root, "remote", "get-url", "origin")
276
+ if url and os.fsdecode(url).strip():
277
+ return repo_name_from_url(os.fsdecode(url))
278
+ return os.path.basename(os.path.normpath(root))
279
+
280
+
281
+ def git_ls_files(root):
282
+ """Return the files git tracks under `root`, as sorted repo-relative paths.
283
+
284
+ `-z` keeps names unquoted (non-ASCII and spaces as they are). An index
285
+ with a merge conflict lists a path once per stage, hence the set.
286
+ """
287
+ out = _git(root, "ls-files", "-z")
288
+ if out is None:
289
+ raise OSError(f"`git ls-files` failed in {root}")
290
+ return sorted({os.fsdecode(name) for name in out.split(b"\0") if name})
291
+
292
+
293
+ def _join(site_root, rel):
294
+ """Join a site root and a site-relative path into a repo-relative path."""
295
+ return f"{site_root}/{rel}" if site_root else rel
296
+
297
+
298
+ def _clean_dir(text):
299
+ """A folder name from siteConfig.xml, without surrounding space or slashes.
300
+
301
+ `./Content`, `.\\Content\\` and `Content/./` all mean Content; `.` (or
302
+ nothing) names no folder, so the caller uses the default.
303
+ """
304
+ text = (text or "").strip().replace("\\", "/")
305
+ if not text:
306
+ return ""
307
+ text = posixpath.normpath(text).strip("/")
308
+ return "" if text == "." else text
309
+
310
+
311
+ def read_site_config(root, site_root, files):
312
+ """Read the folder names from `<site_root>/siteConfig.xml`.
313
+
314
+ Only a tracked siteConfig.xml counts, since the server only sees what is
315
+ committed. Returns a SiteConfig: the three folder names (defaults for
316
+ any not set), `named` the ones it renames to something other than
317
+ the default, and `issue` a W807
318
+ (lineno, code, message) when the file is not readable XML, in which
319
+ case the defaults are used. Validating it is bes-schema-validate's job.
320
+ """
321
+ defaults = SiteConfig(*DEFAULT_DIRS, named=(), issue=None)
322
+ rel = _join(site_root, SITE_CONFIG)
323
+ if rel not in set(files):
324
+ return defaults
325
+ parser = etree.XMLParser(resolve_entities=False, no_network=True)
326
+ try:
327
+ tree = etree.parse(os.path.join(root, rel), parser)
328
+ except (OSError, etree.XMLSyntaxError) as err:
329
+ return defaults._replace(
330
+ issue=(
331
+ 1,
332
+ "W807",
333
+ (
334
+ f"{SITE_CONFIG} is not readable XML ({err}); assuming the "
335
+ f"default folders {', '.join(DEFAULT_DIRS)}"
336
+ ),
337
+ )
338
+ )
339
+ found = []
340
+ for tag, default in zip(SITE_CONFIG_ELEMENTS, DEFAULT_DIRS):
341
+ value = _clean_dir(tree.getroot().findtext(tag))
342
+ found.append(value or default)
343
+ # a default name spelled out (as the RepositorySiteExample template does)
344
+ # renames nothing, so it does not count
345
+ named = tuple(
346
+ name
347
+ for name, default in zip(found, DEFAULT_DIRS)
348
+ if name.lower() != default.lower()
349
+ )
350
+ return SiteConfig(*found, named=named, issue=None)
351
+
352
+
353
+ def _windows_safe(char):
354
+ """True for a printable ASCII character Windows allows in a file name."""
355
+ return " " <= char <= "~" and char not in WINDOWS_FORBIDDEN
356
+
357
+
358
+ def fold_ascii(text):
359
+ """Return `text` with each non-ASCII character replaced by ASCII (#32).
360
+
361
+ The text is first composed (NFC), so a decomposed accent (a combining
362
+ mark after its letter, as macOS writes names) joins its letter. Then
363
+ ASCII_TABLE, then Unicode NFKD with the combining marks dropped
364
+ (\u00e9 -> e, \u2122 -> TM); a character with no Windows-safe ASCII
365
+ equivalent becomes `_`. A combining mark with no letter to join, and an
366
+ invisible format character (category Cf: zero-width space, BOM), are
367
+ dropped. ASCII characters are left as they are.
368
+ """
369
+ out = []
370
+ for char in unicodedata.normalize("NFC", text):
371
+ if char.isascii():
372
+ out.append(char)
373
+ elif char in ASCII_TABLE:
374
+ out.append(ASCII_TABLE[char])
375
+ elif unicodedata.combining(char) or unicodedata.category(char) == "Cf":
376
+ continue
377
+ else:
378
+ decomposed = "".join(
379
+ c
380
+ for c in unicodedata.normalize("NFKD", char)
381
+ if not unicodedata.combining(c)
382
+ )
383
+ ok = decomposed and all(_windows_safe(c) for c in decomposed)
384
+ out.append(decomposed if ok else "_")
385
+ return "".join(out)
386
+
387
+
388
+ def safe_title(title):
389
+ """Turn a Title into ASCII text that is safe in a Windows file name.
390
+
391
+ Runs of whitespace become one space, and the ends are trimmed; then
392
+ non-ASCII is folded and anything Windows forbids becomes `_`.
393
+ """
394
+ text = fold_ascii(re.sub(r"\s+", " ", title).strip())
395
+ return "".join(c if _windows_safe(c) else "_" for c in text)
396
+
397
+
398
+ def read_title(path):
399
+ """The first content object's <Title> in a .bes file, or None."""
400
+ parser = etree.XMLParser(resolve_entities=False, no_network=True)
401
+ try:
402
+ titles = etree.parse(path, parser).xpath("/BES/*/Title")
403
+ except (OSError, ValueError, etree.XMLSyntaxError): # ValueError: Unicode
404
+ return None
405
+ return "".join(titles[0].itertext()) if titles else None
406
+
407
+
408
+ def _within(rel, folder):
409
+ """`rel` relative to `folder` if it is inside it, else None.
410
+
411
+ Compared ignoring case, so nothing goes unchecked: on a Windows root
412
+ server `fixlets/` and `Fixlets/` are the same folder. A RHEL root server
413
+ is case-sensitive, so a match that holds only this way is flagged W808.
414
+ """
415
+ if not folder:
416
+ return rel
417
+ if rel.lower().startswith(folder.lower() + "/"):
418
+ return rel[len(folder) + 1 :]
419
+ return None
420
+
421
+
422
+ def _is_bes(path):
423
+ """True for a .bes file, whatever the case of its extension."""
424
+ return path.lower().endswith(".bes")
425
+
426
+
427
+ def _assign_sites(files, site_roots):
428
+ """Return {site root: [repo-relative paths]}; a file goes to its deepest root."""
429
+ by_site = {site: [] for site in site_roots}
430
+ deepest_first = sorted(site_roots, key=len, reverse=True)
431
+ for path in files:
432
+ for site in deepest_first:
433
+ if _within(path, site) is not None:
434
+ by_site[site].append(path)
435
+ break
436
+ return by_site
437
+
438
+
439
+ class _Results:
440
+ """Collects (lineno, code, message) issues and fixes per path."""
441
+
442
+ def __init__(self):
443
+ self.issues = collections.defaultdict(list)
444
+ self.fixed = collections.defaultdict(list)
445
+
446
+ def issue(self, path, code, message):
447
+ self.issues[path].append((1, code, message))
448
+
449
+ def as_list(self, disabled):
450
+ paths = sorted(set(self.issues) | set(self.fixed))
451
+ return [
452
+ (
453
+ path,
454
+ sorted(i for i in self.issues[path] if i[1] not in disabled),
455
+ self.fixed[path],
456
+ )
457
+ for path in paths
458
+ ]
459
+
460
+
461
+ def _id_of(path):
462
+ """The numeric ID a .bes file's name starts with, as an int, or None."""
463
+ match = ID_PREFIX_RE.match(path.rsplit("/", 1)[-1])
464
+ return int(match.group(1)) if match else None
465
+
466
+
467
+ def _check_ids(results, bes_paths):
468
+ """E800, W806 and E801 for the .bes files of one site's .bes folder."""
469
+ by_id = collections.defaultdict(list)
470
+ for path in bes_paths:
471
+ name = path.rsplit("/", 1)[-1]
472
+ match = ID_PREFIX_RE.match(name)
473
+ if match is None:
474
+ results.issue(
475
+ path,
476
+ "E800",
477
+ "file name does not start with a numeric ID followed by a space "
478
+ "or hyphen (`42- Name.bes` or `42 Name.bes`); the server "
479
+ "skips it",
480
+ )
481
+ continue
482
+ number = int(match.group(1))
483
+ by_id[number].append(path)
484
+ if not 1 <= number <= MAX_ID:
485
+ results.issue(
486
+ path,
487
+ "W806",
488
+ f"ID {number} is outside 1..{MAX_ID} (the range is undocumented)",
489
+ )
490
+ for number, paths in by_id.items():
491
+ if len(paths) < 2:
492
+ continue
493
+ for path in paths:
494
+ others = ", ".join(other for other in paths if other != path)
495
+ results.issue(
496
+ path,
497
+ "E801",
498
+ f"ID {number} is also used by {others} (IDs are compared as "
499
+ "integers, so leading zeros do not count); the server skips "
500
+ "duplicates",
501
+ )
502
+
503
+
504
+ def _check_layout(results, site, site_files, config):
505
+ """W800-W804 for one site; return the .bes files in its .bes folder."""
506
+ bes_dir = config.bes_dir
507
+ bes_paths = []
508
+ case_reported = set()
509
+ for path in site_files:
510
+ rel = _within(path, site)
511
+ for folder in (bes_dir, config.nonclient_dir, config.other_dir):
512
+ actual = rel[: len(folder)]
513
+ if (
514
+ _within(rel, folder) is not None
515
+ and actual != folder
516
+ and actual not in case_reported
517
+ ):
518
+ case_reported.add(actual)
519
+ results.issue(
520
+ _join(site, actual),
521
+ "W808",
522
+ f"folder {actual}/ is the {folder}/ the site uses only when "
523
+ "case is ignored; a case-sensitive root server (RHEL) may "
524
+ "not find it",
525
+ )
526
+ inside = _within(rel, bes_dir)
527
+ if inside is None:
528
+ if _is_bes(path):
529
+ results.issue(
530
+ path,
531
+ "W800",
532
+ f"a .bes file outside the .bes folder ({_join(site, bes_dir)}/) "
533
+ "is not gathered",
534
+ )
535
+ continue
536
+ name = inside.rsplit("/", 1)[-1]
537
+ if _is_bes(path):
538
+ bes_paths.append(path)
539
+ if not path.endswith(".bes"):
540
+ results.issue(
541
+ path,
542
+ "W808",
543
+ f"extension {path[-4:]} is .bes only when case is ignored; "
544
+ "a case-sensitive root server (RHEL) may not gather it",
545
+ )
546
+ depth = inside.count("/")
547
+ if depth == 0 or depth > 2:
548
+ where = "directly in" if depth == 0 else "more than two folders into"
549
+ results.issue(
550
+ path,
551
+ "W801",
552
+ f"a .bes file {where} {bes_dir}/; the documented layout is "
553
+ f"{bes_dir}/<fxf>/ or {bes_dir}/<fxf>/<digest>/ (how the "
554
+ "server handles this is undocumented)",
555
+ )
556
+ elif name.lower() == "digest.xml":
557
+ if name != "digest.xml":
558
+ results.issue(
559
+ path,
560
+ "W808",
561
+ f"{name} is digest.xml only when case is ignored; a "
562
+ "case-sensitive root server (RHEL) may not find it",
563
+ )
564
+ elif name.lower().endswith(".xsd"):
565
+ results.issue(
566
+ path,
567
+ "W802",
568
+ f"an .xsd in {bes_dir}/ is not site content; the site does not "
569
+ "need schemas, bes-schema-validate already has them",
570
+ )
571
+ else:
572
+ results.issue(
573
+ path,
574
+ "W802",
575
+ f"only .bes files and digest.xml belong in {bes_dir}/; this is "
576
+ "not site content",
577
+ )
578
+
579
+ for folder in config.named:
580
+ # a folder name may have several parts (Content/Fixlets)
581
+ if not any(_within(_within(p, site), folder) for p in site_files):
582
+ results.issue(
583
+ _join(site, SITE_CONFIG),
584
+ "W803",
585
+ f"{SITE_CONFIG} names the folder {folder}/, which has no files",
586
+ )
587
+
588
+ has_content = bes_paths or any(
589
+ _within(_within(p, site), folder)
590
+ for p in site_files
591
+ for folder in (config.nonclient_dir, config.other_dir)
592
+ )
593
+ if not has_content:
594
+ results.issue(
595
+ site or ".",
596
+ "W804",
597
+ f"the site has no content: no .bes files in {bes_dir}/ and no files "
598
+ f"in {config.nonclient_dir}/ or {config.other_dir}/",
599
+ )
600
+ return bes_paths
601
+
602
+
603
+ def _shortest_name(path):
604
+ """The shortest name the length fix could give a .bes file: `<ID> X.bes`."""
605
+ folder, _, name = path.rpartition("/")
606
+ extension = os.path.splitext(name)[1]
607
+ short = f"{_id_of(path)} X{extension}"
608
+ return f"{folder}/{short}" if folder else short
609
+
610
+
611
+ def _check_limits(results, files, gathered, prefix, max_path, disabled=frozenset()):
612
+ """E802, E807 and E808: names and paths a root server cannot hold.
613
+
614
+ Path: the server path must be at most `max_path` (Windows). Name: each
615
+ file and folder name must be at most NAME_MAX bytes (Linux). `gathered`
616
+ are the .bes files in a site's .bes folder; the rename fix can shorten
617
+ those that have an ID, so only one even the shortest name cannot bring
618
+ under the path limit is reported here (E802), or, with the E805 fix
619
+ disabled, any of them over a limit. Every other tracked file over a
620
+ limit is an error too (E807, E808): the server clones the whole repo, so
621
+ one long path anywhere fails the sync. Those are not renamed, since
622
+ actions may refer to them by name, and a folder is not renamed since
623
+ that changes its .fxf or digest.
624
+ """
625
+ long_folders = set()
626
+ for path in files:
627
+ parts = path.split("/")
628
+ for depth, part in enumerate(parts[:-1], start=1):
629
+ folder = "/".join(parts[:depth])
630
+ size = name_bytes(part)
631
+ if size > NAME_MAX and folder not in long_folders:
632
+ long_folders.add(folder)
633
+ results.issue(
634
+ folder,
635
+ "E808",
636
+ f"folder name is {size} bytes in UTF-8, over the {NAME_MAX}-"
637
+ "byte limit of Linux file systems, so a RHEL root server "
638
+ "cannot check it out; rename it by hand",
639
+ )
640
+ fixable = path in gathered and _id_of(path) is not None
641
+ _check_path_length(
642
+ results, path, path in gathered, fixable, prefix, max_path, disabled
643
+ )
644
+ size = name_bytes(parts[-1])
645
+ if size <= NAME_MAX:
646
+ continue
647
+ if fixable and "E805" not in disabled:
648
+ continue # the rename makes it ASCII and short
649
+ if (
650
+ fixable
651
+ and "E804" not in disabled
652
+ and name_bytes(fold_ascii(parts[-1])) <= NAME_MAX
653
+ ):
654
+ continue # folding it to ASCII is enough
655
+ results.issue(
656
+ path,
657
+ "E808",
658
+ f"file name is {size} bytes in UTF-8, over the {NAME_MAX}-byte limit "
659
+ "of Linux file systems, so a RHEL root server cannot check it out"
660
+ + ("; renaming it would fix it, but E805 is disabled" if fixable else "")
661
+ + ("" if fixable else "; shorten it by hand"),
662
+ )
663
+
664
+
665
+ def _check_path_length(results, path, gathered, fixable, prefix, max_path, disabled):
666
+ """E802 or E807 for `path` if its server path is over `max_path`.
667
+
668
+ `gathered`: it is a .bes file in a site's .bes folder; `fixable`: one
669
+ with an ID, which the E805 rename can shorten.
670
+ """
671
+ length = server_path_length(prefix, path)
672
+ if length <= max_path:
673
+ return
674
+ over = f"server path is {length} characters, over the {max_path} limit"
675
+ if not fixable:
676
+ if gathered: # a gathered .bes file with no ID
677
+ results.issue(
678
+ path,
679
+ "E802",
680
+ f"{over}, and with no numeric ID there is no short name to "
681
+ "rename it to",
682
+ )
683
+ else:
684
+ results.issue(
685
+ path,
686
+ "E807",
687
+ f"{over}, so a Windows root server cannot check it out; "
688
+ "shorten it by hand (it is not renamed automatically, since "
689
+ "actions may refer to it by name)",
690
+ )
691
+ return
692
+ shortest = _shortest_name(path)
693
+ shortest_length = server_path_length(prefix, shortest)
694
+ if shortest_length > max_path:
695
+ results.issue(
696
+ path,
697
+ "E802",
698
+ f"{over}, and even the shortest name ({shortest}) would be "
699
+ f"{shortest_length}: the folders are too long. Renaming a folder "
700
+ "changes its .fxf or digest, so this is not fixed",
701
+ )
702
+ elif "E805" in disabled:
703
+ results.issue(
704
+ path,
705
+ "E802",
706
+ f"{over}; renaming it to <ID> <Title> would fix it, but E805 is "
707
+ "disabled",
708
+ )
709
+
710
+
711
+ class _Renamer:
712
+ """Plans the one rename each gathered .bes file may get, then makes them.
713
+
714
+ The new name combines E803 (zero padding stripped from the ID), E804
715
+ (non-ASCII folded) and, when the server path is still too long, E805
716
+ (`<ID> <Title>` cut to fit). The ID never changes and the file never
717
+ leaves its folder. A name already taken, ignoring case, by a tracked
718
+ file, another planned rename or anything on disk is E806 instead.
719
+ """
720
+
721
+ def __init__(self, root, files, prefix, max_path, disabled, held=frozenset()):
722
+ self.root = root
723
+ self.held = held
724
+ self.temporary_index = False
725
+ self.prefix = prefix
726
+ self.max_path = max_path
727
+ self.disabled = disabled
728
+ # lowercased path -> the path holding that name
729
+ self.taken = {path.lower(): path for path in files}
730
+ self.renames = [] # (old path, new path, [(code, reason)])
731
+
732
+ def _length(self, path):
733
+ return server_path_length(self.prefix, path)
734
+
735
+ def _new_name(self, results, path):
736
+ """Return (new name, [(code, reason)]) for `path`; reasons may be []."""
737
+ folder, _, name = path.rpartition("/")
738
+ digits = ID_PREFIX_RE.match(name).group(1)
739
+ number = int(digits)
740
+ new, reasons = name, []
741
+ if "E803" not in self.disabled and digits != str(number):
742
+ new = str(number) + new[len(digits) :]
743
+ reasons.append(("E803", f"zero-padded ID {digits} is {number}"))
744
+ if "E804" not in self.disabled and not new.isascii():
745
+ new = fold_ascii(new)
746
+ reasons.append(("E804", "non-ASCII characters in the file name"))
747
+ length = self._length(_join(folder, new))
748
+ size = name_bytes(new)
749
+ if "E805" not in self.disabled and (length > self.max_path or size > NAME_MAX):
750
+ extension = os.path.splitext(name)[1]
751
+ stem = _join(folder, f"{number} ")
752
+ room = min(
753
+ self.max_path - self._length(stem + extension),
754
+ NAME_MAX - name_bytes(f"{number} {extension}"),
755
+ )
756
+ if length > self.max_path:
757
+ why = (
758
+ f"server path is {length} characters, over the "
759
+ f"{self.max_path} limit"
760
+ )
761
+ else:
762
+ why = (
763
+ f"file name is {size} bytes in UTF-8, over the {NAME_MAX}-"
764
+ "byte limit of Linux file systems"
765
+ )
766
+ if room >= 1: # otherwise _check_limits reported E802
767
+ title = read_title(os.path.join(self.root, path))
768
+ # a Title of only forbidden characters would give `42 ___`
769
+ if title is None or not any(c.isalnum() for c in safe_title(title)):
770
+ # the current name, without its ID prefix
771
+ title = os.path.splitext(name[len(digits) :])[0].lstrip(" -")
772
+ short = safe_title(title)[:room].rstrip(" .-")
773
+ if short:
774
+ new = f"{number} {short}{extension}"
775
+ reasons.append(("E805", why))
776
+ else:
777
+ # E802 is the path code, E808 the name code
778
+ results.issue(
779
+ path,
780
+ "E802" if length > self.max_path else "E808",
781
+ f"{why}, and there is no title to build a shorter name from",
782
+ )
783
+ return new, reasons
784
+
785
+ def plan(self, results, path):
786
+ """Plan the rename of gathered .bes file `path`, if it needs one."""
787
+ if ID_PREFIX_RE.match(path.rsplit("/", 1)[-1]) is None:
788
+ return # E800: no ID to keep
789
+ new, reasons = self._new_name(results, path)
790
+ if not reasons:
791
+ return
792
+ new_path = _join(path.rpartition("/")[0], new)
793
+ if path in self.held:
794
+ results.issue(
795
+ path,
796
+ "E806",
797
+ f"not renamed to {new}: pre-commit set aside its unstaged "
798
+ "changes for this run, and pre-commit could not restore them "
799
+ "to a renamed file; stage or stash them, then run again",
800
+ )
801
+ return
802
+ holder = self._holder(path, new_path)
803
+ if holder:
804
+ why = "; ".join(reason for _code, reason in reasons)
805
+ results.issue(
806
+ path,
807
+ "E806",
808
+ f"not renamed to {new} ({why}): {holder} has that name "
809
+ "(names are compared ignoring case); rename it by hand",
810
+ )
811
+ return
812
+ self.renames.append((path, new_path, reasons))
813
+
814
+ def _holder(self, path, new_path):
815
+ """Describe what already has the name `new_path`, or return None."""
816
+ key = new_path.lower()
817
+ if key == path.lower():
818
+ return None # only the file itself
819
+ if key in self.taken:
820
+ return self.taken[key]
821
+ for old, planned, _reasons in self.renames:
822
+ if key == planned.lower():
823
+ return f"{old}, which is being renamed to it,"
824
+ if os.path.lexists(os.path.join(self.root, new_path)):
825
+ return "an untracked file"
826
+ return None
827
+
828
+ def apply(self, results, check, tracked):
829
+ """Make the planned renames (or, under `check`, report them)."""
830
+ for old, new_path, reasons in self.renames:
831
+ new = new_path.rsplit("/", 1)[-1]
832
+ if check:
833
+ for code, reason in reasons:
834
+ results.issue(
835
+ old,
836
+ code,
837
+ f"{reason}; would be renamed to {new} (run without --check)",
838
+ )
839
+ continue
840
+ if self.temporary_index:
841
+ results.issue(
842
+ old,
843
+ "E806",
844
+ f"not renamed to {new}: this commit uses a temporary index "
845
+ "(git commit -a, or git commit <paths>), which git throws "
846
+ "away when the hook fails, so the rename would be lost. "
847
+ "git add the changes and run a plain git commit (or "
848
+ "pre-commit run) to rename it",
849
+ )
850
+ continue
851
+ error = _rename(self.root, old, new_path, old in tracked)
852
+ if error:
853
+ results.issue(old, "E806", f"could not rename to {new}: {error}")
854
+ continue
855
+ for code, reason in reasons:
856
+ results.fixed[old].append((1, code, f"{reason}; renamed to {new}"))
857
+
858
+
859
+ def _rename(root, old, new, use_git):
860
+ """Rename repo-relative `old` to `new`; return an error message or None."""
861
+ if use_git:
862
+ try:
863
+ result = subprocess.run(
864
+ ["git", "mv", "--", old, new],
865
+ cwd=root,
866
+ capture_output=True,
867
+ encoding="utf-8",
868
+ errors="backslashreplace", # git echoes names that may not be UTF-8
869
+ check=False,
870
+ )
871
+ except OSError as err:
872
+ return str(err)
873
+ return result.stderr.strip() or "git mv failed" if result.returncode else None
874
+ try:
875
+ os.rename(os.path.join(root, old), os.path.join(root, new))
876
+ except OSError as err:
877
+ return str(err)
878
+ return None
879
+
880
+
881
+ def uses_real_index(root, environ=None):
882
+ """False when git gave this hook a temporary index (GIT_INDEX_FILE).
883
+
884
+ git commit -a and git commit <paths> run the pre-commit hook against a
885
+ temporary index (index.lock, next-index-<pid>.lock) that git discards
886
+ when the hook fails, so a `git mv` made there is lost while the work
887
+ tree keeps the new name. A relative GIT_INDEX_FILE is taken relative to
888
+ `root`, where the hook runs.
889
+ """
890
+ environ = os.environ if environ is None else environ
891
+ index = environ.get("GIT_INDEX_FILE")
892
+ if not index:
893
+ return True
894
+ git_dir = _git(root, "rev-parse", "--absolute-git-dir")
895
+ if git_dir is None:
896
+ return False
897
+ real = os.path.join(os.fsdecode(git_dir).strip(), "index")
898
+ given = os.path.join(root, index) # an absolute `index` wins in join
899
+ return os.path.realpath(given) == os.path.realpath(real)
900
+
901
+
902
+ def _pre_commit_home(environ):
903
+ """The folder pre-commit keeps its cache, and so its stash patches, in."""
904
+ if environ.get("PRE_COMMIT_HOME"):
905
+ return environ["PRE_COMMIT_HOME"]
906
+ cache = environ.get("XDG_CACHE_HOME") or os.path.join(
907
+ environ.get("HOME") or os.path.expanduser("~"), ".cache"
908
+ )
909
+ return os.path.join(cache, "pre-commit")
910
+
911
+
912
+ def _is_windows():
913
+ """True on Windows; a function so tests can pretend either way."""
914
+ return os.name == "nt"
915
+
916
+
917
+ def _parent_pid_windows(pid):
918
+ """The parent of process `pid` on Windows, from a Toolhelp snapshot."""
919
+ import ctypes # pylint: disable=import-outside-toplevel
920
+ from ctypes import wintypes # pylint: disable=import-outside-toplevel
921
+
922
+ class ProcessEntry(ctypes.Structure): # PROCESSENTRY32W
923
+ _fields_ = [
924
+ ("dwSize", wintypes.DWORD),
925
+ ("cntUsage", wintypes.DWORD),
926
+ ("th32ProcessID", wintypes.DWORD),
927
+ ("th32DefaultHeapID", ctypes.c_size_t),
928
+ ("th32ModuleID", wintypes.DWORD),
929
+ ("cntThreads", wintypes.DWORD),
930
+ ("th32ParentProcessID", wintypes.DWORD),
931
+ ("pcPriClassBase", ctypes.c_long),
932
+ ("dwFlags", wintypes.DWORD),
933
+ ("szExeFile", ctypes.c_wchar * 260),
934
+ ]
935
+
936
+ kernel32 = ctypes.windll.kernel32
937
+ # declared, so a HANDLE is passed whole rather than as a C int
938
+ kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
939
+ kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
940
+ for walk in (kernel32.Process32FirstW, kernel32.Process32NextW):
941
+ walk.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry)]
942
+ walk.restype = wintypes.BOOL
943
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
944
+ kernel32.CloseHandle.restype = wintypes.BOOL
945
+ snapshot = kernel32.CreateToolhelp32Snapshot(2, 0) # TH32CS_SNAPPROCESS
946
+ if not snapshot or snapshot == wintypes.HANDLE(-1).value:
947
+ return None
948
+ try:
949
+ entry = ProcessEntry()
950
+ entry.dwSize = ctypes.sizeof(ProcessEntry)
951
+ found = kernel32.Process32FirstW(snapshot, ctypes.byref(entry))
952
+ while found:
953
+ if entry.th32ProcessID == pid:
954
+ return entry.th32ParentProcessID
955
+ found = kernel32.Process32NextW(snapshot, ctypes.byref(entry))
956
+ return None
957
+ finally:
958
+ kernel32.CloseHandle(snapshot)
959
+
960
+
961
+ def _parent_pid(pid):
962
+ """The parent of process `pid`, or None when it cannot be told."""
963
+ try:
964
+ if _is_windows():
965
+ return _parent_pid_windows(pid)
966
+ out = subprocess.run(
967
+ ["ps", "-o", "ppid=", "-p", str(pid)],
968
+ capture_output=True,
969
+ text=True,
970
+ check=False,
971
+ ).stdout.strip()
972
+ return int(out) if out else None
973
+ except Exception: # noqa: BLE001 - e.g. ctypes.ArgumentError: fail safe
974
+ return None
975
+
976
+
977
+ def pre_commit_stashed_paths(root, pid=None, environ=None):
978
+ """Return the files whose unstaged changes pre-commit set aside for this run.
979
+
980
+ Before running hooks on staged files, pre-commit saves unstaged changes
981
+ as a patch named `patch<time>-<its pid>` in its cache folder, checks
982
+ them out, and re-applies the patch afterwards. Its pid is this hook's
983
+ parent's (`pid` defaults to os.getppid()). On Windows a launcher may sit
984
+ in between (a console script is an .exe that starts python.exe as its
985
+ child), so there the grandparent and great-grandparent are tried too,
986
+ found with a Toolhelp snapshot; if that fails, only `pid` is tried. Not
987
+ on Linux or macOS: there those are git and a shell, and pre-commit never
988
+ deletes old patches, so one whose pid matches theirs would hold a file
989
+ with no unstaged changes. Verified on macOS only. A file renamed in between
990
+ is not where the patch expects it, so pre-commit cannot restore those
991
+ changes and stops with an error. The file names are read with
992
+ `git apply --numstat -z`, which applies nothing. Outside a pre-commit
993
+ run, or with no unstaged changes, there is no such patch: empty set.
994
+ This relies on how pre-commit names the patch; if that changes, the set
995
+ is empty and such a file is renamed as before.
996
+ """
997
+ pid = os.getppid() if pid is None else pid
998
+ environ = os.environ if environ is None else environ
999
+ pids = [pid]
1000
+ while _is_windows() and len(pids) < 3 and pids[-1] is not None:
1001
+ pids.append(_parent_pid(pids[-1]))
1002
+ home = glob.escape(_pre_commit_home(environ))
1003
+ patches = []
1004
+ for candidate in pids:
1005
+ if candidate is not None:
1006
+ patches += glob.glob(os.path.join(home, f"patch*-{candidate}"))
1007
+ paths = set()
1008
+ for patch in patches:
1009
+ out = _git(root, "apply", "--numstat", "-z", os.path.abspath(patch))
1010
+ # records are `added<TAB>deleted<TAB>path`; a rename is
1011
+ # `added<TAB>deleted<TAB>`, then the old and new paths
1012
+ fields = (out or b"").split(b"\0")
1013
+ index = 0
1014
+ while index < len(fields):
1015
+ record = fields[index].split(b"\t", 2)
1016
+ index += 1
1017
+ if len(record) < 3:
1018
+ continue
1019
+ if record[2]:
1020
+ paths.add(os.fsdecode(record[2]))
1021
+ else:
1022
+ paths.update(os.fsdecode(f) for f in fields[index : index + 2])
1023
+ index += 2
1024
+ return paths
1025
+
1026
+
1027
+ def check_repo(
1028
+ root,
1029
+ files,
1030
+ site_roots=("",),
1031
+ *,
1032
+ prefix=None,
1033
+ max_path=DEFAULT_MAX_PATH,
1034
+ disabled=frozenset(),
1035
+ check=False,
1036
+ tracked=frozenset(),
1037
+ held=frozenset(),
1038
+ temporary_index=False,
1039
+ ):
1040
+ """Check the sites in repo `root`; return a list of (path, issues, fixed).
1041
+
1042
+ `files` are the repo-relative paths to consider (normally `git ls-files`)
1043
+ and `site_roots` the repo-relative folders holding a site ("" is the repo
1044
+ root). `prefix` is the server folder the repo is cloned into (see
1045
+ server_prefix(); by default the default server root, the repo's name and
1046
+ branch main). This is the programmatic entry point: it does no printing.
1047
+
1048
+ Unless `check`, the .bes files the fix codes (E803-E805) apply to are
1049
+ renamed: with `git mv` when they are in `tracked`, otherwise with a
1050
+ plain filesystem rename. A file in `held` (see pre_commit_stashed_paths)
1051
+ is not renamed (E806); with `temporary_index` (see uses_real_index)
1052
+ nothing is (E806). Codes in `disabled` are dropped; for a fix code
1053
+ that also stops that part of the rename.
1054
+ """
1055
+ if prefix is None:
1056
+ prefix = server_prefix(
1057
+ DEFAULT_SERVER_ROOT, default_repo_name(root), DEFAULT_BRANCH
1058
+ )
1059
+ results = _Results()
1060
+ gathered = set()
1061
+ for site, site_files in _assign_sites(files, site_roots).items():
1062
+ config = read_site_config(root, site, files)
1063
+ if config.issue:
1064
+ results.issue(_join(site, SITE_CONFIG), *config.issue[1:])
1065
+ bes_paths = _check_layout(results, site, site_files, config)
1066
+ _check_ids(results, bes_paths)
1067
+ gathered.update(bes_paths)
1068
+ _check_limits(results, files, gathered, prefix, max_path, disabled)
1069
+ renamer = _Renamer(root, files, prefix, max_path, disabled, held)
1070
+ renamer.temporary_index = temporary_index
1071
+ for path in sorted(gathered):
1072
+ renamer.plan(results, path)
1073
+ renamer.apply(results, check, tracked)
1074
+ return results.as_list(disabled)
1075
+
1076
+
1077
+ def main(argv=None, list_files=git_ls_files):
1078
+ """Execution starts here.
1079
+
1080
+ `list_files(repo_root)` returns the repo-relative files to check; tests
1081
+ can pass their own instead of the default `git ls-files`.
1082
+ """
1083
+ parser = argparse.ArgumentParser(
1084
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
1085
+ )
1086
+ parser.add_argument(
1087
+ "--check",
1088
+ action="store_true",
1089
+ help="dry run: report what would be renamed, change nothing",
1090
+ )
1091
+ parser.add_argument(
1092
+ "--strict",
1093
+ action="store_true",
1094
+ help="treat warnings (W8xx) as failures; default: advisory",
1095
+ )
1096
+ parser.add_argument(
1097
+ "--disable",
1098
+ default="",
1099
+ metavar="CODES",
1100
+ help="comma-separated check IDs to skip entirely, e.g. --disable W806",
1101
+ )
1102
+ parser.add_argument(
1103
+ "--site-root",
1104
+ action="append",
1105
+ default=[],
1106
+ metavar="DIR",
1107
+ help=(
1108
+ "a folder holding a site, relative to the current folder; repeat "
1109
+ "for a repo of several sites (default: the repo root)"
1110
+ ),
1111
+ )
1112
+ parser.add_argument(
1113
+ "--server-root",
1114
+ default=DEFAULT_SERVER_ROOT,
1115
+ help=f"the BES Server install folder (default: {DEFAULT_SERVER_ROOT})",
1116
+ )
1117
+ parser.add_argument(
1118
+ "--repo-name",
1119
+ help=(
1120
+ "the repo name the server names its clone after (default: the last "
1121
+ "part of the origin remote URL, or the repo folder's name)"
1122
+ ),
1123
+ )
1124
+ parser.add_argument(
1125
+ "--branch",
1126
+ default=DEFAULT_BRANCH,
1127
+ help=f"the branch the site syncs from (default: {DEFAULT_BRANCH})",
1128
+ )
1129
+ parser.add_argument(
1130
+ "--max-path",
1131
+ type=int,
1132
+ default=DEFAULT_MAX_PATH,
1133
+ metavar="N",
1134
+ help=f"longest allowed server path (default: {DEFAULT_MAX_PATH})",
1135
+ )
1136
+ args = parser.parse_args(argv)
1137
+ # a file name that is not UTF-8 holds lone surrogates; show them escaped
1138
+ # rather than fail on print
1139
+ if hasattr(sys.stdout, "reconfigure"):
1140
+ sys.stdout.reconfigure(errors="backslashreplace")
1141
+
1142
+ disabled = {
1143
+ code.strip().upper() for code in args.disable.split(",") if code.strip()
1144
+ }
1145
+ unknown = disabled - KNOWN_CODES
1146
+ if unknown:
1147
+ print(
1148
+ f"warning: ignoring unknown --disable code(s): {', '.join(sorted(unknown))}"
1149
+ )
1150
+
1151
+ root = repo_root()
1152
+ if root is None:
1153
+ print("bes-siterepo-validate-layout: not in a git repo (git rev-parse failed)")
1154
+ return 1
1155
+ try:
1156
+ files = list_files(root)
1157
+ except OSError as err:
1158
+ print(f"bes-siterepo-validate-layout: {err}")
1159
+ return 1
1160
+ try:
1161
+ tracked = set(files if list_files is git_ls_files else git_ls_files(root))
1162
+ except OSError:
1163
+ tracked = set()
1164
+ site_roots = []
1165
+ for site in args.site_root or [root]:
1166
+ rel = os.path.relpath(os.path.realpath(site), os.path.realpath(root))
1167
+ if rel == os.pardir or rel.startswith(os.pardir + os.sep):
1168
+ print(f"bes-siterepo-validate-layout: --site-root {site} is outside {root}")
1169
+ return 1
1170
+ site_roots.append("" if rel == os.curdir else rel.replace(os.sep, "/"))
1171
+
1172
+ issue_count, warning_count, fix_count = bes_common.report(
1173
+ check_repo(
1174
+ root,
1175
+ files,
1176
+ site_roots,
1177
+ prefix=server_prefix(
1178
+ args.server_root, args.repo_name or default_repo_name(root), args.branch
1179
+ ),
1180
+ max_path=args.max_path,
1181
+ disabled=disabled,
1182
+ check=args.check,
1183
+ tracked=tracked,
1184
+ held=set() if args.check else pre_commit_stashed_paths(root),
1185
+ temporary_index=not uses_real_index(root),
1186
+ )
1187
+ )
1188
+ if fix_count:
1189
+ print(
1190
+ f"\nrenamed .bes file(s) ({fix_count} fix(es)); tracked files are "
1191
+ "renamed with git mv, so the renames are staged: review them and "
1192
+ "commit again."
1193
+ )
1194
+ if warning_count:
1195
+ print(f"{warning_count} site layout warning(s).")
1196
+ if issue_count:
1197
+ print(f"{issue_count} site layout issue(s).")
1198
+ # E-codes and any rename always fail; warnings fail only under --strict
1199
+ return 1 if (issue_count or fix_count or (warning_count and args.strict)) else 0
1200
+
1201
+
1202
+ if __name__ == "__main__":
1203
+ sys.exit(main(sys.argv[1:]))