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.
@@ -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
+ )
@@ -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"
@@ -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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git-graphable = git_graphable.cli:main