From bc8d5717857c0a4e5f5961cd9d55f17adf7ccc0d Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Thu, 29 Jan 2026 12:00:29 +0000 Subject: [PATCH] Initial upload of auto-changelog-generator --- src/changeloggen/git_client.py | 126 +++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/changeloggen/git_client.py diff --git a/src/changeloggen/git_client.py b/src/changeloggen/git_client.py new file mode 100644 index 0000000..321136a --- /dev/null +++ b/src/changeloggen/git_client.py @@ -0,0 +1,126 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class FileChange: + """Represents a single file change with its diff content.""" + file_path: str + change_type: str + diff_content: str + staged: bool = False + lines_added: int = 0 + lines_removed: int = 0 + + def __post_init__(self): + self._calculate_line_changes() + + def _calculate_line_changes(self): + lines_added = 0 + lines_removed = 0 + for line in self.diff_content.split('\n'): + if line.startswith('+') and not line.startswith('+++'): + lines_added += 1 + elif line.startswith('-') and not line.startswith('---'): + lines_removed += 1 + self.lines_added = lines_added + self.lines_removed = lines_removed + + +@dataclass +class ChangeSet: + """Represents a collection of file changes from git diff.""" + staged_changes: list[FileChange] = field(default_factory=list) + unstaged_changes: list[FileChange] = field(default_factory=list) + commit_hash: Optional[str] = None + commit_message: Optional[str] = None + author: Optional[str] = None + timestamp: Optional[str] = None + + @property + def all_changes(self) -> list[FileChange]: + """Return all changes (staged and unstaged).""" + return self.staged_changes + self.unstaged_changes + + @property + def total_files_changed(self) -> int: + """Return total number of files changed.""" + return len(self.all_changes) + + @property + def total_lines_added(self) -> int: + """Return total lines added across all changes.""" + return sum(change.lines_added for change in self.all_changes) + + @property + def total_lines_removed(self) -> int: + """Return total lines removed across all changes.""" + return sum(change.lines_removed for change in self.all_changes) + + +def parse_change_type(status_char: str) -> str: + """Parse git status character to change type.""" + status_map = { + 'A': 'added', + 'M': 'modified', + 'D': 'deleted', + 'R': 'renamed', + 'C': 'copied', + 'M': 'modified', + '??': 'untracked', + } + return status_map.get(status_char, 'modified') + + +def get_diff_lines(diff_output: str) -> list[str]: + """Parse diff output into individual file diffs.""" + if not diff_output.strip(): + return [] + lines = diff_output.split('\n') + file_diffs = [] + current_diff = [] + + for line in lines: + if line.startswith('diff --git'): + if current_diff: + file_diffs.append('\n'.join(current_diff)) + current_diff = [line] + else: + current_diff.append(line) + + if current_diff: + file_diffs.append('\n'.join(current_diff)) + + return file_diffs + + +def parse_file_diff(diff_text: str, staged: bool = False) -> Optional[FileChange]: + """Parse a single file diff into a FileChange object.""" + lines = diff_text.split('\n') + + file_path = None + change_type = 'modified' + + for line in lines: + if line.startswith('diff --git'): + parts = line.split(' ') + if len(parts) >= 4: + old_path = parts[2].lstrip('a/') + new_path = parts[3].lstrip('b/') + file_path = new_path if new_path else old_path + elif line.startswith('new file mode'): + change_type = 'added' + elif line.startswith('deleted file mode'): + change_type = 'deleted' + elif line.startswith('similarity index'): + change_type = 'renamed' + + if not file_path: + return None + + return FileChange( + file_path=file_path, + change_type=change_type, + diff_content=diff_text, + staged=staged + )