Skip to content

Commit cb64d7a

Browse files
committed
feat(cli): add history commands and content-addressed snapshot store
- Introduce `pacta history show` and `pacta history export` with docs and tests - Store snapshots as immutable hash-addressed objects with git-like refs (`latest`, `baseline`, etc.) - Always update `latest` on scan and optionally update additional refs via `--save-ref` - Include git commit/branch metadata in snapshots and update e2e tests accordingly
1 parent 2a5ac31 commit cb64d7a

10 files changed

Lines changed: 964 additions & 88 deletions

File tree

README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Codebases rot. The "clean architecture" you designed becomes spaghetti after a f
4545
- **Layer enforcement** — domain can't import from infra, etc.
4646
- **Baseline mode** — fail only on *new* violations, not legacy debt
4747
- **Snapshots** — version your architecture like code
48+
- **History tracking** — view architecture evolution over time
4849

4950
## Quick example
5051

@@ -106,6 +107,24 @@ pacta scan . --model architecture.yml --rules rules.pacta.yml --save-ref baselin
106107
pacta scan . --model architecture.yml --rules rules.pacta.yml --baseline baseline
107108
```
108109

110+
## History tracking
111+
112+
Every scan creates a content-addressed snapshot. View your architecture evolution:
113+
114+
```bash
115+
# View timeline
116+
$ pacta history show --last 5
117+
118+
Architecture Timeline (5 entries)
119+
============================================================
120+
a1b2c3d4 2025-01-22 abc1234 main 42 nodes 87 edges 2 violations (latest)
121+
e5f6g7h8 2025-01-20 def5678 main 42 nodes 85 edges 4 violations
122+
...
123+
124+
# Export for external tools
125+
pacta history export --format json > history.json
126+
```
127+
109128
## Docs
110129

111130
- [CLI Reference](https://akhundmurad.github.io/pacta/cli/)
@@ -114,8 +133,11 @@ pacta scan . --model architecture.yml --rules rules.pacta.yml --baseline baselin
114133

115134
## Roadmap (short)
116135

117-
- Open-source CLI and analysis engine
118-
- Proprietary hosted service with:
136+
- [x] Open-source CLI and analysis engine
137+
- [x] Content-addressed snapshots with history tracking
138+
- [ ] Architecture visualization (Mermaid, D2)
139+
- [ ] Health metrics (drift score, instability)
140+
- [ ] Proprietary hosted service with:
119141
- Cross-repository insights
120142
- Historical trend analysis
121143
- Team-level governance and reporting

docs/cli.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,104 @@ pacta diff [PATH] --from REF --to REF
108108
pacta diff . --from v1 --to v2
109109
```
110110

111+
## history show
112+
113+
View architecture timeline (list of snapshots).
114+
115+
```bash
116+
pacta history show [PATH] [OPTIONS]
117+
```
118+
119+
**Options:**
120+
121+
| Option | Default | Description |
122+
|--------|---------|-------------|
123+
| `--last N` | - | Show only last N entries |
124+
| `--since DATE` | - | Show entries since date (ISO-8601) |
125+
| `--branch NAME` | - | Filter by branch name |
126+
| `--format {text,json}` | `text` | Output format |
127+
128+
**Examples:**
129+
130+
```bash
131+
# Show all snapshots
132+
pacta history show .
133+
134+
# Show last 10 entries
135+
pacta history show . --last 10
136+
137+
# Filter by branch and date
138+
pacta history show . --branch main --since 2025-01-01
139+
140+
# JSON output for scripting
141+
pacta history show . --format json
142+
```
143+
144+
**Example output:**
145+
146+
```
147+
Architecture Timeline (3 entries)
148+
============================================================
149+
150+
a1b2c3d4 2025-01-22 abc1234 main 42 nodes 87 edges 2 violations (latest)
151+
e5f6g7h8 2025-01-20 def5678 main 42 nodes 85 edges 4 violations
152+
12345678 2025-01-18 789abcd feature/x 40 nodes 82 edges 3 violations
153+
```
154+
155+
## history export
156+
157+
Export full history data for external processing or SaaS integration.
158+
159+
```bash
160+
pacta history export [PATH] [OPTIONS]
161+
```
162+
163+
**Options:**
164+
165+
| Option | Default | Description |
166+
|--------|---------|-------------|
167+
| `--format {json,jsonl}` | `json` | Export format |
168+
| `-o, --output FILE` | stdout | Output file path |
169+
170+
**Examples:**
171+
172+
```bash
173+
# Export as JSON
174+
pacta history export . --format json > history.json
175+
176+
# Export as JSON Lines (one entry per line)
177+
pacta history export . --format jsonl > history.jsonl
178+
179+
# Export to file
180+
pacta history export . -o export.json
181+
```
182+
183+
**JSON output structure:**
184+
185+
```json
186+
{
187+
"version": 1,
188+
"exported_at": "2025-01-22T12:00:00",
189+
"repo_root": "/path/to/repo",
190+
"refs": {
191+
"latest": "a1b2c3d4",
192+
"baseline": "e5f6g7h8"
193+
},
194+
"entries": [
195+
{
196+
"hash": "a1b2c3d4",
197+
"timestamp": "2025-01-22T12:00:00+00:00",
198+
"commit": "abc1234",
199+
"branch": "main",
200+
"refs": ["latest"],
201+
"node_count": 42,
202+
"edge_count": 87,
203+
"violations": [...]
204+
}
205+
]
206+
}
207+
```
208+
111209
## Exit Codes
112210

113211
| Code | Meaning |

pacta/cli/history.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import json
2+
import sys
3+
from datetime import datetime
4+
from pathlib import Path
5+
6+
from pacta.cli._io import ensure_repo_root
7+
from pacta.cli.exitcodes import EXIT_OK
8+
from pacta.snapshot.store import FsSnapshotStore
9+
10+
11+
def show(
12+
*,
13+
path: str,
14+
last: int | None = None,
15+
since: str | None = None,
16+
branch: str | None = None,
17+
format: str = "text",
18+
) -> int:
19+
"""
20+
Show architecture history (list of snapshots).
21+
22+
Args:
23+
path: Repository root path
24+
last: Show only last N entries
25+
since: Show entries since date (ISO-8601)
26+
branch: Filter by branch name
27+
format: Output format (text or json)
28+
"""
29+
repo_root = Path(ensure_repo_root(path))
30+
store = FsSnapshotStore(repo_root=str(repo_root))
31+
32+
# Get all objects sorted by timestamp
33+
objects = store.list_objects()
34+
35+
if not objects:
36+
if format == "json":
37+
print(json.dumps({"entries": [], "count": 0}))
38+
else:
39+
print("No history entries found.")
40+
print("Run 'pacta scan' to create snapshots.")
41+
return EXIT_OK
42+
43+
# Apply filters
44+
entries = []
45+
for short_hash, snapshot in objects:
46+
meta = snapshot.meta
47+
48+
# Filter by branch if specified
49+
if branch and meta.branch != branch:
50+
continue
51+
52+
# Filter by since date if specified
53+
if since and meta.created_at:
54+
try:
55+
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
56+
created_dt = datetime.fromisoformat(meta.created_at.replace("Z", "+00:00"))
57+
# Normalize to compare: strip timezone info for comparison
58+
since_naive = since_dt.replace(tzinfo=None) if since_dt.tzinfo else since_dt
59+
created_naive = created_dt.replace(tzinfo=None) if created_dt.tzinfo else created_dt
60+
if created_naive < since_naive:
61+
continue
62+
except ValueError:
63+
pass # Invalid date format, skip filter
64+
65+
entries.append((short_hash, snapshot))
66+
67+
# Apply limit
68+
if last and last > 0:
69+
entries = entries[:last]
70+
71+
# Get refs for display
72+
refs = store.list_refs()
73+
hash_to_refs: dict[str, list[str]] = {}
74+
for ref_name, ref_hash in refs.items():
75+
if ref_hash not in hash_to_refs:
76+
hash_to_refs[ref_hash] = []
77+
hash_to_refs[ref_hash].append(ref_name)
78+
79+
if format == "json":
80+
_output_json(entries, hash_to_refs)
81+
else:
82+
_output_text(entries, hash_to_refs)
83+
84+
return EXIT_OK
85+
86+
87+
def _output_text(
88+
entries: list[tuple[str, "Snapshot"]], # noqa: F821
89+
hash_to_refs: dict[str, list[str]],
90+
) -> None:
91+
"""Output history in text format."""
92+
print(f"Architecture Timeline ({len(entries)} entries)")
93+
print("=" * 60)
94+
print()
95+
96+
for short_hash, snapshot in entries:
97+
meta = snapshot.meta
98+
refs_list = hash_to_refs.get(short_hash, [])
99+
100+
# Format timestamp
101+
timestamp = meta.created_at or "unknown"
102+
if "T" in timestamp:
103+
timestamp = timestamp.split("T")[0] # Just date
104+
105+
# Format commit (short)
106+
commit = (meta.commit or "-------")[:7]
107+
108+
# Format branch
109+
branch = meta.branch or "?"
110+
111+
# Counts
112+
node_count = len(snapshot.nodes)
113+
edge_count = len(snapshot.edges)
114+
violation_count = len(snapshot.violations)
115+
116+
# Refs
117+
refs_str = f" ({', '.join(refs_list)})" if refs_list else ""
118+
119+
# Output line
120+
print(f"{short_hash} {timestamp} {commit} {branch:<12} "
121+
f"{node_count:>3} nodes {edge_count:>3} edges "
122+
f"{violation_count:>2} violations{refs_str}")
123+
124+
print()
125+
126+
127+
def _output_json(
128+
entries: list[tuple[str, "Snapshot"]], # noqa: F821
129+
hash_to_refs: dict[str, list[str]],
130+
) -> None:
131+
"""Output history in JSON format."""
132+
result = {
133+
"entries": [],
134+
"count": len(entries),
135+
}
136+
137+
for short_hash, snapshot in entries:
138+
meta = snapshot.meta
139+
refs_list = hash_to_refs.get(short_hash, [])
140+
141+
# Count violations by severity
142+
violations_by_severity: dict[str, int] = {}
143+
for v in snapshot.violations:
144+
if hasattr(v, "rule") and hasattr(v.rule, "severity"):
145+
sev = str(v.rule.severity.value) if hasattr(v.rule.severity, "value") else str(v.rule.severity)
146+
elif isinstance(v, dict) and "rule" in v and "severity" in v["rule"]:
147+
sev = v["rule"]["severity"]
148+
else:
149+
sev = "unknown"
150+
violations_by_severity[sev] = violations_by_severity.get(sev, 0) + 1
151+
152+
entry = {
153+
"hash": short_hash,
154+
"timestamp": meta.created_at,
155+
"commit": meta.commit,
156+
"branch": meta.branch,
157+
"refs": refs_list,
158+
"node_count": len(snapshot.nodes),
159+
"edge_count": len(snapshot.edges),
160+
"violation_count": len(snapshot.violations),
161+
"violations_by_severity": violations_by_severity,
162+
}
163+
result["entries"].append(entry)
164+
165+
print(json.dumps(result, indent=2, default=str))
166+
167+
168+
def export(
169+
*,
170+
path: str,
171+
format: str = "json",
172+
output: str | None = None,
173+
) -> int:
174+
"""
175+
Export full history data for external processing.
176+
177+
Args:
178+
path: Repository root path
179+
format: Export format (json or jsonl)
180+
output: Output file path (default: stdout)
181+
"""
182+
repo_root = Path(ensure_repo_root(path))
183+
store = FsSnapshotStore(repo_root=str(repo_root))
184+
185+
objects = store.list_objects()
186+
refs = store.list_refs()
187+
188+
# Build hash to refs mapping
189+
hash_to_refs: dict[str, list[str]] = {}
190+
for ref_name, ref_hash in refs.items():
191+
if ref_hash not in hash_to_refs:
192+
hash_to_refs[ref_hash] = []
193+
hash_to_refs[ref_hash].append(ref_name)
194+
195+
# Build export data
196+
entries = []
197+
for short_hash, snapshot in objects:
198+
meta = snapshot.meta
199+
200+
entry = {
201+
"hash": short_hash,
202+
"timestamp": meta.created_at,
203+
"commit": meta.commit,
204+
"branch": meta.branch,
205+
"refs": hash_to_refs.get(short_hash, []),
206+
"repo_root": meta.repo_root,
207+
"tool_version": meta.tool_version,
208+
"node_count": len(snapshot.nodes),
209+
"edge_count": len(snapshot.edges),
210+
"violations": [
211+
v.to_dict() if hasattr(v, "to_dict") else v
212+
for v in snapshot.violations
213+
],
214+
}
215+
entries.append(entry)
216+
217+
# Output
218+
out_stream = open(output, "w") if output else sys.stdout
219+
220+
try:
221+
if format == "jsonl":
222+
for entry in entries:
223+
out_stream.write(json.dumps(entry, default=str) + "\n")
224+
else:
225+
result = {
226+
"version": 1,
227+
"exported_at": datetime.now().isoformat(),
228+
"repo_root": str(repo_root),
229+
"refs": refs,
230+
"entries": entries,
231+
}
232+
out_stream.write(json.dumps(result, indent=2, default=str) + "\n")
233+
finally:
234+
if output:
235+
out_stream.close()
236+
237+
if output:
238+
print(f"Exported {len(entries)} entries to {output}", file=sys.stderr)
239+
240+
return EXIT_OK

0 commit comments

Comments
 (0)