gitvars-render 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.
- gitvars/__init__.py +10 -0
- gitvars/__main__.py +3 -0
- gitvars/cli.py +126 -0
- gitvars/config.py +115 -0
- gitvars/errors.py +2 -0
- gitvars/files.py +137 -0
- gitvars/git.py +98 -0
- gitvars/providers.py +99 -0
- gitvars/render.py +44 -0
- gitvars_render-0.1.0.dist-info/METADATA +426 -0
- gitvars_render-0.1.0.dist-info/RECORD +15 -0
- gitvars_render-0.1.0.dist-info/WHEEL +5 -0
- gitvars_render-0.1.0.dist-info/entry_points.txt +2 -0
- gitvars_render-0.1.0.dist-info/licenses/LICENSE +21 -0
- gitvars_render-0.1.0.dist-info/top_level.txt +1 -0
gitvars/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Git provenance rendering with no runtime dependencies."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = version("gitvars-render")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
# A source checkout can be imported before the distribution is installed.
|
|
10
|
+
__version__ = "0+unknown"
|
gitvars/__main__.py
ADDED
gitvars/cli.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Console commands; all user-facing output is sent to stdout."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from html import escape
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .config import load_config
|
|
10
|
+
from .errors import GitvarsError
|
|
11
|
+
from .files import (
|
|
12
|
+
Stats, absolute, atomic_write, copy_file, discover_entries, prepare_inputs,
|
|
13
|
+
prepare_output, validate_output,
|
|
14
|
+
)
|
|
15
|
+
from .git import collect_snapshot, require_clean_inputs
|
|
16
|
+
from .render import Renderer, unified_diff
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Parser(argparse.ArgumentParser):
|
|
20
|
+
def _print_message(self, message, file=None):
|
|
21
|
+
if message:
|
|
22
|
+
sys.stdout.write(message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_parser() -> Parser:
|
|
26
|
+
parser = Parser(prog="gitvars", description="Render Git provenance into selected UTF-8 files.")
|
|
27
|
+
parser.add_argument("--version", action="version", version=f"gitvars {__version__}")
|
|
28
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
29
|
+
for name in ("render", "check"):
|
|
30
|
+
command = commands.add_parser(name, help="Render files" if name == "render" else "Check whether rendering would change files")
|
|
31
|
+
command.add_argument("paths", nargs="*", metavar="PATH", help="Files or directories (default: current directory)")
|
|
32
|
+
command.add_argument("--config", metavar="FILE")
|
|
33
|
+
command.add_argument("--prefix", metavar="PREFIX")
|
|
34
|
+
command.add_argument("--include", action="append", metavar="GLOB", help="Add a repository-relative rendering glob")
|
|
35
|
+
command.add_argument("--exclude", action="append", metavar="GLOB", help="Add a repository-relative exclusion glob")
|
|
36
|
+
command.add_argument("--exclude-regex", action="append", metavar="REGEX", help="Add a regex exclusion against repository-relative paths")
|
|
37
|
+
command.add_argument("--escape", action=argparse.BooleanOptionalAction, default=None, help="Recognize backslash escapes (default: enabled)")
|
|
38
|
+
command.add_argument("--html-escape", action="store_true", help="Escape inserted values for HTML text and quoted attributes")
|
|
39
|
+
verbosity = command.add_mutually_exclusive_group()
|
|
40
|
+
verbosity.add_argument("-v", "--verbose", action="store_true")
|
|
41
|
+
verbosity.add_argument("-q", "--quiet", action="store_true")
|
|
42
|
+
if name == "render":
|
|
43
|
+
command.add_argument("--dry-run", action="store_true", help="Print unified diffs without writing")
|
|
44
|
+
mode = command.add_mutually_exclusive_group()
|
|
45
|
+
mode.add_argument("--in-place", action="store_true", help="Replace clean tracked inputs (default)")
|
|
46
|
+
mode.add_argument("--output-dir", metavar="DIR", help="Copy files under DIR using repository-relative paths")
|
|
47
|
+
command.add_argument("-f", "--force", action="store_true", help="Clear a nonempty output directory; requires --output-dir")
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run(args, stats: Stats) -> int:
|
|
52
|
+
output_arg = getattr(args, "output_dir", None)
|
|
53
|
+
force = getattr(args, "force", False)
|
|
54
|
+
if force and output_arg is None:
|
|
55
|
+
raise GitvarsError("-f/--force requires --output-dir; it cannot bypass clean-input checks")
|
|
56
|
+
config = load_config(Path.cwd(), args)
|
|
57
|
+
root, inputs = prepare_inputs(args.paths or ["."], config)
|
|
58
|
+
output = absolute(output_arg) if output_arg is not None else None
|
|
59
|
+
if output is not None:
|
|
60
|
+
validate_output(output, inputs, root, force)
|
|
61
|
+
entries = discover_entries(root, inputs, config, stats)
|
|
62
|
+
snapshot = collect_snapshot(root, config)
|
|
63
|
+
dry_run = getattr(args, "dry_run", False)
|
|
64
|
+
writing = args.command == "render" and not dry_run
|
|
65
|
+
if writing and output is None:
|
|
66
|
+
require_clean_inputs(snapshot, [entry.relative for entry in entries if entry.render])
|
|
67
|
+
if writing and output is not None:
|
|
68
|
+
prepare_output(output)
|
|
69
|
+
renderer = Renderer(config.prefix, config.escape)
|
|
70
|
+
for entry in entries:
|
|
71
|
+
stats.scanned += 1
|
|
72
|
+
destination = output / entry.relative if output else entry.source
|
|
73
|
+
if not entry.render:
|
|
74
|
+
stats.skipped += 1
|
|
75
|
+
if output is not None:
|
|
76
|
+
if writing:
|
|
77
|
+
copy_file(entry.source, destination)
|
|
78
|
+
stats.copied += 1
|
|
79
|
+
if args.verbose:
|
|
80
|
+
print(f"{'Would copy' if dry_run else 'Copied'} {entry.relative} unchanged")
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
original = entry.source.read_bytes()
|
|
84
|
+
text = original.decode("utf-8")
|
|
85
|
+
except UnicodeDecodeError as exc:
|
|
86
|
+
raise GitvarsError(f"Selected file is not valid UTF-8: {entry.source}; adjust include/exclude patterns") from exc
|
|
87
|
+
values = snapshot.for_file(entry.relative)
|
|
88
|
+
if args.html_escape:
|
|
89
|
+
values = {name: escape(value) for name, value in values.items()}
|
|
90
|
+
rendered, unavailable = renderer.render(text, values)
|
|
91
|
+
changed = rendered != text
|
|
92
|
+
if changed:
|
|
93
|
+
stats.changed += 1
|
|
94
|
+
if args.verbose:
|
|
95
|
+
for name in sorted(unavailable):
|
|
96
|
+
print(f"Warning: {entry.relative}: {config.prefix}{name}__ is unavailable; using an empty string")
|
|
97
|
+
action = "Would render" if not writing else "Rendered"
|
|
98
|
+
print(f"{action} {entry.relative}" if changed else f"Unchanged {entry.relative}")
|
|
99
|
+
if dry_run and changed:
|
|
100
|
+
print(unified_diff(text, rendered, entry.relative, str(destination) if output else None), end="")
|
|
101
|
+
if writing:
|
|
102
|
+
if changed:
|
|
103
|
+
atomic_write(destination, rendered.encode("utf-8"), entry.source.stat().st_mode)
|
|
104
|
+
elif output is not None:
|
|
105
|
+
copy_file(entry.source, destination)
|
|
106
|
+
if output is not None:
|
|
107
|
+
stats.copied += 1
|
|
108
|
+
return 1 if args.command == "check" and stats.changed else 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main(argv: list[str] | None = None) -> int:
|
|
112
|
+
args = build_parser().parse_args(argv)
|
|
113
|
+
stats = Stats()
|
|
114
|
+
try:
|
|
115
|
+
result = run(args, stats)
|
|
116
|
+
except (GitvarsError, OSError) as exc:
|
|
117
|
+
print(f"gitvars: error: {exc}")
|
|
118
|
+
stats.errors += 1
|
|
119
|
+
result = 2
|
|
120
|
+
if not args.quiet:
|
|
121
|
+
preview = "Dry run: " if getattr(args, "dry_run", False) else ""
|
|
122
|
+
print(
|
|
123
|
+
f"{preview}scanned {stats.scanned}; changed {stats.changed}; "
|
|
124
|
+
f"copied {stats.copied}; skipped {stats.skipped}; errors {stats.errors}"
|
|
125
|
+
)
|
|
126
|
+
return result
|
gitvars/config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Configuration discovery and repository-relative file selection."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from fnmatch import fnmatchcase
|
|
5
|
+
from functools import cache
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import tomllib
|
|
9
|
+
|
|
10
|
+
from .errors import GitvarsError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Config:
|
|
15
|
+
prefix: str = "__git_"
|
|
16
|
+
remote: str = "origin"
|
|
17
|
+
provider: str = "auto"
|
|
18
|
+
url_prefix: str | None = None
|
|
19
|
+
include: tuple[str, ...] = ()
|
|
20
|
+
exclude: tuple[str, ...] = ()
|
|
21
|
+
exclude_regex: tuple[str, ...] = ()
|
|
22
|
+
escape: bool = True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def discover_config(cwd: Path) -> Path | None:
|
|
26
|
+
for directory in (cwd, *cwd.parents):
|
|
27
|
+
candidate = directory / ".gitvars"
|
|
28
|
+
if candidate.exists():
|
|
29
|
+
return candidate
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_config(cwd: Path, args) -> Config:
|
|
34
|
+
path = Path(args.config) if args.config else discover_config(cwd)
|
|
35
|
+
values = {}
|
|
36
|
+
if path is not None:
|
|
37
|
+
try:
|
|
38
|
+
with path.open("rb") as stream:
|
|
39
|
+
values = tomllib.load(stream)
|
|
40
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
41
|
+
raise GitvarsError(f"Cannot read configuration {path}: {exc}") from exc
|
|
42
|
+
unknown = values.keys() - Config.__dataclass_fields__.keys()
|
|
43
|
+
if unknown:
|
|
44
|
+
raise GitvarsError(f"Unknown configuration keys: {', '.join(sorted(unknown))}")
|
|
45
|
+
for key in ("prefix", "remote", "provider", "url_prefix"):
|
|
46
|
+
if key in values and (not isinstance(values[key], str) or not values[key]):
|
|
47
|
+
raise GitvarsError(f"Configuration '{key}' must be a nonempty string")
|
|
48
|
+
for key in ("include", "exclude", "exclude_regex"):
|
|
49
|
+
items = values.get(key, [])
|
|
50
|
+
if not isinstance(items, list) or any(
|
|
51
|
+
not isinstance(item, str) or not item for item in items
|
|
52
|
+
):
|
|
53
|
+
raise GitvarsError(f"Configuration '{key}' must be a list of nonempty strings")
|
|
54
|
+
values[key] = tuple(items) + tuple(getattr(args, key, None) or ())
|
|
55
|
+
if "escape" in values and not isinstance(values["escape"], bool):
|
|
56
|
+
raise GitvarsError("Configuration 'escape' must be true or false")
|
|
57
|
+
if args.prefix is not None:
|
|
58
|
+
values["prefix"] = args.prefix
|
|
59
|
+
if args.escape is not None:
|
|
60
|
+
values["escape"] = args.escape
|
|
61
|
+
config = Config(**values)
|
|
62
|
+
if not config.prefix or any(char in config.prefix for char in "\r\n\\\x00"):
|
|
63
|
+
raise GitvarsError("Prefix must be nonempty and contain no newline, NUL, or backslash")
|
|
64
|
+
if config.provider not in {"auto", "github", "gitlab", "bitbucket_cloud"}:
|
|
65
|
+
raise GitvarsError("Provider must be auto, github, gitlab, or bitbucket_cloud")
|
|
66
|
+
for pattern in (*config.include, *config.exclude):
|
|
67
|
+
if pattern.startswith("/") or ".." in pattern.split("/"):
|
|
68
|
+
raise GitvarsError(f"Glob must be repository-relative: {pattern!r}")
|
|
69
|
+
for pattern in config.exclude_regex:
|
|
70
|
+
try:
|
|
71
|
+
re.compile(pattern)
|
|
72
|
+
except re.error as exc:
|
|
73
|
+
raise GitvarsError(f"Invalid exclusion regex {pattern!r}: {exc}") from exc
|
|
74
|
+
return config
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def match_glob(path: str, pattern: str) -> bool:
|
|
78
|
+
"""Match whole POSIX paths; ** matches zero or more complete segments."""
|
|
79
|
+
parts = tuple(path.split("/"))
|
|
80
|
+
patterns = tuple(pattern.split("/"))
|
|
81
|
+
|
|
82
|
+
@cache
|
|
83
|
+
def match(index: int, pattern_index: int) -> bool:
|
|
84
|
+
if pattern_index == len(patterns):
|
|
85
|
+
return index == len(parts)
|
|
86
|
+
token = patterns[pattern_index]
|
|
87
|
+
if token == "**":
|
|
88
|
+
return match(index, pattern_index + 1) or (
|
|
89
|
+
index < len(parts) and match(index + 1, pattern_index)
|
|
90
|
+
)
|
|
91
|
+
return (
|
|
92
|
+
index < len(parts)
|
|
93
|
+
and fnmatchcase(parts[index], token)
|
|
94
|
+
and match(index + 1, pattern_index + 1)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return match(0, 0)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Selector:
|
|
101
|
+
def __init__(self, config: Config):
|
|
102
|
+
self.config = config
|
|
103
|
+
self.regexes = tuple(re.compile(pattern) for pattern in config.exclude_regex)
|
|
104
|
+
|
|
105
|
+
def excluded(self, relative: str) -> bool:
|
|
106
|
+
path = Path(relative)
|
|
107
|
+
candidates = (relative, *(parent.as_posix() for parent in path.parents if parent != Path(".")))
|
|
108
|
+
return any(
|
|
109
|
+
any(match_glob(candidate, pattern) for pattern in self.config.exclude)
|
|
110
|
+
or any(regex.search(candidate) for regex in self.regexes)
|
|
111
|
+
for candidate in candidates
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def included(self, relative: str) -> bool:
|
|
115
|
+
return any(match_glob(relative, pattern) for pattern in self.config.include)
|
gitvars/errors.py
ADDED
gitvars/files.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Traversal, destination validation, and per-file atomic writes."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
from .config import Config, Selector
|
|
11
|
+
from .errors import GitvarsError
|
|
12
|
+
from .git import git, repository_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Entry:
|
|
17
|
+
source: Path
|
|
18
|
+
relative: str
|
|
19
|
+
render: bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Stats:
|
|
24
|
+
scanned: int = 0
|
|
25
|
+
changed: int = 0
|
|
26
|
+
copied: int = 0
|
|
27
|
+
skipped: int = 0
|
|
28
|
+
errors: int = 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def absolute(path: str | Path) -> Path:
|
|
32
|
+
return Path(os.path.abspath(path))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def has_symlink(path: Path) -> bool:
|
|
36
|
+
return any(part.is_symlink() for part in (path, *path.parents))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def prepare_inputs(paths: list[str], config: Config) -> tuple[Path, list[Path]]:
|
|
40
|
+
inputs = list(dict.fromkeys(absolute(path) for path in paths))
|
|
41
|
+
for path in inputs:
|
|
42
|
+
if not path.exists() and not path.is_symlink():
|
|
43
|
+
raise GitvarsError(f"Input does not exist: {path}")
|
|
44
|
+
if path.is_dir() and not has_symlink(path) and not config.include:
|
|
45
|
+
raise GitvarsError("Directory inputs require include patterns in .gitvars or --include; alternatively supply files explicitly")
|
|
46
|
+
root = repository_root(inputs[0])
|
|
47
|
+
for path in inputs:
|
|
48
|
+
if has_symlink(path):
|
|
49
|
+
continue
|
|
50
|
+
if repository_root(path) != root:
|
|
51
|
+
raise GitvarsError("Each invocation must use one Git repository; inputs span multiple repositories")
|
|
52
|
+
return root, inputs
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def discover_entries(root: Path, inputs: list[Path], config: Config, stats: Stats) -> list[Entry]:
|
|
56
|
+
selector = Selector(config)
|
|
57
|
+
entries: dict[Path, Entry] = {}
|
|
58
|
+
|
|
59
|
+
def visit(path: Path, explicit: bool = False) -> None:
|
|
60
|
+
if has_symlink(path) or ".git" in path.relative_to(root).parts:
|
|
61
|
+
stats.skipped += 1
|
|
62
|
+
return
|
|
63
|
+
relative = path.relative_to(root).as_posix()
|
|
64
|
+
if relative != "." and selector.excluded(relative):
|
|
65
|
+
stats.skipped += 1
|
|
66
|
+
return
|
|
67
|
+
if path.is_dir():
|
|
68
|
+
if path != root and (path / ".git").exists():
|
|
69
|
+
stats.skipped += 1
|
|
70
|
+
return
|
|
71
|
+
for child in sorted(path.iterdir()):
|
|
72
|
+
visit(child)
|
|
73
|
+
elif path.is_file():
|
|
74
|
+
render = explicit or selector.included(relative)
|
|
75
|
+
previous = entries.get(path)
|
|
76
|
+
entries[path] = Entry(path, relative, render or bool(previous and previous.render))
|
|
77
|
+
else:
|
|
78
|
+
stats.skipped += 1
|
|
79
|
+
|
|
80
|
+
for path in inputs:
|
|
81
|
+
# Explicit symlinks can point outside the root; never traverse them.
|
|
82
|
+
if has_symlink(path):
|
|
83
|
+
stats.skipped += 1
|
|
84
|
+
continue
|
|
85
|
+
visit(path, explicit=path.is_file())
|
|
86
|
+
return sorted(entries.values(), key=lambda entry: entry.relative)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def validate_output(output: Path, inputs: list[Path], root: Path, force: bool) -> None:
|
|
90
|
+
if has_symlink(output):
|
|
91
|
+
raise GitvarsError(f"Output directory and its parents must not be symlinks: {output}")
|
|
92
|
+
for path in inputs:
|
|
93
|
+
source = path.resolve()
|
|
94
|
+
if output == source or output in source.parents or (path.is_dir() and source in output.parents):
|
|
95
|
+
raise GitvarsError(f"Output directory overlaps an input: {output}; choose a disjoint destination")
|
|
96
|
+
if ".git" in output.parts:
|
|
97
|
+
raise GitvarsError("Output must not be inside Git metadata (.git)")
|
|
98
|
+
if output.exists():
|
|
99
|
+
if not output.is_dir():
|
|
100
|
+
raise GitvarsError(f"Output path is not a directory: {output}")
|
|
101
|
+
if any(output.iterdir()) and not force:
|
|
102
|
+
raise GitvarsError(f"Output directory is not empty: {output}; pass -f/--force to clear it")
|
|
103
|
+
# --separate-git-dir and linked worktrees need not store metadata under .git.
|
|
104
|
+
for option in ("--absolute-git-dir", "--git-common-dir"):
|
|
105
|
+
location = Path(os.fsdecode(git(root, "rev-parse", option).rstrip(b"\n")))
|
|
106
|
+
metadata = (root / location).resolve()
|
|
107
|
+
if output == metadata or output in metadata.parents or metadata in output.parents:
|
|
108
|
+
raise GitvarsError("Output directory must not overlap Git metadata")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def prepare_output(output: Path) -> None:
|
|
112
|
+
if output.exists():
|
|
113
|
+
for child in output.iterdir():
|
|
114
|
+
if child.is_dir() and not child.is_symlink():
|
|
115
|
+
shutil.rmtree(child)
|
|
116
|
+
else:
|
|
117
|
+
child.unlink()
|
|
118
|
+
else:
|
|
119
|
+
output.mkdir(parents=True)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def atomic_write(destination: Path, content: bytes, mode: int) -> None:
|
|
123
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
descriptor, name = tempfile.mkstemp(prefix=f".{destination.name}.gitvars-", dir=destination.parent)
|
|
125
|
+
temporary = Path(name)
|
|
126
|
+
try:
|
|
127
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
128
|
+
stream.write(content)
|
|
129
|
+
temporary.chmod(stat.S_IMODE(mode))
|
|
130
|
+
os.replace(temporary, destination)
|
|
131
|
+
finally:
|
|
132
|
+
temporary.unlink(missing_ok=True)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def copy_file(source: Path, destination: Path) -> None:
|
|
136
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
shutil.copy2(source, destination)
|
gitvars/git.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Read one immutable commit snapshot using the installed Git executable."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .errors import GitvarsError
|
|
10
|
+
from .providers import RepositoryURL, resolve_repository_url
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def git(directory: Path, *arguments: str, optional: bool = False) -> bytes:
|
|
14
|
+
try:
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
["git", "-C", str(directory), *arguments],
|
|
17
|
+
stdout=subprocess.PIPE,
|
|
18
|
+
stderr=subprocess.PIPE,
|
|
19
|
+
check=False,
|
|
20
|
+
)
|
|
21
|
+
except FileNotFoundError as exc:
|
|
22
|
+
raise GitvarsError("Git is not installed or not on PATH; install Git to use gitvars") from exc
|
|
23
|
+
if result.returncode and not optional:
|
|
24
|
+
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
|
25
|
+
raise GitvarsError(f"Git failed in {directory}: {detail}")
|
|
26
|
+
return result.stdout if result.returncode == 0 else b""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def repository_root(path: Path) -> Path:
|
|
30
|
+
directory = path if path.is_dir() and not path.is_symlink() else path.parent
|
|
31
|
+
result = git(directory, "rev-parse", "--show-toplevel", optional=True)
|
|
32
|
+
if not result:
|
|
33
|
+
raise GitvarsError(f"Input is outside a Git working tree: {path}")
|
|
34
|
+
return Path(os.fsdecode(result.rstrip(b"\n"))).resolve()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Snapshot:
|
|
39
|
+
root: Path
|
|
40
|
+
values: dict[str, str]
|
|
41
|
+
files: frozenset[str]
|
|
42
|
+
repository_url: RepositoryURL
|
|
43
|
+
|
|
44
|
+
def for_file(self, relative: str) -> dict[str, str]:
|
|
45
|
+
values = dict(self.values)
|
|
46
|
+
url = self.repository_url
|
|
47
|
+
values["file_url"] = (
|
|
48
|
+
url.provider.file_url(url.base, values["commit"], relative)
|
|
49
|
+
if relative in self.files and url.base and url.provider else ""
|
|
50
|
+
)
|
|
51
|
+
return values
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def collect_snapshot(root: Path, config: Config) -> Snapshot:
|
|
55
|
+
commit = git(root, "rev-parse", "--verify", "HEAD", optional=True).decode().strip()
|
|
56
|
+
if not commit:
|
|
57
|
+
raise GitvarsError(f"Repository has no commit at HEAD: {root}; create a commit before rendering")
|
|
58
|
+
fields = (
|
|
59
|
+
"commit", "commit_short", "author_name", "author_email", "committer_name",
|
|
60
|
+
"committer_email", "commit_subject", "commit_message", "commit_date",
|
|
61
|
+
)
|
|
62
|
+
output = git(
|
|
63
|
+
root, "show", "--no-patch", "--no-show-signature", "--encoding=UTF-8",
|
|
64
|
+
"--format=%H%x00%h%x00%an%x00%ae%x00%cn%x00%ce%x00%s%x00%B%x00%aI%x00", commit,
|
|
65
|
+
)
|
|
66
|
+
values = dict(zip(fields, output.decode("utf-8", errors="replace").split("\x00")[:len(fields)]))
|
|
67
|
+
values["branch"] = git(root, "symbolic-ref", "--quiet", "--short", "HEAD", optional=True).decode().strip()
|
|
68
|
+
tags = git(
|
|
69
|
+
root, "tag", "--points-at", commit, "--sort=-version:refname", "--no-column", "--color=never",
|
|
70
|
+
).decode().splitlines()
|
|
71
|
+
values["tag"] = tags[0] if tags else ""
|
|
72
|
+
remote = git(root, "remote", "get-url", "--", config.remote, optional=True).decode().strip() or None
|
|
73
|
+
url = resolve_repository_url(config, remote, root.name)
|
|
74
|
+
values["repository"] = url.name
|
|
75
|
+
values["commit_url"] = url.provider.commit_url(url.base, commit) if url.base and url.provider else ""
|
|
76
|
+
files = set()
|
|
77
|
+
for record in git(root, "ls-tree", "-rz", "--full-tree", commit).split(b"\0"):
|
|
78
|
+
if record:
|
|
79
|
+
info, path = record.split(b"\t", 1)
|
|
80
|
+
if info.split()[0] in {b"100644", b"100755"}:
|
|
81
|
+
files.add(os.fsdecode(path))
|
|
82
|
+
return Snapshot(root, values, frozenset(files), url)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def require_clean_inputs(snapshot: Snapshot, relatives: list[str]) -> None:
|
|
86
|
+
dirty = set()
|
|
87
|
+
for staged in (False, True):
|
|
88
|
+
arguments = ["diff", "--no-ext-diff", "--no-renames", "--name-only", "-z"]
|
|
89
|
+
if staged:
|
|
90
|
+
arguments.append("--cached")
|
|
91
|
+
dirty.update(os.fsdecode(path) for path in git(snapshot.root, *arguments).split(b"\0") if path)
|
|
92
|
+
unsafe = [path for path in relatives if path not in snapshot.files or path in dirty]
|
|
93
|
+
if unsafe:
|
|
94
|
+
sample = ", ".join(unsafe[:5])
|
|
95
|
+
raise GitvarsError(
|
|
96
|
+
f"In-place rendering requires clean tracked inputs: {sample}. "
|
|
97
|
+
"Commit or restore those files, or use --output-dir"
|
|
98
|
+
)
|
gitvars/providers.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Offline URL parsing and small provider-specific route adapters."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .errors import GitvarsError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Provider:
|
|
14
|
+
commit_route: str
|
|
15
|
+
file_route: str
|
|
16
|
+
|
|
17
|
+
def commit_url(self, base: str, commit: str) -> str:
|
|
18
|
+
return f"{base}/{self.commit_route}/{commit}"
|
|
19
|
+
|
|
20
|
+
def file_url(self, base: str, commit: str, path: str) -> str:
|
|
21
|
+
return f"{base}/{self.file_route}/{commit}/{quote(path, safe='/')}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
PROVIDERS = {
|
|
25
|
+
"github": Provider("commit", "blob"),
|
|
26
|
+
"gitlab": Provider("-/commit", "-/blob"),
|
|
27
|
+
"bitbucket_cloud": Provider("commits", "src"),
|
|
28
|
+
}
|
|
29
|
+
HOSTS = {"github.com": "github", "gitlab.com": "gitlab", "bitbucket.org": "bitbucket_cloud"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class RepositoryURL:
|
|
34
|
+
name: str
|
|
35
|
+
base: str | None = None
|
|
36
|
+
provider: Provider | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def remote_parts(remote: str) -> tuple[str | None, str | None, str]:
|
|
40
|
+
"""Return browser base, hostname, and repository name for common remotes."""
|
|
41
|
+
if "://" in remote:
|
|
42
|
+
parsed = urlsplit(remote)
|
|
43
|
+
path = parsed.path.rstrip("/")
|
|
44
|
+
hostname = parsed.hostname
|
|
45
|
+
if parsed.scheme in {"http", "https", "ssh", "git"} and hostname:
|
|
46
|
+
scheme = parsed.scheme if parsed.scheme in {"http", "https"} else "https"
|
|
47
|
+
host = f"[{hostname}]" if ":" in hostname else hostname
|
|
48
|
+
if scheme == parsed.scheme and parsed.port:
|
|
49
|
+
host += f":{parsed.port}"
|
|
50
|
+
base = urlunsplit((scheme, host, path, "", ""))
|
|
51
|
+
else:
|
|
52
|
+
base = None
|
|
53
|
+
else:
|
|
54
|
+
scp = re.fullmatch(r"(?:[^@/]+@)?([^/:]+):(.+)", remote)
|
|
55
|
+
if scp:
|
|
56
|
+
hostname, path = scp.groups()
|
|
57
|
+
path = "/" + path.strip("/")
|
|
58
|
+
base = f"https://{hostname}{path}"
|
|
59
|
+
else:
|
|
60
|
+
hostname, path, base = None, remote.rstrip("/"), None
|
|
61
|
+
if path.endswith(".git"):
|
|
62
|
+
path = path[:-4]
|
|
63
|
+
if base is not None:
|
|
64
|
+
base = base[:-4]
|
|
65
|
+
name = unquote(PurePosixPath(path).name)
|
|
66
|
+
if base is not None and len([part for part in path.split("/") if part]) < 2:
|
|
67
|
+
base = None
|
|
68
|
+
return base, hostname.lower() if hostname else None, name
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_repository_url(config: Config, remote: str | None, directory_name: str) -> RepositoryURL:
|
|
72
|
+
source = config.url_prefix or remote
|
|
73
|
+
if source is None:
|
|
74
|
+
return RepositoryURL(directory_name)
|
|
75
|
+
try:
|
|
76
|
+
base, host, name = remote_parts(source)
|
|
77
|
+
if config.url_prefix:
|
|
78
|
+
parsed = urlsplit(source)
|
|
79
|
+
if (
|
|
80
|
+
parsed.scheme not in {"http", "https"}
|
|
81
|
+
or not parsed.hostname
|
|
82
|
+
or parsed.username is not None
|
|
83
|
+
or parsed.password is not None
|
|
84
|
+
or parsed.query
|
|
85
|
+
or parsed.fragment
|
|
86
|
+
or len([part for part in parsed.path.split("/") if part]) < 2
|
|
87
|
+
or any(char.isspace() for char in source)
|
|
88
|
+
):
|
|
89
|
+
raise GitvarsError("url_prefix must be an HTTP(S) repository web root, including owner and repository")
|
|
90
|
+
except ValueError as exc:
|
|
91
|
+
if config.url_prefix:
|
|
92
|
+
raise GitvarsError(f"Invalid url_prefix: {exc}") from exc
|
|
93
|
+
return RepositoryURL(directory_name)
|
|
94
|
+
provider_name = config.provider if config.provider != "auto" else HOSTS.get(host)
|
|
95
|
+
if config.url_prefix and provider_name is None:
|
|
96
|
+
raise GitvarsError("Cannot infer provider from url_prefix; set provider explicitly for self-hosted domains")
|
|
97
|
+
if provider_name == "bitbucket_cloud" and host != "bitbucket.org":
|
|
98
|
+
raise GitvarsError("bitbucket_cloud supports bitbucket.org only")
|
|
99
|
+
return RepositoryURL(name or directory_name, base, PROVIDERS.get(provider_name))
|
gitvars/render.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Single-pass substitution of supported tokens, with optional escaping."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
NAMES = (
|
|
8
|
+
"commit", "commit_short", "author_name", "author_email", "committer_name",
|
|
9
|
+
"committer_email", "commit_subject", "commit_message", "commit_date",
|
|
10
|
+
"branch", "tag", "repository", "commit_url", "file_url",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Renderer:
|
|
15
|
+
def __init__(self, prefix: str, escape: bool):
|
|
16
|
+
escaped = r"(?P<escaped>\\)?" if escape else ""
|
|
17
|
+
self.pattern = re.compile(escaped + re.escape(prefix) + r"(?P<name>" + "|".join(NAMES) + r")__")
|
|
18
|
+
self.escape = escape
|
|
19
|
+
|
|
20
|
+
def render(self, text: str, values: dict[str, str]) -> tuple[str, set[str]]:
|
|
21
|
+
unavailable = set()
|
|
22
|
+
|
|
23
|
+
def replace(match: re.Match) -> str:
|
|
24
|
+
if self.escape and match.group("escaped"):
|
|
25
|
+
return match.group()[1:]
|
|
26
|
+
name = match.group("name")
|
|
27
|
+
value = values[name]
|
|
28
|
+
if not value:
|
|
29
|
+
unavailable.add(name)
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
return self.pattern.sub(replace, text), unavailable
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def unified_diff(before: str, after: str, relative: str, destination: str | None = None) -> str:
|
|
36
|
+
parts = []
|
|
37
|
+
for line in difflib.unified_diff(
|
|
38
|
+
before.splitlines(keepends=True), after.splitlines(keepends=True),
|
|
39
|
+
fromfile=f"a/{relative}", tofile=destination or f"b/{relative}",
|
|
40
|
+
):
|
|
41
|
+
parts.append(line)
|
|
42
|
+
if not line.endswith("\n"):
|
|
43
|
+
parts.append("\n\\n")
|
|
44
|
+
return "".join(parts)
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitvars-render
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Render Git provenance into text templates without network access.
|
|
5
|
+
Author: Andres Velasco
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/AndresVelasco/gitvars-render
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: demo
|
|
15
|
+
Requires-Dist: markdown-it-py<5,>=4; extra == "demo"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# gitvars-render
|
|
19
|
+
|
|
20
|
+
`gitvars` exists so that ***your deployed assets can point back to their source.***
|
|
21
|
+
|
|
22
|
+
Git knows the commit, its author, and the code behind a change. `gitvars` carries
|
|
23
|
+
that context into the files you deploy. Add placeholders to your configuration,
|
|
24
|
+
code, or documentation, and replace them with Git metadata during your build—
|
|
25
|
+
including links to the exact commit and source file.
|
|
26
|
+
|
|
27
|
+
## Why gitvars exists
|
|
28
|
+
|
|
29
|
+
I built it for a recurring need in data engineering: **labeling data assets and
|
|
30
|
+
pipelines with the version of code behind them.** When inspecting a table or an
|
|
31
|
+
Airflow DAG, I wanted a direct route back to the change that produced it.
|
|
32
|
+
Embedding Git metadata in labels, descriptions, and configuration makes that
|
|
33
|
+
connection easier to follow when investigating an issue or understanding a
|
|
34
|
+
deployment.
|
|
35
|
+
|
|
36
|
+
The same idea works for Terraform resources, application configuration, and
|
|
37
|
+
documentation. The [example page](https://andresvelasco.github.io/gitvars-render/) uses `gitvars` to fill in its
|
|
38
|
+
Git metadata at build time.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
The Python distribution is named `gitvars-render` and installs the `gitvars` command.
|
|
43
|
+
The Python import remains `gitvars`. It
|
|
44
|
+
requires Python 3.11+ and Git on `PATH`, with no runtime dependencies or network
|
|
45
|
+
calls during rendering.
|
|
46
|
+
|
|
47
|
+
Install the package from a source checkout into your Python environment,
|
|
48
|
+
replacing `/path/to/gitvars-render` with the directory containing its `pyproject.toml`:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
python -m pip install /path/to/gitvars-render
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Once this project is published to PyPI, install its release by package name:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
python -m pip install gitvars-render
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
Run `gitvars` in the Git repository containing the files you want to process.
|
|
63
|
+
There are two commands:
|
|
64
|
+
|
|
65
|
+
| Command | Behavior |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `gitvars render [PATH ...]` | Replace placeholders in selected files, in place by default, or write copies with `--output-dir`. |
|
|
68
|
+
| `gitvars check [PATH ...]` | Read selected files without writing. Exit `1` if rendering would change any file, or `0` if no changes are needed. |
|
|
69
|
+
|
|
70
|
+
Add `--dry-run` to `render` to preview a unified diff without writing anything.
|
|
71
|
+
Both commands read configuration from the nearest `.gitvars` found by searching
|
|
72
|
+
upward from the **working directory**, not from each input path. Use
|
|
73
|
+
`--config FILE` to select a specific configuration file. A configuration file is optional:
|
|
74
|
+
you can supply file selection and other options on the command line instead.
|
|
75
|
+
|
|
76
|
+
### Select files and directories
|
|
77
|
+
|
|
78
|
+
Both commands accept **zero, one, or several positional paths**. Paths are files
|
|
79
|
+
or directories relative to your working directory; absolute paths also work.
|
|
80
|
+
You may mix files and directories, but every input must belong to the same Git
|
|
81
|
+
repository. Overlapping inputs are deduplicated.
|
|
82
|
+
|
|
83
|
+
| Inputs | Selection |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| No paths | Scan the current directory recursively. |
|
|
86
|
+
| One or more directories | Scan those directories recursively. |
|
|
87
|
+
| One or more files | Select those files directly; no include patterns are required. |
|
|
88
|
+
|
|
89
|
+
**Paths choose where to scan; include patterns choose which files to render.**
|
|
90
|
+
Directory scans require `include` patterns in `.gitvars` or repeatable
|
|
91
|
+
`--include` options. Globs are relative to the repository root. Exclusions still
|
|
92
|
+
apply to explicitly supplied files, even though include patterns are unnecessary
|
|
93
|
+
for them.
|
|
94
|
+
|
|
95
|
+
For example, preview SQL files under `models/` and Markdown files under `docs/`,
|
|
96
|
+
without requiring a configuration file:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
gitvars render models docs --include '**/*.sql' --include '**/*.md' --dry-run
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Or preview two specific files without `--include`:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
gitvars render models/model.sql docs/pipeline.md --dry-run
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
To keep selection in configuration, create `.gitvars` at the repository root:
|
|
109
|
+
|
|
110
|
+
```toml
|
|
111
|
+
include = ["**/*.sql", "**/*.md"]
|
|
112
|
+
exclude = ["build/**", "dist/**", ".venv/**"]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
With that configuration, no CLI `--include` is needed. Narrow the scan to the
|
|
116
|
+
two directories, or omit paths to scan the current directory:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
gitvars render models docs --dry-run
|
|
120
|
+
gitvars check models docs
|
|
121
|
+
gitvars render --dry-run
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
For the complete configuration options and matching rules, see
|
|
125
|
+
[Configuration](#configuration).
|
|
126
|
+
|
|
127
|
+
### Choose where rendering writes
|
|
128
|
+
|
|
129
|
+
`render` defaults to **in-place replacement**. This is intended for fresh CI
|
|
130
|
+
checkouts: substitution consumes the placeholders. Local use is supported, but
|
|
131
|
+
every selected input must be tracked and have no staged or unstaged changes.
|
|
132
|
+
Unrelated modified files do not block rendering. Restore the templates from Git
|
|
133
|
+
before rendering them again for a different commit.
|
|
134
|
+
|
|
135
|
+
Using the configuration above, render both directories in place:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
gitvars render models docs
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
For generated artifacts, keep templates intact and render copies instead:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
gitvars render models docs --output-dir build
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`models/nested/model.sql` becomes `build/models/nested/model.sql`: paths
|
|
148
|
+
are always relative to the repository root, without flattening. Unselected files
|
|
149
|
+
in input directories are copied unchanged as bytes, including images and other
|
|
150
|
+
assets. Excluded files are omitted.
|
|
151
|
+
|
|
152
|
+
The output directory must be empty or absent. Use `-f` / `--force` to clear a
|
|
153
|
+
nonempty output directory before generating it again. Its contents are removed,
|
|
154
|
+
including files unrelated to the previous run. The flag never permits overlapping
|
|
155
|
+
inputs and outputs: an output directory cannot be inside an input directory,
|
|
156
|
+
equal an input, or contain an input. Symlink destinations and destinations inside
|
|
157
|
+
`.git` are refused. A common safe layout is sibling `models/`, `docs/`, and
|
|
158
|
+
`build/` directories, with only `models docs` as the inputs. Omitting the paths
|
|
159
|
+
would scan the current directory, so a `build/` destination inside it would be
|
|
160
|
+
rejected even if `build/**` is excluded.
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
gitvars render models docs --output-dir build --force
|
|
164
|
+
gitvars render models docs --output-dir build --force --dry-run
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`--dry-run` prints unified diffs without changing any files, creating directories,
|
|
168
|
+
or clearing existing output. `check` reports whether rendering would change
|
|
169
|
+
selected files. Both can inspect dirty or untracked inputs.
|
|
170
|
+
|
|
171
|
+
## Configuration
|
|
172
|
+
|
|
173
|
+
Use TOML in `.gitvars`. The nearest file found by searching upward from the
|
|
174
|
+
working directory wins. `--config FILE` overrides discovery. A directory input
|
|
175
|
+
requires include patterns from configuration or `--include`; with no paths,
|
|
176
|
+
the input is the working directory.
|
|
177
|
+
|
|
178
|
+
```toml
|
|
179
|
+
prefix = "__git_"
|
|
180
|
+
escape = true
|
|
181
|
+
remote = "origin"
|
|
182
|
+
provider = "auto"
|
|
183
|
+
include = ["**/*.sql", "**/*.md", "**/*.yml", "**/*.yaml"]
|
|
184
|
+
exclude = ["build/**", "dist/**", ".venv/**"]
|
|
185
|
+
exclude_regex = ['(^|/)README\.md$']
|
|
186
|
+
# Optional repository web root, including owner/group and repository:
|
|
187
|
+
# url_prefix = "https://git.company.com/team/analytics"
|
|
188
|
+
# provider = "gitlab" # Required for self-hosted hosts.
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
All selection patterns are **repository-relative**, regardless of the working
|
|
192
|
+
directory or configuration location. Paths use `/`, and matching is case-sensitive:
|
|
193
|
+
|
|
194
|
+
| Pattern | Matches |
|
|
195
|
+
| --- | --- |
|
|
196
|
+
| `*.md` | Markdown files at the repository root |
|
|
197
|
+
| `**/*.md` | Markdown files at any depth, including the root |
|
|
198
|
+
| `build/**` | The root build directory and everything beneath it |
|
|
199
|
+
| `templates/?.sql` | One-character SQL filenames directly under `templates/` |
|
|
200
|
+
|
|
201
|
+
`*`, `?`, and character classes match within a path segment. A standalone `**`
|
|
202
|
+
segment matches zero or more directories. Globs match the whole relative path;
|
|
203
|
+
regex exclusions search it. Matching an excluded directory excludes its subtree.
|
|
204
|
+
There are no negation/reinclude rules. `.gitignore` is not implicitly consulted.
|
|
205
|
+
|
|
206
|
+
CLI `--include`, `--exclude`, and `--exclude-regex` are repeatable and extend their
|
|
207
|
+
configuration lists. CLI `--prefix` and `--escape` / `--no-escape` override scalar
|
|
208
|
+
configuration. Matching any exclusion wins over inclusion or an explicit file.
|
|
209
|
+
|
|
210
|
+
Symlinks, `.git`, and nested repositories are skipped during traversal. Explicit
|
|
211
|
+
inputs from different repositories are rejected. All files in an invocation use
|
|
212
|
+
one commit snapshot. A repository without a commit at `HEAD` is an error.
|
|
213
|
+
|
|
214
|
+
## Placeholders
|
|
215
|
+
|
|
216
|
+
| Placeholder | Value |
|
|
217
|
+
| --- | --- |
|
|
218
|
+
| `__git_commit__` | Full `HEAD` commit SHA |
|
|
219
|
+
| `__git_commit_short__` | Git's abbreviated SHA |
|
|
220
|
+
| `__git_author_name__` | Commit author name |
|
|
221
|
+
| `__git_author_email__` | Commit author email |
|
|
222
|
+
| `__git_committer_name__` | Committer name |
|
|
223
|
+
| `__git_committer_email__` | Committer email |
|
|
224
|
+
| `__git_commit_subject__` | Commit subject |
|
|
225
|
+
| `__git_commit_message__` | Full commit message, including newlines |
|
|
226
|
+
| `__git_commit_date__` | Author date in ISO 8601 with timezone |
|
|
227
|
+
| `__git_branch__` | Current branch; empty in detached HEAD |
|
|
228
|
+
| `__git_tag__` | Highest version-ordered tag pointing at `HEAD`; empty if none |
|
|
229
|
+
| `__git_repository__` | Repository name |
|
|
230
|
+
| `__git_commit_url__` | Commit browser URL, if available |
|
|
231
|
+
| `__git_file_url__` | Input-file permalink at `HEAD`, if the file exists there |
|
|
232
|
+
|
|
233
|
+
Commit metadata describes checked-out `HEAD`, including when output-directory
|
|
234
|
+
inputs have local edits. It does not identify the deployer or the last author of
|
|
235
|
+
each file. File links refer to the version committed at `HEAD`, which can differ
|
|
236
|
+
from a dirty local input. Tags are ordered using Git's version sort, not creation
|
|
237
|
+
date; only tags available in the local checkout participate.
|
|
238
|
+
|
|
239
|
+
Supported placeholders are replaced everywhere, including Markdown inline code
|
|
240
|
+
and fenced code blocks. Unknown or incomplete placeholders are left untouched.
|
|
241
|
+
Prefix strings are literal, not regexes. Unavailable known values become empty
|
|
242
|
+
strings, with warnings under `--verbose`.
|
|
243
|
+
|
|
244
|
+
Escape a supported placeholder with an immediately preceding backslash:
|
|
245
|
+
|
|
246
|
+
```text
|
|
247
|
+
__git_commit__ -> the actual SHA
|
|
248
|
+
\__git_commit__ -> literal __git_commit__
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
One escape backslash is consumed; all other backslashes remain unchanged.
|
|
252
|
+
Disable escape handling with `escape = false` or `--no-escape`. Then a backslash
|
|
253
|
+
is ordinary text: `\__git_commit__` becomes a backslash followed by the SHA.
|
|
254
|
+
Replacement is always one-pass; inserted metadata and escaped tokens are never
|
|
255
|
+
processed again during that invocation.
|
|
256
|
+
|
|
257
|
+
Values are inserted verbatim by default. Use `--html-escape` with `render` or
|
|
258
|
+
`check` to encode inserted values for HTML text and quoted attributes, including
|
|
259
|
+
`&`, `<`, `>`, and quotes. Template text and literal placeholders stay unchanged.
|
|
260
|
+
This is separate from backslash placeholder escaping. Template authors remain
|
|
261
|
+
responsible for quoting and escaping values for other output formats.
|
|
262
|
+
|
|
263
|
+
## Repository URLs
|
|
264
|
+
|
|
265
|
+
GitHub, GitLab, and Bitbucket Cloud are supported, including common HTTPS,
|
|
266
|
+
SCP-style SSH, and `ssh://` remotes. `provider = "auto"` recognizes `github.com`,
|
|
267
|
+
`gitlab.com`, and `bitbucket.org`. Self-hosted GitHub/GitLab installations require
|
|
268
|
+
an explicit `provider`. A self-hosted `url_prefix` with `auto` is a configuration
|
|
269
|
+
error; an unrecognized remote without an override simply produces empty links.
|
|
270
|
+
|
|
271
|
+
`url_prefix` takes precedence over the selected remote for links and repository
|
|
272
|
+
names. Name discovery uses its final path component, then the selected remote's,
|
|
273
|
+
then the local repository-root directory name. A trailing `.git` is removed from
|
|
274
|
+
URL-derived names. The default remote is `origin`. No hosting APIs are used.
|
|
275
|
+
File URLs use the original input's repository-relative path, with components
|
|
276
|
+
URL-encoded, and the full commit SHA. A file absent from `HEAD` has no permalink.
|
|
277
|
+
|
|
278
|
+
## Files, failures, and exit codes
|
|
279
|
+
|
|
280
|
+
Only selected files are decoded as UTF-8; there is no binary detection. A selected
|
|
281
|
+
file that cannot be decoded produces an error. Original newline characters,
|
|
282
|
+
including mixed line endings and the UTF-8 BOM, are preserved outside substituted
|
|
283
|
+
values. Multiline metadata is inserted verbatim. File permissions are preserved;
|
|
284
|
+
unchanged in-place files are not rewritten. Each changed file is replaced atomically.
|
|
285
|
+
|
|
286
|
+
Inputs, repository membership, selections, and destination safety are checked
|
|
287
|
+
before writing or clearing output. In-place clean-input checks also happen before
|
|
288
|
+
writing. Files are then processed sequentially: there is no bulk token validation
|
|
289
|
+
or automatic rollback. A later decoding, reading, or writing failure can leave
|
|
290
|
+
earlier files rendered. Restore affected in-place files from Git, or delete and
|
|
291
|
+
regenerate output-directory contents.
|
|
292
|
+
|
|
293
|
+
| Exit | Meaning |
|
|
294
|
+
| --- | --- |
|
|
295
|
+
| `0` | Successful render/dry-run, even with changes; or check needs no changes |
|
|
296
|
+
| `1` | Check found files rendering would change |
|
|
297
|
+
| `2` | Invalid arguments/configuration, missing Git/commit/repository, dirty in-place inputs, or an I/O/encoding error |
|
|
298
|
+
|
|
299
|
+
All logging and diagnostics go to stdout. Default output is a concise summary;
|
|
300
|
+
`--verbose` adds paths and unavailable-value warnings, and `--quiet` suppresses
|
|
301
|
+
routine logging. Dry-run diffs and errors remain visible with `--quiet`.
|
|
302
|
+
|
|
303
|
+
## Development and demo
|
|
304
|
+
|
|
305
|
+
```sh
|
|
306
|
+
uv sync --locked --extra demo
|
|
307
|
+
uv run --locked --extra demo python -m unittest discover -s tests -v
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`uv.lock` is versioned so local development, CI, and demo builds use the same
|
|
311
|
+
resolved dependencies. `--locked` fails if it needs updating. After changing
|
|
312
|
+
dependencies, run `uv lock` and commit both `pyproject.toml` and `uv.lock`.
|
|
313
|
+
The runtime package still has no dependencies; the default development group
|
|
314
|
+
adds the version-comparison helper used by release automation.
|
|
315
|
+
|
|
316
|
+
To build and preview the page locally:
|
|
317
|
+
|
|
318
|
+
```sh
|
|
319
|
+
uv run --locked --extra demo python demo/build.py
|
|
320
|
+
uv run --locked --extra demo gitvars render demo/index.html --html-escape --output-dir build --force
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
| Path | Purpose |
|
|
324
|
+
| --- | --- |
|
|
325
|
+
| `README.md` | Page content, maintained in Markdown. |
|
|
326
|
+
| `demo/layout.html` | Tracked HTML layout and styles. |
|
|
327
|
+
| `demo/index.html` | Generated HTML with Git placeholders intact; ignored by Git. |
|
|
328
|
+
| `build/demo/index.html` | Final rendered page with actual Git values; ignored by Git. Open this file in your browser. |
|
|
329
|
+
|
|
330
|
+
`demo/build.py` only combines the README and layout into `demo/index.html`,
|
|
331
|
+
leaving the Git placeholders intact. The separate `gitvars render` command
|
|
332
|
+
replaces them in a copy at `build/demo/index.html`, preserving the input path
|
|
333
|
+
under `build/`. `--force` clears an existing `build/` directory before rendering.
|
|
334
|
+
Neither generated HTML file is committed. Run both commands again after editing
|
|
335
|
+
the README or layout. CI uses the same two steps on pushes and pull requests.
|
|
336
|
+
|
|
337
|
+
The table is inserted immediately after the introduction. Its three columns show
|
|
338
|
+
the literal variable, its replaced value, and its explanation. README examples
|
|
339
|
+
stay literal; only the table's value cells are substituted. Values are escaped
|
|
340
|
+
for HTML, including multiline commit messages. The file URL cell is empty
|
|
341
|
+
because the generated input is absent from `HEAD`; the commit URL still links
|
|
342
|
+
to the source revision. Both commands run once and exit. The optional `demo`
|
|
343
|
+
dependencies are needed only to convert the README to HTML; the gitvars CLI
|
|
344
|
+
itself still has no runtime dependencies.
|
|
345
|
+
|
|
346
|
+
### GitHub Pages output
|
|
347
|
+
|
|
348
|
+
On a push to `main`, the Pages workflow first runs `demo/build.py`, then invokes
|
|
349
|
+
`gitvars render` to produce `build/demo/index.html`. It uploads **`build/demo/` as the
|
|
350
|
+
Pages artifact**. That directory becomes the website root, so its `index.html`
|
|
351
|
+
is the homepage. With the default project-site URL, it appears at
|
|
352
|
+
[https://andresvelasco.github.io/gitvars-render/](https://andresvelasco.github.io/gitvars-render/),
|
|
353
|
+
without `build/demo/` in the URL. The workflow can also be run manually.
|
|
354
|
+
|
|
355
|
+
Select **GitHub Actions** as the repository's Pages publishing source to enable
|
|
356
|
+
deployment, following the [GitHub Pages workflow documentation](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages).
|
|
357
|
+
Generated output is not committed, so the page can link to the exact source
|
|
358
|
+
commit without a self-referential SHA update.
|
|
359
|
+
|
|
360
|
+
## Publishing releases
|
|
361
|
+
|
|
362
|
+
The distribution on PyPI and TestPyPI is **`gitvars-render`**. The GitHub
|
|
363
|
+
repository is `AndresVelasco/gitvars-render`; the executable and Python import
|
|
364
|
+
remain `gitvars`. `pyproject.toml` is the single version source, and
|
|
365
|
+
`gitvars --version` reads the installed distribution's metadata.
|
|
366
|
+
|
|
367
|
+
The publishing workflow is `.github/workflows/publish.yml`:
|
|
368
|
+
|
|
369
|
+
| Event | Result after successful checks |
|
|
370
|
+
| --- | --- |
|
|
371
|
+
| Pull request | Tests and distribution builds; no publishing. |
|
|
372
|
+
| Push to `main` with no release tags yet | Publish a development build to TestPyPI. |
|
|
373
|
+
| Push to `main` with an unchanged released version | Publish a development build to TestPyPI. |
|
|
374
|
+
| Push to `main` with a version above the latest release tag | Create the matching `vMAJOR.MINOR.PATCH` tag and publish to PyPI. |
|
|
375
|
+
| Push of a matching version tag | Verify the tag, version, and tests, then publish to PyPI. |
|
|
376
|
+
|
|
377
|
+
With no existing release tags, the first successful `main` build publishes
|
|
378
|
+
`0.1.0.dev...` to TestPyPI. After verifying that build, request the initial
|
|
379
|
+
production release explicitly:
|
|
380
|
+
|
|
381
|
+
```sh
|
|
382
|
+
git tag v0.1.0
|
|
383
|
+
git push origin v0.1.0
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
This starts the production publishing workflow, including tests and a check that
|
|
387
|
+
the tag matches the version in `pyproject.toml`. For subsequent releases, update
|
|
388
|
+
the version instead:
|
|
389
|
+
|
|
390
|
+
```sh
|
|
391
|
+
uv version 0.1.1
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Commit the updated `pyproject.toml` and `uv.lock`, preferably in a release PR,
|
|
395
|
+
and merge into `main`. Release versions use stable `MAJOR.MINOR.PATCH` numbers.
|
|
396
|
+
The workflow compares versions numerically, rejects version regressions and
|
|
397
|
+
tag/version mismatches, and never moves an existing tag. It tags the commit CI
|
|
398
|
+
actually tested, even if `main` has advanced. Tag creation and publishing are
|
|
399
|
+
jobs in the same workflow, so no additional token is needed to trigger another
|
|
400
|
+
workflow from an automated tag.
|
|
401
|
+
|
|
402
|
+
After `v0.1.0`, ordinary pushes publish versions such as
|
|
403
|
+
`0.1.1.dev1230001`: the next patch version plus a serial derived from the
|
|
404
|
+
workflow run ID and attempt. Only the temporary build checkout gets this
|
|
405
|
+
development version; it is not committed. Re-running a failed release on its
|
|
406
|
+
tagged commit retries publishing and skips distributions already uploaded.
|
|
407
|
+
Wheel and source distributions are installed and checked before publishing.
|
|
408
|
+
|
|
409
|
+
Configure pending or existing trusted publishers separately on PyPI and
|
|
410
|
+
TestPyPI, and create the corresponding GitHub environments:
|
|
411
|
+
|
|
412
|
+
| Setting | PyPI | TestPyPI |
|
|
413
|
+
| --- | --- | --- |
|
|
414
|
+
| Project name | `gitvars-render` | `gitvars-render` |
|
|
415
|
+
| Repository owner | `AndresVelasco` | `AndresVelasco` |
|
|
416
|
+
| Repository name | `gitvars-render` | `gitvars-render` |
|
|
417
|
+
| Workflow filename | `publish.yml` | `publish.yml` |
|
|
418
|
+
| Environment | `pypi` | `testpypi` |
|
|
419
|
+
|
|
420
|
+
Publishing uses OIDC trusted publishing; no PyPI API token is stored in GitHub.
|
|
421
|
+
The first upload creates a project configured with a pending publisher. See
|
|
422
|
+
the [PyPI trusted-publishing documentation](https://docs.pypi.org/trusted-publishers/).
|
|
423
|
+
|
|
424
|
+
CI is configured to run the standard-library test suite on Python 3.11–3.14 on Linux, plus Python
|
|
425
|
+
3.12 on Windows and macOS. The local implementation has been validated on Python
|
|
426
|
+
3.12 and Git 2.43; hosted CI results determine broader compatibility.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
gitvars/__init__.py,sha256=dOrcGUCGwsI7eY5okoMN5tfGZTiIwFLGbA0emrCr_0w,311
|
|
2
|
+
gitvars/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
gitvars/cli.py,sha256=3_foDtQKQE_JQap5ywVX9P_5bXuqhd2Ahb_BYKp6WwY,6268
|
|
4
|
+
gitvars/config.py,sha256=ENaJnO8sQUzLzt6jIQS6zV66qSjjpF0n3oOmsL1VMBY,4473
|
|
5
|
+
gitvars/errors.py,sha256=o5eyqRt54gjpQO4q3licTIX4Im0ym6UQXea_uhGY2Ck,103
|
|
6
|
+
gitvars/files.py,sha256=FOx-oPusce5AbP35Cza0f6ePpc5oxfw8HqKSAcjeIH4,5076
|
|
7
|
+
gitvars/git.py,sha256=O7va92e3z53YrB6V0BfZ0C71hqA_85DEJITeQWEVpEs,4174
|
|
8
|
+
gitvars/providers.py,sha256=BjMvEn5hLshJluJuUq4ldnHcryk4mEVKVplqI5AMztc,3858
|
|
9
|
+
gitvars/render.py,sha256=P4rc5gZ2UpEGECjWI3C8rEkN7uvqg2-2L_3V6Wb0sDQ,1520
|
|
10
|
+
gitvars_render-0.1.0.dist-info/licenses/LICENSE,sha256=rz0gH8KmOlLUJMlhOe6Wl5WBf2t8C9JYdzNIP8P17Vc,1078
|
|
11
|
+
gitvars_render-0.1.0.dist-info/METADATA,sha256=wpeYFHdRwZl9tMPzOxb-M4FtgFEhQXoOsbmRF3PlJCU,19645
|
|
12
|
+
gitvars_render-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
gitvars_render-0.1.0.dist-info/entry_points.txt,sha256=UiBID9z_jDVs7eQL1-sn-nnbYBNHw8aCMfbHSMGIcMU,45
|
|
14
|
+
gitvars_render-0.1.0.dist-info/top_level.txt,sha256=o73PHSI1P11rB1LXdeotvqhHenjzJPiXWutOGmrCAJw,8
|
|
15
|
+
gitvars_render-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andres Velasco Garcia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gitvars
|