Skip to content

Commit 08b6d76

Browse files
authored
Merge pull request #3 from akhundMurad/feat/history
2 parents 2a5ac31 + a9effdf commit 08b6d76

10 files changed

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