Skip to content

Commit ad2723e

Browse files
author
Kevin Allioli
committed
feat: initial release of git-change-detection
0 parents  commit ad2723e

7 files changed

Lines changed: 348 additions & 0 deletions

File tree

.github/workflows/publish.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
jobs:
9+
test:
10+
name: Test
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
with:
16+
fetch-depth: 0
17+
18+
- name: Set up Python
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: "3.13"
22+
23+
- name: Install package
24+
run: pip install -e .
25+
26+
- name: Verify CLI
27+
run: git-change-detection --help
28+
29+
release:
30+
name: Create GitHub Release
31+
runs-on: ubuntu-latest
32+
needs: test
33+
permissions:
34+
contents: write
35+
36+
steps:
37+
- name: Checkout
38+
uses: actions/checkout@v4
39+
40+
- name: Set up Python
41+
uses: actions/setup-python@v5
42+
with:
43+
python-version: "3.13"
44+
45+
- name: Build package
46+
run: |
47+
pip install build
48+
python -m build
49+
50+
- name: Create release
51+
uses: softprops/action-gh-release@v2
52+
with:
53+
files: dist/*
54+
generate_release_notes: true

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
__pycache__/
2+
*.pyc
3+
*.egg-info/
4+
dist/
5+
build/
6+
.venv/
7+
*.lock

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# git-change-detection
2+
3+
Detect which Ansible playbooks need to run based on git changes and metadata files.
4+
5+
## Usage
6+
7+
```bash
8+
git-change-detection <START_SHA> <END_SHA> \
9+
--metadata ansible/inventory/prod/.metadata.yml \
10+
--metadata ansible/playbooks/.metadata.yml \
11+
--json
12+
```
13+
14+
## Metadata format
15+
16+
Create `.metadata.yml` files that map file glob patterns to playbook names:
17+
18+
```yaml
19+
playbooks/setup-networking.yml:
20+
stage: 1
21+
depends_on: []
22+
paths:
23+
- "ansible/roles/networking/**"
24+
- "ansible/inventory/*/group_vars/networking.yml"
25+
26+
playbooks/setup-monitoring.yml:
27+
stage: 2
28+
depends_on:
29+
- "playbooks/setup-networking.yml"
30+
paths:
31+
- "ansible/roles/monitoring/**"
32+
```
33+
34+
## Output (JSON)
35+
36+
```json
37+
{
38+
"playbooks/setup-networking.yml": {
39+
"triggered": true,
40+
"stage": 1,
41+
"depends_on": []
42+
},
43+
"playbooks/setup-monitoring.yml": {
44+
"triggered": true,
45+
"stage": 2,
46+
"depends_on": ["playbooks/setup-networking.yml"]
47+
}
48+
}
49+
```
50+
51+
- **`triggered`**: `true` if any changed file matches the patterns, or if a dependency was triggered
52+
- **`stage`**: execution order (sorted by the CI)
53+
- **`depends_on`**: list of playbook names this depends on (triggers cascade)
54+
55+
## Install
56+
57+
```bash
58+
pip install git-change-detection
59+
```
60+
61+
## Development
62+
63+
```bash
64+
uv sync
65+
uv run git-change-detection --help
66+
```

pyproject.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[project]
2+
name = "git-change-detection"
3+
version = "0.1.0"
4+
description = "Detect which Ansible playbooks need to run based on git changes and metadata files"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
license = {text = "MIT"}
8+
dependencies = [
9+
"click>=8.1",
10+
"jinja2>=3.1.6",
11+
"pyyaml>=6.0",
12+
"gitpython>=3.1",
13+
]
14+
15+
[project.scripts]
16+
git-change-detection = "git_change_detection.cli:main"
17+
18+
[build-system]
19+
requires = ["hatchling"]
20+
build-backend = "hatchling.build"
21+
22+
[tool.hatch.build.targets.wheel]
23+
packages = ["src/git_change_detection"]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""git-change-detection: Detect which Ansible playbooks to run based on git diff and metadata."""
2+
3+
__version__ = "0.1.0"

src/git_change_detection/cli.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""CLI entry point for git-change-detection."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
8+
import click
9+
import yaml
10+
11+
from git_change_detection.detector import detect_changes
12+
13+
14+
@click.command()
15+
@click.argument("start")
16+
@click.argument("end")
17+
@click.option(
18+
"--metadata",
19+
"-m",
20+
multiple=True,
21+
required=True,
22+
help="Path to a .metadata.yml file (can be specified multiple times)",
23+
)
24+
@click.option("--json-output", "--json", "output_json", is_flag=True, help="Output as JSON")
25+
@click.option("--repo-dir", default=".", help="Path to the git repository")
26+
def main(
27+
start: str,
28+
end: str,
29+
metadata: tuple[str, ...],
30+
output_json: bool,
31+
repo_dir: str,
32+
) -> None:
33+
"""Detect which playbooks are triggered by git changes between START and END refs.
34+
35+
Reads .metadata.yml files that map file glob patterns to playbook names,
36+
then checks the git diff to determine which playbooks should run.
37+
"""
38+
try:
39+
result = detect_changes(
40+
start=start,
41+
end=end,
42+
metadata_files=list(metadata),
43+
repo_dir=repo_dir,
44+
)
45+
except Exception as e:
46+
click.echo(f"Error: {e}", err=True)
47+
sys.exit(1)
48+
49+
if output_json:
50+
# Strip matched_files for clean CI output
51+
clean = {
52+
name: {
53+
"triggered": info["triggered"],
54+
"stage": info["stage"],
55+
"depends_on": info["depends_on"],
56+
}
57+
for name, info in result.items()
58+
}
59+
click.echo(json.dumps(clean, indent=2))
60+
else:
61+
triggered = [n for n, i in result.items() if i["triggered"]]
62+
skipped = [n for n, i in result.items() if not i["triggered"]]
63+
64+
if triggered:
65+
click.echo("Triggered playbooks:")
66+
for name in sorted(triggered):
67+
info = result[name]
68+
click.echo(f" ▶ {name} (stage {info['stage']})")
69+
for f in info["matched_files"]:
70+
click.echo(f" └─ {f}")
71+
else:
72+
click.echo("No playbooks triggered.")
73+
74+
if skipped:
75+
click.echo(f"\nSkipped: {', '.join(sorted(skipped))}")
76+
77+
78+
if __name__ == "__main__":
79+
main()
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Core detection logic: match git diff against metadata to find triggered playbooks."""
2+
3+
from __future__ import annotations
4+
5+
import fnmatch
6+
import json
7+
import subprocess
8+
from pathlib import Path
9+
from typing import Any
10+
11+
import yaml
12+
13+
14+
def get_changed_files(start: str, end: str, repo_dir: str = ".") -> list[str]:
15+
"""Return list of files changed between two git refs."""
16+
result = subprocess.run(
17+
["git", "diff", "--name-only", f"{start}...{end}"],
18+
capture_output=True,
19+
text=True,
20+
cwd=repo_dir,
21+
check=True,
22+
)
23+
return [f.strip() for f in result.stdout.strip().splitlines() if f.strip()]
24+
25+
26+
def load_metadata(path: str) -> dict[str, Any]:
27+
"""Load a .metadata.yml file.
28+
29+
Expected format:
30+
playbook_name:
31+
stage: 1
32+
depends_on: []
33+
paths:
34+
- "ansible/roles/my_role/**"
35+
- "ansible/inventory/*/group_vars/**"
36+
"""
37+
with open(path) as f:
38+
data = yaml.safe_load(f)
39+
return data or {}
40+
41+
42+
def match_paths(changed_files: list[str], patterns: list[str]) -> bool:
43+
"""Check if any changed file matches any of the glob patterns."""
44+
for changed in changed_files:
45+
for pattern in patterns:
46+
if fnmatch.fnmatch(changed, pattern):
47+
return True
48+
return False
49+
50+
51+
def detect_changes(
52+
start: str,
53+
end: str,
54+
metadata_files: list[str],
55+
repo_dir: str = ".",
56+
) -> dict[str, Any]:
57+
"""Run change detection and return playbook trigger map.
58+
59+
Returns a dict like:
60+
{
61+
"playbook_name": {
62+
"triggered": true,
63+
"stage": 1,
64+
"depends_on": [],
65+
"matched_files": ["path/to/file"]
66+
}
67+
}
68+
"""
69+
changed_files = get_changed_files(start, end, repo_dir)
70+
71+
result: dict[str, Any] = {}
72+
73+
for meta_path in metadata_files:
74+
metadata = load_metadata(meta_path)
75+
76+
for playbook_name, config in metadata.items():
77+
if not isinstance(config, dict):
78+
continue
79+
80+
patterns = config.get("paths", [])
81+
stage = config.get("stage", 0)
82+
depends_on = config.get("depends_on", [])
83+
84+
matched = [
85+
f for f in changed_files
86+
if any(fnmatch.fnmatch(f, p) for p in patterns)
87+
]
88+
89+
triggered = len(matched) > 0
90+
91+
if playbook_name in result:
92+
if triggered:
93+
result[playbook_name]["triggered"] = True
94+
result[playbook_name]["matched_files"].extend(matched)
95+
else:
96+
result[playbook_name] = {
97+
"triggered": triggered,
98+
"stage": stage,
99+
"depends_on": depends_on,
100+
"matched_files": matched,
101+
}
102+
103+
# Propagate triggers through dependencies
104+
changed = True
105+
while changed:
106+
changed = False
107+
for name, info in result.items():
108+
if info["triggered"]:
109+
continue
110+
for dep in info["depends_on"]:
111+
if dep in result and result[dep]["triggered"]:
112+
info["triggered"] = True
113+
changed = True
114+
break
115+
116+
return result

0 commit comments

Comments
 (0)