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 ADDED
@@ -0,0 +1,6 @@
1
+ from .version import VERSION, __version__
2
+
3
+ __all__ = (
4
+ "__version__",
5
+ "VERSION",
6
+ )
git_trace/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from git_trace.main import main
2
+
3
+ main()
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
@@ -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