git-trace 1.0.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_trace/__init__.py +6 -0
- git_trace/__main__.py +3 -0
- git_trace/analysis.py +183 -0
- git_trace/git.py +54 -0
- git_trace/input/__init__.py +0 -0
- git_trace/input/args.py +151 -0
- git_trace/input/parser.py +138 -0
- git_trace/main.py +115 -0
- git_trace/output/__init__.py +0 -0
- git_trace/output/assets/analysis_controls.html +116 -0
- git_trace/output/assets/analysis_legend.html +27 -0
- git_trace/output/assets/pick_controls.html +137 -0
- git_trace/output/assets/pick_legend.html +32 -0
- git_trace/output/assets/styling.html +5 -0
- git_trace/output/graph.py +198 -0
- git_trace/output/html_injection.py +34 -0
- git_trace/output/text.py +77 -0
- git_trace/utils.py +84 -0
- git_trace/version.py +2 -0
- git_trace-1.0.0.dist-info/METADATA +221 -0
- git_trace-1.0.0.dist-info/RECORD +25 -0
- git_trace-1.0.0.dist-info/WHEEL +5 -0
- git_trace-1.0.0.dist-info/entry_points.txt +2 -0
- git_trace-1.0.0.dist-info/licenses/LICENSE +21 -0
- git_trace-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
#gt-controls {
|
|
3
|
+
position: fixed;
|
|
4
|
+
top: 16px;
|
|
5
|
+
left: 50%;
|
|
6
|
+
transform: translateX(-50%);
|
|
7
|
+
z-index: 9999;
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
gap: 10px;
|
|
11
|
+
background: rgba(30,30,46,0.92);
|
|
12
|
+
border: 1px solid #555;
|
|
13
|
+
border-radius: 8px;
|
|
14
|
+
padding: 8px 14px;
|
|
15
|
+
box-shadow: 0 2px 12px rgba(0,0,0,0.5);
|
|
16
|
+
font-family: monospace;
|
|
17
|
+
font-size: 13px;
|
|
18
|
+
color: #eee;
|
|
19
|
+
}
|
|
20
|
+
#gt-search {
|
|
21
|
+
background: #2a2a3e;
|
|
22
|
+
border: 1px solid #666;
|
|
23
|
+
border-radius: 4px;
|
|
24
|
+
color: #eee;
|
|
25
|
+
padding: 4px 8px;
|
|
26
|
+
width: 220px;
|
|
27
|
+
font-size: 13px;
|
|
28
|
+
outline: none;
|
|
29
|
+
}
|
|
30
|
+
#gt-search:focus { border-color: #87ceeb; }
|
|
31
|
+
#gt-search::placeholder { color: #888; }
|
|
32
|
+
#gt-match-count { color: #aaa; min-width: 60px; }
|
|
33
|
+
#gt-hide-isolated {
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
background: #2a2a3e;
|
|
36
|
+
border: 1px solid #666;
|
|
37
|
+
border-radius: 4px;
|
|
38
|
+
color: #eee;
|
|
39
|
+
padding: 4px 10px;
|
|
40
|
+
font-size: 12px;
|
|
41
|
+
white-space: nowrap;
|
|
42
|
+
}
|
|
43
|
+
#gt-hide-isolated:hover { border-color: #87ceeb; }
|
|
44
|
+
#gt-hide-isolated.active { background: #4682b4; border-color: #87ceeb; }
|
|
45
|
+
</style>
|
|
46
|
+
|
|
47
|
+
<div id="gt-controls">
|
|
48
|
+
<input id="gt-search" type="text" placeholder="Search commits…" autocomplete="off" />
|
|
49
|
+
<span id="gt-match-count"></span>
|
|
50
|
+
<button id="gt-hide-isolated">Hide isolated</button>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<script>
|
|
54
|
+
(function () {
|
|
55
|
+
function waitForNetwork(cb) {
|
|
56
|
+
var t = setInterval(function () {
|
|
57
|
+
if (typeof network !== "undefined") { clearInterval(t); cb(); }
|
|
58
|
+
}, 100);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
waitForNetwork(function () {
|
|
62
|
+
var allNodes = network.body.data.nodes;
|
|
63
|
+
var allEdges = network.body.data.edges;
|
|
64
|
+
var searchInput = document.getElementById("gt-search");
|
|
65
|
+
var matchCount = document.getElementById("gt-match-count");
|
|
66
|
+
var hideBtn = document.getElementById("gt-hide-isolated");
|
|
67
|
+
var hideIsolated = false;
|
|
68
|
+
|
|
69
|
+
function connectedIds() {
|
|
70
|
+
var ids = new Set();
|
|
71
|
+
allEdges.getIds().forEach(function (eid) {
|
|
72
|
+
var e = allEdges.get(eid);
|
|
73
|
+
ids.add(e.from); ids.add(e.to);
|
|
74
|
+
});
|
|
75
|
+
return ids;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function applyFilters() {
|
|
79
|
+
var query = searchInput.value.trim().toLowerCase();
|
|
80
|
+
var connected = hideIsolated ? connectedIds() : null;
|
|
81
|
+
var updates = [];
|
|
82
|
+
var matchTotal = 0;
|
|
83
|
+
|
|
84
|
+
allNodes.getIds().forEach(function (nid) {
|
|
85
|
+
var node = allNodes.get(nid);
|
|
86
|
+
var label = (node.label || "").toLowerCase();
|
|
87
|
+
var title = (typeof node.title === "string" ? node.title : "").toLowerCase();
|
|
88
|
+
var hidden = false;
|
|
89
|
+
|
|
90
|
+
if (hideIsolated && connected && !connected.has(nid)) { hidden = true; }
|
|
91
|
+
|
|
92
|
+
if (!hidden && query) {
|
|
93
|
+
var matches = label.includes(query) || title.includes(query);
|
|
94
|
+
if (!matches) { hidden = true; } else { matchTotal++; }
|
|
95
|
+
} else if (!hidden) {
|
|
96
|
+
matchTotal++;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
updates.push({ id: nid, hidden: hidden });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
allNodes.update(updates);
|
|
103
|
+
matchCount.textContent = query ? matchTotal + " found" : "";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
searchInput.addEventListener("input", applyFilters);
|
|
107
|
+
|
|
108
|
+
hideBtn.addEventListener("click", function () {
|
|
109
|
+
hideIsolated = !hideIsolated;
|
|
110
|
+
hideBtn.classList.toggle("active", hideIsolated);
|
|
111
|
+
hideBtn.textContent = hideIsolated ? "Show isolated" : "Hide isolated";
|
|
112
|
+
applyFilters();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
})();
|
|
116
|
+
</script>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
#gt-legend {
|
|
3
|
+
position: fixed;
|
|
4
|
+
top: 16px;
|
|
5
|
+
right: 16px;
|
|
6
|
+
z-index: 9999;
|
|
7
|
+
background: rgba(30,30,46,0.92);
|
|
8
|
+
border: 1px solid #555;
|
|
9
|
+
border-radius: 8px;
|
|
10
|
+
padding: 10px 14px;
|
|
11
|
+
font-family: monospace;
|
|
12
|
+
font-size: 12px;
|
|
13
|
+
color: #eee;
|
|
14
|
+
line-height: 1.8;
|
|
15
|
+
}
|
|
16
|
+
.gt-swatch {
|
|
17
|
+
display: inline-block;
|
|
18
|
+
width: 12px; height: 12px;
|
|
19
|
+
border-radius: 2px;
|
|
20
|
+
margin-right: 6px;
|
|
21
|
+
vertical-align: middle;
|
|
22
|
+
}
|
|
23
|
+
</style>
|
|
24
|
+
<div id="gt-legend">
|
|
25
|
+
<div><span class="gt-swatch" style="background:#4caf50"></span>Independent (no dependencies)</div>
|
|
26
|
+
<div><span class="gt-swatch" style="background:#546e7a"></span>Has dependencies on earlier commits</div>
|
|
27
|
+
</div>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
#gt-controls {
|
|
3
|
+
position: fixed;
|
|
4
|
+
top: 12px;
|
|
5
|
+
left: 50%;
|
|
6
|
+
transform: translateX(-50%);
|
|
7
|
+
z-index: 9999;
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
gap: 10px;
|
|
11
|
+
background: rgba(30,30,46,0.92);
|
|
12
|
+
border: 1px solid #555;
|
|
13
|
+
border-radius: 8px;
|
|
14
|
+
padding: 8px 14px;
|
|
15
|
+
box-shadow: 0 2px 12px rgba(0,0,0,0.5);
|
|
16
|
+
font-family: monospace;
|
|
17
|
+
font-size: 13px;
|
|
18
|
+
color: #eee;
|
|
19
|
+
}
|
|
20
|
+
#gt-search {
|
|
21
|
+
background: #2a2a3e;
|
|
22
|
+
border: 1px solid #666;
|
|
23
|
+
border-radius: 4px;
|
|
24
|
+
color: #eee;
|
|
25
|
+
padding: 4px 8px;
|
|
26
|
+
width: 220px;
|
|
27
|
+
font-size: 13px;
|
|
28
|
+
outline: none;
|
|
29
|
+
}
|
|
30
|
+
#gt-search:focus { border-color: #87ceeb; }
|
|
31
|
+
#gt-search::placeholder { color: #888; }
|
|
32
|
+
#gt-match-count { color: #aaa; min-width: 60px; }
|
|
33
|
+
.gt-btn {
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
background: #2a2a3e;
|
|
36
|
+
border: 1px solid #666;
|
|
37
|
+
border-radius: 4px;
|
|
38
|
+
color: #eee;
|
|
39
|
+
padding: 4px 10px;
|
|
40
|
+
font-size: 12px;
|
|
41
|
+
font-family: monospace;
|
|
42
|
+
white-space: nowrap;
|
|
43
|
+
}
|
|
44
|
+
.gt-btn:hover { border-color: #87ceeb; }
|
|
45
|
+
.gt-btn.active { background: #4682b4; border-color: #87ceeb; }
|
|
46
|
+
</style>
|
|
47
|
+
|
|
48
|
+
<div id="gt-controls">
|
|
49
|
+
<input id="gt-search" type="text" placeholder="Search commits…" autocomplete="off" />
|
|
50
|
+
<span id="gt-match-count"></span>
|
|
51
|
+
<button id="gt-hide-isolated" class="gt-btn">Hide isolated</button>
|
|
52
|
+
<button id="gt-hide-resolved" class="gt-btn">Hide safe & context</button>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<script>
|
|
56
|
+
(function () {
|
|
57
|
+
var CONTEXT_BG = "#546e7a";
|
|
58
|
+
var SAFE_BG = "#4caf50";
|
|
59
|
+
|
|
60
|
+
function waitForNetwork(cb) {
|
|
61
|
+
var t = setInterval(function () {
|
|
62
|
+
if (typeof network !== "undefined") { clearInterval(t); cb(); }
|
|
63
|
+
}, 100);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
waitForNetwork(function () {
|
|
67
|
+
var allNodes = network.body.data.nodes;
|
|
68
|
+
var allEdges = network.body.data.edges;
|
|
69
|
+
|
|
70
|
+
var searchInput = document.getElementById("gt-search");
|
|
71
|
+
var matchCount = document.getElementById("gt-match-count");
|
|
72
|
+
var btnIsolated = document.getElementById("gt-hide-isolated");
|
|
73
|
+
var btnResolved = document.getElementById("gt-hide-resolved");
|
|
74
|
+
|
|
75
|
+
var hideIsolated = false;
|
|
76
|
+
var hideResolved = false;
|
|
77
|
+
|
|
78
|
+
function connectedIds() {
|
|
79
|
+
var ids = new Set();
|
|
80
|
+
allEdges.getIds().forEach(function (eid) {
|
|
81
|
+
var e = allEdges.get(eid);
|
|
82
|
+
ids.add(e.from); ids.add(e.to);
|
|
83
|
+
});
|
|
84
|
+
return ids;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function applyFilters() {
|
|
88
|
+
var query = searchInput.value.trim().toLowerCase();
|
|
89
|
+
var connected = hideIsolated ? connectedIds() : null;
|
|
90
|
+
var updates = [];
|
|
91
|
+
var matchTotal = 0;
|
|
92
|
+
|
|
93
|
+
allNodes.getIds().forEach(function (nid) {
|
|
94
|
+
var node = allNodes.get(nid);
|
|
95
|
+
var bg = node.color && node.color.background ? node.color.background : "";
|
|
96
|
+
var label = (node.label || "").toLowerCase();
|
|
97
|
+
var title = (typeof node.title === "string" ? node.title : "").toLowerCase();
|
|
98
|
+
var hidden = false;
|
|
99
|
+
|
|
100
|
+
if (hideIsolated && connected && !connected.has(nid)) { hidden = true; }
|
|
101
|
+
if (!hidden && hideResolved && (bg === SAFE_BG || bg === CONTEXT_BG)) { hidden = true; }
|
|
102
|
+
|
|
103
|
+
if (!hidden && query) {
|
|
104
|
+
var matches = label.includes(query) || title.includes(query);
|
|
105
|
+
if (!matches) { hidden = true; } else { matchTotal++; }
|
|
106
|
+
} else if (!hidden) {
|
|
107
|
+
matchTotal++;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
updates.push({ id: nid, hidden: hidden });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
allNodes.update(updates);
|
|
114
|
+
matchCount.textContent = query ? matchTotal + " found" : "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function toggleBtn(btn, active, onLabel, offLabel) {
|
|
118
|
+
btn.classList.toggle("active", active);
|
|
119
|
+
btn.textContent = active ? onLabel : offLabel;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
searchInput.addEventListener("input", applyFilters);
|
|
123
|
+
|
|
124
|
+
btnIsolated.addEventListener("click", function () {
|
|
125
|
+
hideIsolated = !hideIsolated;
|
|
126
|
+
toggleBtn(btnIsolated, hideIsolated, "Show isolated", "Hide isolated");
|
|
127
|
+
applyFilters();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
btnResolved.addEventListener("click", function () {
|
|
131
|
+
hideResolved = !hideResolved;
|
|
132
|
+
toggleBtn(btnResolved, hideResolved, "Show safe & context", "Hide safe & context");
|
|
133
|
+
applyFilters();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
})();
|
|
137
|
+
</script>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
#gt-legend {
|
|
3
|
+
position: fixed;
|
|
4
|
+
top: 16px;
|
|
5
|
+
right: 16px;
|
|
6
|
+
z-index: 9999;
|
|
7
|
+
background: rgba(30,30,46,0.92);
|
|
8
|
+
border: 1px solid #555;
|
|
9
|
+
border-radius: 8px;
|
|
10
|
+
padding: 10px 14px;
|
|
11
|
+
font-family: monospace;
|
|
12
|
+
font-size: 12px;
|
|
13
|
+
color: #eee;
|
|
14
|
+
line-height: 1.8;
|
|
15
|
+
}
|
|
16
|
+
.gt-swatch {
|
|
17
|
+
display: inline-block;
|
|
18
|
+
width: 12px; height: 12px;
|
|
19
|
+
border-radius: 2px;
|
|
20
|
+
margin-right: 6px;
|
|
21
|
+
vertical-align: middle;
|
|
22
|
+
}
|
|
23
|
+
</style>
|
|
24
|
+
<div id="gt-legend">
|
|
25
|
+
<div><span class="gt-swatch" style="background:#4caf50"></span>Safe to pick</div>
|
|
26
|
+
<div><span class="gt-swatch" style="background:#1b7a4a"></span>Safe if parent deps fixed</div>
|
|
27
|
+
<div><span class="gt-swatch" style="background:#f44336"></span>Blocked (missing deps)</div>
|
|
28
|
+
<div><span class="gt-swatch" style="background:#ff9800"></span>Missing dependency</div>
|
|
29
|
+
<div><span class="gt-swatch" style="background:#546e7a"></span>Context (not picked)</div>
|
|
30
|
+
<div style="margin-top:6px; color:#ff4444">—— blocking edge</div>
|
|
31
|
+
<div style="color:#666">- - - regular dep edge</div>
|
|
32
|
+
</div>
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import webbrowser
|
|
5
|
+
|
|
6
|
+
from pyvis.network import Network
|
|
7
|
+
|
|
8
|
+
from git_trace.analysis import DependencyGraph, CherryPickAnalysis
|
|
9
|
+
from git_trace.git import Commit
|
|
10
|
+
from git_trace.output.html_injection import inject_analysis_controls, inject_analysis_legend, inject_pick_controls, inject_pick_legend, inject_styling
|
|
11
|
+
from git_trace.utils import SHORT_HASH_LENGTH, MAX_COMMIT_MESSAGE_LENGTH, INFO_COLOR, cprint
|
|
12
|
+
|
|
13
|
+
# TODO: make this configurable ?
|
|
14
|
+
NODE_LABEL_MAX_LENGTH: int = 36
|
|
15
|
+
NODE_HIGHLIGHT_STYLES: dict[str, str] = {"background": "#2a6496", "border": "#87ceeb"}
|
|
16
|
+
ANALYSIS_NODE_COLORS: dict[str, dict[str, str]] = {
|
|
17
|
+
"independent": {"bg": "#4caf50", "border": "#2e7d32"},
|
|
18
|
+
"has_deps": {"bg": "#546e7a", "border": "#37474f"},
|
|
19
|
+
}
|
|
20
|
+
PICK_NODE_COLORS: dict[str, dict[str, str]] = {
|
|
21
|
+
"safe": {"bg": "#4caf50", "border": "#2e7d32"},
|
|
22
|
+
"conditional": {"bg": "#1b7a4a", "border": "#0d4a2d"},
|
|
23
|
+
"blocked": {"bg": "#f44336", "border": "#b71c1c"},
|
|
24
|
+
"missing": {"bg": "#ff9800", "border": "#e65100"},
|
|
25
|
+
"context": {"bg": "#546e7a", "border": "#37474f"},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_node_color(palette_colors: dict[str, str]) -> dict:
|
|
30
|
+
return {
|
|
31
|
+
"background": palette_colors["bg"],
|
|
32
|
+
"border": palette_colors["border"],
|
|
33
|
+
"highlight": NODE_HIGHLIGHT_STYLES,
|
|
34
|
+
"hover": NODE_HIGHLIGHT_STYLES,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# TODO: make this configurable ?
|
|
39
|
+
NETWORK_OPTIONS: str = """
|
|
40
|
+
{
|
|
41
|
+
"physics": {
|
|
42
|
+
"enabled": true,
|
|
43
|
+
"solver": "repulsion",
|
|
44
|
+
"minVelocity": 0.4,
|
|
45
|
+
"repulsion": {
|
|
46
|
+
"nodeDistance": 180,
|
|
47
|
+
"springLength": 200,
|
|
48
|
+
"springConstant": 0.04,
|
|
49
|
+
"damping": 0.12
|
|
50
|
+
},
|
|
51
|
+
"stabilization": { "enabled": true, "iterations": 1000 }
|
|
52
|
+
},
|
|
53
|
+
"edges": {
|
|
54
|
+
"arrows": { "to": { "enabled": true, "scaleFactor": 1.0 } },
|
|
55
|
+
"color": { "color": "#aaaaaa", "highlight": "#ffffff" },
|
|
56
|
+
"smooth": { "type": "dynamic" }
|
|
57
|
+
},
|
|
58
|
+
"interaction": {
|
|
59
|
+
"hover": true,
|
|
60
|
+
"tooltipDelay": 100,
|
|
61
|
+
"navigationButtons": true,
|
|
62
|
+
"keyboard": true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _make_network() -> Network:
|
|
69
|
+
network: Network = Network(
|
|
70
|
+
height="98vh",
|
|
71
|
+
width="100%",
|
|
72
|
+
bgcolor="#1e1e2e",
|
|
73
|
+
font_color="#eeeeee",
|
|
74
|
+
directed=True,
|
|
75
|
+
notebook=False,
|
|
76
|
+
)
|
|
77
|
+
network.set_options(NETWORK_OPTIONS)
|
|
78
|
+
return network
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def display_graph(commits: list[Commit], commits_hash_dict: dict[str, Commit], graph: DependencyGraph, output_path: str = "output.html"):
|
|
82
|
+
network: Network = _make_network()
|
|
83
|
+
|
|
84
|
+
for commit in commits:
|
|
85
|
+
commit_hash: str = commit.hash
|
|
86
|
+
has_deps: bool = bool(graph.relationships.get(commit_hash))
|
|
87
|
+
palette: dict[str, str] = ANALYSIS_NODE_COLORS["has_deps" if has_deps else "independent"]
|
|
88
|
+
dependency_lines: list[str] = [
|
|
89
|
+
f" • [{dependency_hash[:SHORT_HASH_LENGTH]}] {commits_hash_dict[dependency_hash].message[:MAX_COMMIT_MESSAGE_LENGTH]}"
|
|
90
|
+
for dependency_hash in sorted(graph.get_dependencies_for(commit_hash))
|
|
91
|
+
]
|
|
92
|
+
tooltip: str = "\n".join(
|
|
93
|
+
[f"[{commit_hash[:SHORT_HASH_LENGTH]}] {commit.message}"]
|
|
94
|
+
+ (["\nDepends on:"] + dependency_lines if dependency_lines else [])
|
|
95
|
+
)
|
|
96
|
+
network.add_node(
|
|
97
|
+
commit_hash,
|
|
98
|
+
label=f"{truncate_label(commit.message)}\n{commit_hash[:SHORT_HASH_LENGTH]}",
|
|
99
|
+
title=tooltip,
|
|
100
|
+
color=build_node_color(palette),
|
|
101
|
+
font={"size": 11, "color": "#ffffff"},
|
|
102
|
+
shape="box",
|
|
103
|
+
margin=10,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
for hash_b, dependency_set in graph.relationships.items():
|
|
107
|
+
for hash_a in dependency_set:
|
|
108
|
+
network.add_edge(
|
|
109
|
+
hash_a, hash_b,
|
|
110
|
+
title=f"[{hash_b[:SHORT_HASH_LENGTH]}] depends on [{hash_a[:SHORT_HASH_LENGTH]}]",
|
|
111
|
+
color="#dddddd",
|
|
112
|
+
width=1.5,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
out_path: str = os.path.abspath(output_path)
|
|
116
|
+
network.save_graph(out_path)
|
|
117
|
+
inject_analysis_controls(out_path)
|
|
118
|
+
inject_analysis_legend(out_path)
|
|
119
|
+
inject_styling(out_path)
|
|
120
|
+
cprint(f"[INFO] Interactive graph saved to:\n{' '*4}{out_path}\n", color=INFO_COLOR)
|
|
121
|
+
webbrowser.open(f"file:///{out_path}") # TODO: make this configurable
|
|
122
|
+
cprint("[INFO] Opened in your default browser. Drag nodes freely!\n", color=INFO_COLOR)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def display_pick_graph(commits: list[Commit], commits_hash_dict: dict[str, Commit], graph: DependencyGraph, analysis: CherryPickAnalysis, output_path: str = "git_trace_pick_output.html"):
|
|
126
|
+
network: Network = _make_network()
|
|
127
|
+
|
|
128
|
+
safe_set: set[str] = set(analysis.safe)
|
|
129
|
+
blocked_set: set[str] = set(analysis.blocked.keys())
|
|
130
|
+
conditional_set: set[str] = set(analysis.conditional)
|
|
131
|
+
missing_set: set[str] = {dependency_hash for dependency_hashes in analysis.blocked.values() for dependency_hash in dependency_hashes}
|
|
132
|
+
|
|
133
|
+
for commit in commits:
|
|
134
|
+
commit_hash: str = commit.hash
|
|
135
|
+
role: str
|
|
136
|
+
status_line: str
|
|
137
|
+
|
|
138
|
+
if commit_hash in safe_set:
|
|
139
|
+
role = "safe"
|
|
140
|
+
status_line = "[✔] Safe to cherry-pick"
|
|
141
|
+
elif commit_hash in conditional_set:
|
|
142
|
+
role = "conditional"
|
|
143
|
+
status_line = "[◑] Safe to pick, but only after blocked parent dependencies are resolved"
|
|
144
|
+
elif commit_hash in blocked_set:
|
|
145
|
+
role = "blocked"
|
|
146
|
+
missing_msgs: list[str] = [
|
|
147
|
+
f" • [{dependency_hash[:SHORT_HASH_LENGTH]}] {commits_hash_dict.get(dependency_hash, Commit('<unknown>', '<unknown>')).message[:MAX_COMMIT_MESSAGE_LENGTH]}"
|
|
148
|
+
for dependency_hash in sorted(analysis.blocked[commit_hash])
|
|
149
|
+
]
|
|
150
|
+
status_line = "[✘] Blocked - missing dependencies:\n" + "\n".join(missing_msgs)
|
|
151
|
+
elif commit_hash in missing_set:
|
|
152
|
+
role = "missing"
|
|
153
|
+
status_line = "[⚠] Not picked - required by blocked commit(s)"
|
|
154
|
+
else:
|
|
155
|
+
role = "context"
|
|
156
|
+
status_line = "[-] Context commit (not in pick list)"
|
|
157
|
+
|
|
158
|
+
dependency_lines: list[str] = [
|
|
159
|
+
f" • [{dependency_hash[:SHORT_HASH_LENGTH]}] {commits_hash_dict[dependency_hash].message[:MAX_COMMIT_MESSAGE_LENGTH] if dependency_hash in commits_hash_dict else ''}"
|
|
160
|
+
for dependency_hash in sorted(graph.get_dependencies_for(commit_hash))
|
|
161
|
+
]
|
|
162
|
+
tooltip: str = "\n".join(
|
|
163
|
+
[f"[{commit_hash[:SHORT_HASH_LENGTH]}] {commit.message}", "", status_line]
|
|
164
|
+
+ (["", "Depends on:"] + dependency_lines if dependency_lines else [])
|
|
165
|
+
)
|
|
166
|
+
network.add_node(
|
|
167
|
+
commit_hash,
|
|
168
|
+
label=f"{truncate_label(commit.message)}\n{commit_hash[:SHORT_HASH_LENGTH]}",
|
|
169
|
+
title=tooltip,
|
|
170
|
+
color=build_node_color(PICK_NODE_COLORS[role]),
|
|
171
|
+
font={"size": 11, "color": "#ffffff"},
|
|
172
|
+
shape="box",
|
|
173
|
+
margin=10,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
for hash_b, dependency_set in graph.relationships.items():
|
|
177
|
+
for hash_a in dependency_set:
|
|
178
|
+
is_blocking: bool = hash_b in blocked_set and hash_a in analysis.blocked.get(hash_b, set())
|
|
179
|
+
network.add_edge(
|
|
180
|
+
hash_a, hash_b,
|
|
181
|
+
title=f"[{hash_b[:SHORT_HASH_LENGTH]}] depends on [{hash_a[:SHORT_HASH_LENGTH]}]",
|
|
182
|
+
color="#ff4444" if is_blocking else "#666666",
|
|
183
|
+
width=2.5 if is_blocking else 1.0,
|
|
184
|
+
dashes=not is_blocking,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
out_path: str = os.path.abspath(output_path)
|
|
188
|
+
network.save_graph(out_path)
|
|
189
|
+
inject_pick_controls(out_path)
|
|
190
|
+
inject_pick_legend(out_path)
|
|
191
|
+
inject_styling(out_path)
|
|
192
|
+
cprint(f"[INFO] Pick graph saved to:\n{' '*4}{out_path}\n", color=INFO_COLOR)
|
|
193
|
+
webbrowser.open(f"file:///{out_path}") # TODO: make this configurable
|
|
194
|
+
cprint("[INFO] Opened in your default browser. Drag nodes freely!\n", color=INFO_COLOR)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def truncate_label(msg: str) -> str:
|
|
198
|
+
return (msg[:NODE_LABEL_MAX_LENGTH] + "…") if len(msg) > NODE_LABEL_MAX_LENGTH else msg
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
_ASSETS: Path = Path(__file__).parent / "assets"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _load_asset(name: str) -> str:
|
|
7
|
+
return (_ASSETS / name).read_text(encoding="utf-8")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _inject(html_path: str, snippet: str):
|
|
11
|
+
text = Path(html_path).read_text(encoding="utf-8")
|
|
12
|
+
if "</body>" in text:
|
|
13
|
+
text = text.replace("</body>", snippet + "\n</body>", 1)
|
|
14
|
+
Path(html_path).write_text(text, encoding="utf-8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def inject_analysis_controls(html_path: str):
|
|
18
|
+
_inject(html_path, _load_asset("analysis_controls.html"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def inject_analysis_legend(html_path: str):
|
|
22
|
+
_inject(html_path, _load_asset("analysis_legend.html"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def inject_pick_controls(html_path: str):
|
|
26
|
+
_inject(html_path, _load_asset("pick_controls.html"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def inject_pick_legend(html_path: str):
|
|
30
|
+
_inject(html_path, _load_asset("pick_legend.html"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def inject_styling(html_path: str):
|
|
34
|
+
_inject(html_path, _load_asset("styling.html"))
|
git_trace/output/text.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from git_trace.analysis import DependencyGraph, CherryPickAnalysis
|
|
4
|
+
from git_trace.git import Commit
|
|
5
|
+
from git_trace.utils import SHORT_HASH_LENGTH, cprint, INFO_COLOR, Color, MAX_COMMIT_MESSAGE_LENGTH
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def print_text_tree(commits: list[Commit], commits_hash_dict: dict[str, Commit], graph: DependencyGraph, txt_output_path: str | None = None):
|
|
9
|
+
title: str = "Commit dependency tree (○ = independent ● = has dependencies)"
|
|
10
|
+
lines: list[str] = [title]
|
|
11
|
+
cprint(title, color=Color.CYAN)
|
|
12
|
+
for commit in commits:
|
|
13
|
+
line: str = f" [{'●' if graph.relationships.get(commit.hash) else '○'}] [{commit.hash[:SHORT_HASH_LENGTH]}] {commits_hash_dict[commit.hash].message[:MAX_COMMIT_MESSAGE_LENGTH]}"
|
|
14
|
+
lines.append(line)
|
|
15
|
+
cprint(line)
|
|
16
|
+
|
|
17
|
+
for dependency_hash in sorted(graph.get_dependencies_for(commit.hash)):
|
|
18
|
+
line = f"{' '*8}└─ depends on [{dependency_hash[:SHORT_HASH_LENGTH]}] {commits_hash_dict[dependency_hash].message[:MAX_COMMIT_MESSAGE_LENGTH]}"
|
|
19
|
+
lines.append(line)
|
|
20
|
+
cprint(line)
|
|
21
|
+
|
|
22
|
+
if txt_output_path:
|
|
23
|
+
with open(txt_output_path, "w", encoding="utf-8") as file_handle:
|
|
24
|
+
file_handle.write("\n".join(lines) + "\n")
|
|
25
|
+
cprint(f"[INFO] Text tree saved to:\n{' '*4}{txt_output_path}\n", color=INFO_COLOR)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def print_pick_result(analysis: CherryPickAnalysis, commits_hash_dict: dict[str, Commit], txt_output_path: str | None = None):
|
|
29
|
+
title: str = "Cherry-pick dependency results"
|
|
30
|
+
lines: list[str] = [title]
|
|
31
|
+
cprint(title, color=Color.CYAN)
|
|
32
|
+
|
|
33
|
+
subtitle: str = f"\n [✔] {len(analysis.safe)} commit(s) safe to pick:"
|
|
34
|
+
lines.append(subtitle)
|
|
35
|
+
cprint(subtitle)
|
|
36
|
+
for commit_hash in analysis.safe:
|
|
37
|
+
message: str = commits_hash_dict[commit_hash].message[:MAX_COMMIT_MESSAGE_LENGTH] if commit_hash in commits_hash_dict else "<unknown>"
|
|
38
|
+
line: str = f"{' '*4}[{commit_hash[:SHORT_HASH_LENGTH]}] {message}"
|
|
39
|
+
lines.append(line)
|
|
40
|
+
cprint(line)
|
|
41
|
+
|
|
42
|
+
subtitle: str = f"\n [◑] {len(analysis.conditional)} commit(s) safe to pick - only after blocked parent deps are resolved:"
|
|
43
|
+
lines.append(subtitle)
|
|
44
|
+
cprint(subtitle)
|
|
45
|
+
for commit_hash in analysis.conditional:
|
|
46
|
+
message = commits_hash_dict[commit_hash].message[:MAX_COMMIT_MESSAGE_LENGTH] if commit_hash in commits_hash_dict else "<unknown>"
|
|
47
|
+
line = f"{' ' * 4}[{commit_hash[:SHORT_HASH_LENGTH]}] {message}"
|
|
48
|
+
lines.append(line)
|
|
49
|
+
cprint(line)
|
|
50
|
+
|
|
51
|
+
subtitle: str = f"\n [✘] {len(analysis.blocked)} commit(s) blocked (depend on commits not in the pick list):"
|
|
52
|
+
lines.append(subtitle)
|
|
53
|
+
cprint(subtitle)
|
|
54
|
+
for commit_hash, missing in sorted(analysis.blocked.items()):
|
|
55
|
+
message = commits_hash_dict[commit_hash].message[:MAX_COMMIT_MESSAGE_LENGTH] if commit_hash in commits_hash_dict else "<unknown>"
|
|
56
|
+
line = f"{' ' * 4}[{commit_hash[:SHORT_HASH_LENGTH]}] {message}"
|
|
57
|
+
lines.append(line)
|
|
58
|
+
cprint(line)
|
|
59
|
+
|
|
60
|
+
for dependency_hash in sorted(missing):
|
|
61
|
+
dependency_message: str = commits_hash_dict[dependency_hash].message[:MAX_COMMIT_MESSAGE_LENGTH] if dependency_hash in commits_hash_dict else "<not in range>"
|
|
62
|
+
line = f"{' '*8}└─ missing [{dependency_hash[:SHORT_HASH_LENGTH]}] {dependency_message}"
|
|
63
|
+
lines.append(line)
|
|
64
|
+
cprint(line)
|
|
65
|
+
|
|
66
|
+
if txt_output_path:
|
|
67
|
+
with open(txt_output_path, "w", encoding="utf-8") as file_handle:
|
|
68
|
+
file_handle.write("\n".join(lines) + "\n")
|
|
69
|
+
cprint(f"[INFO] Pick result saved to:\n{' '*4}{txt_output_path}\n", color=INFO_COLOR)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# TODO: blocked or blocked + conditional ? & with or without messages ?
|
|
73
|
+
def print_pick_list(blocked: dict[str, set[str]], commits_hash_dict: dict[str, Commit]):
|
|
74
|
+
missing_hashes: set[str] = {dependency_hash for dependency_hashes in blocked.values() for dependency_hash in dependency_hashes}
|
|
75
|
+
for dependency_hash in sorted(missing_hashes):
|
|
76
|
+
message: str = commits_hash_dict[dependency_hash].message if dependency_hash in commits_hash_dict else "<unknown>"
|
|
77
|
+
cprint(f"{dependency_hash[:SHORT_HASH_LENGTH]} {message}")
|