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
git_trace/__init__.py
ADDED
git_trace/__main__.py
ADDED
git_trace/analysis.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from git_trace.git import Commit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
HUNK_HEADER_PATTERN: re.Pattern[str] = re.compile(r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class DiffHunk:
|
|
15
|
+
old_start: int
|
|
16
|
+
old_count: int
|
|
17
|
+
new_start: int
|
|
18
|
+
new_count: int
|
|
19
|
+
lines: list[tuple[str, str]] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class FileChange:
|
|
24
|
+
path: str
|
|
25
|
+
old_path: str | None = None
|
|
26
|
+
hunks: list[DiffHunk] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class VirtualLine:
|
|
31
|
+
content: str
|
|
32
|
+
owner_hash: str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class DependencyGraph:
|
|
37
|
+
relationships: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set))
|
|
38
|
+
|
|
39
|
+
def add_dependency(self, commit_hash: str, parent_hash: str) -> None:
|
|
40
|
+
self.relationships[commit_hash].add(parent_hash)
|
|
41
|
+
|
|
42
|
+
def get_dependencies_for(self, commit_hash: str) -> set[str]:
|
|
43
|
+
return self.relationships.get(commit_hash, set())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class CherryPickAnalysis:
|
|
48
|
+
safe: list[str] = field(default_factory=list)
|
|
49
|
+
conditional: list[str] = field(default_factory=list)
|
|
50
|
+
blocked: dict[str, set[str]] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_diff(diff_text: str, ignore_paths: set[str]) -> list[FileChange]:
|
|
54
|
+
changes: list[FileChange] = []
|
|
55
|
+
current_change: FileChange | None = None
|
|
56
|
+
current_hunk: DiffHunk | None = None
|
|
57
|
+
rename_from: str | None = None
|
|
58
|
+
old_path_candidate: str | None = None
|
|
59
|
+
|
|
60
|
+
for line in diff_text.splitlines():
|
|
61
|
+
if line.startswith("diff --git "):
|
|
62
|
+
if current_change is not None:
|
|
63
|
+
changes.append(current_change)
|
|
64
|
+
current_change = None
|
|
65
|
+
current_hunk = None
|
|
66
|
+
rename_from = None
|
|
67
|
+
old_path_candidate = None
|
|
68
|
+
elif line.startswith("rename from "):
|
|
69
|
+
rename_from = line[len("rename from "):].replace("\\", "/")
|
|
70
|
+
elif line.startswith("rename to "):
|
|
71
|
+
rename_to: str = line[len("rename to "):].replace("\\", "/")
|
|
72
|
+
if rename_to not in ignore_paths:
|
|
73
|
+
current_change = FileChange(path=rename_to, old_path=rename_from)
|
|
74
|
+
elif line.startswith("--- "):
|
|
75
|
+
current_hunk = None
|
|
76
|
+
old_path_candidate = line[6:].replace("\\", "/") if line.startswith("--- a/") else None
|
|
77
|
+
elif line.startswith("+++ "):
|
|
78
|
+
current_hunk = None
|
|
79
|
+
new_path: str | None = line[6:].replace("\\", "/") if line.startswith("+++ b/") else None
|
|
80
|
+
canonical_path: str | None = new_path or old_path_candidate
|
|
81
|
+
if canonical_path is None or canonical_path in ignore_paths:
|
|
82
|
+
current_change = None
|
|
83
|
+
elif current_change is not None and current_change.path == canonical_path:
|
|
84
|
+
pass
|
|
85
|
+
else:
|
|
86
|
+
old: str | None = old_path_candidate if (old_path_candidate and new_path and old_path_candidate != new_path) else None
|
|
87
|
+
current_change = FileChange(path=canonical_path, old_path=old)
|
|
88
|
+
elif current_change is not None:
|
|
89
|
+
hunk_match: re.Match[str] | None = HUNK_HEADER_PATTERN.match(line)
|
|
90
|
+
if hunk_match:
|
|
91
|
+
old_start: int = int(hunk_match.group(1))
|
|
92
|
+
old_count: int = int(hunk_match.group(2)) if hunk_match.group(2) is not None else 1
|
|
93
|
+
new_start: int = int(hunk_match.group(3))
|
|
94
|
+
new_count: int = int(hunk_match.group(4)) if hunk_match.group(4) is not None else 1
|
|
95
|
+
current_hunk = DiffHunk(
|
|
96
|
+
old_start=old_start,
|
|
97
|
+
old_count=old_count,
|
|
98
|
+
new_start=new_start,
|
|
99
|
+
new_count=new_count,
|
|
100
|
+
)
|
|
101
|
+
current_change.hunks.append(current_hunk)
|
|
102
|
+
elif current_hunk is not None and line and line[0] in ("+", "-", " "):
|
|
103
|
+
current_hunk.lines.append((line[0], line[1:]))
|
|
104
|
+
|
|
105
|
+
if current_change is not None:
|
|
106
|
+
changes.append(current_change)
|
|
107
|
+
return changes
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# wild opus dependency graph building logic
|
|
111
|
+
def build_dependency_graph(commits: list[Commit], raw_diffs: dict[str, str], ignore_paths: set[str] | None = None) -> DependencyGraph:
|
|
112
|
+
virtual_files: dict[str, list[VirtualLine]] = {}
|
|
113
|
+
graph: DependencyGraph = DependencyGraph()
|
|
114
|
+
ignore_paths = ignore_paths or set()
|
|
115
|
+
|
|
116
|
+
for commit in commits:
|
|
117
|
+
diff_text: str = raw_diffs.get(commit.hash, "")
|
|
118
|
+
file_changes: list[FileChange] = _parse_diff(diff_text, ignore_paths)
|
|
119
|
+
|
|
120
|
+
for change in file_changes:
|
|
121
|
+
if change.old_path is not None and change.old_path in virtual_files:
|
|
122
|
+
virtual_files[change.path] = virtual_files.pop(change.old_path)
|
|
123
|
+
else:
|
|
124
|
+
virtual_files.setdefault(change.path, [])
|
|
125
|
+
|
|
126
|
+
virtual_file: list[VirtualLine] = virtual_files[change.path]
|
|
127
|
+
cumulative_offset: int = 0
|
|
128
|
+
|
|
129
|
+
for hunk in change.hunks:
|
|
130
|
+
position: int = (
|
|
131
|
+
hunk.old_start if hunk.old_count == 0
|
|
132
|
+
else max(0, hunk.old_start - 1)
|
|
133
|
+
) + cumulative_offset
|
|
134
|
+
|
|
135
|
+
while len(virtual_file) < position + hunk.old_count:
|
|
136
|
+
virtual_file.append(VirtualLine(content="", owner_hash=None))
|
|
137
|
+
|
|
138
|
+
new_entries: list[VirtualLine] = []
|
|
139
|
+
old_index: int = position
|
|
140
|
+
|
|
141
|
+
for line_type, content in hunk.lines:
|
|
142
|
+
if line_type == " ":
|
|
143
|
+
new_entries.append(virtual_file[old_index])
|
|
144
|
+
old_index += 1
|
|
145
|
+
elif line_type == "-":
|
|
146
|
+
existing_line = virtual_file[old_index]
|
|
147
|
+
if existing_line.owner_hash is not None and existing_line.owner_hash != commit.hash:
|
|
148
|
+
graph.add_dependency(commit.hash, existing_line.owner_hash)
|
|
149
|
+
old_index += 1
|
|
150
|
+
elif line_type == "+":
|
|
151
|
+
new_entries.append(VirtualLine(content=content, owner_hash=commit.hash))
|
|
152
|
+
|
|
153
|
+
virtual_file[position:position + hunk.old_count] = new_entries
|
|
154
|
+
cumulative_offset += hunk.new_count - hunk.old_count
|
|
155
|
+
|
|
156
|
+
return graph
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def filter_picks(pick_hashes: set[str], graph: DependencyGraph) -> CherryPickAnalysis:
|
|
160
|
+
blocked: dict[str, set[str]] = {}
|
|
161
|
+
for commit_hash in pick_hashes:
|
|
162
|
+
missing: set[str] = graph.get_dependencies_for(commit_hash) - pick_hashes
|
|
163
|
+
if missing:
|
|
164
|
+
blocked[commit_hash] = missing
|
|
165
|
+
|
|
166
|
+
tainted: set[str] = set(blocked.keys())
|
|
167
|
+
changed: bool = True
|
|
168
|
+
while changed:
|
|
169
|
+
changed = False
|
|
170
|
+
for commit_hash in pick_hashes:
|
|
171
|
+
if commit_hash not in tainted:
|
|
172
|
+
in_pick_dependencies: set[str] = graph.get_dependencies_for(commit_hash) & pick_hashes
|
|
173
|
+
if in_pick_dependencies & tainted:
|
|
174
|
+
tainted.add(commit_hash)
|
|
175
|
+
changed = True
|
|
176
|
+
|
|
177
|
+
conditional: list[str] = [commit_hash for commit_hash in pick_hashes if commit_hash in tainted and commit_hash not in blocked]
|
|
178
|
+
safe: list[str] = [commit_hash for commit_hash in pick_hashes if commit_hash not in tainted]
|
|
179
|
+
return CherryPickAnalysis(
|
|
180
|
+
safe=safe,
|
|
181
|
+
conditional=conditional,
|
|
182
|
+
blocked=blocked
|
|
183
|
+
)
|
git_trace/git.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Commit:
|
|
9
|
+
hash: str
|
|
10
|
+
message: str
|
|
11
|
+
|
|
12
|
+
def is_inside_hash_set(self, hash_set: set[str]) -> bool:
|
|
13
|
+
return any(self.hash.startswith(prefix) or prefix.startswith(self.hash) for prefix in hash_set)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_git(*args: str, repo_directory: str) -> str:
|
|
17
|
+
command: list[str] = ["git"]
|
|
18
|
+
if repo_directory:
|
|
19
|
+
command += ["-C", repo_directory]
|
|
20
|
+
command += list(args)
|
|
21
|
+
process: subprocess.CompletedProcess[str] = subprocess.run(
|
|
22
|
+
command, capture_output=True, encoding="utf-8", errors="replace"
|
|
23
|
+
)
|
|
24
|
+
return process.stdout or ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_commits(branch: str, repo_directory: str, after: str | None = None, before: str | None = None) -> list[Commit]:
|
|
28
|
+
scope: str = f"{after if after else ''}{'..' if after else ''}{before if before else branch}"
|
|
29
|
+
output: str = run_git("log", scope, "--reverse", "--format=%H %s", repo_directory=repo_directory)
|
|
30
|
+
|
|
31
|
+
commits: list[Commit] = []
|
|
32
|
+
for line in output.splitlines():
|
|
33
|
+
line = line.strip()
|
|
34
|
+
if line:
|
|
35
|
+
commit_hash, _, message = line.partition(" ")
|
|
36
|
+
commits.append(Commit(hash=commit_hash, message=message))
|
|
37
|
+
|
|
38
|
+
if before and commits: # skip last commit to exclude it
|
|
39
|
+
commits.pop()
|
|
40
|
+
|
|
41
|
+
return commits
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_commit_diff(sha: str, repo_directory: str) -> str:
|
|
45
|
+
return run_git("show", "--format=", "-p", sha, repo_directory=repo_directory)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_hashes(hashes: list[str], all_commits: list[Commit]) -> dict[str, str | None]:
|
|
49
|
+
full_hashes: list[str] = [commit.hash for commit in all_commits]
|
|
50
|
+
result: dict[str, str | None] = {}
|
|
51
|
+
for hash_ in hashes: # if user provided short hashes map them to full hashes if possible
|
|
52
|
+
matches: list[str] = [full_hash for full_hash in full_hashes if full_hash.lower().startswith(hash_.lower())]
|
|
53
|
+
result[hash_] = matches[0] if len(matches) == 1 else None
|
|
54
|
+
return result
|
|
File without changes
|
git_trace/input/args.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import copy
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from git_trace.input.parser import build_cli_parser
|
|
12
|
+
from git_trace.utils import cprint, WARNING_COLOR, yaml2list, INFO_COLOR, read_cleaned_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ArgumentDefinition:
|
|
17
|
+
identifier: str
|
|
18
|
+
yaml_identifier: str | None = None
|
|
19
|
+
conversion_from_yaml: Callable | None = None
|
|
20
|
+
default_value: Any | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
CLI_ARGUMENTS_LIST: tuple[ArgumentDefinition, ...] = (
|
|
24
|
+
# Identifier YAML Key Converter Default Value
|
|
25
|
+
ArgumentDefinition("after", "after", bool),
|
|
26
|
+
ArgumentDefinition("before", "before", str),
|
|
27
|
+
ArgumentDefinition("blacklist", "blacklist", yaml2list, "blacklist.txt"),
|
|
28
|
+
ArgumentDefinition("branch", "branch", str, "main"),
|
|
29
|
+
ArgumentDefinition("config", default_value="config.yml"),
|
|
30
|
+
ArgumentDefinition("ignore_paths", "ignore-paths", yaml2list, "ignore_paths.txt"),
|
|
31
|
+
ArgumentDefinition("list", default_value=False),
|
|
32
|
+
ArgumentDefinition("no_graph", "no-graph", bool, False),
|
|
33
|
+
ArgumentDefinition("output", "output", str, "./output.html"),
|
|
34
|
+
ArgumentDefinition("picks", "picks", yaml2list, "picks.txt"),
|
|
35
|
+
ArgumentDefinition("repo", "repo", str, "./"),
|
|
36
|
+
ArgumentDefinition("txt_output", "txt-output", str),
|
|
37
|
+
ArgumentDefinition("whitelist", "whitelist", yaml2list, "whitelist.txt"),
|
|
38
|
+
)
|
|
39
|
+
CLI_ARGUMENTS: dict[str, ArgumentDefinition] = {arg.identifier: arg for arg in CLI_ARGUMENTS_LIST}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_args_dict() -> dict[str, Any]:
|
|
43
|
+
parser: argparse.ArgumentParser = build_cli_parser()
|
|
44
|
+
args: argparse.Namespace = parser.parse_args()
|
|
45
|
+
|
|
46
|
+
provided_via_cli: set[str] = {
|
|
47
|
+
arg.identifier
|
|
48
|
+
for arg in CLI_ARGUMENTS_LIST
|
|
49
|
+
if hasattr(args, arg.identifier)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
config_path: str = getattr(args, "config", None) or "./config.yml"
|
|
53
|
+
config: dict[str, Any] | None = _load_config(config_path)
|
|
54
|
+
if config:
|
|
55
|
+
args = _fill_out_args_with_config_values(args, config, provided_via_cli)
|
|
56
|
+
|
|
57
|
+
args.blacklist = _check_if_autoloading_list_input(getattr(args, "blacklist", None), "./blacklist.txt", "blacklist")
|
|
58
|
+
args.ignore_paths = _check_if_autoloading_list_input(getattr(args, "ignore_paths", None), "./ignore_paths.txt", "ignore_paths")
|
|
59
|
+
args.picks = _check_if_autoloading_list_input(getattr(args, "picks", None), "./picks.txt", "picks")
|
|
60
|
+
args.whitelist = _check_if_autoloading_list_input(getattr(args, "whitelist", None), "./whitelist.txt", "whitelist")
|
|
61
|
+
|
|
62
|
+
blacklist: set[str] | None = _resolve_hash_list_input(args.blacklist, "blacklist") if args.blacklist else None
|
|
63
|
+
ignore_paths: set[str] = _resolve_file_paths_input(args.ignore_paths, "ignore_paths") if args.ignore_paths else None
|
|
64
|
+
picks: set[str] | None = _resolve_hash_list_input(args.picks, "picks") if args.picks else None
|
|
65
|
+
whitelist: set[str] | None = _resolve_hash_list_input(args.whitelist, "whitelist") if args.whitelist else None
|
|
66
|
+
|
|
67
|
+
result: dict[str, Any] = {
|
|
68
|
+
"after": getattr(args, "after", None) or CLI_ARGUMENTS["after"].default_value,
|
|
69
|
+
"before": getattr(args, "before", None) or CLI_ARGUMENTS["before"].default_value,
|
|
70
|
+
"blacklist": blacklist,
|
|
71
|
+
"branch": getattr(args, "branch", None) or CLI_ARGUMENTS["branch"].default_value,
|
|
72
|
+
"config": getattr(args, "config", None) or CLI_ARGUMENTS["config"].default_value,
|
|
73
|
+
"ignore-paths": ignore_paths,
|
|
74
|
+
"list": getattr(args, "list", None) or CLI_ARGUMENTS["list"].default_value,
|
|
75
|
+
"no-graph": getattr(args, "no_graph", None) or CLI_ARGUMENTS["no_graph"].default_value,
|
|
76
|
+
"output": getattr(args, "output", None) or CLI_ARGUMENTS["output"].default_value,
|
|
77
|
+
"picks": picks,
|
|
78
|
+
"repo": getattr(args, "repo", None) or CLI_ARGUMENTS["repo"].default_value,
|
|
79
|
+
"txt-output": getattr(args, "txt_output", None) or CLI_ARGUMENTS["txt_output"].default_value,
|
|
80
|
+
"whitelist": whitelist,
|
|
81
|
+
}
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_config(path: str) -> dict[str, Any] | None:
|
|
86
|
+
if not os.path.isfile(path):
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
with open(path, "r", encoding="utf-8") as file:
|
|
90
|
+
data: dict[str, Any] | None = yaml.safe_load(file) or None
|
|
91
|
+
|
|
92
|
+
if data is None or not isinstance(data, dict):
|
|
93
|
+
cprint(f"[WARNING] Could not load config from '{path}', check if it's a correct YAML mapping, ignoring.", color=WARNING_COLOR)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
cprint(f"[INFO] Loaded config from '{path}'.", color=INFO_COLOR)
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _fill_out_args_with_config_values(args: argparse.Namespace, config: dict[str, Any], provided_via_cli: set[str]) -> argparse.Namespace:
|
|
101
|
+
new_args: argparse.Namespace = copy.deepcopy(args)
|
|
102
|
+
|
|
103
|
+
for argument in CLI_ARGUMENTS_LIST:
|
|
104
|
+
if argument.identifier in provided_via_cli or argument.yaml_identifier is None:
|
|
105
|
+
continue
|
|
106
|
+
raw_value: Any = config.get(argument.yaml_identifier)
|
|
107
|
+
if raw_value is None:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if argument.conversion_from_yaml is None:
|
|
111
|
+
converted_value: Any = raw_value
|
|
112
|
+
else:
|
|
113
|
+
converted_value: Any = argument.conversion_from_yaml(raw_value)
|
|
114
|
+
|
|
115
|
+
if converted_value is not None:
|
|
116
|
+
setattr(new_args, argument.identifier, converted_value)
|
|
117
|
+
|
|
118
|
+
return new_args
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _check_if_autoloading_list_input(values: list[str] | None, default_file_path: str, argument_identifier: str) -> list[str] | None:
|
|
122
|
+
if values is not None:
|
|
123
|
+
return values
|
|
124
|
+
if os.path.isfile(default_file_path):
|
|
125
|
+
cprint(f"[INFO] Auto-loading {argument_identifier} from '{default_file_path}'")
|
|
126
|
+
return [default_file_path]
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _resolve_hash_list_input(values: list[str], argument_identifier: str) -> set[str]:
|
|
131
|
+
if len(values) == 1 and os.path.isfile(file_path := values[0]):
|
|
132
|
+
loaded: set[str] = _load_commit_set(file_path)
|
|
133
|
+
cprint(f"[INFO] Loaded {len(loaded)} {'hash' if len(loaded) == 1 else 'hashes'} from '{file_path}' (via {argument_identifier})", color=INFO_COLOR)
|
|
134
|
+
return loaded
|
|
135
|
+
return {hash_.lower() for hash_ in values}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_file_paths_input(values: list[str], argument_identifier: str) -> set[str]:
|
|
139
|
+
if len(values) == 1 and os.path.isfile(file_path := values[0]):
|
|
140
|
+
loaded: set[str] = _load_ignore_paths(file_path)
|
|
141
|
+
cprint(f"[INFO] Loaded {len(loaded)} {'path' if len(loaded) == 1 else 'paths'} from '{file_path}' (via {argument_identifier})", color=INFO_COLOR)
|
|
142
|
+
return loaded
|
|
143
|
+
return {path.replace("\\", "/") for path in values}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _load_commit_set(path: str) -> set[str]:
|
|
147
|
+
return {line.lower() for line in read_cleaned_file(path)}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _load_ignore_paths(path: str) -> set[str]:
|
|
151
|
+
return {line.replace("\\", "/") for line in read_cleaned_file(path)}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from git_trace import __version__
|
|
6
|
+
from git_trace.utils import Color
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_cli_parser() -> argparse.ArgumentParser:
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="git-trace",
|
|
12
|
+
description="Visualise and analyse git commit dependencies.",
|
|
13
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
14
|
+
epilog=(
|
|
15
|
+
f" A {Color.BOLD.value}{Color.BRIGHT_BLUE.value}config.yml{Color.RESET.value} file if present in the current directory is loaded automatically and"
|
|
16
|
+
" can set any of these options. CLI arguments take priority over config.yml,"
|
|
17
|
+
" which takes priority over .txt auto-load files.\n\n"
|
|
18
|
+
"examples:\n"
|
|
19
|
+
f" {Color.BOLD.value}{Color.MAGENTA.value}git-trace {Color.RESET.value}# analyse main, full history\n"
|
|
20
|
+
f" {Color.BOLD.value}{Color.MAGENTA.value}git-trace {Color.GREEN.value}dev {Color.CYAN.value}--after {Color.YELLOW.value}abc1234 {Color.RESET.value}# analyse dev after a commit\n"
|
|
21
|
+
f" {Color.BOLD.value}{Color.MAGENTA.value}git-trace {Color.GREEN.value}dev {Color.CYAN.value}--after {Color.YELLOW.value}abc {Color.CYAN.value}--before {Color.YELLOW.value}def {Color.RESET.value}# analyse a commit range\n"
|
|
22
|
+
f" {Color.BOLD.value}{Color.MAGENTA.value}git-trace {Color.GREEN.value}dev {Color.CYAN.value}--whitelist {Color.YELLOW.value}picks.txt {Color.RESET.value}# whitelist via file\n"
|
|
23
|
+
f" {Color.BOLD.value}{Color.MAGENTA.value}git-trace {Color.GREEN.value}dev {Color.CYAN.value}--whitelist {Color.YELLOW.value}h1 h2 h3 {Color.RESET.value}# whitelist inline\n"
|
|
24
|
+
f" {Color.BOLD.value}{Color.MAGENTA.value}git-trace {Color.GREEN.value}dev {Color.CYAN.value}--no-graph {Color.RESET.value}# text-only output\n"
|
|
25
|
+
f" {Color.BOLD.value}{Color.MAGENTA.value}git-trace {Color.GREEN.value}dev {Color.CYAN.value}--after {Color.YELLOW.value}abc {Color.CYAN.value}--picks {Color.YELLOW.value}h1 h2 {Color.RESET.value}# check if cherry picking is safe\n"
|
|
26
|
+
f" {Color.BOLD.value}{Color.MAGENTA.value}git-trace {Color.GREEN.value}dev {Color.CYAN.value}--picks {Color.YELLOW.value}picks.txt {Color.RESET.value}# cherry pick safety check via file\n"
|
|
27
|
+
).strip(),
|
|
28
|
+
)
|
|
29
|
+
# positional args
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"branch",
|
|
32
|
+
nargs="?",
|
|
33
|
+
default=argparse.SUPPRESS,
|
|
34
|
+
help=f"branch to analyse {Color.BRIGHT_BLACK.value}(default: main){Color.RESET.value}.",
|
|
35
|
+
)
|
|
36
|
+
# optional args
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--after",
|
|
39
|
+
metavar="HASH",
|
|
40
|
+
default=argparse.SUPPRESS,
|
|
41
|
+
help=f"only include commits after this hash {Color.BRIGHT_BLACK.value}(excluding it){Color.RESET.value}.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--before",
|
|
45
|
+
metavar="HASH",
|
|
46
|
+
default=argparse.SUPPRESS,
|
|
47
|
+
help=f"only include commits up to this hash {Color.BRIGHT_BLACK.value}(excluding it){Color.RESET.value}.",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--blacklist",
|
|
51
|
+
nargs="+",
|
|
52
|
+
metavar="HASH_OR_FILE",
|
|
53
|
+
default=argparse.SUPPRESS,
|
|
54
|
+
help=(
|
|
55
|
+
f"commit hashes to ignore during analysis, OR a single path to a file containing them {Color.BRIGHT_BLACK.value}(one per line){Color.RESET.value}. "
|
|
56
|
+
"Auto-loaded from blacklist.txt if the file exists and the flag is omitted."
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--config",
|
|
61
|
+
metavar="FILE",
|
|
62
|
+
default=argparse.SUPPRESS,
|
|
63
|
+
help=f"path to a YAML config file {Color.BRIGHT_BLACK.value}(default: config.yml){Color.RESET.value}. Values are overridden by explicit CLI args.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--ignore-paths",
|
|
67
|
+
nargs="+",
|
|
68
|
+
metavar="PATH_OR_FILE",
|
|
69
|
+
default=argparse.SUPPRESS,
|
|
70
|
+
help=(
|
|
71
|
+
"repo-relative paths to exclude from diff analysis, OR a single path to a "
|
|
72
|
+
f"file containing them {Color.BRIGHT_BLACK.value}(one per line){Color.RESET.value}. "
|
|
73
|
+
"Auto-loaded from ignore-paths.txt if the file exists and the flag is omitted."
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--list",
|
|
78
|
+
action="store_true",
|
|
79
|
+
default=argparse.SUPPRESS,
|
|
80
|
+
help=(
|
|
81
|
+
f"works only if used alongside --picks. Prints a simple list of blocked commit hashes {Color.BRIGHT_BLACK.value}(one per line, outputs nothing if no dependencies are found){Color.RESET.value} instead of the formatted text tree. "
|
|
82
|
+
"Combine with --no-graph to suppress HTML output as well."
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--no-graph",
|
|
87
|
+
action="store_true",
|
|
88
|
+
default=argparse.SUPPRESS,
|
|
89
|
+
help="skip HTML graph generation and print only the text output.",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--output",
|
|
93
|
+
metavar="PATH",
|
|
94
|
+
default=argparse.SUPPRESS,
|
|
95
|
+
help=f"output path for the HTML graph {Color.BRIGHT_BLACK.value}(default: output.html){Color.RESET.value}.",
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--picks",
|
|
99
|
+
nargs="+",
|
|
100
|
+
metavar="HASH_OR_FILE",
|
|
101
|
+
default=argparse.SUPPRESS,
|
|
102
|
+
help=(
|
|
103
|
+
"commit hashes to cherry-pick, OR a single path to a file containing them. "
|
|
104
|
+
"When provided, analysis additionally reports which commits are safe to "
|
|
105
|
+
"pick and which are blocked by missing dependencies. "
|
|
106
|
+
"Auto-loaded from picks.txt if the file exists and the flag is omitted."
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--repo",
|
|
111
|
+
metavar="DIR",
|
|
112
|
+
default=argparse.SUPPRESS,
|
|
113
|
+
help=f"path to the git repository root {Color.BRIGHT_BLACK.value}(default: current directory){Color.RESET.value}.",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--txt-output",
|
|
117
|
+
metavar="PATH",
|
|
118
|
+
default=argparse.SUPPRESS,
|
|
119
|
+
help="write the text output to this file.",
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"-v", "--version",
|
|
123
|
+
action="version",
|
|
124
|
+
version=f"git-trace {__version__}",
|
|
125
|
+
)
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
"--whitelist",
|
|
128
|
+
nargs="+",
|
|
129
|
+
metavar="HASH_OR_FILE",
|
|
130
|
+
default=argparse.SUPPRESS,
|
|
131
|
+
help=(
|
|
132
|
+
"only analyse these commit hashes, OR a single path to a file "
|
|
133
|
+
f"containing them {Color.BRIGHT_BLACK.value}(one per line){Color.RESET.value}. Runs before --blacklist logic. "
|
|
134
|
+
"Auto-loaded from whitelist.txt if the file exists and the flag is omitted."
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return parser
|
git_trace/main.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import colorama
|
|
8
|
+
|
|
9
|
+
from git_trace.analysis import build_dependency_graph, filter_picks, CherryPickAnalysis, DependencyGraph
|
|
10
|
+
from git_trace.git import get_commit_diff, get_commits, Commit, resolve_hashes
|
|
11
|
+
from git_trace.input.args import build_args_dict
|
|
12
|
+
from git_trace.output.graph import display_pick_graph, display_graph
|
|
13
|
+
from git_trace.output.text import print_pick_result, print_pick_list, print_text_tree
|
|
14
|
+
from git_trace.utils import cprint, Color, SHORT_HASH_LENGTH, ERROR_COLOR, WARNING_COLOR, MAX_COMMIT_MESSAGE_LENGTH
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
colorama.init()
|
|
19
|
+
args: dict[str, Any] = build_args_dict()
|
|
20
|
+
|
|
21
|
+
git_directory_path: str = os.path.join(args["repo"], ".git")
|
|
22
|
+
if not os.path.isdir(git_directory_path):
|
|
23
|
+
cprint(
|
|
24
|
+
f"[ERROR] .git directory not found in '{os.path.abspath(args['repo'])}'. "
|
|
25
|
+
"Run git-trace from the root of your git repository, "
|
|
26
|
+
"or use --repo to specify the path.",
|
|
27
|
+
color=ERROR_COLOR,
|
|
28
|
+
)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
commits: list[Commit] = _fetch_commits(args["branch"], args["repo"], args["after"], args["before"], args["list"])
|
|
32
|
+
if not commits:
|
|
33
|
+
cprint("[ERROR] No commits found. Check that the branch name and hash bounds are correct, exiting...", color=ERROR_COLOR)
|
|
34
|
+
sys.exit(2)
|
|
35
|
+
|
|
36
|
+
if args["whitelist"] and args["blacklist"]:
|
|
37
|
+
cprint("[WARNING] Both --whitelist and --blacklist provided, --whitelist takes priority and runs before --blacklist logic.", color=WARNING_COLOR)
|
|
38
|
+
|
|
39
|
+
commit_count_before_filtering: int = len(commits)
|
|
40
|
+
if args["whitelist"]:
|
|
41
|
+
commits = [commit for commit in commits if commit.is_inside_hash_set(args["whitelist"])]
|
|
42
|
+
cprint(f"Selected {len(commits)} out of {commit_count_before_filtering} commit(s) due to whitelist.")
|
|
43
|
+
commit_count_after_whitelisting: int = len(commits)
|
|
44
|
+
if args["blacklist"]:
|
|
45
|
+
commits = [commit for commit in commits if not commit.is_inside_hash_set(args["blacklist"])]
|
|
46
|
+
cprint(f"Skipped {commit_count_after_whitelisting - len(commits)} blacklisted commit(s).")
|
|
47
|
+
|
|
48
|
+
if not commits:
|
|
49
|
+
cprint("[ERROR] No commits left after applying filters, exiting...", color=ERROR_COLOR)
|
|
50
|
+
sys.exit(3)
|
|
51
|
+
|
|
52
|
+
if not args["list"]:
|
|
53
|
+
cprint(f"Selected {len(commits)} commit(s). Analysing diffs...")
|
|
54
|
+
|
|
55
|
+
raw_diffs: dict[str, str] = _fetch_diffs(commits, args["repo"], args["list"])
|
|
56
|
+
commits_hash_dict: dict[str, Commit] = {commit.hash: commit for commit in commits}
|
|
57
|
+
|
|
58
|
+
if not args["list"]:
|
|
59
|
+
cprint("Building dependency graph...")
|
|
60
|
+
dependency_graph: DependencyGraph = build_dependency_graph(commits, raw_diffs, args["ignore-paths"])
|
|
61
|
+
dependency_count: int = sum(1 for dep_set in dependency_graph.relationships.values() if dep_set)
|
|
62
|
+
if not args["list"]:
|
|
63
|
+
cprint(f" {dependency_count} commit(s) have at least one dependency.\n")
|
|
64
|
+
|
|
65
|
+
if args["picks"]:
|
|
66
|
+
resolved: dict[str, str | None] = resolve_hashes(list(args["picks"]), commits)
|
|
67
|
+
unknown_hashes: list[str] = [short_hash for short_hash, full_hash in resolved.items() if full_hash is None]
|
|
68
|
+
if unknown_hashes:
|
|
69
|
+
cprint("[WARNING] The following hashes were not found in the analyzed range and will be skipped:", color=WARNING_COLOR)
|
|
70
|
+
for short_hash in unknown_hashes:
|
|
71
|
+
cprint(f"{' '*4}{short_hash}")
|
|
72
|
+
|
|
73
|
+
full_pick_hashes: set[str] = {full_hash for full_hash in resolved.values() if full_hash is not None}
|
|
74
|
+
cherry_pick_analysis: CherryPickAnalysis = filter_picks(full_pick_hashes, dependency_graph)
|
|
75
|
+
|
|
76
|
+
if args["list"]:
|
|
77
|
+
print_pick_list(cherry_pick_analysis.blocked, commits_hash_dict)
|
|
78
|
+
else:
|
|
79
|
+
print_pick_result(cherry_pick_analysis, commits_hash_dict, txt_output_path=args["txt-output"])
|
|
80
|
+
|
|
81
|
+
if not args["no-graph"]:
|
|
82
|
+
display_pick_graph(commits, commits_hash_dict, dependency_graph, cherry_pick_analysis, output_path=args["output"])
|
|
83
|
+
else:
|
|
84
|
+
print_text_tree(commits, commits_hash_dict, dependency_graph, txt_output_path=args["txt-output"])
|
|
85
|
+
|
|
86
|
+
if not args["no-graph"]:
|
|
87
|
+
display_graph(commits, commits_hash_dict, dependency_graph, output_path=args["output"])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _fetch_commits(branch: str, repo_directory: str, after: str | None, before: str | None, using_list_arg: bool = False) -> list[Commit]:
|
|
91
|
+
message_parts: list[str] = [f"Fetching commits on '{Color.BRIGHT_BLUE.value}{branch}{Color.RESET.value}'"]
|
|
92
|
+
if after:
|
|
93
|
+
message_parts.append(f"after {Color.BRIGHT_BLUE.value}{after[:SHORT_HASH_LENGTH]}{Color.RESET.value}")
|
|
94
|
+
if before:
|
|
95
|
+
message_parts.append(f"up to {before[:SHORT_HASH_LENGTH]}")
|
|
96
|
+
if not using_list_arg:
|
|
97
|
+
cprint(" ".join(message_parts) + "...")
|
|
98
|
+
|
|
99
|
+
return get_commits(branch=branch, after=after, before=before, repo_directory=repo_directory)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fetch_diffs(commits: list[Commit], repo_directory: str, using_list_arg: bool = False) -> dict[str, str]:
|
|
103
|
+
raw_diffs: dict[str, str] = {}
|
|
104
|
+
commit_count: int = len(commits)
|
|
105
|
+
message_index_padding_width: int = len(str(commit_count))
|
|
106
|
+
for index, commit in enumerate(commits, 1):
|
|
107
|
+
if not using_list_arg:
|
|
108
|
+
message_index_prefix: str = f"[{index:>{message_index_padding_width}}/{commit_count}]"
|
|
109
|
+
cprint(f" {message_index_prefix} {commit.hash[:SHORT_HASH_LENGTH]} {commit.message[:MAX_COMMIT_MESSAGE_LENGTH]}")
|
|
110
|
+
raw_diffs[commit.hash] = get_commit_diff(commit.hash, repo_directory=repo_directory)
|
|
111
|
+
return raw_diffs
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
File without changes
|