kigit 0.0.1__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.
kigit/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ __version__ = "0.0.1"
kigit/cli.py ADDED
@@ -0,0 +1,157 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ import typer
7
+ from typing import Annotated
8
+ from pathlib import Path
9
+ from typer import rich_utils
10
+
11
+ from kigit.extractor import export_netlist
12
+ from kigit.parser import parse_netlist_from_xml
13
+ from kigit.file_ops import find_sch_file, cleanup_temp_data
14
+ from kigit.git_operations import get_reference_file
15
+ from kigit.diff import diff_netlists
16
+ from kigit.output_generator import generate_output
17
+ from kigit.health_checker import verify_dependencies
18
+ from kigit.logger import configure_logger, get_logger
19
+ from kigit.constants import STATUS_MESSAGES, LOG_DIR, SCHEMATICS_FILE_EXTENSION
20
+ from kigit.message_printer import print_message
21
+ import importlib.metadata
22
+ import sys
23
+
24
+ logger = get_logger()
25
+
26
+ # Override color of help subtext to white.
27
+ rich_utils.STYLE_HELPTEXT = ""
28
+
29
+ app = typer.Typer(
30
+ no_args_is_help=True,
31
+ rich_markup_mode="rich",
32
+ help="""
33
+ Welcome to kigit :sparkles:
34
+
35
+ Use [bold bright_green]kigit[/bold bright_green] or [bold bright_green]kigit --help[/bold bright_green] for general help and [bold bright_green]kigit COMMAND --help[/bold bright_green] for help on a specific command.
36
+
37
+ :warning: [yellow]Important:[/yellow] In order for this tool to work, committing an initial schematics file to the git repository is expected. An untracked schematics file won't work.
38
+ """,
39
+ )
40
+
41
+
42
+ # Runs before each command
43
+ @app.callback()
44
+ def init():
45
+ configure_logger()
46
+
47
+
48
+ @app.command()
49
+ def version():
50
+ """
51
+ Gives app version.
52
+ """
53
+ version = importlib.metadata.version("kigit")
54
+ print_message(f"kigit version {version}", type="Override")
55
+
56
+
57
+ @app.command()
58
+ def check_health():
59
+ """
60
+ Checks if all required dependencies to run kigit are present on the system.
61
+ """
62
+
63
+ verify_dependencies(silent=False)
64
+
65
+
66
+ @app.command()
67
+ def diff(
68
+ c1_ref: Annotated[
69
+ str,
70
+ typer.Option(
71
+ "--c1-ref", "-c1", help="Hash of reference commit for comparison."
72
+ ),
73
+ ] = "HEAD",
74
+ c2_ref: Annotated[
75
+ str | None,
76
+ typer.Option(
77
+ "--c2-ref",
78
+ "-c2",
79
+ help="Hash of reference commit for comparison with the first reference commit.",
80
+ ),
81
+ ] = None,
82
+ search_depth: Annotated[
83
+ int,
84
+ typer.Option(
85
+ "--search-depth",
86
+ "-p",
87
+ help="Search depth when looking up the schematics file.",
88
+ ),
89
+ ] = 2,
90
+ search_directory: Annotated[
91
+ Path,
92
+ typer.Option(
93
+ "--search-directory", "-d", help="Root path for schematics file lookup."
94
+ ),
95
+ ] = Path("."),
96
+ verbose: Annotated[
97
+ bool,
98
+ typer.Option("--verbose", "-v", help="Shows a more detailed diffing output."),
99
+ ] = False,
100
+ ):
101
+ """
102
+ Summarizes differences between two KiCad project states in a structured and understandable way, filtering out noise from formatting, ordering, and tool-generated metadata.
103
+
104
+ [deep_sky_blue1]Remarks:[/deep_sky_blue1]
105
+ When omitting the second reference commit, the comparison will be done between the first reference commit (default: HEAD) and the file in the working directory.
106
+ """
107
+
108
+ try:
109
+ result = verify_dependencies()
110
+
111
+ if not result:
112
+ raise typer.Exit(code=1)
113
+
114
+ schematics_file_path = find_sch_file(
115
+ search_directory, SCHEMATICS_FILE_EXTENSION, search_depth
116
+ )
117
+
118
+ if schematics_file_path is None:
119
+ print_message(
120
+ formatted_message=STATUS_MESSAGES[
121
+ "NO_SCHEMATICS_FILE_FOUND_MESSAGE"
122
+ ].format(search_directory=search_directory),
123
+ type="Error",
124
+ )
125
+ raise typer.Exit(code=1)
126
+
127
+ c1_sch_path = get_reference_file(c1_ref, schematics_file_path)
128
+ c1_netlist_xml = export_netlist(c1_sch_path)
129
+ c1_netlist = parse_netlist_from_xml(c1_netlist_xml)
130
+
131
+ if c2_ref is None:
132
+ c2_sch_path = schematics_file_path
133
+ else:
134
+ c2_sch_path = get_reference_file(c2_ref, schematics_file_path)
135
+
136
+ c2_netlist_xml = export_netlist(c2_sch_path)
137
+ c2_netlist = parse_netlist_from_xml(c2_netlist_xml)
138
+
139
+ netlist_diff = diff_netlists(c1_netlist, c2_netlist)
140
+
141
+ print_message(generate_output(netlist_diff, verbose), type="Override")
142
+ except typer.Exit as e:
143
+ sys.exit(e.exit_code)
144
+ except Exception as x:
145
+ logger.exception(x)
146
+ print_message(
147
+ formatted_message=STATUS_MESSAGES["GENERAL_EXCEPTION_MESSAGE"].format(
148
+ log_dir=LOG_DIR
149
+ ),
150
+ type="Error",
151
+ )
152
+ finally:
153
+ cleanup_temp_data()
154
+
155
+
156
+ if __name__ == "__main__":
157
+ app()
kigit/constants.py ADDED
@@ -0,0 +1,22 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ SCHEMATICS_FILE_EXTENSION = ".kicad_sch"
7
+ PROJECT_FILE_EXTENSION = ".kicad_pro"
8
+ IGNORED_LOOKUP_PATHS = [".git", ".history", "kigit-log"]
9
+ NETLIST_TEMP_DIR = ".kigit_netlist_temp"
10
+ SUBDIR_FOR_DIFFS = ".kigit_temp"
11
+ REF_SCH_FILENAME = "schematic.kicad_sch"
12
+ LOGGER_KEY = "c_logger"
13
+ LOG_DIR = "kigit-log"
14
+ LOG_FILE_NAME = "kigit.log"
15
+
16
+ STATUS_MESSAGES = {
17
+ "NO_SCHEMATICS_FILE_FOUND_MESSAGE": "No schematics file (.kicad_sch) has been found with the current parameters. Try adjusting the search depth if the file is nested.\nRoot path used: '{search_directory}'",
18
+ "GENERAL_EXCEPTION_MESSAGE": "App has exited with an exception. See the '{log_dir}' folder for app logs.",
19
+ "GIT_NOT_FOUND_MESSAGE": "Couldn't find Git on your system. Git is required for this tool to work.",
20
+ "KICAD_NOT_FOUND_MESSAGE": "Couldn't find KiCad on your system. KiCad is required for this tool to work.",
21
+ "NO_CHANGES_TO_SHOW_MESSAGE": "No semantically meaningful changes to show.\n\n (Hint: only added, removed, changed and renamed items are shown (i.e. code changes are discarded.)",
22
+ }
kigit/diff.py ADDED
@@ -0,0 +1,121 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ from dataclasses import dataclass
7
+ from kigit.parser import Net, Node
8
+ from kigit.semantic_categorization import classify_change_impact
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class NetRename:
13
+ old: Net
14
+ new: Net
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class NetDiff:
19
+ new: Net
20
+ old: Net
21
+ impact_tags: tuple[str, ...] = ()
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class NetlistDiff:
26
+ added: frozenset[Net]
27
+ removed: frozenset[Net]
28
+ changed: frozenset[NetDiff]
29
+ renamed: frozenset[NetRename]
30
+
31
+
32
+ def _match_renames(removed, added):
33
+ candidates = [
34
+ (r, a)
35
+ for r in removed
36
+ for a in added
37
+ if r.nodes and set(r.nodes) == set(a.nodes)
38
+ ]
39
+
40
+ candidates.sort(key=lambda pair: (pair[0].name, pair[1].name))
41
+
42
+ used_old, used_new, renames = set(), set(), []
43
+ for r, a in candidates:
44
+ if r.name in used_old or a.name in used_new:
45
+ continue
46
+ renames.append(NetRename(old=r, new=a))
47
+ used_old.add(r.name)
48
+ used_new.add(a.name)
49
+
50
+ leftover_removed = frozenset(r for r in removed if r.name not in used_old)
51
+ leftover_added = frozenset(a for a in added if a.name not in used_new)
52
+ return frozenset(renames), leftover_removed, leftover_added
53
+
54
+
55
+ def diff_netlists(old, new):
56
+ old_nets = {n.name: n for n in old.nets if not _is_unconnected(n)}
57
+ new_nets = {n.name: n for n in new.nets if not _is_unconnected(n)}
58
+
59
+ added = frozenset(new_nets[name] for name in new_nets.keys() - old_nets.keys())
60
+ removed = frozenset(old_nets[name] for name in old_nets.keys() - new_nets.keys())
61
+
62
+ renamed, removed, added = _match_renames(removed, added)
63
+
64
+ changed = set()
65
+ for name in old_nets.keys() & new_nets.keys():
66
+ old_net = old_nets[name]
67
+ new_net = new_nets[name]
68
+
69
+ changes = get_net_changes(old_net, new_net)
70
+ if not changes:
71
+ continue
72
+
73
+ impact_tags = tuple(classify_change_impact(old_net, new_net, changes))
74
+ changed.add(NetDiff(old=old_net, new=new_net, impact_tags=impact_tags))
75
+
76
+ return NetlistDiff(
77
+ added=added, removed=removed, changed=frozenset(changed), renamed=renamed
78
+ )
79
+
80
+
81
+ def _is_unconnected(net):
82
+ return net.name.startswith("unconnected-")
83
+
84
+
85
+ def get_net_changes(old: Net, new: Net) -> dict | None:
86
+ if old == new:
87
+ return None
88
+
89
+ changes = {}
90
+
91
+ if old.name != new.name:
92
+ changes["name"] = {"old": old.name, "new": new.name}
93
+
94
+ if old.net_class != new.net_class:
95
+ changes["net_class"] = {"old": old.net_class, "new": new.net_class}
96
+
97
+ if old.nodes != new.nodes:
98
+ changes["nodes"] = compare_node_lists(old.nodes, new.nodes)
99
+
100
+ return changes
101
+
102
+
103
+ def compare_node_lists(
104
+ old_nodes: tuple[Node, ...], new_nodes: tuple[Node, ...]
105
+ ) -> dict:
106
+
107
+ # Assuming n.ref, n.pin can uniquely identify a node.
108
+ # TODO: Might change later if criteria isn't enough
109
+
110
+ old_dict = {(n.ref, n.pin): n for n in old_nodes}
111
+ new_dict = {(n.ref, n.pin): n for n in new_nodes}
112
+
113
+ return {
114
+ "added": [new_dict[key] for key in new_dict.keys() - old_dict.keys()],
115
+ "removed": [old_dict[key] for key in old_dict.keys() - new_dict.keys()],
116
+ "changed": [
117
+ {"old": old_dict[key], "new": new_dict[key]}
118
+ for key in old_dict.keys() & new_dict.keys()
119
+ if old_dict[key] != new_dict[key]
120
+ ],
121
+ }
kigit/extractor.py ADDED
@@ -0,0 +1,46 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ import subprocess
7
+ from pathlib import Path
8
+ from kigit.constants import NETLIST_TEMP_DIR
9
+
10
+ KICAD_CLI = "kicad-cli"
11
+
12
+
13
+ def export_netlist(sch_path: Path) -> str:
14
+ """Runs `kicad-cli sch export netlist --format kicadxml` and returns the unparsed xml as str."""
15
+
16
+ if not sch_path.is_file():
17
+ raise FileNotFoundError(sch_path)
18
+
19
+ ouput_path = Path(NETLIST_TEMP_DIR) / "netlist.xml"
20
+ ouput_path.parent.mkdir(parents=True, exist_ok=True)
21
+
22
+ try:
23
+ subprocess.run(
24
+ [
25
+ "kicad-cli",
26
+ "sch",
27
+ "export",
28
+ "netlist",
29
+ "--format",
30
+ "kicadxml",
31
+ "--output",
32
+ ouput_path,
33
+ sch_path.as_posix(),
34
+ ],
35
+ capture_output=True,
36
+ text=True,
37
+ encoding="utf-8",
38
+ check=True,
39
+ ).stdout
40
+
41
+ with open(ouput_path) as file:
42
+ return file.read()
43
+ except subprocess.CalledProcessError as e:
44
+ raise RuntimeError(
45
+ f"Got an invalid response when calling kicad-cli:\n Error:\n {e.stderr}"
46
+ )
kigit/file_ops.py ADDED
@@ -0,0 +1,88 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ from os import walk
7
+ from os.path import join
8
+ from pathlib import Path
9
+ from shutil import rmtree
10
+ from kigit.constants import (
11
+ PROJECT_FILE_EXTENSION,
12
+ SUBDIR_FOR_DIFFS,
13
+ NETLIST_TEMP_DIR,
14
+ IGNORED_LOOKUP_PATHS,
15
+ )
16
+
17
+
18
+ def _find_first_file(root_path: Path, extension: str, depth: int) -> Path | None:
19
+ """Searches the current directory with the provided depth for the first file that matches the provided extension"""
20
+
21
+ if not extension.startswith("."):
22
+ extension = "." + extension
23
+
24
+ for dirpath, dirs, files in walk(root_path):
25
+ current_dir = Path(dirpath).relative_to(root_path)
26
+
27
+ current_depth = len(current_dir.parts)
28
+ if current_depth > depth:
29
+ dirs.clear()
30
+ continue
31
+
32
+ dirs[:] = [d for d in dirs if not should_ignore_path(current_dir / d)]
33
+
34
+ for file in files:
35
+ if file.endswith(extension):
36
+ return Path(join(dirpath, file))
37
+
38
+ return None
39
+
40
+
41
+ def find_sch_file(root_path: Path, extension: str, depth: int) -> Path | None:
42
+ """Searches the current directory with the provided depth for the first file that matches the provided extension"""
43
+
44
+ pro_file_path: Path | None = _find_first_file(
45
+ root_path=root_path, extension=PROJECT_FILE_EXTENSION, depth=depth
46
+ )
47
+ if pro_file_path is None:
48
+ return None
49
+
50
+ if not extension.startswith("."):
51
+ extension = "." + extension
52
+
53
+ for dirpath, dirs, files in walk(root_path):
54
+ current_dir = Path(dirpath).relative_to(root_path)
55
+
56
+ current_depth = len(current_dir.parts)
57
+ if current_depth > depth:
58
+ dirs.clear()
59
+ continue
60
+
61
+ dirs[:] = [d for d in dirs if not should_ignore_path(current_dir / d)]
62
+
63
+ for file in files:
64
+ if file.endswith(extension) and Path(file).stem == pro_file_path.stem:
65
+ return Path(join(dirpath, file))
66
+
67
+ return None
68
+
69
+
70
+ def should_ignore_path(path: Path) -> bool:
71
+ for ignored in IGNORED_LOOKUP_PATHS:
72
+ ignored_path = Path(ignored)
73
+ try:
74
+ path.relative_to(ignored_path)
75
+ return True
76
+ except ValueError:
77
+ # Not relative, continue
78
+ pass
79
+
80
+ return False
81
+
82
+
83
+ def cleanup_temp_data():
84
+ if Path.exists(Path(SUBDIR_FOR_DIFFS)):
85
+ rmtree(SUBDIR_FOR_DIFFS, ignore_errors=True)
86
+
87
+ if Path.exists(Path(NETLIST_TEMP_DIR)):
88
+ rmtree(NETLIST_TEMP_DIR, ignore_errors=True)
@@ -0,0 +1,31 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ import shutil
7
+ from pathlib import Path
8
+ from kigit.constants import SUBDIR_FOR_DIFFS, REF_SCH_FILENAME
9
+ import subprocess
10
+
11
+
12
+ def get_reference_file(ref: str, file_path: Path) -> Path:
13
+ out_file_path = Path(SUBDIR_FOR_DIFFS) / REF_SCH_FILENAME
14
+
15
+ try:
16
+ result = subprocess.run(
17
+ ["git", "show", f"{ref}:{Path(file_path).as_posix()}"],
18
+ text=True,
19
+ check=True,
20
+ capture_output=True,
21
+ encoding="utf-8",
22
+ )
23
+ except subprocess.CalledProcessError as e:
24
+ raise RuntimeError(
25
+ f"Failed to retrieve file '{file_path}' from git ref '{ref}': {e.stderr}"
26
+ )
27
+
28
+ out_file_path.parent.mkdir(parents=True, exist_ok=True)
29
+ out_file_path.write_text(result.stdout, encoding="utf-8")
30
+
31
+ return out_file_path
@@ -0,0 +1,57 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ import subprocess
7
+ from kigit.constants import STATUS_MESSAGES
8
+ from kigit.message_printer import print_message
9
+
10
+
11
+ def check_git() -> bool:
12
+ try:
13
+ subprocess.run(
14
+ ["git", "--version"],
15
+ capture_output=True,
16
+ check=True,
17
+ text=True,
18
+ encoding="utf-8",
19
+ )
20
+ except (subprocess.CalledProcessError, FileNotFoundError):
21
+ return False
22
+
23
+ return True
24
+
25
+
26
+ def check_kicad() -> bool:
27
+ try:
28
+ subprocess.run(
29
+ ["kicad-cli", "--version"],
30
+ capture_output=True,
31
+ check=True,
32
+ text=True,
33
+ encoding="utf-8",
34
+ )
35
+ except (subprocess.CalledProcessError, FileNotFoundError):
36
+ return False
37
+
38
+ return True
39
+
40
+
41
+ def verify_dependencies(silent: bool = True) -> bool:
42
+ has_git = check_git()
43
+ has_kicad = check_kicad()
44
+ has_errors = False
45
+
46
+ if not has_git:
47
+ print_message(STATUS_MESSAGES["GIT_NOT_FOUND_MESSAGE"], type="Error")
48
+ has_errors = True
49
+
50
+ if not has_kicad:
51
+ print_message(STATUS_MESSAGES["KICAD_NOT_FOUND_MESSAGE"], type="Error")
52
+ has_errors = True
53
+
54
+ if not silent and not has_errors:
55
+ print_message("All dependencies OK. Ready to use kigit.", type="Success")
56
+
57
+ return not has_errors
kigit/logger.py ADDED
@@ -0,0 +1,34 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ from kigit.constants import LOG_DIR, LOGGER_KEY, LOG_FILE_NAME
7
+ from pathlib import Path
8
+
9
+ import logging
10
+
11
+
12
+ def get_logger():
13
+ return logging.getLogger(LOGGER_KEY)
14
+
15
+
16
+ def ensure_log_path_exists():
17
+ path = Path(".") / LOG_DIR
18
+
19
+ if not path.exists():
20
+ path.mkdir(exist_ok=True)
21
+
22
+
23
+ def configure_logger():
24
+ ensure_log_path_exists()
25
+ logger = get_logger()
26
+
27
+ # Remove all present handlers to avoid duplicate logging messages
28
+ logger.handlers.clear()
29
+
30
+ path = Path(".") / LOG_DIR / LOG_FILE_NAME
31
+ logging.basicConfig(
32
+ format="\n[%(levelname)s]\nDate: %(asctime)s\nFile: (%(filename)s)\nLine: %(lineno)d\nFunction: %(funcName)s\nLog Message: %(message)s\n",
33
+ handlers=[logging.FileHandler(Path(path))],
34
+ )
@@ -0,0 +1,32 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ from typing import Literal
7
+
8
+ import typer
9
+
10
+
11
+ def print_message(
12
+ formatted_message: str,
13
+ type: Literal[
14
+ "Error", "Success", "Warning", "Information", "Override"
15
+ ] = "Override",
16
+ ):
17
+ if type == "Error":
18
+ color = typer.colors.BRIGHT_RED
19
+ elif type == "Warning":
20
+ color = typer.colors.BRIGHT_YELLOW
21
+ elif type == "Success":
22
+ color = typer.colors.BRIGHT_GREEN
23
+ elif type == "Information":
24
+ color = typer.colors.BRIGHT_BLUE
25
+ else:
26
+ color = typer.colors.WHITE
27
+
28
+ if type == "Override":
29
+ # Use the colors that are specified in the message
30
+ typer.echo(typer.style(formatted_message))
31
+ else:
32
+ typer.echo(typer.style(formatted_message, color), err=type == "Error")
@@ -0,0 +1,243 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ import typer
7
+ from typing import Optional
8
+ from kigit.parser import Node, Net
9
+ from kigit.diff import NetRename, NetlistDiff, get_net_changes, NetDiff
10
+ from kigit.constants import STATUS_MESSAGES
11
+ from kigit.semantic_categorization import classify_net_properties
12
+
13
+ SECTION_SEPARATOR = "========================================="
14
+
15
+
16
+ def format_pin_function(pin_function: Optional[str]) -> str:
17
+ """Extract and format pin function from KiCad format."""
18
+ if not pin_function:
19
+ return ""
20
+
21
+ base_function = (
22
+ pin_function[: pin_function.rfind("_")] if "_" in pin_function else pin_function
23
+ )
24
+ return f"with {base_function} function "
25
+
26
+
27
+ def format_node(node: Node, prefix: str = "") -> str:
28
+ """Format a single node for display."""
29
+ pin_func = format_pin_function(node.pin_function)
30
+ return f"{prefix}Element {node.ref} on pin {node.pin} {pin_func}and {node.pintype} type."
31
+
32
+
33
+ def format_net_nodes(nodes: tuple[Node, ...], prefix: str = "") -> str:
34
+ """Format all nodes in a net."""
35
+ return "\n".join(format_node(node, prefix) for node in nodes)
36
+
37
+
38
+ def generate_net_section(nets: frozenset[Net], section_type: str) -> str:
39
+ if not nets:
40
+ return ""
41
+
42
+ color_map = {
43
+ "added": typer.colors.GREEN,
44
+ "removed": typer.colors.RED,
45
+ }
46
+ color = color_map.get(section_type, typer.colors.WHITE)
47
+
48
+ lines = []
49
+ for net in nets:
50
+ lines.append(typer.style(f"\tNet '{net.name}':", fg=color))
51
+ for node in net.nodes:
52
+ prefix = "+ " if section_type == "added" else "- "
53
+ lines.append(f"\t\t{prefix}{format_node(node)}")
54
+
55
+ header = typer.style(f"{section_type.title()} Nets:\n", fg=color, bold=True)
56
+ body = "\n".join(lines) + "\n"
57
+ return header + body + "\n" + SECTION_SEPARATOR + "\n\n"
58
+
59
+
60
+ def format_node_change(node_info: dict) -> str:
61
+ """Format a single changed node."""
62
+ old, new = node_info["old"], node_info["new"]
63
+ changes = []
64
+
65
+ if old.ref != new.ref:
66
+ changes.append(f"ref {old.ref} → {new.ref}")
67
+ if old.pin != new.pin:
68
+ changes.append(f"pin {old.pin} → {new.pin}")
69
+ if old.pin_function != new.pin_function:
70
+ changes.append(f"pin_function {old.pin_function} → {new.pin_function}")
71
+ if old.pintype != new.pintype:
72
+ changes.append(f"pintype {old.pintype} → {new.pintype}")
73
+
74
+ return ", ".join(changes) if changes else "no significant changes"
75
+
76
+
77
+ def generate_node_changes(node_changes: dict) -> str:
78
+ """Generate output for node changes (added, removed, changed)."""
79
+ lines = []
80
+
81
+ if node_changes.get("added"):
82
+ lines.append("\t\tAdded nodes:")
83
+ for node in node_changes["added"]:
84
+ lines.append(
85
+ typer.style(
86
+ f"\t\t\t+ {node.ref} pin {node.pin} ({node.pin_function or 'no function'}, {node.pintype})",
87
+ fg=typer.colors.GREEN,
88
+ )
89
+ )
90
+
91
+ if node_changes.get("changed"):
92
+ lines.append("\t\tChanged nodes:")
93
+ for change in node_changes["changed"]:
94
+ lines.append(
95
+ typer.style(
96
+ f"\t\t\t* {format_node_change(change)}", fg=typer.colors.YELLOW
97
+ )
98
+ )
99
+
100
+ if node_changes.get("removed"):
101
+ lines.append("\t\tRemoved nodes:")
102
+ for node in node_changes["removed"]:
103
+ lines.append(
104
+ typer.style(
105
+ f"\t\t\t- {node.ref} pin {node.pin} ({node.pin_function or 'no function'}, {node.pintype})",
106
+ fg=typer.colors.RED,
107
+ )
108
+ )
109
+
110
+ return "\n".join(lines)
111
+
112
+
113
+ def generate_changed_section(changed_diffs: frozenset[NetDiff]) -> str:
114
+ """Generate output for changed nets."""
115
+ if not changed_diffs:
116
+ return ""
117
+
118
+ lines = [typer.style("Changed Nets:", fg=typer.colors.YELLOW, bold=True)]
119
+
120
+ for net_diff in sorted(changed_diffs, key=lambda nd: nd.old.name):
121
+ old_net = net_diff.old
122
+ new_net = net_diff.new
123
+
124
+ changes = get_net_changes(old_net, new_net)
125
+ if not changes:
126
+ continue
127
+
128
+ lines.append(
129
+ typer.style(
130
+ f"\tNet '{old_net.name}':",
131
+ fg=typer.colors.YELLOW,
132
+ )
133
+ )
134
+
135
+ properties = classify_net_properties(new_net)
136
+ if properties:
137
+ lines.append(f"\t\t* properties: {', '.join(properties)}")
138
+
139
+ if net_diff.impact_tags:
140
+ lines.append(
141
+ typer.style(
142
+ f"\t\t* impact: {', '.join(net_diff.impact_tags)}",
143
+ fg=typer.colors.CYAN,
144
+ )
145
+ )
146
+
147
+ for field_name, change_info in changes.items():
148
+ if field_name == "nodes":
149
+ lines.append(generate_node_changes(change_info))
150
+ else:
151
+ lines.append(
152
+ f"\t\t* {field_name}: {change_info['old']} → {change_info['new']}"
153
+ )
154
+
155
+ body = "\n".join(lines) + "\n"
156
+
157
+ return body + "\n" + SECTION_SEPARATOR + "\n"
158
+
159
+
160
+ def generate_renamed_section(net_renames: frozenset[NetRename]) -> str:
161
+ """Generate output for changed nets."""
162
+ if not net_renames:
163
+ return ""
164
+
165
+ lines = [typer.style("Renamed Nets:", fg=typer.colors.BRIGHT_MAGENTA, bold=True)]
166
+
167
+ for net_rename in net_renames:
168
+ old_net = net_rename.old
169
+ new_net = net_rename.new
170
+
171
+ lines.append(
172
+ typer.style(
173
+ f"\tNet renamed: '{old_net.name}' -> '{new_net.name}'", # dont need (code: {old_net.code}) - Ilya
174
+ typer.colors.BRIGHT_MAGENTA,
175
+ )
176
+ )
177
+
178
+ body = "\n".join(lines) + "\n"
179
+ return body + "\n" + SECTION_SEPARATOR + "\n"
180
+
181
+
182
+ def generate_output(netlist_diff: NetlistDiff, verbose: bool) -> str:
183
+ """Generate human-readable output from netlist differences."""
184
+
185
+ if not verbose:
186
+ return _generate_brief_output(netlist_diff)
187
+
188
+ sections = []
189
+
190
+ added_section = generate_net_section(netlist_diff.added, "added")
191
+ removed_section = generate_net_section(netlist_diff.removed, "removed")
192
+ changed_section = generate_changed_section(netlist_diff.changed)
193
+ renamed_secion = generate_renamed_section(netlist_diff.renamed)
194
+
195
+ if added_section:
196
+ sections.append(added_section)
197
+ if removed_section:
198
+ sections.append(removed_section)
199
+ if changed_section:
200
+ sections.append(changed_section)
201
+ if renamed_secion:
202
+ sections.append(renamed_secion)
203
+
204
+ return (
205
+ "".join(sections) if sections else STATUS_MESSAGES["NO_CHANGES_TO_SHOW_MESSAGE"]
206
+ )
207
+
208
+
209
+ def _generate_brief_output(netlist_diff: NetlistDiff) -> str:
210
+ lines = []
211
+ if netlist_diff.added:
212
+ lines.append(
213
+ typer.style(
214
+ f"+ {len(netlist_diff.added)} net(s) added",
215
+ fg=typer.colors.GREEN,
216
+ bold=True,
217
+ )
218
+ )
219
+ if netlist_diff.removed:
220
+ lines.append(
221
+ typer.style(
222
+ f"- {len(netlist_diff.removed)} net(s) removed",
223
+ fg=typer.colors.RED,
224
+ bold=True,
225
+ )
226
+ )
227
+ if netlist_diff.changed:
228
+ lines.append(
229
+ typer.style(
230
+ f"~ {len(netlist_diff.changed)} net(s) changed",
231
+ fg=typer.colors.YELLOW,
232
+ bold=True,
233
+ )
234
+ )
235
+ if netlist_diff.renamed:
236
+ lines.append(
237
+ typer.style(
238
+ f"~ {len(netlist_diff.renamed)} net(s) renamed",
239
+ fg=typer.colors.BRIGHT_MAGENTA,
240
+ bold=True,
241
+ )
242
+ )
243
+ return "\n".join(lines) if lines else STATUS_MESSAGES["NO_CHANGES_TO_SHOW_MESSAGE"]
kigit/parser.py ADDED
@@ -0,0 +1,61 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional
8
+ import xml.etree.ElementTree as ET
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Node:
13
+ ref: str
14
+ pin: str
15
+ pin_function: Optional[str]
16
+ pintype: str
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Net:
21
+ code: str = field(compare=False)
22
+ name: str
23
+ net_class: str
24
+ nodes: tuple["Node", ...]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Netlist:
29
+ nets: tuple["Net", ...]
30
+
31
+
32
+ def parse_netlist_from_xml(xml: str) -> Netlist:
33
+ root = ET.fromstring(xml)
34
+
35
+ nets_tag = root.find("nets")
36
+ if nets_tag is None:
37
+ raise ValueError("No <nets> tag found in netlist XML.")
38
+
39
+ nets = []
40
+ for net_el in nets_tag.findall("net"):
41
+ a = net_el.attrib
42
+ code = a.get("code") or ""
43
+ nodes = tuple(
44
+ Node(
45
+ ref=ne.attrib.get("ref") or "",
46
+ pin=ne.attrib.get("pin") or "",
47
+ pin_function=ne.attrib.get("pinfunction"),
48
+ pintype=ne.attrib.get("pintype") or "",
49
+ )
50
+ for ne in net_el.findall("node")
51
+ )
52
+ nets.append(
53
+ Net(
54
+ code=code,
55
+ name=a.get("name") or f"<net {code}>",
56
+ net_class=a.get("class") or "",
57
+ nodes=nodes,
58
+ )
59
+ )
60
+
61
+ return Netlist(nets=tuple(nets))
@@ -0,0 +1,74 @@
1
+ # Copyright (c) 2026 RUB-SELAB-2026
2
+ #
3
+ # Licensed under the MIT License.
4
+ # See the LICENSE file in the project root for full license information.
5
+
6
+ from kigit.parser import Net
7
+
8
+ CRITICAL_NET_NAMES = {"GND", "VCC", "+3V3", "+5V", "+24V", "VIN"}
9
+ POWER_NAME_FRAGMENTS = ("VCC", "3V3", "5V", "24V", "VIN")
10
+ HIGH_SPEED_KEYWORDS = {"CLK", "CLOCK", "SPI", "USB", "I2C", "UART", "TX", "RX"}
11
+
12
+ MEDIUM_CONNECTIVITY_THRESHOLD = 5
13
+ HIGH_CONNECTIVITY_THRESHOLD = 10
14
+
15
+
16
+ def _is_power_net(name: str) -> bool:
17
+ return any(fragment in name for fragment in POWER_NAME_FRAGMENTS)
18
+
19
+
20
+ def _is_critical_net(name: str) -> bool:
21
+ return name in CRITICAL_NET_NAMES or _is_power_net(name)
22
+
23
+
24
+ def classify_net_properties(net: Net) -> list[str]:
25
+ """Describe the *nature* of a net (power, ground, high-speed, ...)."""
26
+ tags: list[str] = []
27
+ name = net.name.upper()
28
+
29
+ if _is_critical_net(name):
30
+ tags.append("critical-net")
31
+ if _is_power_net(name):
32
+ tags.append("power-net")
33
+ if "GND" in name:
34
+ tags.append("ground-net")
35
+ if any(keyword in name for keyword in HIGH_SPEED_KEYWORDS):
36
+ tags.append("high-speed-net")
37
+
38
+ node_count = len(net.nodes)
39
+ if node_count > HIGH_CONNECTIVITY_THRESHOLD:
40
+ tags.append("high-connectivity")
41
+ elif node_count > MEDIUM_CONNECTIVITY_THRESHOLD:
42
+ tags.append("medium-connectivity")
43
+ if node_count == 0:
44
+ tags.append("empty-net")
45
+
46
+ return tags
47
+
48
+
49
+ def classify_change_impact(old: Net, new: Net, net_changes: dict | None) -> list[str]:
50
+ """Describe the *meaning* of a change."""
51
+ tags: list[str] = []
52
+
53
+ if _is_critical_net(old.name.upper()) or _is_critical_net(new.name.upper()):
54
+ tags.append("critical-net-change")
55
+
56
+ if len(new.nodes) > len(old.nodes):
57
+ tags.append("connectivity-increased")
58
+ elif len(new.nodes) < len(old.nodes):
59
+ tags.append("connectivity-decreased")
60
+
61
+ if net_changes and "nodes" in net_changes:
62
+ nodes_diff = net_changes["nodes"]
63
+ if nodes_diff.get("added"):
64
+ tags.append("node-added")
65
+ if nodes_diff.get("removed"):
66
+ tags.append("node-removed")
67
+ for change in nodes_diff.get("changed", []):
68
+ old_node, new_node = change["old"], change["new"]
69
+ if old_node.pin_function != new_node.pin_function:
70
+ tags.append("pin-function-changed")
71
+ if old_node.pintype != new_node.pintype:
72
+ tags.append("pin-type-changed")
73
+
74
+ return sorted(set(tags))
@@ -0,0 +1,228 @@
1
+ Metadata-Version: 2.3
2
+ Name: kigit
3
+ Version: 0.0.1
4
+ Summary: KiCad-semantics aware git diff and git status CLI tool
5
+ Requires-Dist: typer>=0.25.1
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+
9
+ # kigit
10
+
11
+ **KiCad-aware `git diff` and `git status` for your schematics.**
12
+
13
+ [![PyPI version](https://img.shields.io/pypi/v/kigit.svg)](https://pypi.org/project/kigit/)
14
+ [![Python versions](https://img.shields.io/pypi/pyversions/kigit.svg)](https://pypi.org/project/kigit/)
15
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
16
+
17
+ `kigit` is a command-line tool that makes Git output for [KiCad](https://www.kicad.org/)
18
+ projects readable. Raw `git diff` on a `.kicad_sch` file is dominated by formatting,
19
+ re-ordering, and tool-generated metadata, so it tells you *that* a file changed but not
20
+ *what* meaningfully changed. `kigit` cuts through that noise and reports the change in
21
+ electrical terms instead: which nets were added, removed, renamed, or rewired.
22
+
23
+ > **Status: early preview (0.0.1).** This is the first public release. It focuses on the
24
+ > schematic netlist and intentionally does a small thing well. Interfaces and output may
25
+ > change in future versions. Bug reports and feedback are very welcome.
26
+
27
+ ---
28
+
29
+ ## What it does
30
+
31
+ `kigit` answers questions a plain text diff struggles with, such as *"Did the wiring
32
+ actually change, or is this just KiCad reshuffling the file?"*
33
+
34
+ For the schematic in your repository it:
35
+
36
+ - locates the schematic via your project (`.kicad_pro`) file,
37
+ - exports the **netlist** for two project states using KiCad's own `kicad-cli`,
38
+ - compares the two netlists semantically, ignoring text-level noise, and
39
+ - prints a structured, color-coded summary of the real changes.
40
+
41
+ It reports nets that were **added**, **removed**, **changed** (nodes/pins added,
42
+ removed, or modified; net-class changes), and **renamed** (a net whose connections are
43
+ unchanged but whose name differs). Each changed net is annotated with semantic tags such
44
+ as `power-net`, `ground-net`, `critical-net`, `high-speed-net`, connectivity level, and
45
+ impact tags like `connectivity-increased`, `node-added`, or `pin-function-changed`.
46
+ Purely cosmetic differences and unconnected (`unconnected-*`) nets are filtered out.
47
+
48
+ ### Not in scope yet
49
+
50
+ To set expectations for 0.0.1, the following are **not** analyzed yet and are planned for
51
+ later releases:
52
+
53
+ - component **value** changes (e.g. a resistor going from 10k to 4.7k),
54
+ - **PCB layout** (`.kicad_pcb`) changes,
55
+ - further changes
56
+
57
+ ---
58
+
59
+ ## Requirements
60
+
61
+ `kigit` orchestrates the tools you already use; it does not reimplement them.
62
+
63
+ - **Python** 3.10 or newer.
64
+ - **Git**, available on your `PATH`.
65
+ - **KiCad 10.x**, with the bundled **`kicad-cli`** available on your `PATH`.
66
+ (`kicad-cli` ships with the standard KiCad installation on Windows, Linux, and macOS.)
67
+
68
+ `kigit` is cross-platform and runs on **Windows and Linux** (and other platforms where
69
+ Python, Git, and KiCad are available). Run `kigit check-health` at any time to confirm
70
+ your environment is ready.
71
+
72
+ ---
73
+
74
+ ## Installation
75
+
76
+ From PyPI with `pip`:
77
+
78
+ ```bash
79
+ pip install kigit
80
+ ```
81
+
82
+ Or as an isolated tool with [uv](https://docs.astral.sh/uv/):
83
+
84
+ ```bash
85
+ uv tool install kigit
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Quick start
91
+
92
+ > **Prerequisite:** your schematic must be committed to Git at least once. `kigit`
93
+ > retrieves historical versions with `git show`, so an untracked schematic cannot be
94
+ > diffed.
95
+
96
+ ```bash
97
+ # Compare your working changes against the last commit (the "status" use case):
98
+ kigit diff
99
+
100
+ # Compare two commits, branches, or tags:
101
+ kigit diff -c1 main -c2 my-feature-branch
102
+
103
+ # See full detail instead of a summary:
104
+ kigit diff --verbose
105
+
106
+ # Confirm Git and KiCad are installed and reachable:
107
+ kigit check-health
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Commands
113
+
114
+ Run `kigit` or `kigit --help` for general help, and `kigit COMMAND --help` for details on
115
+ a specific command.
116
+
117
+ ### `kigit diff`
118
+
119
+ Summarizes the differences between two KiCad project states, filtering out noise from
120
+ formatting, ordering, and tool-generated metadata.
121
+
122
+ | Option | Alias | Default | Description |
123
+ | --- | --- | --- | --- |
124
+ | `--c1-ref` | `-c1` | `HEAD` | Git reference (commit, branch, or tag) used as the **first** state for comparison. |
125
+ | `--c2-ref` | `-c2` | *none* | Git reference used as the **second** state. When omitted, the first ref is compared against the **working-directory** schematic. |
126
+ | `--search-depth` | `-p` | `2` | How many directory levels to descend when locating the schematic file. |
127
+ | `--search-directory` | `-d` | `.` | Root directory for the schematic file lookup. |
128
+ | `--verbose` | `-v` | `false` | Show a detailed, per-net and per-node breakdown instead of a brief count summary. |
129
+
130
+ Behavior of the default invocation: with no `-c2`, `kigit diff` compares the first
131
+ reference (default `HEAD`) against the current schematic on disk — i.e. "what have I
132
+ changed since my last commit?".
133
+
134
+ ### `kigit check-health`
135
+
136
+ Verifies that all required dependencies (Git and `kicad-cli`) are present on the system
137
+ and reports any that are missing.
138
+
139
+ ### `kigit version`
140
+
141
+ Prints the installed `kigit` version.
142
+
143
+ ---
144
+
145
+ ## Example output
146
+
147
+ > The examples below are illustrative; actual output is color-coded in your terminal.
148
+
149
+ Brief summary (default):
150
+
151
+ ```text
152
+ + 3 net(s) added
153
+ - 1 net(s) removed
154
+ ~ 2 net(s) changed
155
+ ~ 1 net(s) renamed
156
+ ```
157
+
158
+ Detailed view (`--verbose`), showing how a changed net is annotated:
159
+
160
+ ```text
161
+ Changed Nets:
162
+ Net '+5V':
163
+ * properties: critical-net, power-net, medium-connectivity
164
+ * impact: connectivity-increased, critical-net-change, node-added
165
+ Added nodes:
166
+ + U3 pin 4 (VCC, power_in)
167
+ ```
168
+
169
+ When nothing meaningful changed:
170
+
171
+ ```text
172
+ No semantically meaningful changes to show.
173
+
174
+ (Hint: only added, removed, changed and renamed items are shown (i.e. code changes are discarded.)
175
+ ```
176
+
177
+ ---
178
+
179
+ ## How it works
180
+
181
+ 1. **Locate** — find the project's `.kicad_pro` and the matching `.kicad_sch` within the
182
+ configured search directory and depth.
183
+ 2. **Retrieve** — for each requested Git reference, extract that version of the schematic
184
+ with `git show <ref>:<path>` into a temporary working area.
185
+ 3. **Export** — run `kicad-cli sch export netlist --format kicadxml` on each version to
186
+ produce a netlist in KiCad XML.
187
+ 4. **Parse** — read the `<nets>` section into an immutable model of nets and their
188
+ nodes (component reference, pin, pin function, pin type).
189
+ 5. **Diff** — compute added / removed / changed / renamed nets, ignoring net codes and
190
+ `unconnected-*` nets, and detect renames by matching nets with identical connections.
191
+ 6. **Classify & report** — tag each net and change with semantic categories and print a
192
+ readable summary.
193
+
194
+ ---
195
+
196
+ ## Logs and temporary files
197
+
198
+ - Diagnostic logs are written to a `kigit-log/` directory in the current working
199
+ directory. If a run fails unexpectedly, this is the first place to look.
200
+ - Temporary working data (`.kigit_temp/`, `.kigit_netlist_temp/`) is created during a run
201
+ and cleaned up automatically afterwards.
202
+
203
+ You may wish to add `kigit-log/`, `.kigit_temp/`, and `.kigit_netlist_temp/` to your
204
+ `.gitignore`.
205
+
206
+ ---
207
+
208
+ ## Development
209
+
210
+ The project uses [uv](https://docs.astral.sh/uv/) for packaging, [pytest](https://docs.pytest.org/)
211
+ and [Hypothesis](https://hypothesis.readthedocs.io/) for testing, and
212
+ [Typer](https://typer.tiangolo.com/) for the command-line interface.
213
+
214
+ ---
215
+
216
+ ## License
217
+
218
+ `kigit` is released under the [MIT License](LICENSE).
219
+
220
+ ---
221
+
222
+ ## Acknowledgements
223
+
224
+ `kigit` is developed as a Software Engineering course project for and with help from
225
+ Auto-Intern GmbH, whose hardware team uses KiCad and Git for industrial monitoring
226
+ systems. The goal is to help engineers make better commit decisions and to keep project
227
+ history readable, with the intention of maintaining it as an open-source tool for the
228
+ wider KiCad community.
@@ -0,0 +1,17 @@
1
+ kigit/__init__.py,sha256=Er02rv_MVlDoGZxPg9k32_6Lmlz3nY09F12A8NvW4X0,168
2
+ kigit/cli.py,sha256=M_s2TVGnVuvR9DNuEzPtgcSvPVgUxQlSfEE5dQi0uPU,4784
3
+ kigit/constants.py,sha256=WDLKbiPJ4veusBMLa30XKE5huRUpmw3YYn2OCMPw8Wg,1216
4
+ kigit/diff.py,sha256=gaQHWULXJ3-ImNyECztcVEZpyUSxK7JwhX1QP6Imzb4,3470
5
+ kigit/extractor.py,sha256=yz-xEV3eeGEAvA-KHJ9tBbQrDKU7Q-GUUYViqCQF37A,1278
6
+ kigit/file_ops.py,sha256=d2yyDHRRYqfYVsVoeludo90vD5-JTCtWD8Ga3XipCwU,2581
7
+ kigit/git_operations.py,sha256=CeZAisab3l1fn3YXIwy5fe6HPYBilPx0nvQ6Wmbz0vU,940
8
+ kigit/health_checker.py,sha256=xMc1cMJ397DDD6c7wvulUH5n5y5i7yslCxCfY71prkA,1431
9
+ kigit/logger.py,sha256=osAgd6q8RjU6wB6qYws1S7aSmztbkOXZjEvUo7JU9YI,893
10
+ kigit/message_printer.py,sha256=c5inyH9lUsLwfqGvhE4b2kN4dQZ3L_971rWPGhiXS2M,893
11
+ kigit/output_generator.py,sha256=V1x-XMzIt_Q_rUrv1zYibA-A3csEX5G82eJZaBwy2Yo,7805
12
+ kigit/parser.py,sha256=hgcnnO1XuT_BV2LhLIxNgGHxNPGuBPjgfDJDb0QNyXk,1472
13
+ kigit/semantic_categorization.py,sha256=TGit2Q8sXcEO7LRnrS8qbLeYiuiZ4sLK12h632-i4Ak,2487
14
+ kigit-0.0.1.dist-info/WHEEL,sha256=1uB4K0BXH0KDzZOLGYqB4NnSgGon9oosxpA-QGkDuEA,80
15
+ kigit-0.0.1.dist-info/entry_points.txt,sha256=92yizwoTA61iqADxwmHAfqdTWVEXavZkjDyHcr_KlCY,41
16
+ kigit-0.0.1.dist-info/METADATA,sha256=5cXL3PnTQ8qgRTzjrt-JrS9bqGlLGBBOdxyQeGWI9mo,7843
17
+ kigit-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.9
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ kigit = kigit.cli:app
3
+