git-graphable 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.
- git_graphable/__init__.py +19 -0
- git_graphable/cli.py +398 -0
- git_graphable/core.py +127 -0
- git_graphable/highlighter.py +203 -0
- git_graphable/models.py +23 -0
- git_graphable/parser.py +79 -0
- git_graphable/styler.py +246 -0
- git_graphable-0.1.0.dist-info/METADATA +108 -0
- git_graphable-0.1.0.dist-info/RECORD +11 -0
- git_graphable-0.1.0.dist-info/WHEEL +4 -0
- git_graphable-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .core import (
|
|
2
|
+
CommitMetadata,
|
|
3
|
+
GitCommit,
|
|
4
|
+
GitLogConfig,
|
|
5
|
+
generate_summary,
|
|
6
|
+
process_repo,
|
|
7
|
+
)
|
|
8
|
+
from .parser import get_git_log
|
|
9
|
+
from .styler import export_graph
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CommitMetadata",
|
|
13
|
+
"GitCommit",
|
|
14
|
+
"GitLogConfig",
|
|
15
|
+
"generate_summary",
|
|
16
|
+
"get_git_log",
|
|
17
|
+
"export_graph",
|
|
18
|
+
"process_repo",
|
|
19
|
+
]
|
git_graphable/cli.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import webbrowser
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from graphable.enums import Engine
|
|
9
|
+
|
|
10
|
+
# Try to import rich
|
|
11
|
+
try:
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
HAS_CLI_EXTRAS = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
HAS_CLI_EXTRAS = False
|
|
20
|
+
|
|
21
|
+
from .core import GitCommit, GitLogConfig, generate_summary, process_repo
|
|
22
|
+
from .styler import export_graph
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_extension(engine: Engine, as_image: bool) -> str:
|
|
26
|
+
"""Get file extension for the given engine and export type."""
|
|
27
|
+
if as_image:
|
|
28
|
+
return ".svg" # Default to SVG for images
|
|
29
|
+
|
|
30
|
+
extensions = {
|
|
31
|
+
Engine.MERMAID: ".mmd",
|
|
32
|
+
Engine.GRAPHVIZ: ".dot",
|
|
33
|
+
Engine.D2: ".d2",
|
|
34
|
+
Engine.PLANTUML: ".puml",
|
|
35
|
+
}
|
|
36
|
+
return extensions.get(engine, ".txt")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def handle_output(
|
|
40
|
+
graph,
|
|
41
|
+
engine: Engine,
|
|
42
|
+
output: Optional[str],
|
|
43
|
+
config: GitLogConfig,
|
|
44
|
+
as_image: bool = False,
|
|
45
|
+
):
|
|
46
|
+
"""Handles exporting and optionally opening the graph."""
|
|
47
|
+
if output:
|
|
48
|
+
# If output path is provided, we use the specified as_image flag or infer from extension
|
|
49
|
+
image_exts = [".png", ".svg", ".jpg", ".jpeg", ".pdf"]
|
|
50
|
+
is_image = as_image or any(output.lower().endswith(ext) for ext in image_exts)
|
|
51
|
+
export_graph(graph, output, config, engine, as_image=is_image)
|
|
52
|
+
print(f"Exported to {output}")
|
|
53
|
+
else:
|
|
54
|
+
# Create temp file and open as image
|
|
55
|
+
ext = get_extension(engine, as_image=True)
|
|
56
|
+
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tf:
|
|
57
|
+
temp_path = tf.name
|
|
58
|
+
|
|
59
|
+
export_graph(graph, temp_path, config, engine, as_image=True)
|
|
60
|
+
print(f"Opening temporary image: {temp_path}")
|
|
61
|
+
webbrowser.open(f"file://{os.path.abspath(temp_path)}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def display_summary(summary: Dict[str, List[GitCommit]], bare: bool = False):
|
|
65
|
+
"""Displays a summary of flagged commits."""
|
|
66
|
+
if bare:
|
|
67
|
+
print("\n--- Git Hygiene Summary ---")
|
|
68
|
+
for category, commits in summary.items():
|
|
69
|
+
if commits:
|
|
70
|
+
print(f"{category}: {len(commits)} commits")
|
|
71
|
+
for c in commits[:5]: # Show first 5
|
|
72
|
+
branches = (
|
|
73
|
+
f" ({', '.join(c.reference.branches)})"
|
|
74
|
+
if c.reference.branches
|
|
75
|
+
else ""
|
|
76
|
+
)
|
|
77
|
+
print(
|
|
78
|
+
f" - {c.reference.hash[:7]}{branches}: {c.reference.message}"
|
|
79
|
+
)
|
|
80
|
+
if len(commits) > 5:
|
|
81
|
+
print(f" ... and {len(commits) - 5} more")
|
|
82
|
+
else:
|
|
83
|
+
console = Console()
|
|
84
|
+
table = Table(title="Git Hygiene Summary", box=None)
|
|
85
|
+
table.add_column("Category", style="cyan", no_wrap=True)
|
|
86
|
+
table.add_column("Count", style="magenta")
|
|
87
|
+
table.add_column("Examples (SHA - Message)", style="green")
|
|
88
|
+
|
|
89
|
+
for category, commits in summary.items():
|
|
90
|
+
if commits:
|
|
91
|
+
examples = []
|
|
92
|
+
for c in commits[:3]:
|
|
93
|
+
examples.append(
|
|
94
|
+
f"{c.reference.hash[:7]} - {c.reference.message[:50]}"
|
|
95
|
+
)
|
|
96
|
+
example_str = "\n".join(examples)
|
|
97
|
+
if len(commits) > 3:
|
|
98
|
+
example_str += f"\n... and {len(commits) - 3} more"
|
|
99
|
+
table.add_row(category, str(len(commits)), example_str)
|
|
100
|
+
|
|
101
|
+
if table.row_count > 0:
|
|
102
|
+
console.print(Panel(table, border_style="blue"))
|
|
103
|
+
else:
|
|
104
|
+
console.print("[bold green]History looks clean! No issues flagged.[/]")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_highlights(
|
|
108
|
+
highlight_authors: bool,
|
|
109
|
+
highlight_distance_from: Optional[str],
|
|
110
|
+
highlight_stale: Optional[int],
|
|
111
|
+
) -> Optional[str]:
|
|
112
|
+
"""Check for conflicting fill-based highlight options."""
|
|
113
|
+
active = []
|
|
114
|
+
if highlight_authors:
|
|
115
|
+
active.append("--highlight-authors")
|
|
116
|
+
if highlight_distance_from:
|
|
117
|
+
active.append("--highlight-distance-from")
|
|
118
|
+
if highlight_stale is not None:
|
|
119
|
+
active.append("--highlight-stale")
|
|
120
|
+
|
|
121
|
+
if len(active) > 1:
|
|
122
|
+
return f"Error: Cannot use multiple fill-based highlights at once: {', '.join(active)}"
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- Bare Argument Parser CLI ---
|
|
127
|
+
def run_bare_cli(argv: List[str]):
|
|
128
|
+
parser = argparse.ArgumentParser(
|
|
129
|
+
description="Git graph to Mermaid/Graphviz/D2/PlantUML converter"
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument("path", help="Path to local directory or git URL")
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--date-format", default="%Y%m%d%H%M%S", help="Date format for commit labels"
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--engine",
|
|
137
|
+
type=str,
|
|
138
|
+
default="mermaid",
|
|
139
|
+
choices=[e.value for e in Engine],
|
|
140
|
+
help="Visualization engine",
|
|
141
|
+
)
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"-o", "--output", help="Output file path (default: create and open temp image)"
|
|
144
|
+
)
|
|
145
|
+
parser.add_argument(
|
|
146
|
+
"--image",
|
|
147
|
+
action="store_true",
|
|
148
|
+
help="Export as image even when output path is provided",
|
|
149
|
+
)
|
|
150
|
+
parser.add_argument(
|
|
151
|
+
"--simplify",
|
|
152
|
+
action="store_true",
|
|
153
|
+
help="Pass --simplify-by-decoration to git log",
|
|
154
|
+
)
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
"--limit", type=int, help="Limit the number of commits to process"
|
|
157
|
+
)
|
|
158
|
+
parser.add_argument(
|
|
159
|
+
"--highlight-critical",
|
|
160
|
+
action="append",
|
|
161
|
+
default=[],
|
|
162
|
+
help="Branch names to highlight as critical",
|
|
163
|
+
)
|
|
164
|
+
parser.add_argument(
|
|
165
|
+
"--highlight-authors",
|
|
166
|
+
action="store_true",
|
|
167
|
+
help="Assign colors to different authors",
|
|
168
|
+
)
|
|
169
|
+
parser.add_argument(
|
|
170
|
+
"--highlight-distance-from", help="Base branch/hash for distance highlighting"
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--highlight-path", help="Highlight path between two SHAs (format: START..END)"
|
|
174
|
+
)
|
|
175
|
+
parser.add_argument(
|
|
176
|
+
"--highlight-diverging-from",
|
|
177
|
+
help="Base branch/hash for divergence/behind analysis",
|
|
178
|
+
)
|
|
179
|
+
parser.add_argument(
|
|
180
|
+
"--highlight-orphans",
|
|
181
|
+
action="store_true",
|
|
182
|
+
help="Highlight dangling/orphan commits",
|
|
183
|
+
)
|
|
184
|
+
parser.add_argument(
|
|
185
|
+
"--highlight-stale",
|
|
186
|
+
type=int,
|
|
187
|
+
help="Threshold in days to highlight stale branch tips",
|
|
188
|
+
)
|
|
189
|
+
parser.add_argument(
|
|
190
|
+
"--highlight-long-running",
|
|
191
|
+
type=int,
|
|
192
|
+
help="Threshold in days to highlight long-running branches",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument(
|
|
195
|
+
"--long-running-base",
|
|
196
|
+
default="main",
|
|
197
|
+
help="Base branch for long-running analysis",
|
|
198
|
+
)
|
|
199
|
+
parser.add_argument(
|
|
200
|
+
"--bare", action="store_true", help="Force bare mode (already active)"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
args = parser.parse_args(argv)
|
|
204
|
+
|
|
205
|
+
error = validate_highlights(
|
|
206
|
+
args.highlight_authors, args.highlight_distance_from, args.highlight_stale
|
|
207
|
+
)
|
|
208
|
+
if error:
|
|
209
|
+
print(error, file=sys.stderr)
|
|
210
|
+
sys.exit(1)
|
|
211
|
+
|
|
212
|
+
engine = Engine(args.engine)
|
|
213
|
+
|
|
214
|
+
highlight_path = None
|
|
215
|
+
if args.highlight_path and ".." in args.highlight_path:
|
|
216
|
+
parts = args.highlight_path.split("..")
|
|
217
|
+
highlight_path = (parts[0], parts[1])
|
|
218
|
+
|
|
219
|
+
config = GitLogConfig(
|
|
220
|
+
simplify=args.simplify,
|
|
221
|
+
limit=args.limit,
|
|
222
|
+
date_format=args.date_format,
|
|
223
|
+
highlight_critical=args.highlight_critical,
|
|
224
|
+
highlight_authors=args.highlight_authors,
|
|
225
|
+
highlight_distance_from=args.highlight_distance_from,
|
|
226
|
+
highlight_path=highlight_path,
|
|
227
|
+
highlight_diverging_from=args.highlight_diverging_from,
|
|
228
|
+
highlight_orphans=args.highlight_orphans,
|
|
229
|
+
highlight_stale=args.highlight_stale,
|
|
230
|
+
highlight_long_running=args.highlight_long_running,
|
|
231
|
+
long_running_base=args.long_running_base,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
graph = process_repo(args.path, config)
|
|
236
|
+
|
|
237
|
+
# Safety check
|
|
238
|
+
if engine == Engine.MERMAID and len(graph) > 500:
|
|
239
|
+
print(
|
|
240
|
+
f"Warning: Graph contains {len(graph)} nodes. Mermaid might exceed size limits.",
|
|
241
|
+
file=sys.stderr,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
handle_output(graph, engine, args.output, config, as_image=args.image)
|
|
245
|
+
display_summary(generate_summary(graph), bare=True)
|
|
246
|
+
except Exception as e:
|
|
247
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
248
|
+
sys.exit(1)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# --- Typer CLI ---
|
|
252
|
+
if HAS_CLI_EXTRAS:
|
|
253
|
+
app = typer.Typer(
|
|
254
|
+
help="Git graph to Mermaid/Graphviz/D2/PlantUML converter",
|
|
255
|
+
no_args_is_help=True,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
@app.command()
|
|
259
|
+
def convert(
|
|
260
|
+
path: str = typer.Argument(..., help="Path to local directory or git URL"),
|
|
261
|
+
date_format: str = typer.Option(
|
|
262
|
+
"%Y%m%d%H%M%S", help="Date format for commit labels"
|
|
263
|
+
),
|
|
264
|
+
engine: Engine = typer.Option(Engine.MERMAID, help="Visualization engine"),
|
|
265
|
+
output: Optional[str] = typer.Option(
|
|
266
|
+
None, "--output", "-o", help="Output file path"
|
|
267
|
+
),
|
|
268
|
+
image: bool = typer.Option(
|
|
269
|
+
False, "--image", help="Export as image even when output path is provided"
|
|
270
|
+
),
|
|
271
|
+
simplify: bool = typer.Option(
|
|
272
|
+
False, "--simplify", help="Pass --simplify-by-decoration to git log"
|
|
273
|
+
),
|
|
274
|
+
limit: Optional[int] = typer.Option(
|
|
275
|
+
None, "--limit", help="Limit the number of commits to process"
|
|
276
|
+
),
|
|
277
|
+
highlight_critical: List[str] = typer.Option(
|
|
278
|
+
[], "--highlight-critical", help="Branch names to highlight as critical"
|
|
279
|
+
),
|
|
280
|
+
highlight_authors: bool = typer.Option(
|
|
281
|
+
False, "--highlight-authors", help="Assign colors to different authors"
|
|
282
|
+
),
|
|
283
|
+
highlight_distance_from: Optional[str] = typer.Option(
|
|
284
|
+
None,
|
|
285
|
+
"--highlight-distance-from",
|
|
286
|
+
help="Base branch/hash for distance highlighting",
|
|
287
|
+
),
|
|
288
|
+
highlight_path: Optional[str] = typer.Option(
|
|
289
|
+
None,
|
|
290
|
+
"--highlight-path",
|
|
291
|
+
help="Highlight path between two SHAs (START..END)",
|
|
292
|
+
),
|
|
293
|
+
highlight_diverging_from: Optional[str] = typer.Option(
|
|
294
|
+
None,
|
|
295
|
+
"--highlight-diverging-from",
|
|
296
|
+
help="Base branch/hash for divergence/behind analysis",
|
|
297
|
+
),
|
|
298
|
+
highlight_orphans: bool = typer.Option(
|
|
299
|
+
False, "--highlight-orphans", help="Highlight dangling/orphan commits"
|
|
300
|
+
),
|
|
301
|
+
highlight_stale: Optional[int] = typer.Option(
|
|
302
|
+
None,
|
|
303
|
+
"--highlight-stale",
|
|
304
|
+
help="Threshold in days to highlight stale branch tips",
|
|
305
|
+
),
|
|
306
|
+
highlight_long_running: Optional[int] = typer.Option(
|
|
307
|
+
None,
|
|
308
|
+
"--highlight-long-running",
|
|
309
|
+
help="Threshold in days to highlight long-running branches",
|
|
310
|
+
),
|
|
311
|
+
long_running_base: str = typer.Option(
|
|
312
|
+
"main", "--long-running-base", help="Base branch for long-running analysis"
|
|
313
|
+
),
|
|
314
|
+
bare: bool = typer.Option(
|
|
315
|
+
False, "--bare", help="Force bare mode (no rich output)"
|
|
316
|
+
),
|
|
317
|
+
):
|
|
318
|
+
"""Git graph to Mermaid/Graphviz/D2/PlantUML converter."""
|
|
319
|
+
error = validate_highlights(
|
|
320
|
+
highlight_authors, highlight_distance_from, highlight_stale
|
|
321
|
+
)
|
|
322
|
+
if error:
|
|
323
|
+
typer.secho(error, fg=typer.colors.RED, err=True)
|
|
324
|
+
raise typer.Exit(code=1)
|
|
325
|
+
|
|
326
|
+
path_tuple = None
|
|
327
|
+
if highlight_path and ".." in highlight_path:
|
|
328
|
+
parts = highlight_path.split("..")
|
|
329
|
+
path_tuple = (parts[0], parts[1])
|
|
330
|
+
|
|
331
|
+
config = GitLogConfig(
|
|
332
|
+
simplify=simplify,
|
|
333
|
+
limit=limit,
|
|
334
|
+
date_format=date_format,
|
|
335
|
+
highlight_critical=highlight_critical,
|
|
336
|
+
highlight_authors=highlight_authors,
|
|
337
|
+
highlight_distance_from=highlight_distance_from,
|
|
338
|
+
highlight_path=path_tuple,
|
|
339
|
+
highlight_diverging_from=highlight_diverging_from,
|
|
340
|
+
highlight_orphans=highlight_orphans,
|
|
341
|
+
highlight_stale=highlight_stale,
|
|
342
|
+
highlight_long_running=highlight_long_running,
|
|
343
|
+
long_running_base=long_running_base,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if bare:
|
|
347
|
+
try:
|
|
348
|
+
graph = process_repo(path, config)
|
|
349
|
+
if engine == Engine.MERMAID and len(graph) > 500:
|
|
350
|
+
print(
|
|
351
|
+
f"Warning: Graph contains {len(graph)} nodes. Mermaid might exceed size limits.",
|
|
352
|
+
file=sys.stderr,
|
|
353
|
+
)
|
|
354
|
+
handle_output(graph, engine, output, config, as_image=image)
|
|
355
|
+
display_summary(generate_summary(graph), bare=True)
|
|
356
|
+
except Exception as e:
|
|
357
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
358
|
+
sys.exit(1)
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
console = Console()
|
|
362
|
+
with console.status(
|
|
363
|
+
f"[bold green]Processing repository using {engine.value} engine..."
|
|
364
|
+
):
|
|
365
|
+
try:
|
|
366
|
+
graph = process_repo(path, config)
|
|
367
|
+
|
|
368
|
+
if engine == Engine.MERMAID and len(graph) > 500:
|
|
369
|
+
console.print(
|
|
370
|
+
f"[bold yellow]Warning:[/] Graph contains {len(graph)} nodes. Mermaid might exceed size limits."
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
handle_output(graph, engine, output, config, as_image=image)
|
|
374
|
+
display_summary(generate_summary(graph), bare=False)
|
|
375
|
+
except Exception as e:
|
|
376
|
+
console.print(f"[bold red]Error:[/] {e}")
|
|
377
|
+
sys.exit(1)
|
|
378
|
+
else:
|
|
379
|
+
app = None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def main():
|
|
383
|
+
# If forced bare OR if typer/rich are missing, use bare CLI
|
|
384
|
+
if "--bare" in sys.argv or not HAS_CLI_EXTRAS:
|
|
385
|
+
# Just pass everything except --bare to let argparse handle it
|
|
386
|
+
bare_argv = [a for a in sys.argv[1:] if a != "--bare"]
|
|
387
|
+
run_bare_cli(bare_argv)
|
|
388
|
+
else:
|
|
389
|
+
# Default to Typer
|
|
390
|
+
if app is not None:
|
|
391
|
+
app()
|
|
392
|
+
else:
|
|
393
|
+
# Fallback if Typer is somehow missing but logic got here
|
|
394
|
+
run_bare_cli(sys.argv[1:])
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
main()
|
git_graphable/core.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import tempfile
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from graphable import Graph, Graphable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class CommitMetadata:
|
|
12
|
+
hash: str
|
|
13
|
+
parents: List[str]
|
|
14
|
+
branches: List[str] = field(default_factory=list)
|
|
15
|
+
tags: List[str] = field(default_factory=list)
|
|
16
|
+
timestamp: int = 0
|
|
17
|
+
author: str = ""
|
|
18
|
+
message: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class GitLogConfig:
|
|
23
|
+
"""Configuration for retrieving git log."""
|
|
24
|
+
|
|
25
|
+
simplify: bool = False
|
|
26
|
+
limit: Optional[int] = None
|
|
27
|
+
date_format: str = "%Y%m%d%H%M%S"
|
|
28
|
+
highlight_critical: List[str] = field(default_factory=list)
|
|
29
|
+
highlight_authors: bool = False
|
|
30
|
+
highlight_distance_from: Optional[str] = None # For distance highlighting
|
|
31
|
+
highlight_path: Optional[Tuple[str, str]] = None # (start_input, end_input)
|
|
32
|
+
highlight_diverging_from: Optional[str] = None # For divergence analysis
|
|
33
|
+
highlight_orphans: bool = False
|
|
34
|
+
highlight_stale: Optional[int] = None # Days
|
|
35
|
+
highlight_long_running: Optional[int] = None # Days
|
|
36
|
+
long_running_base: str = "main"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GitCommit(Graphable[CommitMetadata]):
|
|
40
|
+
"""A Git commit represented as a graphable object."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, metadata: CommitMetadata, config: GitLogConfig):
|
|
43
|
+
super().__init__(metadata)
|
|
44
|
+
from .models import Tag
|
|
45
|
+
|
|
46
|
+
# Add metadata as tags for filtering/formatting
|
|
47
|
+
self.add_tag(f"{Tag.AUTHOR.value}{metadata.author}")
|
|
48
|
+
for branch in metadata.branches:
|
|
49
|
+
self.add_tag(f"{Tag.BRANCH.value}{branch}")
|
|
50
|
+
if branch in config.highlight_critical:
|
|
51
|
+
self.add_tag(Tag.CRITICAL.value)
|
|
52
|
+
|
|
53
|
+
for tag in metadata.tags:
|
|
54
|
+
self.add_tag(f"{Tag.TAG.value}{tag}")
|
|
55
|
+
|
|
56
|
+
self.add_tag(Tag.GIT_COMMIT.value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def generate_summary(graph: Graph[GitCommit]) -> Dict[str, List[GitCommit]]:
|
|
60
|
+
"""Generate a summary of flagged commits."""
|
|
61
|
+
from .models import Tag
|
|
62
|
+
|
|
63
|
+
summary = {
|
|
64
|
+
"Critical": [],
|
|
65
|
+
"Behind Base": [],
|
|
66
|
+
"Orphan": [],
|
|
67
|
+
"Stale": [],
|
|
68
|
+
"Long-Running": [],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for commit in graph:
|
|
72
|
+
if commit.is_tagged(Tag.CRITICAL.value):
|
|
73
|
+
summary["Critical"].append(commit)
|
|
74
|
+
if commit.is_tagged(Tag.BEHIND.value):
|
|
75
|
+
summary["Behind Base"].append(commit)
|
|
76
|
+
if commit.is_tagged(Tag.ORPHAN.value):
|
|
77
|
+
summary["Orphan"].append(commit)
|
|
78
|
+
if commit.is_tagged(Tag.STALE_COLOR.value):
|
|
79
|
+
summary["Stale"].append(commit)
|
|
80
|
+
if commit.is_tagged(Tag.LONG_RUNNING.value):
|
|
81
|
+
# Only count branch tips for long-running summary to avoid redundancy
|
|
82
|
+
if commit.reference.branches:
|
|
83
|
+
summary["Long-Running"].append(commit)
|
|
84
|
+
|
|
85
|
+
return summary
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def process_repo(input_path: str, config: GitLogConfig) -> Graph[GitCommit]:
|
|
89
|
+
"""Clones (if URL) and processes the repo, returning a Graph of GitCommits."""
|
|
90
|
+
from .highlighter import apply_highlights
|
|
91
|
+
from .parser import get_git_log
|
|
92
|
+
|
|
93
|
+
repo_path = input_path
|
|
94
|
+
temp_dir = None
|
|
95
|
+
|
|
96
|
+
if input_path.startswith(("http://", "https://", "git@", "ssh://")):
|
|
97
|
+
temp_dir = tempfile.mkdtemp()
|
|
98
|
+
try:
|
|
99
|
+
import subprocess
|
|
100
|
+
|
|
101
|
+
subprocess.run(
|
|
102
|
+
["git", "clone", input_path, temp_dir], check=True, capture_output=True
|
|
103
|
+
)
|
|
104
|
+
repo_path = temp_dir
|
|
105
|
+
except Exception as e:
|
|
106
|
+
if temp_dir:
|
|
107
|
+
shutil.rmtree(temp_dir)
|
|
108
|
+
raise RuntimeError(f"Failed to clone repository: {e}")
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
if not os.path.exists(os.path.join(repo_path, ".git")):
|
|
112
|
+
raise RuntimeError(f"{repo_path} is not a git repository.")
|
|
113
|
+
|
|
114
|
+
commits_dict = get_git_log(repo_path, config=config)
|
|
115
|
+
|
|
116
|
+
for sha, commit in commits_dict.items():
|
|
117
|
+
for p_sha in commit.reference.parents:
|
|
118
|
+
if p_sha in commits_dict:
|
|
119
|
+
commit.requires(commits_dict[p_sha])
|
|
120
|
+
|
|
121
|
+
graph = Graph(list(commits_dict.values()))
|
|
122
|
+
apply_highlights(graph, config)
|
|
123
|
+
return graph
|
|
124
|
+
|
|
125
|
+
finally:
|
|
126
|
+
if temp_dir and os.path.exists(temp_dir):
|
|
127
|
+
shutil.rmtree(temp_dir)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from graphable import Graph
|
|
5
|
+
|
|
6
|
+
from .core import GitCommit, GitLogConfig
|
|
7
|
+
from .models import Tag
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def apply_highlights(graph: Graph[GitCommit], config: GitLogConfig):
|
|
11
|
+
"""Apply highlighting tags based on configuration."""
|
|
12
|
+
|
|
13
|
+
# 1. Author highlighting
|
|
14
|
+
if config.highlight_authors:
|
|
15
|
+
authors = sorted(list(set(c.reference.author for c in graph)))
|
|
16
|
+
palette = [
|
|
17
|
+
"#FFD700",
|
|
18
|
+
"#C0C0C0",
|
|
19
|
+
"#CD7F32",
|
|
20
|
+
"#ADD8E6",
|
|
21
|
+
"#90EE90",
|
|
22
|
+
"#F08080",
|
|
23
|
+
"#E6E6FA",
|
|
24
|
+
"#FFE4E1",
|
|
25
|
+
]
|
|
26
|
+
author_to_color = {
|
|
27
|
+
author: palette[i % len(palette)] for i, author in enumerate(authors)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for commit in graph:
|
|
31
|
+
color = author_to_color.get(commit.reference.author)
|
|
32
|
+
if color:
|
|
33
|
+
commit.add_tag(f"{Tag.COLOR.value}{color}")
|
|
34
|
+
|
|
35
|
+
# 2. Distance highlighting
|
|
36
|
+
if config.highlight_distance_from:
|
|
37
|
+
|
|
38
|
+
def find_base_node(query: str) -> Optional[GitCommit]:
|
|
39
|
+
for commit in graph:
|
|
40
|
+
if (
|
|
41
|
+
query in commit.reference.branches
|
|
42
|
+
or commit.reference.hash.startswith(query)
|
|
43
|
+
):
|
|
44
|
+
return commit
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
base_commit = find_base_node(config.highlight_distance_from)
|
|
48
|
+
|
|
49
|
+
if base_commit:
|
|
50
|
+
distances = {base_commit: 0}
|
|
51
|
+
queue = [(base_commit, 0)]
|
|
52
|
+
visited = {base_commit}
|
|
53
|
+
|
|
54
|
+
while queue:
|
|
55
|
+
current, dist = queue.pop(0)
|
|
56
|
+
for parent, _ in graph.internal_depends_on(current):
|
|
57
|
+
if parent not in visited:
|
|
58
|
+
visited.add(parent)
|
|
59
|
+
distances[parent] = dist + 1
|
|
60
|
+
queue.append((parent, dist + 1))
|
|
61
|
+
for child, _ in graph.internal_dependents(current):
|
|
62
|
+
if child not in visited:
|
|
63
|
+
visited.add(child)
|
|
64
|
+
distances[child] = dist + 1
|
|
65
|
+
queue.append((child, dist + 1))
|
|
66
|
+
|
|
67
|
+
if distances:
|
|
68
|
+
max_dist = max(distances.values())
|
|
69
|
+
for commit, dist in distances.items():
|
|
70
|
+
intensity = int(230 * (dist / max_dist)) if max_dist > 0 else 0
|
|
71
|
+
color = f"#{intensity:02x}{intensity:02x}ff"
|
|
72
|
+
commit.add_tag(f"{Tag.DISTANCE_COLOR.value}{color}")
|
|
73
|
+
if not config.highlight_authors and not config.highlight_stale:
|
|
74
|
+
commit.add_tag(f"{Tag.COLOR.value}{color}")
|
|
75
|
+
|
|
76
|
+
# 3. Path highlighting
|
|
77
|
+
if config.highlight_path:
|
|
78
|
+
start_input, end_input = config.highlight_path
|
|
79
|
+
|
|
80
|
+
def find_node(query: str) -> Optional[GitCommit]:
|
|
81
|
+
for commit in graph:
|
|
82
|
+
if (
|
|
83
|
+
query in commit.reference.branches
|
|
84
|
+
or commit.reference.hash.startswith(query)
|
|
85
|
+
):
|
|
86
|
+
return commit
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
start_node = find_node(start_input)
|
|
90
|
+
end_node = find_node(end_input)
|
|
91
|
+
|
|
92
|
+
if start_node and end_node:
|
|
93
|
+
path_graph = graph.subgraph_between(start_node, end_node)
|
|
94
|
+
path_nodes = set(path_graph)
|
|
95
|
+
for commit in path_nodes:
|
|
96
|
+
# Tag edges that connect nodes within this path
|
|
97
|
+
for parent, _ in graph.internal_depends_on(commit):
|
|
98
|
+
if parent in path_nodes:
|
|
99
|
+
commit.set_edge_attribute(parent, Tag.EDGE_PATH.value, True)
|
|
100
|
+
|
|
101
|
+
# 4. Divergence highlighting
|
|
102
|
+
if config.highlight_diverging_from:
|
|
103
|
+
|
|
104
|
+
def find_divergence_node(query: str) -> Optional[GitCommit]:
|
|
105
|
+
for commit in graph:
|
|
106
|
+
if (
|
|
107
|
+
query in commit.reference.branches
|
|
108
|
+
or commit.reference.hash.startswith(query)
|
|
109
|
+
):
|
|
110
|
+
return commit
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
base_node = find_divergence_node(config.highlight_diverging_from)
|
|
114
|
+
|
|
115
|
+
if base_node:
|
|
116
|
+
base_reach = set(graph.ancestors(base_node))
|
|
117
|
+
base_reach.add(base_node)
|
|
118
|
+
other_reach = set()
|
|
119
|
+
for commit in graph:
|
|
120
|
+
if (
|
|
121
|
+
commit.reference.branches
|
|
122
|
+
and config.highlight_diverging_from not in commit.reference.branches
|
|
123
|
+
):
|
|
124
|
+
other_reach.update(graph.ancestors(commit))
|
|
125
|
+
other_reach.add(commit)
|
|
126
|
+
|
|
127
|
+
behind_commits = base_reach - other_reach
|
|
128
|
+
for commit in behind_commits:
|
|
129
|
+
commit.add_tag(Tag.BEHIND.value)
|
|
130
|
+
|
|
131
|
+
# 5. Orphan highlighting
|
|
132
|
+
if config.highlight_orphans:
|
|
133
|
+
branch_reachable = set()
|
|
134
|
+
for commit in graph:
|
|
135
|
+
if commit.reference.branches:
|
|
136
|
+
branch_reachable.update(graph.ancestors(commit))
|
|
137
|
+
branch_reachable.add(commit)
|
|
138
|
+
|
|
139
|
+
for commit in graph:
|
|
140
|
+
if commit not in branch_reachable:
|
|
141
|
+
commit.add_tag(Tag.ORPHAN.value)
|
|
142
|
+
|
|
143
|
+
# 6. Stale branch detection
|
|
144
|
+
if config.highlight_stale:
|
|
145
|
+
now = time.time()
|
|
146
|
+
stale_threshold_sec = config.highlight_stale * 86400
|
|
147
|
+
|
|
148
|
+
for commit in graph:
|
|
149
|
+
if commit.reference.branches:
|
|
150
|
+
age_sec = now - commit.reference.timestamp
|
|
151
|
+
if age_sec > 0:
|
|
152
|
+
ratio = min(age_sec / stale_threshold_sec, 1.0)
|
|
153
|
+
gb_value = int(255 - (ratio * 85)) # 255 -> 170
|
|
154
|
+
color = f"#ff{gb_value:02x}{gb_value:02x}"
|
|
155
|
+
commit.add_tag(f"{Tag.STALE_COLOR.value}{color}")
|
|
156
|
+
if not config.highlight_authors:
|
|
157
|
+
commit.add_tag(f"{Tag.COLOR.value}{color}")
|
|
158
|
+
|
|
159
|
+
# 7. Long-running branch detection
|
|
160
|
+
if config.highlight_long_running is not None:
|
|
161
|
+
now = time.time()
|
|
162
|
+
# Use a small negative threshold for 0 to handle near-instant commits in tests
|
|
163
|
+
threshold_sec = config.highlight_long_running * 86400
|
|
164
|
+
if config.highlight_long_running == 0:
|
|
165
|
+
threshold_sec = -1
|
|
166
|
+
|
|
167
|
+
def find_base_tip(query: str) -> Optional[GitCommit]:
|
|
168
|
+
for commit in graph:
|
|
169
|
+
if (
|
|
170
|
+
query in commit.reference.branches
|
|
171
|
+
or commit.reference.hash.startswith(query)
|
|
172
|
+
):
|
|
173
|
+
return commit
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
base_tip = find_base_tip(config.long_running_base)
|
|
177
|
+
if base_tip:
|
|
178
|
+
base_reach = set(graph.ancestors(base_tip))
|
|
179
|
+
base_reach.add(base_tip)
|
|
180
|
+
|
|
181
|
+
for tip in graph:
|
|
182
|
+
if (
|
|
183
|
+
tip.reference.branches
|
|
184
|
+
and config.long_running_base not in tip.reference.branches
|
|
185
|
+
):
|
|
186
|
+
branch_reach = set(graph.ancestors(tip))
|
|
187
|
+
branch_reach.add(tip)
|
|
188
|
+
|
|
189
|
+
unique_commits = branch_reach - base_reach
|
|
190
|
+
if unique_commits:
|
|
191
|
+
oldest_unique = min(
|
|
192
|
+
unique_commits, key=lambda c: c.reference.timestamp
|
|
193
|
+
)
|
|
194
|
+
age_sec = now - oldest_unique.reference.timestamp
|
|
195
|
+
|
|
196
|
+
if age_sec > threshold_sec:
|
|
197
|
+
for commit in unique_commits:
|
|
198
|
+
commit.add_tag(Tag.LONG_RUNNING.value)
|
|
199
|
+
for parent, _ in graph.internal_depends_on(commit):
|
|
200
|
+
if parent in unique_commits or parent in base_reach:
|
|
201
|
+
commit.set_edge_attribute(
|
|
202
|
+
parent, Tag.EDGE_LONG_RUNNING.value, True
|
|
203
|
+
)
|
git_graphable/models.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Tag(str, Enum):
|
|
5
|
+
GIT_COMMIT = "git_commit"
|
|
6
|
+
CRITICAL = "critical"
|
|
7
|
+
BEHIND = "behind"
|
|
8
|
+
ORPHAN = "orphan"
|
|
9
|
+
LONG_RUNNING = "long_running"
|
|
10
|
+
PATH_HIGHLIGHT = "path_highlight"
|
|
11
|
+
|
|
12
|
+
# Prefix tags
|
|
13
|
+
AUTHOR = "author:"
|
|
14
|
+
BRANCH = "branch:"
|
|
15
|
+
TAG = "tag:"
|
|
16
|
+
COLOR = "color:"
|
|
17
|
+
DISTANCE_COLOR = "distance_color:"
|
|
18
|
+
STALE_COLOR = "stale_color:"
|
|
19
|
+
AUTHOR_HIGHLIGHT = "author_highlight:"
|
|
20
|
+
|
|
21
|
+
# Edge attributes
|
|
22
|
+
EDGE_PATH = "highlight"
|
|
23
|
+
EDGE_LONG_RUNNING = "long_running_edge"
|
git_graphable/parser.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from .core import CommitMetadata, GitCommit, GitLogConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_git_command(args: List[str], cwd: Optional[str] = None) -> str:
|
|
9
|
+
"""Run a git command and return its output."""
|
|
10
|
+
try:
|
|
11
|
+
return subprocess.check_output(
|
|
12
|
+
["git"] + args, cwd=cwd, text=True, stderr=subprocess.PIPE
|
|
13
|
+
).strip()
|
|
14
|
+
except subprocess.CalledProcessError as e:
|
|
15
|
+
print(f"Error running git command: {e.stderr}", file=sys.stderr)
|
|
16
|
+
raise
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_ref_names(ref_names: str) -> tuple[List[str], List[str]]:
|
|
20
|
+
"""Parse git log ref names (%D) into branches and tags."""
|
|
21
|
+
branches = []
|
|
22
|
+
tags = []
|
|
23
|
+
if not ref_names:
|
|
24
|
+
return branches, tags
|
|
25
|
+
|
|
26
|
+
parts = [p.strip() for p in ref_names.split(",")]
|
|
27
|
+
for part in parts:
|
|
28
|
+
if part.startswith("tag: "):
|
|
29
|
+
tags.append(part[len("tag: ") :])
|
|
30
|
+
elif "->" in part:
|
|
31
|
+
branches.append(part.split("->")[-1].strip())
|
|
32
|
+
elif part == "HEAD":
|
|
33
|
+
continue
|
|
34
|
+
else:
|
|
35
|
+
branches.append(part)
|
|
36
|
+
return branches, tags
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_git_log(repo_path: str, config: GitLogConfig) -> Dict[str, GitCommit]:
|
|
40
|
+
"""Retrieve git log and parse into GitCommit objects."""
|
|
41
|
+
# Format: hash|parents|refs|timestamp|author|message
|
|
42
|
+
format_str = "%H|%P|%D|%at|%an|%s"
|
|
43
|
+
args = ["log", "--all", f"--format={format_str}"]
|
|
44
|
+
if config.simplify:
|
|
45
|
+
args.append("--simplify-by-decoration")
|
|
46
|
+
if config.limit:
|
|
47
|
+
args.append(f"-n {config.limit}")
|
|
48
|
+
|
|
49
|
+
output = run_git_command(args, cwd=repo_path)
|
|
50
|
+
|
|
51
|
+
commits: Dict[str, GitCommit] = {}
|
|
52
|
+
for line in output.split("\n"):
|
|
53
|
+
if not line:
|
|
54
|
+
continue
|
|
55
|
+
parts = line.split("|")
|
|
56
|
+
if len(parts) < 6:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
sha = parts[0]
|
|
60
|
+
parents = parts[1].split() if parts[1] else []
|
|
61
|
+
refs = parts[2]
|
|
62
|
+
timestamp = parts[3]
|
|
63
|
+
author = parts[4]
|
|
64
|
+
message = parts[5]
|
|
65
|
+
|
|
66
|
+
branches, tags = parse_ref_names(refs)
|
|
67
|
+
|
|
68
|
+
metadata = CommitMetadata(
|
|
69
|
+
hash=sha,
|
|
70
|
+
parents=parents,
|
|
71
|
+
branches=branches,
|
|
72
|
+
tags=tags,
|
|
73
|
+
timestamp=int(timestamp) if timestamp.isdigit() else 0,
|
|
74
|
+
author=author,
|
|
75
|
+
message=message,
|
|
76
|
+
)
|
|
77
|
+
commits[sha] = GitCommit(metadata, config)
|
|
78
|
+
|
|
79
|
+
return commits
|
git_graphable/styler.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from graphable import Graph, Graphable
|
|
5
|
+
from graphable.enums import Engine
|
|
6
|
+
from graphable.views import (
|
|
7
|
+
D2StylingConfig,
|
|
8
|
+
GraphvizStylingConfig,
|
|
9
|
+
MermaidStylingConfig,
|
|
10
|
+
PlantUmlStylingConfig,
|
|
11
|
+
export_topology_d2,
|
|
12
|
+
export_topology_d2_image,
|
|
13
|
+
export_topology_graphviz_dot,
|
|
14
|
+
export_topology_graphviz_image,
|
|
15
|
+
export_topology_mermaid_image,
|
|
16
|
+
export_topology_mermaid_mmd,
|
|
17
|
+
export_topology_plantuml,
|
|
18
|
+
export_topology_plantuml_image,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from .core import GitCommit, GitLogConfig
|
|
22
|
+
from .models import Tag
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_node_text(
|
|
26
|
+
node: GitCommit, date_format: str = "%Y%m%d%H%M%S", engine: Engine = Engine.MERMAID
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Generate node text for a commit, specialized for the visualization engine."""
|
|
29
|
+
meta = node.reference
|
|
30
|
+
|
|
31
|
+
def sanitize(s: str) -> str:
|
|
32
|
+
if engine == Engine.MERMAID:
|
|
33
|
+
for char in "[](){}|":
|
|
34
|
+
s = s.replace(char, " ")
|
|
35
|
+
return " ".join(s.split())
|
|
36
|
+
elif engine == Engine.D2:
|
|
37
|
+
return s.replace('"', '\\"')
|
|
38
|
+
return s
|
|
39
|
+
|
|
40
|
+
branches = [
|
|
41
|
+
sanitize(t[len(Tag.BRANCH.value) :])
|
|
42
|
+
for t in node.tags
|
|
43
|
+
if t.startswith(Tag.BRANCH.value)
|
|
44
|
+
]
|
|
45
|
+
tags = [
|
|
46
|
+
sanitize(t[len(Tag.TAG.value) :])
|
|
47
|
+
for t in node.tags
|
|
48
|
+
if t.startswith(Tag.TAG.value)
|
|
49
|
+
]
|
|
50
|
+
author_raw = next(
|
|
51
|
+
(
|
|
52
|
+
t[len(Tag.AUTHOR.value) :]
|
|
53
|
+
for t in node.tags
|
|
54
|
+
if t.startswith(Tag.AUTHOR.value)
|
|
55
|
+
),
|
|
56
|
+
meta.author,
|
|
57
|
+
)
|
|
58
|
+
author = sanitize(author_raw)
|
|
59
|
+
|
|
60
|
+
display_label = f"{meta.hash[:7]}"
|
|
61
|
+
|
|
62
|
+
sep = " - "
|
|
63
|
+
newline = " - "
|
|
64
|
+
if engine in [Engine.D2, Engine.GRAPHVIZ, Engine.PLANTUML]:
|
|
65
|
+
newline = "\\n"
|
|
66
|
+
|
|
67
|
+
if branches:
|
|
68
|
+
display_label += f"{sep}{', '.join(branches)}"
|
|
69
|
+
if tags:
|
|
70
|
+
display_label += f"{sep}tags: {', '.join(tags)}"
|
|
71
|
+
|
|
72
|
+
dt_str = (
|
|
73
|
+
datetime.fromtimestamp(meta.timestamp).strftime(date_format)
|
|
74
|
+
if meta.timestamp
|
|
75
|
+
else ""
|
|
76
|
+
)
|
|
77
|
+
label = f"{display_label}{newline}{author}{newline}{dt_str}"
|
|
78
|
+
|
|
79
|
+
if engine == Engine.D2:
|
|
80
|
+
return f'"{label}"'
|
|
81
|
+
|
|
82
|
+
return label
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_generic_style(node: Graphable[Any], engine: Engine) -> dict[str, str]:
|
|
86
|
+
styles = {}
|
|
87
|
+
for tag in node.tags:
|
|
88
|
+
if tag.startswith(Tag.COLOR.value):
|
|
89
|
+
color = tag.split(":", 1)[1]
|
|
90
|
+
if engine == Engine.D2:
|
|
91
|
+
styles.update(
|
|
92
|
+
{
|
|
93
|
+
"fill": color,
|
|
94
|
+
"font-color": "black"
|
|
95
|
+
if color.startswith("#F") or color.startswith("#E")
|
|
96
|
+
else "white",
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
elif engine == Engine.GRAPHVIZ:
|
|
100
|
+
styles.update({"fillcolor": color, "style": "filled"})
|
|
101
|
+
|
|
102
|
+
if node.is_tagged(Tag.CRITICAL.value):
|
|
103
|
+
if engine == Engine.D2:
|
|
104
|
+
styles["stroke"] = "red"
|
|
105
|
+
styles["stroke-width"] = "6"
|
|
106
|
+
styles["double-border"] = "true"
|
|
107
|
+
elif engine == Engine.GRAPHVIZ:
|
|
108
|
+
styles["color"] = "red"
|
|
109
|
+
styles["penwidth"] = "5"
|
|
110
|
+
styles["style"] = styles.get("style", "") + ",bold"
|
|
111
|
+
|
|
112
|
+
if node.is_tagged(Tag.BEHIND.value):
|
|
113
|
+
if engine == Engine.D2:
|
|
114
|
+
styles["stroke"] = "orange"
|
|
115
|
+
styles["stroke-dash"] = "5"
|
|
116
|
+
elif engine == Engine.GRAPHVIZ:
|
|
117
|
+
styles["color"] = "orange"
|
|
118
|
+
styles["style"] = styles.get("style", "") + ",dashed"
|
|
119
|
+
|
|
120
|
+
if node.is_tagged(Tag.ORPHAN.value):
|
|
121
|
+
if engine == Engine.D2:
|
|
122
|
+
styles["stroke"] = "grey"
|
|
123
|
+
styles["stroke-dash"] = "3"
|
|
124
|
+
styles["opacity"] = "0.6"
|
|
125
|
+
elif engine == Engine.GRAPHVIZ:
|
|
126
|
+
styles["color"] = "grey"
|
|
127
|
+
styles["style"] = styles.get("style", "") + ",dashed"
|
|
128
|
+
|
|
129
|
+
if node.is_tagged(Tag.LONG_RUNNING.value):
|
|
130
|
+
if engine == Engine.D2:
|
|
131
|
+
styles["stroke"] = "purple"
|
|
132
|
+
styles["stroke-width"] = "4"
|
|
133
|
+
elif engine == Engine.GRAPHVIZ:
|
|
134
|
+
styles["color"] = "purple"
|
|
135
|
+
styles["penwidth"] = "3"
|
|
136
|
+
|
|
137
|
+
return styles
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_generic_link_style(
|
|
141
|
+
node: Graphable[Any], subnode: Graphable[Any], engine: Engine
|
|
142
|
+
) -> dict[str, str]:
|
|
143
|
+
styles = {}
|
|
144
|
+
if node.edge_attributes(subnode).get(Tag.EDGE_PATH.value):
|
|
145
|
+
if engine == Engine.D2:
|
|
146
|
+
styles.update({"stroke": "#FFA500", "stroke-width": "6"})
|
|
147
|
+
elif engine == Engine.GRAPHVIZ:
|
|
148
|
+
styles.update({"color": "#FFA500", "penwidth": "4"})
|
|
149
|
+
elif node.edge_attributes(subnode).get(Tag.EDGE_LONG_RUNNING.value):
|
|
150
|
+
if engine == Engine.D2:
|
|
151
|
+
styles.update({"stroke": "purple", "stroke-width": "4"})
|
|
152
|
+
elif engine == Engine.GRAPHVIZ:
|
|
153
|
+
styles.update({"color": "purple", "penwidth": "3"})
|
|
154
|
+
return styles
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def export_graph(
|
|
158
|
+
graph: Graph[GitCommit],
|
|
159
|
+
output_path: str,
|
|
160
|
+
config: GitLogConfig,
|
|
161
|
+
engine: Engine = Engine.MERMAID,
|
|
162
|
+
as_image: bool = False,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Export the graph to a file using the specified engine."""
|
|
165
|
+
|
|
166
|
+
def label_fnc(n):
|
|
167
|
+
return get_node_text(n, config.date_format, engine)
|
|
168
|
+
|
|
169
|
+
def node_ref_fnc(n):
|
|
170
|
+
return n.reference.hash
|
|
171
|
+
|
|
172
|
+
if engine == Engine.MERMAID:
|
|
173
|
+
|
|
174
|
+
def mermaid_style(node: Graphable[Any]) -> Optional[str]:
|
|
175
|
+
style_parts = []
|
|
176
|
+
for tag in node.tags:
|
|
177
|
+
if tag.startswith(Tag.COLOR.value):
|
|
178
|
+
color = tag.split(":", 1)[1]
|
|
179
|
+
style_parts.append(f"fill:{color}")
|
|
180
|
+
style_parts.append(
|
|
181
|
+
"color:black"
|
|
182
|
+
if color.startswith("#F") or color.startswith("#E")
|
|
183
|
+
else "color:white"
|
|
184
|
+
)
|
|
185
|
+
if node.is_tagged(Tag.CRITICAL.value):
|
|
186
|
+
style_parts.append("stroke:red,stroke-width:4px")
|
|
187
|
+
if node.is_tagged(Tag.BEHIND.value):
|
|
188
|
+
style_parts.append(
|
|
189
|
+
"stroke:orange,stroke-width:2px,stroke-dasharray: 5 5"
|
|
190
|
+
)
|
|
191
|
+
if node.is_tagged(Tag.ORPHAN.value):
|
|
192
|
+
style_parts.append(
|
|
193
|
+
"stroke:grey,stroke-width:1px,stroke-dasharray: 3 3,opacity:0.5"
|
|
194
|
+
)
|
|
195
|
+
if node.is_tagged(Tag.LONG_RUNNING.value) and not node.is_tagged(
|
|
196
|
+
Tag.CRITICAL.value
|
|
197
|
+
):
|
|
198
|
+
style_parts.append("stroke:purple,stroke-width:3px")
|
|
199
|
+
return ",".join(style_parts) if style_parts else None
|
|
200
|
+
|
|
201
|
+
def mermaid_link_style(
|
|
202
|
+
node: Graphable[Any], subnode: Graphable[Any]
|
|
203
|
+
) -> Optional[str]:
|
|
204
|
+
attrs = node.edge_attributes(subnode)
|
|
205
|
+
if attrs.get(Tag.EDGE_PATH.value):
|
|
206
|
+
return "stroke:#FFA500,stroke-width:4px"
|
|
207
|
+
if attrs.get(Tag.EDGE_LONG_RUNNING.value):
|
|
208
|
+
return "stroke:purple,stroke-width:3px"
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
styling_config = MermaidStylingConfig(
|
|
212
|
+
node_ref_fnc=node_ref_fnc,
|
|
213
|
+
node_text_fnc=label_fnc,
|
|
214
|
+
node_style_fnc=mermaid_style,
|
|
215
|
+
link_style_fnc=mermaid_link_style,
|
|
216
|
+
)
|
|
217
|
+
fnc = export_topology_mermaid_image if as_image else export_topology_mermaid_mmd
|
|
218
|
+
graph.export(fnc, output_path, config=styling_config)
|
|
219
|
+
elif engine == Engine.GRAPHVIZ:
|
|
220
|
+
styling_config = GraphvizStylingConfig(
|
|
221
|
+
node_ref_fnc=node_ref_fnc,
|
|
222
|
+
node_label_fnc=label_fnc,
|
|
223
|
+
node_attr_fnc=lambda n: get_generic_style(n, Engine.GRAPHVIZ),
|
|
224
|
+
edge_attr_fnc=lambda n, sn: get_generic_link_style(n, sn, Engine.GRAPHVIZ),
|
|
225
|
+
)
|
|
226
|
+
fnc = (
|
|
227
|
+
export_topology_graphviz_image if as_image else export_topology_graphviz_dot
|
|
228
|
+
)
|
|
229
|
+
graph.export(fnc, output_path, config=styling_config)
|
|
230
|
+
elif engine == Engine.D2:
|
|
231
|
+
styling_config = D2StylingConfig(
|
|
232
|
+
node_ref_fnc=node_ref_fnc,
|
|
233
|
+
node_label_fnc=label_fnc,
|
|
234
|
+
node_style_fnc=lambda n: get_generic_style(n, Engine.D2),
|
|
235
|
+
edge_style_fnc=lambda n, sn: get_generic_link_style(n, sn, Engine.D2),
|
|
236
|
+
)
|
|
237
|
+
fnc = export_topology_d2_image if as_image else export_topology_d2
|
|
238
|
+
graph.export(fnc, output_path, config=styling_config)
|
|
239
|
+
elif engine == Engine.PLANTUML:
|
|
240
|
+
styling_config = PlantUmlStylingConfig(
|
|
241
|
+
node_ref_fnc=node_ref_fnc, node_label_fnc=label_fnc
|
|
242
|
+
)
|
|
243
|
+
fnc = export_topology_plantuml_image if as_image else export_topology_plantuml
|
|
244
|
+
graph.export(fnc, output_path, config=styling_config)
|
|
245
|
+
else:
|
|
246
|
+
graph.write(output_path)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-graphable
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A powerful tool to convert Git commit history into beautiful flowcharts.
|
|
5
|
+
Project-URL: Homepage, https://github.com/TheTrueSCU/git-graphable
|
|
6
|
+
Project-URL: Issues, https://github.com/TheTrueSCU/git-graphable/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/TheTrueSCU/git-graphable
|
|
8
|
+
Author-email: Richard West <dopplereffect.us@gmail.com>
|
|
9
|
+
Keywords: analysis,d2,git,graph,hygiene,mermaid,topology,visualization
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Requires-Dist: graphable>=0.6.0
|
|
12
|
+
Provides-Extra: cli
|
|
13
|
+
Requires-Dist: rich>=13.0.0; extra == 'cli'
|
|
14
|
+
Requires-Dist: typer>=0.12.0; extra == 'cli'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Git Graphable
|
|
18
|
+
|
|
19
|
+
A powerful Python tool to convert Git commit history into beautiful, interactive flowcharts using the `graphable` library. Supporting Mermaid, D2, Graphviz, and PlantUML.
|
|
20
|
+
|
|
21
|
+
## Git Plugin Support
|
|
22
|
+
When installed in your PATH, you can use this as a native Git plugin:
|
|
23
|
+
```bash
|
|
24
|
+
git graphable .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Multi-Engine Support**: Export to Mermaid (.mmd), D2 (.d2), Graphviz (.dot), or PlantUML (.puml).
|
|
30
|
+
- **Automatic Visualization**: Generates and opens an image (SVG/PNG) automatically if no output is specified.
|
|
31
|
+
- **Advanced Highlighting**: Visualize author patterns, topological distance, and specific merge paths.
|
|
32
|
+
- **Hygiene Analysis**: Identify commits that are "behind" a base branch with divergence analysis.
|
|
33
|
+
- **Flexible Input**: Works with local repository paths or remote Git URLs.
|
|
34
|
+
- **Dual CLI**: Modern Rich/Typer interface with a robust argparse fallback for bare environments.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Using uv (recommended)
|
|
40
|
+
uv sync --all-extras
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
For a complete reference of all command-line options, see the [USAGE.md](USAGE.md) file.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Basic usage (opens a Mermaid image)
|
|
49
|
+
uv run git-graphable .
|
|
50
|
+
|
|
51
|
+
# Specify an engine and output file
|
|
52
|
+
uv run git-graphable https://github.com/TheTrueSCU/graphable/ --engine d2 -o graph.svg
|
|
53
|
+
|
|
54
|
+
# Simplify the graph (only show branches/tags)
|
|
55
|
+
uv run git-graphable . --simplify
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Highlighting Options
|
|
59
|
+
|
|
60
|
+
Git Graphable provides several ways to highlight commits and relationships. Multiple options can be combined to layer information.
|
|
61
|
+
|
|
62
|
+
| Option | Target | Effect | Conflicts With |
|
|
63
|
+
| :--- | :--- | :--- | :--- |
|
|
64
|
+
| `--highlight-authors` | **Fill** | Unique color per author | Distance, Stale |
|
|
65
|
+
| `--highlight-distance-from` | **Fill** | Blue gradient fading by distance | Authors, Stale |
|
|
66
|
+
| `--highlight-stale` | **Fill** | Gradient white to red by age | Authors, Distance |
|
|
67
|
+
| `--highlight-path` | **Edge** | Thick Orange edge connecting nodes | None |
|
|
68
|
+
| `--highlight-critical` | **Stroke** | Thick Red Solid outline | None |
|
|
69
|
+
| `--highlight-diverging-from` | **Stroke** | Orange Dashed outline | None |
|
|
70
|
+
| `--highlight-orphans` | **Stroke** | Grey Dashed outline | None |
|
|
71
|
+
| `--highlight-long-running` | **Stroke/Edge** | Purple outline and thick Purple edge | None |
|
|
72
|
+
|
|
73
|
+
### Highlighting Priorities
|
|
74
|
+
- **Fill**: `--highlight-authors`, `--highlight-distance-from`, and `--highlight-stale` are mutually exclusive.
|
|
75
|
+
- **Edge**: Path highlighting (Thick Orange) takes priority over Long-Running highlighting (Thick Purple).
|
|
76
|
+
- **Stroke**: Critical outlines (Thick Red) take priority over all other outlines (Divergence, Orphan, Long-Running).
|
|
77
|
+
|
|
78
|
+
## Advanced Examples
|
|
79
|
+
|
|
80
|
+
### Divergence Analysis (Hygiene)
|
|
81
|
+
Highlight commits that exist in `main` but are missing from your feature branches:
|
|
82
|
+
```bash
|
|
83
|
+
uv run git-graphable . --highlight-diverging-from main
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Path Highlighting
|
|
87
|
+
See the exact sequence of commits between a feature branch and a specific tag:
|
|
88
|
+
```bash
|
|
89
|
+
uv run git-graphable . --highlight-path develop..v1.0.0
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Large Repositories
|
|
93
|
+
For repositories with long histories, use the `--limit` flag to keep the graph readable and avoid engine rendering limits:
|
|
94
|
+
```bash
|
|
95
|
+
uv run git-graphable . --limit 100 --highlight-authors
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
Run tests and linting:
|
|
101
|
+
```bash
|
|
102
|
+
just check
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### CI/CD
|
|
106
|
+
This project uses GitHub Actions for continuous integration and automated publishing:
|
|
107
|
+
- **CI**: Runs `just check` on all pushes and PRs to `main`.
|
|
108
|
+
- **Publish**: Automatically builds and publishes to PyPI when a version tag (`v*`) is pushed.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
git_graphable/__init__.py,sha256=6LX5QNSP5_QZnebjIPFZK9uR_flXuB9eP_JzDPFnwXw,337
|
|
2
|
+
git_graphable/cli.py,sha256=H7vRPQhIgRVMvHJzq33PzCuWxVoDXQtjd59U0XupqSs,13929
|
|
3
|
+
git_graphable/core.py,sha256=KPA5mYQoNq5yH_FkvuxAdJpK_4a464GSuyo3NhHCBqc,4150
|
|
4
|
+
git_graphable/highlighter.py,sha256=0XP8204XShcXhflapUOK9yGoUKbJvV-ZCv0QWSz0Tdo,7761
|
|
5
|
+
git_graphable/models.py,sha256=fUPdXylbMY2IbLokudocL60d-Ct9iXnGSJYyG7flD2o,531
|
|
6
|
+
git_graphable/parser.py,sha256=rbS0n0ZGCKboEV8YB1FgDN2BbADmB_pWN0puOhPMaVU,2367
|
|
7
|
+
git_graphable/styler.py,sha256=crt2ow1YIyaoknOTHvp6AeVSBT5NxL3BsmfnNSRDsso,8582
|
|
8
|
+
git_graphable-0.1.0.dist-info/METADATA,sha256=o8TE2uWzC7Ku7-TSWoFIjr0lX6NrE3U4nvjghdSJjb0,4210
|
|
9
|
+
git_graphable-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
git_graphable-0.1.0.dist-info/entry_points.txt,sha256=zQ1J0MYhxnambuj__xHwpr_KBYH-GYxumYkuntJw6WU,57
|
|
11
|
+
git_graphable-0.1.0.dist-info/RECORD,,
|