diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 628118f..0000000 --- a/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -.idea/encodings.xml -.idea/git_commit_edit.iml -.idea/inspectionProfiles/profiles_settings.xml -.idea/inspectionProfiles/Project_Default.xml -.idea/misc.xml -.idea/modules.xml -.idea/vcs.xml -.idea/workspace.xml -build/main/Analysis-00.toc -build/main/base_library.zip -build/main/EXE-00.toc -build/main/localpycs/pyimod01_archive.pyc -build/main/localpycs/pyimod02_importers.pyc -build/main/localpycs/pyimod03_ctypes.pyc -build/main/localpycs/pyimod04_pywin32.pyc -build/main/localpycs/struct.pyc -build/main/main.pkg -build/main/PKG-00.toc -build/main/PYZ-00.pyz -build/main/PYZ-00.toc -build/main/warn-main.txt -build/main/xref-main.html -src/dist/app.log -/src/app.log diff --git a/README.md b/README.md index 78dbdac..dc955d1 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,55 @@ -### 用途 - -- 修改任意次提交的作者、时间及提交描述 - -- 批量按照指定的作者、时间线 随机调整作者、时间 - - > 依赖本地的git用户权限 - - - -### 前置条件: - -- 本地需要安装python 并配置了环境变量 - - > 不低于python3.8 - -- 本地安装了Git - - - -### 打包 - -- 安装依赖包 - - ```shell - pip install -r .\requirements.txt - ``` +### 用途 + +- 修改任意次提交的作者、时间及提交描述 + +- 批量按照指定的作者、时间线 随机调整作者、时间 + +- 支持强推修改到远程仓库 + + > 依赖本地的git用户权限 + + + +### 前置条件: + +- 本地需要安装python 并配置了环境变量 + + > 不低于python3.8 + +- 本地安装了Git + +- 将`git-filter-repo.exe` 复制到系统配置 并配置环境 + + + +### 打包 + +- 安装依赖包 + + ```shell + pip install -r .\requirements.txt + ``` + +- 打exe包 + + ```shell + pip install pyinstaller + python.exe -m PyInstaller -F main.py --noconsole + ``` + +### 配置用户 + +- 在exe包的同目录下增加`config.json` + + ```json + { + "authors": [ + "zhangsan ", + "lisi ", + "wangwu ", + "zhaoliu " + ] + } + ``` + +> authors 用于指定作者列表 \ No newline at end of file diff --git a/src/callback/callback_builder.py b/callback_builder.py similarity index 92% rename from src/callback/callback_builder.py rename to callback_builder.py index 64937a3..1b6d01c 100644 --- a/src/callback/callback_builder.py +++ b/callback_builder.py @@ -1,81 +1,82 @@ -import os -from datetime import datetime, timezone, timedelta - - -class CallbackScriptBuilder: - @staticmethod - def build_bulk_commit_callback(filepath: str, commit_changes: dict) -> bool: - """ - 生成 callback 脚本,针对多次提交,每个提交设置独立的 author/email/date/message - """ - try: - import json - changes_json = json.dumps(commit_changes, indent=2, ensure_ascii=False) - - content = f''' -from datetime import datetime, timezone, timedelta - -commit_changes = {changes_json} -commit_id = commit.original_id.decode()[:7] -if commit_id in commit_changes: - change = commit_changes[commit_id] - commit.author_name = change["name"].encode("utf-8") - commit.author_email = change["email"].encode("utf-8") - commit.committer_name = change["name"].encode("utf-8") - commit.committer_email = change["email"].encode("utf-8") - - dt = datetime.strptime(change["date"], "%Y-%m-%dT%H:%M:%S") - dt = dt.replace(tzinfo=timezone(timedelta(hours=8))) - timestamp = int(dt.timestamp()) - commit.author_date = f"{{timestamp}} +0800".encode("utf-8") - commit.committer_date = f"{{timestamp}} +0800".encode("utf-8") - commit.message = change["message"].encode("utf-8") -''' - os.makedirs(os.path.dirname(filepath), exist_ok=True) - with open(filepath, "w", encoding="utf-8", newline="\n") as f: - f.write(content.lstrip()) - return True - except Exception as e: - print(f"[CallbackScriptBuilder] 错误: {e}") - return False - - @staticmethod - def build_single_commit_callback( - filepath: str, - target_hash: str, - author_name: str, - author_email: str, - commit_message: str, - commit_time: datetime - ): - """ - 生成 callback 脚本(用于 git-filter-repo),只修改一个指定提交 - """ - try: - safe_msg = commit_message.replace('"""', r'\"\"\"') - - new_dt = datetime(commit_time.year, commit_time.month, commit_time.day, commit_time.hour, commit_time.minute, commit_time.second, - tzinfo=timezone(timedelta(hours=8))) - timestamp = int(new_dt.timestamp()) - - def encode_line(key: str, value: str) -> str: - return f'commit.{key} = "{value}".encode("utf-8")' - - content = f''' -if commit.original_id.decode()[:7] == "{target_hash}": - {encode_line("author_name", author_name)} - {encode_line("author_email", author_email)} - {encode_line("committer_name", author_name)} - {encode_line("committer_email", author_email)} - commit.message = "{safe_msg}".encode("utf-8") - commit.author_date = f"{timestamp} +0800".encode("utf-8") # 格式化为字节 - commit.committer_date = f"{timestamp} +0800".encode("utf-8") # 格式化为字节 -''' - - os.makedirs(os.path.dirname(filepath), exist_ok=True) - with open(filepath, "w", encoding="utf-8", newline="\n") as f: - f.write(content) - return True - except Exception as e: - print(f"CallbackScriptBuilder 错误: {e}") - return False +import os +from datetime import datetime, timezone, timedelta + + +class CallbackScriptBuilder: + @staticmethod + def build_bulk_commit_callback(filepath: str, commit_changes: dict) -> bool: + """ + 生成 callback 脚本,针对多次提交,每个提交设置独立的 author/email/date/message + """ + try: + import json + changes_json = json.dumps(commit_changes, indent=2, ensure_ascii=False) + + content = f''' +from datetime import datetime, timezone, timedelta + +commit_changes = {changes_json} +commit_id = commit.original_id.decode()[:7] +if commit_id in commit_changes: + change = commit_changes[commit_id] + commit.author_name = change["name"].encode("utf-8") + commit.author_email = change["email"].encode("utf-8") + commit.committer_name = change["name"].encode("utf-8") + commit.committer_email = change["email"].encode("utf-8") + + dt = datetime.strptime(change["date"], "%Y-%m-%dT%H:%M:%S") + dt = dt.replace(tzinfo=timezone(timedelta(hours=8))) + timestamp = int(dt.timestamp()) + commit.author_date = f"{{timestamp}} +0800".encode("utf-8") + commit.committer_date = f"{{timestamp}} +0800".encode("utf-8") + commit.message = change["message"].encode("utf-8") +''' + os.makedirs(os.path.dirname(filepath), exist_ok=True) + with open(filepath, "w", encoding="utf-8", newline="\n") as f: + f.write(content.lstrip()) + return True + except Exception as e: + print(f"[CallbackScriptBuilder] 错误: {e}") + return False + + @staticmethod + def build_single_commit_callback( + filepath: str, + target_hash: str, + author_name: str, + author_email: str, + commit_message: str, + date_str: str + ): + """ + 生成 callback 脚本(用于 git-filter-repo),只修改一个指定提交 + """ + try: + dt = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S") + safe_msg = commit_message.replace('"""', r'\"\"\"') + + new_dt = datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, + tzinfo=timezone(timedelta(hours=8))) + timestamp = int(new_dt.timestamp()) + + def encode_line(key: str, value: str) -> str: + return f'commit.{key} = "{value}".encode("utf-8")' + + content = f''' +if commit.original_id.decode()[:7] == "{target_hash}": + {encode_line("author_name", author_name)} + {encode_line("author_email", author_email)} + {encode_line("committer_name", author_name)} + {encode_line("committer_email", author_email)} + commit.message = "{safe_msg}".encode("utf-8") + commit.author_date = f"{timestamp} +0800".encode("utf-8") # 格式化为字节 + commit.committer_date = f"{timestamp} +0800".encode("utf-8") # 格式化为字节 +''' + + os.makedirs(os.path.dirname(filepath), exist_ok=True) + with open(filepath, "w", encoding="utf-8", newline="\n") as f: + f.write(content) + return True + except Exception as e: + print(f"CallbackScriptBuilder 错误: {e}") + return False diff --git a/dist/config.json b/config.json similarity index 100% rename from dist/config.json rename to config.json diff --git a/dist/main.exe b/dist/main.exe deleted file mode 100644 index 39ae12c..0000000 Binary files a/dist/main.exe and /dev/null differ diff --git a/git-filter-repo.exe b/git-filter-repo.exe new file mode 100644 index 0000000..3665d8e Binary files /dev/null and b/git-filter-repo.exe differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..c7404ca --- /dev/null +++ b/main.py @@ -0,0 +1,550 @@ +import json +import logging +import os +import random +import subprocess +import sys +from datetime import timedelta, datetime + +from PyQt5.QtCore import QDateTime +from PyQt5.QtWidgets import ( + QApplication, QWidget, QLabel, QLineEdit, QPushButton, + QVBoxLayout, QFileDialog, QListWidget, QMessageBox, QComboBox, QDialog, + QFormLayout, QDateTimeEdit, QDialogButtonBox, QListWidgetItem, QTextEdit +) + +from callback_builder import CallbackScriptBuilder + +CONFIG_PATH = "config.json" + + +# ---------------------- 配置函数 ---------------------- +def save_config(data): + try: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(data, f) + except Exception as e: + print(f"配置保存失败: {e}") + + +def load_config(): + if os.path.exists(CONFIG_PATH): + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + return {} + + +def save_last_repo_path(path): + config = load_config() + config["last_path"] = path + save_config(config) + + +def load_last_repo_path(): + return load_config().get("last_path", "") + + +def load_authors(): + config = load_config() + if "authors" in config: + return config["authors"] + return [] + + +# ---------------------- Git 工具函数 ---------------------- +def run_git_command(cmd_list, cwd=None, env=None): + try: + # Windows下隐藏黑窗口 + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + result = subprocess.run( + cmd_list, + cwd=cwd, + capture_output=True, + text=True, + env=env, + encoding='utf-8', + errors='replace', + startupinfo=startupinfo # 添加这个 + ) + return result.stdout.strip() + except Exception as e: + return str(e) + + +def generate_rebase_editor_script(path): + with open(path, "w", encoding="utf-8") as f: + f.write( + """import sys\nwith open(sys.argv[1], 'r+', encoding='utf-8') as f:\n lines = f.readlines()\n for i, line in enumerate(lines):\n if line.startswith('pick '):\n lines[i] = line.replace('pick', 'edit', 1)\n break\n f.seek(0)\n f.writelines(lines)\n f.truncate()\n""") + + +def generate_random_author_date(author_list, start_dt, end_dt): + rand_author = random.choice(author_list) + rand_time = start_dt + timedelta(seconds=random.randint(0, int((end_dt - start_dt).total_seconds()))) + formatted_date = rand_time.strftime("%Y-%m-%dT%H:%M:%S") + + author_name = rand_author.split("<")[0].strip() + author_email = rand_author.split("<")[1].strip(" >") + return author_name, author_email, formatted_date + + +def build_env(author_name, author_email, date, editor_script): + env = os.environ.copy() + env["GIT_AUTHOR_NAME"] = author_name + env["GIT_AUTHOR_EMAIL"] = author_email + env["GIT_COMMITTER_DATE"] = date + env["GIT_AUTHOR_DATE"] = date + python_path = "python" if hasattr(sys, "_MEIPASS") else sys.executable + env["GIT_SEQUENCE_EDITOR"] = f'"{python_path}" "{editor_script}"' + return env + + +def amend_commit(repo_path, env, message): + subprocess.run([ + "git", "commit", "--amend", + "--author", f"{env['GIT_AUTHOR_NAME']} <{env['GIT_AUTHOR_EMAIL']}>", + "--date", env["GIT_AUTHOR_DATE"], + "-m", message + ], cwd=repo_path, env=env) + + +def rebase_continue(repo_path, env): + subprocess.run(["git", "rebase", "--continue"], cwd=repo_path, env=env) + + +def rebase_interactive(repo_path, start_commit, env, is_root=False): + if is_root: + subprocess.run(["git", "rebase", "-i", "--root"], cwd=repo_path, env=env) + else: + subprocess.run(["git", "rebase", "-i", f"{start_commit}^"], cwd=repo_path, env=env) + + +def delete_temp_file(path): + try: + if os.path.exists(path): + os.remove(path) + except Exception: + pass + + +# ---------------------- 批量重写对话框 ---------------------- +class BulkRewriteDialog(QDialog): + def __init__(self): + super().__init__() + self.setWindowTitle("批量重写提交作者与时间") + + self.authors_list = QListWidget() + self.authors_list.setSelectionMode(QListWidget.MultiSelection) + for author in load_authors(): + item = QListWidgetItem(author) + self.authors_list.addItem(item) + + self.base_commit = QLineEdit() + self.base_commit.setPlaceholderText("commit hash的前7位") + self.base_commit.setMaxLength(7) + + self.start_time = QDateTimeEdit() + self.start_time.setCalendarPopup(True) + self.start_time.setDateTime(QDateTime.currentDateTime().addDays(-7)) + self.start_time.setDisplayFormat("yyyy-MM-dd HH:mm:ss") + + self.end_time = QDateTimeEdit() + self.end_time.setCalendarPopup(True) + self.end_time.setDateTime(QDateTime.currentDateTime()) + self.end_time.setDisplayFormat("yyyy-MM-dd HH:mm:ss") + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QFormLayout() + layout.addRow("选择作者:", self.authors_list) + layout.addRow("commit hash值:", self.base_commit) + layout.addRow("开始时间:", self.start_time) + layout.addRow("结束时间:", self.end_time) + layout.addRow(buttons) + + self.setLayout(layout) + + def get_values(self): + authors = [item.text() for item in self.authors_list.selectedItems()] + start = self.start_time.dateTime().toPyDateTime() + end = self.end_time.dateTime().toPyDateTime() + base_commit = self.base_commit.text() + return authors, start, end, base_commit + + +# ---------------------- 编辑对话框 ---------------------- +class EditDialog(QDialog): + def __init__(self, authors, author='', message='', datetime_str=''): + super().__init__() + self.setWindowTitle("编辑提交信息") + self.author_input = QComboBox() + self.author_input.setEditable(True) + self.author_input.addItems(authors) + self.author_input.setCurrentText(author) + + self.message_input = QTextEdit() + self.message_input.setPlainText(message) + + self.date_input = QDateTimeEdit() + self.date_input.setCalendarPopup(True) + dt_py = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S %z") + dt = QDateTime(dt_py) + self.date_input.setDateTime(dt if dt.isValid() else QDateTime.currentDateTime()) + self.date_input.setDisplayFormat("yyyy-MM-dd HH:mm:ss") + + self.ok_button = QPushButton("确定") + self.ok_button.clicked.connect(self.accept) + + layout = QFormLayout() + layout.addRow("作者(如 user ):", self.author_input) + layout.addRow("提交信息:", self.message_input) + layout.addRow("提交时间:", self.date_input) + layout.addRow(self.ok_button) + + self.setLayout(layout) + + def get_values(self): + return ( + self.author_input.currentText(), + self.message_input.toPlainText(), + self.date_input.dateTime().toString("yyyy-MM-ddTHH:mm:ss") + ) + + +# ---------------------- 主窗口 ---------------------- +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QMenu + + +class GitCommitEditor(QWidget): + AUTHORS = load_authors() + + def __init__(self): + super().__init__() + self.remote_url = None + self.setWindowTitle("Git Commit Editor (全功能整合版)") + + self.repo_path = QLineEdit() + self.repo_path.setText(load_last_repo_path()) + + browse_button = QPushButton("浏览") + browse_button.clicked.connect(self.browse_repo) + + # load_button = QPushButton("加载提交记录") + # load_button.clicked.connect(self.load_commits) + + self.branch_selector = QComboBox() + self.branch_selector.currentIndexChanged.connect(self.load_commits) + + self.commit_listbox = QListWidget() + self.commit_listbox.setContextMenuPolicy(Qt.CustomContextMenu) + self.commit_listbox.customContextMenuRequested.connect(self.show_commit_context_menu) + self.commit_listbox.itemDoubleClicked.connect(self.edit_commit) + + self.push_button = QPushButton("强推到远程") + self.push_button.clicked.connect(self.push_force) + + self.rewrite_button = QPushButton("批量随机重写历史") + self.rewrite_button.clicked.connect(self.rewrite_commits_randomly) + + layout = QVBoxLayout() + layout.addWidget(QLabel("选择Git仓库目录:")) + layout.addWidget(self.repo_path) + layout.addWidget(browse_button) + layout.addWidget(QLabel("选择分支:")) + layout.addWidget(self.branch_selector) + # layout.addWidget(load_button) + layout.addWidget(self.commit_listbox) + layout.addWidget(self.rewrite_button) + layout.addWidget(self.push_button) + + self.setLayout(layout) + self.root_commit_log = '' + + if self.repo_path.text() != "": + self.load_branches() + + def browse_repo(self): + file_path = self.repo_path.text() if self.repo_path.text() != '' else '.' + path = QFileDialog.getExistingDirectory(self, "选择Git仓库", file_path) + + if path.strip() != "" and os.path.isdir(path): + if not os.path.exists(os.path.join(path, '.git')): + QMessageBox.warning(self, "错误", "不是Git仓库") + return + self.repo_path.setText(path) + save_last_repo_path(path) + self.load_branches() + def get_remote_url(self): + # 获取远程仓库地址 + output = run_git_command(["git", "remote", "get-url", "origin"], cwd=self.repo_path.text()) + self.remote_url = output.strip() + def reset_remote_url(self): + # 重置远程仓库地址 + if self.remote_url is None: + return + run_git_command(["git", "remote", "add", "origin", self.remote_url], cwd=self.repo_path.text()) + + def load_branches(self): + repo = self.repo_path.text() + if not os.path.isdir(repo): + return + + def get_branches(): + # 获取所有的远程分支 + output = run_git_command(["git", "branch", "-a"], cwd=repo) + all = [line.strip() for line in output.split("\n") if line.strip() != ''] + current = "" + branches = set() + for branch in all: + if "HEAD" in branch: + continue + if branch.startswith("remotes/origin/"): + branches.add(branch.split("/")[-1]) + continue + if branch.startswith("*"): + current = branch.split("*")[-1].strip() + branches.add(current) + else: + branches.add(branch) + # 远程分支列表 + return branches, current + + branches, current = get_branches() + if not len(branches): + return + self.branch_selector.clear() + self.branch_selector.addItems(branches) + # 设置当前分支 + if current != "": + self.branch_selector.setCurrentText(current) + self.load_commits() + self.get_remote_url() + + def load_commits(self): + repo = self.repo_path.text() + branch = self.branch_selector.currentText() + if branch == "": + return + result = subprocess.run(["git", "checkout", branch], cwd=repo, capture_output=True, text=True) + if result.returncode != 0: + QMessageBox.critical(self, "失败", result.stderr) + return + cmd = ["git", "log", branch, "--pretty=format:%h %an <%ae> %ad %s %d", "--date=iso"] + output = run_git_command(cmd, cwd=repo) + self.commit_listbox.clear() + commit_logs = output.splitlines() + if not len(commit_logs): + QMessageBox.critical(self, "失败", "无法获取提交记录") + return + # 记录当前分支的根记录 + self.root_commit_log = commit_logs[-1].split(' ')[0] + self.commit_listbox.addItems(commit_logs) + + def rewrite_commits_randomly(self): + dialog = BulkRewriteDialog() + if dialog.exec_(): + authors, start, end, base_commit = dialog.get_values() + if not len(authors): + QMessageBox.critical(self, "失败", "请选择作者") + return + repo = self.repo_path.text() + branch = self.branch_selector.currentText() + + commits = run_git_command(["git", "rev-list", branch], cwd=repo).splitlines() + + new_commits = [commit[:7] for commit in commits] + if base_commit != "" and base_commit in new_commits: + index = new_commits.index(base_commit) + index = index + 1 if index < (len(commits) - 1) else -1 + commits = commits[:index] + + total = len(commits) + seconds_range = int((end - start).total_seconds()) + time_steps = sorted([random.randint(0, seconds_range) for _ in range(total)], reverse=True) + commit_changes = {} + # 忽略第一次提交,因为无法修改 + for i, commit in enumerate(commits): + rand_time = start + timedelta(seconds=time_steps[i]) + formatted_date = rand_time.strftime("%Y-%m-%dT%H:%M:%S") + + rand_author = random.choice(authors) + name = rand_author.split("<")[0].strip() + email = rand_author.split("<")[1].strip(" >") + _, _, message = self.get_commit_info(commit) + + commit_changes[commit[:7]] = { + "name": name, + "email": email, + "date": formatted_date, + "message": message, + } + callback_path = os.path.join(self.repo_path.text(), "rewrite_callback.py") + CallbackScriptBuilder.build_bulk_commit_callback(callback_path, commit_changes) + try: + result = subprocess.run([ + "git-filter-repo", + "--commit-callback", callback_path + , "--force" + ], encoding='utf-8', + errors='replace', cwd=self.repo_path.text(), capture_output=True, text=True) + + if result.returncode == 0: + self.reset_remote_url() + self.load_commits() + QMessageBox.information(self, "成功", "提交修改完成(使用 filter-repo)") + else: + QMessageBox.critical(self, "失败", result.stderr) + except Exception as e: + logging.error("批量修改失败:", exc_info=e) + QMessageBox.critical(self, "失败", str(e)) + finally: + os.remove(callback_path) + + def push_force(self): + # 增减确认框 确认是否需要强推 这是一个危险操作 + if QMessageBox.question(self, "确认", "这个操作会导致原来的提交记录丢失,确定要强推吗?", QMessageBox.Yes | QMessageBox.No) == QMessageBox.No: + return + repo = self.repo_path.text() + branch = self.branch_selector.currentText() + result = subprocess.run(["git", "push",'--set-upstream', "origin", branch, "--force"], cwd=repo, capture_output=True, text=True) + if result.returncode == 0: + QMessageBox.information(self, "成功", "强推完成") + else: + QMessageBox.critical(self, "失败", result.stderr) + + def edit_commit(self, item): + selected_commit = item.text().split()[0] + + author, date, message = self.get_commit_info(selected_commit) + + dialog = EditDialog(authors=GitCommitEditor.AUTHORS, author=author, message=message, datetime_str=date) + + if dialog.exec_(): + new_author, new_msg, new_date = dialog.get_values() + + if not new_author or not new_msg or not new_date: + QMessageBox.information(self, "提示", "作者、信息或时间不能为空") + return + + repo_path = self.repo_path.text() + file_name = "edit_commit_callback.py" + script_path = os.path.join(repo_path, file_name) + try: + author_name = new_author.split("<")[0].strip() + author_email = new_author.split("<")[1].strip(" >") + ok = CallbackScriptBuilder.build_single_commit_callback( + filepath=script_path, + target_hash=selected_commit, + author_name=author_name, + author_email=author_email, + commit_message=new_msg, + date_str=new_date # 格式:2024-01-01T10:00:00 + ) + + if not ok: + QMessageBox.critical(self, "失败", "生成 callback 脚本失败") + return + result = subprocess.run([ + "git-filter-repo", + "--commit-callback", file_name + , "--force" + ], cwd=repo_path, encoding='utf-8', + errors='replace', capture_output=True, text=True) + + if result.returncode == 0: + self.reset_remote_url() + self.load_commits() + QMessageBox.information(self, "成功", "提交修改完成(使用 filter-repo)") + else: + logging.error(f"edit commit failed: {selected_commit}", result.stderr) + QMessageBox.critical(self, "失败", result.stderr) + except Exception as e: + logging.error(f"edit commit failed: {selected_commit}", exc_info=e) + QMessageBox.critical(self, "错误", str(e)) + finally: + os.remove(script_path) + + def get_commit_info(self, selected_commit): + try: + output = self.run_git_command( + ["git", "show", selected_commit, "--quiet", "--pretty=format:%an <%ae>%n%ad%n%s", "--date=iso"], + cwd=self.repo_path.text()) + lines = output.splitlines() + if len(lines) < 3: + QMessageBox.critical(self, "错误", "获取提交信息失败,可能是 Git 命令错误或提交记录异常") + return None + author, date, message = lines + return author, date, message + except Exception as e: + QMessageBox.critical(self, "错误", str(e)) + return None + + def show_commit_context_menu(self, position): + item = self.commit_listbox.itemAt(position) + if item is None: + return + + menu = QMenu() + copy_action = menu.addAction("复制提交哈希值") + action = menu.exec_(self.commit_listbox.mapToGlobal(position)) + + if action == copy_action: + commit_hash = item.text().split()[0] + QApplication.clipboard().setText(commit_hash) + QMessageBox.information(self, "已复制", f"提交哈希值已复制到剪贴板:{commit_hash}") + + def run_git_command(self, cmd_list, cwd=None, env=None): + try: + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + result = subprocess.run( + cmd_list, + cwd=cwd, + capture_output=True, + text=True, + env=env, + encoding='utf-8', + errors='replace', + startupinfo=startupinfo + ) + return result.stdout.strip() + except Exception as e: + logging.error(f"Error running git command: {cmd_list}", exc_info=e) + QMessageBox.critical(self, "命令执行出错", str(e)) + return "" + + +logging.basicConfig( + filename='app.log', # 日志文件名 + filemode='a', # 文件模式 ('w' 表示覆盖写入, 'a' 表示追加写入) + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # 日志格式 + level=logging.DEBUG # 日志级别 +) + + +def log_exception(exc_type, exc_value, exc_traceback): + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return + print("Uncaught exception") + logging.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) + QMessageBox.information(None, '未知错误', str(exc_type) + '\n' + str(exc_value) + '\n' + str(exc_traceback)) + + +sys.excepthook = log_exception +if __name__ == '__main__': + try: + app = QApplication(sys.argv) + editor = GitCommitEditor() + editor.show() + sys.exit(app.exec_()) + except Exception as e: + print("An error occurred:", e) + sys.exit(1) diff --git a/requirements.txt b/requirements.txt index 6802fa6..46d2fec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -PyQt5==5.15.11 -git-filter-repo==2.47.0 +PyQt5==5.15.11 diff --git a/src/authors/__init__.py b/src/authors/__init__.py deleted file mode 100644 index ca6cf41..0000000 --- a/src/authors/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .authors_manage import ManageAuthorsDialog - -__all__ = ['ManageAuthorsDialog'] \ No newline at end of file diff --git a/src/authors/__pycache__/__init__.cpython-38.pyc b/src/authors/__pycache__/__init__.cpython-38.pyc deleted file mode 100644 index 3b60958..0000000 Binary files a/src/authors/__pycache__/__init__.cpython-38.pyc and /dev/null differ diff --git a/src/authors/__pycache__/authors_manage.cpython-38.pyc b/src/authors/__pycache__/authors_manage.cpython-38.pyc deleted file mode 100644 index c17f892..0000000 Binary files a/src/authors/__pycache__/authors_manage.cpython-38.pyc and /dev/null differ diff --git a/src/authors/authors_manage.py b/src/authors/authors_manage.py deleted file mode 100644 index a7750ce..0000000 --- a/src/authors/authors_manage.py +++ /dev/null @@ -1,128 +0,0 @@ -from PyQt5.QtWidgets import ( - QDialog, QVBoxLayout, QFormLayout, QLineEdit, QTextEdit, QPushButton, - QListWidget, QHBoxLayout, QInputDialog, QMessageBox, QLabel -) -import json -import os - - -class AuthorInputDialog(QDialog): - def __init__(self, parent=None, existing_text=""): - super().__init__(parent) - self.setWindowTitle("输入作者信息") - self.resize(400, 200) - - self.layout = QVBoxLayout(self) - - self.instruction_label = QLabel("请输入作者信息(格式:名字 <邮箱>):") - self.layout.addWidget(self.instruction_label) - - self.author_input = QTextEdit(self) - self.author_input.setText(existing_text) - self.layout.addWidget(self.author_input) - - self.buttons_layout = QHBoxLayout() - - self.save_btn = QPushButton("保存") - self.cancel_btn = QPushButton("取消") - - self.buttons_layout.addWidget(self.save_btn) - self.buttons_layout.addWidget(self.cancel_btn) - - self.layout.addLayout(self.buttons_layout) - - self.save_btn.clicked.connect(self.save_input) - self.cancel_btn.clicked.connect(self.reject) - - def save_input(self): - input_text = self.author_input.toPlainText().strip() - if input_text: - self.accept() - return input_text - else: - QMessageBox.warning(self, "错误", "输入不能为空,请输入有效的作者信息!") - return None - - -class ManageAuthorsDialog(QDialog): - def __init__(self, config_path, parent=None): - super().__init__(parent) - self.config_path = config_path - self.setWindowTitle("管理作者信息") - self.resize(400, 300) - - self.layout = QVBoxLayout(self) - - self.author_list = QListWidget() - self.layout.addWidget(self.author_list) - - btn_layout = QHBoxLayout() - - self.add_btn = QPushButton("添加") - self.edit_btn = QPushButton("编辑") - self.delete_btn = QPushButton("删除") - - btn_layout.addWidget(self.add_btn) - btn_layout.addWidget(self.edit_btn) - btn_layout.addWidget(self.delete_btn) - - self.layout.addLayout(btn_layout) - - self.add_btn.clicked.connect(self.add_author) - self.edit_btn.clicked.connect(self.edit_author) - self.delete_btn.clicked.connect(self.delete_author) - - self.load_authors() - - def load_authors(self): - if os.path.exists(self.config_path): - with open(self.config_path, 'r', encoding='utf-8') as f: - data = json.load(f) - authors = data.get('authors', []) - self.author_list.clear() - self.author_list.addItems(authors) - - def save_authors(self): - authors = [self.author_list.item(i).text() for i in range(self.author_list.count())] - data = {} - if os.path.exists(self.config_path): - with open(self.config_path, 'r', encoding='utf-8') as f: - data = json.load(f) - data['authors'] = authors - with open(self.config_path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - def add_author(self): - dialog = AuthorInputDialog(self) - if dialog.exec_(): - author_info = dialog.author_input.toPlainText().strip() - if author_info: - self.author_list.addItem(author_info) - self.save_authors() - - def edit_author(self): - current_item = self.author_list.currentItem() - if current_item: - dialog = AuthorInputDialog(self, current_item.text()) - if dialog.exec_(): - edited_author = dialog.author_input.toPlainText().strip() - if edited_author: - current_item.setText(edited_author) - self.save_authors() - else: - QMessageBox.warning(self, "提示", "请先选择一个要编辑的作者。") - - def delete_author(self): - current_row = self.author_list.currentRow() - if current_row >= 0: - reply = QMessageBox.question(self, "确认删除", "确定要删除选中的作者吗?", - QMessageBox.Yes | QMessageBox.No) - if reply == QMessageBox.Yes: - self.author_list.takeItem(current_row) - self.save_authors() - else: - QMessageBox.warning(self, "提示", "请先选择一个要删除的作者。") - def get_authors(self): - return [self.author_list.item(i).text() for i in range(self.author_list.count())] - - diff --git a/src/callback/__init__.py b/src/callback/__init__.py deleted file mode 100644 index 281da03..0000000 --- a/src/callback/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .callback_builder import CallbackScriptBuilder -__all__ = ['CallbackScriptBuilder'] \ No newline at end of file diff --git a/src/callback/__pycache__/__init__.cpython-38.pyc b/src/callback/__pycache__/__init__.cpython-38.pyc deleted file mode 100644 index 78cf7dd..0000000 Binary files a/src/callback/__pycache__/__init__.cpython-38.pyc and /dev/null differ diff --git a/src/callback/__pycache__/callback_builder.cpython-38.pyc b/src/callback/__pycache__/callback_builder.cpython-38.pyc deleted file mode 100644 index bcde301..0000000 Binary files a/src/callback/__pycache__/callback_builder.cpython-38.pyc and /dev/null differ diff --git a/src/command/command.py b/src/command/command.py deleted file mode 100644 index 698deb4..0000000 --- a/src/command/command.py +++ /dev/null @@ -1,280 +0,0 @@ -import os -import subprocess -import traceback -from abc import ABC, abstractmethod -from datetime import datetime -from typing import Tuple - -from PyQt5.QtCore import pyqtSignal, QThreadPool, QObject, QRunnable - -from src.callback import CallbackScriptBuilder - - -class Command(ABC): - def __init__(self, repo_path: str): - super().__init__() - self.repo_path = repo_path - - @abstractmethod - def execute(self, ): - """执行命令""" - pass - - -class WorkerSignals(QObject): - finished = pyqtSignal(bool, str) # success, output - - -class CommandWorker(QRunnable): - def __init__(self, command, callback=None): - super().__init__() - self.command = command - self.callback = callback - self.signals = WorkerSignals() - - if callback: - self.signals.finished.connect(callback) - - def run(self): - """在工作线程中执行""" - try: - print(f"执行命令: {self.command.__class__.__name__}") - success, output = self.command.execute() - self.signals.finished.emit(success, output) - except Exception as e: - error_msg = f"命令执行异常: {str(e)}\n{traceback.format_exc()}" - print(error_msg) - self.signals.finished.emit(False, error_msg) - - -# 获取指定分支的提交信息 -class GetBranchCommitsCommand(Command): - def __init__(self, repo_path: str, branch_name: str): - """ - :param repo_path:仓库路径 - :param branch_name:分支名称 - """ - super().__init__(repo_path) - self.branch_name = branch_name - - def execute(self): - try: - print("获取指定分支的提交信息") - result = subprocess.run([ - "git", - "log", - "--pretty=format:%h %an <%ae> %ad %s %d", - "--date=iso", - self.branch_name - ], cwd=self.repo_path, encoding='utf-8', errors='replace', capture_output=True, text=True) - if result.returncode == 0: - return True, result.stdout - else: - return False, result.stderr - except Exception as e: - return False, str(e) - - -# 编辑单条提交信息 -class EditSingleCommitCommand(Command): - def __init__(self, repo_path: str, commit_id: str, new_author: str, new_email: str, - new_commit_message: str, new_commit_time: datetime): - """ - :param repo_path:仓库路径 - :param commit_id:提交ID - :param new_author:新的作者 - :param new_email:新的邮箱 - :param new_commit_message:新的提交信息 - :param new_commit_time:新的提交时间 - """ - super().__init__(repo_path) - self.commit_id = commit_id - self.new_commit_message = new_commit_message - self.new_commit_time = new_commit_time - self.new_author = new_author - self.new_email = new_email - - def execute(self): - # 创建文件 - file_name = "edit_commit_callback.py" - target_file_path = os.path.join(self.repo_path, file_name) - try: - ok = CallbackScriptBuilder.build_single_commit_callback( - filepath=target_file_path, - target_hash=self.commit_id, - author_name=self.new_author, - author_email=self.new_email, - commit_message=self.new_commit_message, - commit_time=self.new_commit_time # 格式:2024-01-01T10:00:00 - ) - if not ok: - return False, "生成文件失败" - - result = subprocess.run([ - "git-filter-repo", - "--commit-callback", file_name - , "--force" - ], cwd=self.repo_path, encoding='utf-8', - errors='replace', capture_output=True, text=True) - - if result.returncode == 0: - return True, "提交信息修改成功" - else: - return False, result.stderr - except Exception as e: - return False, str(e) - finally: - if os.path.exists(target_file_path): - os.remove(target_file_path) - - -# 编辑单条提交信息 -class EditBulkCommitCommand(Command): - def __init__(self, repo_path: str, commit_changes: dict): - """ - :param repo_path:仓库路径 - :param commit_changes:提交信息 - """ - super().__init__(repo_path) - self.commit_changes = commit_changes - - def execute(self): - # 创建文件 - file_name = "rewrite_callback.py" - target_file_path = os.path.join(self.repo_path, file_name) - try: - ok = CallbackScriptBuilder.build_bulk_commit_callback(target_file_path, self.commit_changes) - if not ok: - return False, "生成文件失败" - result = subprocess.run([ - "git-filter-repo", - "--commit-callback", file_name - , "--force" - ], cwd=self.repo_path, encoding='utf-8', - errors='replace', capture_output=True, text=True) - - if result.returncode == 0: - return True, "批量修改作者、邮箱及时间信息成功" - else: - return False, result.stderr - except Exception as e: - return False, str(e) - finally: - if os.path.exists(target_file_path): - os.remove(target_file_path) - - -# 切换分支 -class CheckoutCommand(Command): - def __init__(self, repo_path: str, branch_name: str): - """ - :param repo_path:仓库路径 - :param branch_name:分支名称 - """ - super().__init__(repo_path) - self.branch_name = branch_name - - def execute(self): - try: - result = subprocess.run([ - "git", - "checkout", - self.branch_name - ], cwd=self.repo_path, encoding='utf-8', - errors='replace', capture_output=True, text=True) - - if result.returncode == 0: - return True, "切换分支成功" - else: - return False, result.stderr - except Exception as e: - return False, str(e) - - -# 获取所有分支 -class GetAllBranchesCommand(Command): - def __init__(self, repo_path: str): - """ - :param repo_path:仓库路径 - """ - super().__init__(repo_path) - - def execute(self): - try: - result = subprocess.run([ - "git", - "branch" - , "-a" - ], cwd=self.repo_path, encoding='utf-8', - errors='replace', capture_output=True, text=True) - - if result.returncode == 0: - branches = [line.strip() for line in result.stdout.split("\n") if line.strip() != ''] - return True, branches - else: - return False, result.stderr - except Exception as e: - return False, str(e) - - -# 获取远程仓库地址 -class GetRemoteRepoUrlCommand(Command): - def __init__(self, repo_path: str): - """ - :param repo_path:仓库路径 - """ - super().__init__(repo_path) - - def execute(self): - try: - result = subprocess.run([ - "git", - "remote", - "get-url", - "origin" - ], cwd=self.repo_path, encoding='utf-8', - errors='replace', capture_output=True, text=True) - - if result.returncode == 0: - return True, result.stdout.strip() - else: - return False, result.stderr - except Exception as e: - return False, str(e) - - -# 设置远程仓库 -class SetRemoteUrlCommand(Command): - def __init__(self, repo_path: str, remote_url: str): - super().__init__(repo_path) - self.remote_url = remote_url - - def execute(self) -> Tuple[bool, str]: - try: - result = subprocess.run(["git", "remote", "add", "origin", self.remote_url], cwd=self.repo_path, - capture_output=True, text=True) - if result.returncode == 0: - return True, result.stdout.strip() - else: - return False, result.stderr - except Exception as e: - return False, str(e) - - -class Executor: - @staticmethod - def executeAsync(command: Command, callback=None): - try: - worker = CommandWorker(command, callback) - # 设置自动删除 - worker.setAutoDelete(True) - # 使用全局线程池 - QThreadPool.globalInstance().start(worker) - except Exception as e: - print(f"提交异步任务失败: {e}") - if callback: - callback(False, str(e)) - - @staticmethod - def execute(command: Command): - return command.execute() diff --git a/src/git_utils/__init__.py b/src/git_utils/__init__.py deleted file mode 100644 index 68a4f68..0000000 --- a/src/git_utils/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .git_filter_repo import ( - Blob, Commit, Tag, RepoFilter, FilteringOptions, - string_to_date, date_to_string,RepoAnalyze -) - -__all__ = [ - "Blob", "Commit", "Tag", "RepoFilter", "FilteringOptions", - "string_to_date", "date_to_string", "RepoAnalyze" -] \ No newline at end of file diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 985699c..0000000 --- a/src/main.py +++ /dev/null @@ -1,834 +0,0 @@ -import datetime -import json -import logging -import os -import site -import sys -from dataclasses import dataclass -from datetime import timedelta, datetime -from typing import List, Dict, Tuple, Iterable - -from PyQt5.QtCore import QDateTime -from PyQt5.QtGui import QBrush, QColor -from PyQt5.QtWidgets import ( - QApplication, QWidget, QLabel, QLineEdit, QPushButton, - QVBoxLayout, QFileDialog, QListWidget, QMessageBox, QComboBox, QDialog, - QFormLayout, QDateTimeEdit, QDialogButtonBox, QListWidgetItem, QTextEdit, QInputDialog, QHBoxLayout, QProgressBar, - QMainWindow, QAction, QTableWidget, QTableWidgetItem -) - -from command import EditSingleCommitCommand, Executor, GetAllBranchesCommand, SetRemoteUrlCommand, CheckoutCommand, \ - GetBranchCommitsCommand, GetRemoteRepoUrlCommand -from src.command.command import EditBulkCommitCommand - - -def get_script_dir(): - python_home = sys.prefix - return os.path.join(python_home, 'Scripts') - - -# ---------------------- 批量重写对话框 ---------------------- -class BulkRewriteDialog(QDialog): - def __init__(self, commit_id: str = '', authors: List[Tuple[str, str]] = None): - super().__init__() - self.setWindowTitle("批量重写提交作者与时间") - self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) - self.setMinimumSize(500, 300) - - # === 作者-邮箱表格 === - self.authors_table = QTableWidget(0, 2) - self.authors_table.setHorizontalHeaderLabels(["作者", "邮箱"]) - self.authors_table.horizontalHeader().setStretchLastSection(True) - self.authors_table.setSelectionBehavior(QTableWidget.SelectRows) - self.authors_table.setSelectionMode(QTableWidget.MultiSelection) - - # 加载初始数据 - self.load_authors_to_table(authors) - - # 按钮:添加 / 编辑 / 删除 - add_btn = QPushButton("添加") - edit_btn = QPushButton("编辑") - del_btn = QPushButton("删除") - - add_btn.clicked.connect(self.add_author) - edit_btn.clicked.connect(self.edit_author) - del_btn.clicked.connect(self.delete_author) - - btn_layout = QHBoxLayout() - btn_layout.addWidget(add_btn) - btn_layout.addWidget(edit_btn) - btn_layout.addWidget(del_btn) - - table_layout = QVBoxLayout() - table_layout.addWidget(self.authors_table) - table_layout.addLayout(btn_layout) - - - self.start_time = QDateTimeEdit() - self.start_time.setCalendarPopup(True) - self.start_time.setDateTime(QDateTime.currentDateTime().addDays(-7)) - self.start_time.setDisplayFormat("yyyy-MM-dd") - - self.end_time = QDateTimeEdit() - self.end_time.setCalendarPopup(True) - self.end_time.setDateTime(QDateTime.currentDateTime()) - self.end_time.setDisplayFormat("yyyy-MM-dd") - - # === 按钮框 === - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - buttons.button(QDialogButtonBox.Ok).setText("确定") - buttons.button(QDialogButtonBox.Cancel).setText("取消") - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - - # === 布局 === - layout = QFormLayout() - layout.addRow(QLabel("选择作者(可多选):"), QLabel()) # 占位 - layout.addRow(table_layout) # 注意:QFormLayout 不直接支持复杂 widget,改用 QVBoxLayout 更好 - layout.addRow("开始时间:", self.start_time) - layout.addRow("结束时间:", self.end_time) - layout.addRow(buttons) - - self.setLayout(layout) - - def load_authors_to_table(self, authors): - """从 load_authors() 加载数据(假设返回 [(name, email), ...])""" - if authors is None: - return - for name, email in authors: - self.add_row_to_table(name, email) - - def add_row_to_table(self, name, email): - row = self.authors_table.rowCount() - self.authors_table.insertRow(row) - self.authors_table.setItem(row, 0, QTableWidgetItem(name)) - self.authors_table.setItem(row, 1, QTableWidgetItem(email)) - - def get_selected_authors(self): - """获取用户选中的 (name, email) 列表""" - selected = [] - all = [] - for row in range(self.authors_table.rowCount()): - name = self.authors_table.item(row, 0).text() - email = self.authors_table.item(row, 1).text() - if self.authors_table.item(row, 0).isSelected() or \ - self.authors_table.item(row, 1).isSelected(): - selected.append((name, email)) - all.append((name, email)) - return all, selected - - def add_author(self): - name, ok1 = QInputDialog.getText(self, "添加作者", "作者姓名:") - if not ok1 or not name.strip(): - return - email, ok2 = QInputDialog.getText(self, "添加作者", "邮箱地址:") - if not ok2 or not email.strip(): - return - self.add_row_to_table(name.strip(), email.strip()) - - def edit_author(self): - rows = set(item.row() for item in self.authors_table.selectedItems()) - if len(rows) != 1: - QMessageBox.warning(self, "提示", "请选择一行进行编辑") - return - row = rows.pop() - old_name = self.authors_table.item(row, 0).text() - old_email = self.authors_table.item(row, 1).text() - - name, ok1 = QInputDialog.getText(self, "编辑作者", "作者姓名:", text=old_name) - if not ok1: - return - email, ok2 = QInputDialog.getText(self, "编辑作者", "邮箱地址:", text=old_email) - if not ok2: - return - self.authors_table.item(row, 0).setText(name.strip()) - self.authors_table.item(row, 1).setText(email.strip()) - - def delete_author(self): - rows = sorted(set(item.row() for item in self.authors_table.selectedItems()), reverse=True) - if not rows: - QMessageBox.warning(self, "提示", "请选择要删除的行") - return - for row in rows: - self.authors_table.removeRow(row) - - def get_values(self): - all_authors, authors = self.get_selected_authors() # 返回 [(name, email), ...] - start = datetime.fromtimestamp(self.start_time.dateTime().toSecsSinceEpoch()) - end = datetime.fromtimestamp(self.end_time.dateTime().toSecsSinceEpoch()) - return all_authors, authors, start, end - - -# ---------------------- 编辑对话框 ---------------------- -class EditDialog(QDialog): - def __init__(self, email: str = '', author: str = '', message: str = '', commit_time: datetime = None): - super().__init__() - self.setWindowTitle("编辑提交信息") - self.author_input = QLineEdit() - self.author_input.setText(author) - - self.email_input = QLineEdit() - self.email_input.setText(email[1:-1]) - - self.message_input = QTextEdit() - self.message_input.setPlainText(message) - - self.date_input = QDateTimeEdit() - self.date_input.setCalendarPopup(True) - dt = QDateTime(commit_time) - self.date_input.setDateTime(dt if dt.isValid() else QDateTime.currentDateTime()) - self.date_input.setDisplayFormat("yyyy-MM-dd HH:mm:ss") - - self.ok_button = QPushButton("确定") - self.ok_button.clicked.connect(self.accept) - - layout = QFormLayout() - layout.addRow("作者:", self.author_input) - layout.addRow("邮箱:", self.email_input) - layout.addRow("提交信息:", self.message_input) - layout.addRow("提交时间:", self.date_input) - layout.addRow(self.ok_button) - - self.setLayout(layout) - - def get_values(self): - timestamp = self.date_input.dateTime().toSecsSinceEpoch() - dt = datetime.fromtimestamp(timestamp) - return ( - self.author_input.text().strip(), - self.email_input.text().strip(), - self.message_input.toPlainText(), - dt - ) - - -# ---------------------- 主窗口 ---------------------- -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QMenu - - -@dataclass -class Config: - last_path: str = "", - authors: List[set] = None, - - -@dataclass -class CommitLog: - id: int = None - changed: bool = False, - commit_id: str = None, - message: str = None, - author: str = None, - email: str = None, - commit_time: datetime = None, - - -class MainWindowViewModel: - def __init__(self, config_file: str = 'config.json'): - # 加载配置 - self.config_file = config_file - self.config = None - self.remote_url = None - self.load_config() - # 会在push的时候进行清空 - self.localChangeCommitIds: Dict[str, int] = dict() - self.commits: List[CommitLog] = [] - - def save_config(self): - data = { - "authors": self.config.authors, - "last_path": self.config.last_path, - } - try: - with open(self.config_file, "w", encoding="utf-8") as fp: - json.dump(data, fp) - except Exception as e: - raise e - - def load_config(self): - if os.path.exists(self.config_file): - try: - with open(self.config_file, "r", encoding="utf-8") as f: - data = json.load(f) - data.setdefault("authors", []) - data.setdefault("last_path", "") - self.config = Config(**data) - except Exception as e: - raise e - else: - self.config = Config(last_path="", authors=[]) - - def get_repo_path(self): - return self.config.last_path - - def set_repo_path(self, path): - self.config.last_path = path - self.save_config() - - def get_authors(self): - return self.config.authors - - def set_authors(self, authors): - self.config.authors = authors - self.save_config() - - def get_all_branches(self): - success, all_branches = Executor.execute(GetAllBranchesCommand(self.config.last_path)) - if success: - if len(all_branches) == 0: - return None, "" - current = "" - branches = set() - for branch in all_branches: - if "HEAD" in branch: - continue - if branch.startswith("remotes/origin/"): - branches.add(branch.replace("remotes/origin/", "")) - continue - if branch.startswith("*"): - current = branch.split("*")[-1].strip() - branches.add(current) - else: - branches.add(branch) - # 远程分支列表 - return branches, current - else: - return None, "" - - def checkout(self, branch): - success, msg = Executor.execute(CheckoutCommand(self.config.last_path, branch)) - if not success: - QMessageBox.critical(self, "分支切换失败", msg) - return success - - def commit_logs(self, branch, callback): - def commits_loaded(success, output): - if not success: - callback(False) - return - commit_logs = [line for line in output.split("\n") if line.strip() != ''] - if not len(commit_logs): - callback(False) - return - changes = self.localChangeCommitIds.get( - branch) if branch in self.localChangeCommitIds is not None else set() - - def convert(commit_log: str, index: int): - id, author, email, remain = commit_log.split(" ", 3) - datestr, timestr, _, message = remain.split(" ", 3) - commit_date = datetime.strptime(datestr + " " + timestr, "%Y-%m-%d %H:%M:%S") - return CommitLog(index, index in changes, id, message, author, email, commit_date) - - commit_log_list = [] - for index, commit_log in enumerate(commit_logs): - commit_log_list.append(convert(commit_log, index)) - self.commits = commit_log_list - callback(True, commit_log_list) - - Executor.executeAsync(GetBranchCommitsCommand(self.config.last_path, branch), commits_loaded) - - def get_remote_url(self): - # 获取远程仓库地址 - success, output = Executor.execute(GetRemoteRepoUrlCommand(self.config.last_path)) - if not success: - return None - self.remote_url = output.strip() - return self.remote_url - - def reset_remote_url(self): - # 重置远程仓库地址 - if self.remote_url is None: - return - success, output = Executor.execute(SetRemoteUrlCommand(self.config.last_path, self.remote_url)) - return success, output - - def set_remote_url(self, url): - if self.remote_url is not None: - return - success, output = Executor.execute(SetRemoteUrlCommand(self.config.last_path, url)) - return success, output - - def edit_single_commit_log(self, branch, id, commit_id: str, author: str, email: str, message: str, - commit_time: datetime, - callback): - def wrapper(success, output): - if success: - changes = self.localChangeCommitIds.get(branch) - if changes is None: - changes = set() - changes.add(id) - self.localChangeCommitIds[branch] = changes - callback(success, output) - - command = EditSingleCommitCommand(repo_path=self.get_repo_path(), commit_id=commit_id, - new_author=author, new_email=email, - new_commit_message=message, new_commit_time=commit_time) - Executor.executeAsync(command, wrapper) - - def bulk_rewrite_author_time(self, branch: str, authors: List[Tuple[str, str]], start: datetime, end: datetime, callback): - def wrapper(success, output): - if success: - changes = self.localChangeCommitIds.get(branch) - if changes is None: - changes = set() - changes.add(id) - self.localChangeCommitIds[branch] = changes - callback(success, output) - - commit_changes = self.__rewrite_commits_with_day_mapping(authors, start, end) - if len(commit_changes) == 0: - raise Exception("没有提交记录信息") - command = EditBulkCommitCommand(repo_path=self.get_repo_path(), commit_changes=commit_changes) - Executor.executeAsync(command, wrapper) - - def __rewrite_commits_with_day_mapping(self, authors: List[Tuple[str, str]], target_start, target_end, ): - # 1. 获取提交列表(sha + author date) - commit_times = set([commit.commit_time.strftime("%Y-%m-%d") for commit in self.commits]) - if not commit_times: - print("无提交记录") - return [] - # 升序排列 - commit_times = sorted(commit_times) - # 3. 生成目标日期映射 - day_mapping = self.__distribute_days_evenly(commit_times, target_start, target_end) - import random - - commit_changes = {} - for commit in self.commits: - commit_date = commit.commit_time.strftime("%Y-%m-%d") - if commit_date not in day_mapping: - continue - new_commit_date = datetime.strptime(day_mapping[commit_date], "%Y-%m-%d") - commit_date = datetime(year=new_commit_date.year, month=new_commit_date.month, day=new_commit_date.day, - hour=commit.commit_time.hour, minute=commit.commit_time.minute, - second=commit.commit_time.second) - author, email = random.choice(authors) - commit_changes[commit.commit_id] = { - "name": author, - "email": email, - "date": commit_date.strftime("%Y-%m-%dT%H:%M:%S"), - "message": commit.message, - } - return commit_changes - - @staticmethod - def __distribute_days_evenly(source_dates: Iterable[str], target_start: datetime, target_end: datetime): - """ - 将 source_dates(去重、有序)映射到 [target_start, target_end] 的均匀日期 - - Args: - source_dates: List[str] like ["2023-01-01", "2023-01-03", ...] - target_start_str / target_end_str: "YYYY-MM-DD" - - Returns: - dict: {"2023-01-01": "2024-06-01", ...} - """ - - if target_start > target_end: - raise ValueError("起始日期不能晚于结束日期") - - n = len(source_dates) - if n == 0: - return {} - - # 生成目标日期列表(均匀) - if n == 1: - target_days = [target_start.date()] - else: - total_days = (target_end - target_start).days - if total_days < n - 1: - raise ValueError(f"目标时间段太短:至少需要 {n} 天,但只有 {total_days + 1} 天可用") - step = total_days / (n - 1) - target_days = [ - (target_start + timedelta(days=int(step * i))).date() - for i in range(n) - ] - # 确保最后一天不超过 end - if target_days[-1] > target_end.date(): - target_days[-1] = target_end.date() - - return { - src: tgt.isoformat() - for src, tgt in zip(source_dates, target_days) - } - - -class MainWindowUI(QMainWindow): - def __init__(self): - super(MainWindowUI, self).__init__() - self.setWindowTitle("Git 提交信息修改工具") - self.viewModel = MainWindowViewModel() - - # 初始化菜单 - self.fileMenu = None - self.edit_menu = None - self.open_action = None - self.rewrite_commit_action = None - self.push_commit_action = None - self.manage_authors_action = None - self.init_menu() - # 初始化central区域 - self.repo_label = None - self.branch_selector = None - self.commit_listbox = None - self.current_branch = None - self.remote_label = None - self.init_central_layout() - # 初始化状态栏 - self.status_label = None - self.progress_bar = None - self.init_statusbar() - - # widget = GitCommitEditor() - # self.setCentralWidget(widget) - self.resize(800, 900) - # self.setMinimumHeight(800) - # self.setMinimumWidth(500) - - def init_menu(self): - self.fileMenu = self.menuBar().addMenu("文件") - self.edit_menu = self.menuBar().addMenu("编辑") - - self.open_action = QAction("打开", self) - self.open_action.setShortcut("Ctrl+O") - self.open_action.triggered.connect(self.open) - - self.rewrite_commit_action = QAction("批量重写", self) - self.rewrite_commit_action.setShortcut("Ctrl+R") - self.rewrite_commit_action.triggered.connect(self.bulk_rewrite_author_time) - - self.push_commit_action = QAction("强推提交日志", self) - self.push_commit_action.triggered.connect(self.push_commit) - - - self.fileMenu.addAction(self.open_action) - self.edit_menu.addAction(self.rewrite_commit_action) - self.edit_menu.addAction(self.push_commit_action) - - def init_central_layout(self): - # 主容器 - centralWidget = QWidget() - container = QVBoxLayout(centralWidget) - # 选择的仓库地址 - top_widget = QWidget() - top_layout = QHBoxLayout() - top_widget.setLayout(top_layout) - self.repo_label = QLabel() - self.explorer_button = QPushButton("打开") - self.explorer_button.setVisible(False) - self.explorer_button.clicked.connect(self.reveal_in_file_explorer) - top_layout.addWidget(QLabel("代码仓库:")) - top_layout.addWidget(self.repo_label) - top_layout.addWidget(self.explorer_button) - top_layout.addStretch(1) - container.addWidget(top_widget) - # 远程仓库 - remote_widget = QWidget() - remote_layout = QHBoxLayout() - remote_widget.setLayout(remote_layout) - self.remote_label = QLabel() - self.remote_label.setText('') - remote_layout.addWidget(QLabel("远程仓库:")) - remote_layout.addWidget(self.remote_label) - self.setRemoteUrlButton = QPushButton("设置") - self.setRemoteUrlButton.clicked.connect(self.set_remote_url) - self.setRemoteUrlButton.setVisible(False) - remote_layout.addWidget(self.setRemoteUrlButton) - - remote_layout.addStretch(1) - container.addWidget(remote_widget) - - # 分支信息 - middle_widget = QWidget() - middle_layout = QHBoxLayout() - middle_widget.setLayout(middle_layout) - middle_layout.addWidget(QLabel("分支:")) - self.branch_selector = QComboBox() - self.branch_selector.setMinimumSize(200, 30) - self.branch_selector.currentTextChanged.connect(self.branch_changed) - - middle_layout.addWidget(self.branch_selector) - middle_layout.addStretch(1) - container.addWidget(middle_widget) - # 提交记录 - commit_widget = QWidget() - commit_layout = QVBoxLayout() - commit_widget.setLayout(commit_layout) - commit_label = QLabel("提交记录:") - commit_layout.addWidget(commit_label) - self.commit_listbox = QListWidget() - # 关闭右击事件 - # self.commit_listbox.setContextMenuPolicy(Qt.CustomContextMenu) - self.commit_listbox.customContextMenuRequested.connect(self.show_commit_context_menu) - self.commit_listbox.itemDoubleClicked.connect(self.edit_commit_log) - commit_layout.addWidget(self.commit_listbox) - container.addWidget(commit_widget) - - self.setCentralWidget(centralWidget) - - def init_statusbar(self): - self.status_label = QLabel() - self.status_label.setText("") - self.status_label.setStyleSheet("color:gray") - - self.progress_bar = QProgressBar() - self.progress_bar.setMaximumHeight(20) - self.progress_bar.setMaximumWidth(250) - self.progress_bar.setRange(0, 0) - self.progress_bar.hide() - - self.statusBar().addPermanentWidget(self.status_label) - self.statusBar().addPermanentWidget(self.progress_bar) - - def open(self): - file_path = self.viewModel.get_repo_path() if self.viewModel.get_repo_path() != '' else '.' - path = QFileDialog.getExistingDirectory(self, "选择Git仓库", file_path) - if path.strip() != "" and os.path.isdir(path): - if not os.path.exists(os.path.join(path, '.git')): - QMessageBox.warning(self, "错误", "不是Git仓库") - return - self.viewModel.set_repo_path(path) - self.repo_label.setText(path) - self.explorer_button.setVisible(True) - # 加载分支信息 - self.load_branches() - - def show_commit_context_menu(self, position): - item = self.commit_listbox.itemAt(position) - if item is None: - return - menu = QMenu() - copy_action = menu.addAction("复制提交哈希值") - action = menu.exec_(self.commit_listbox.mapToGlobal(position)) - - if action == copy_action: - commit: CommitLog = item.data(Qt.UserRole) - QApplication.clipboard().setText(commit.commit_id) - QMessageBox.information(self, "已复制", f"提交哈希值已复制到剪贴板:{commit.commit_id}") - - def push_commit(self): - pass - - def load_branches(self): - repo = self.viewModel.get_repo_path() - if not os.path.isdir(repo): - return - branches, current = self.viewModel.get_all_branches() - if branches is None: - return - self.branch_selector.clear() - self.branch_selector.addItems(branches) - self.current_branch = current - # 设置当前分支 - if self.current_branch != "": - self.branch_selector.setCurrentText(self.current_branch) - # 加载提交记录信息 - self.load_commits() - self.refresh_remote_url() - - def refresh_remote_url(self): - remote_url = self.viewModel.get_remote_url() - if remote_url: - self.remote_label.setText(remote_url) - else: - self.remote_label.setText('') - self.setRemoteUrlButton.setVisible(True) - - def load_commits(self): - branch = self.branch_selector.currentText() - if branch == "": - return - success = self.viewModel.checkout(branch) - if not success: - self.branch_selector.setCurrentText(self.current_branch) - QMessageBox.warning(self, "错误", "分支切换失败") - return - self.current_branch = branch - - # 加载当前分支的提交记录 - def handler(success, commits: List[CommitLog]): - if not success: - self.show_error(f"获取{branch}提交记录失败") - return - self.show_done() - self.commit_listbox.clear() - if not len(commits): - return - for commit in commits: - text = f"{commit.author:<10}{commit.message}" - item = QListWidgetItem(text) - item.setData(Qt.UserRole, commit) - item.setToolTip( - f'提交ID:{commit.commit_id}\n作者:{commit.author} {commit.email}\n时间:{datetime.strftime(commit.commit_time, "%Y-%m-%d %H:%M:%S")}\n提交信息:{commit.message}') - if commit.changed: - item.setForeground(QBrush(QColor("#2e7d32"))) # 深绿色文字 - # 添加双击事件 - self.commit_listbox.addItem(item) - # 添加鼠标右键菜单 - - self.viewModel.commit_logs(branch, handler) - self.show_busy(f"获取{branch}提交记录...") - - def branch_changed(self, branch): - if self.current_branch == branch: - return - # 加载提交记录信息 - self.load_commits() - - def edit_commit_log(self, item): - # 展示当前的编辑信息 - commit: CommitLog = item.data(Qt.UserRole) - dialog = EditDialog(email=commit.email, author=commit.author, - message=commit.message, commit_time=commit.commit_time) - - def callback(success, output): - if success: - self.refresh_remote_url() - self.load_commits() - self.show_done() - else: - QMessageBox.critical(self, "失败", output) - self.show_error(f"修改{commit.commit_id}提交信息失败") - - if dialog.exec_(): - new_author, new_email, new_msg, new_date = dialog.get_values() - if not new_author or not new_msg or not new_date or not new_email: - QMessageBox.information(self, "提示", "作者、邮箱、信息或时间不能为空") - return - self.viewModel.edit_single_commit_log(self.current_branch, commit.id, commit.commit_id, new_author, - new_email, new_msg, new_date, callback) - self.show_busy(f"正在修改{commit.commit_id}提交信息...") - - def set_remote_url(self): - url, ok = QInputDialog.getText( - self, - "设置远程仓库", - "请输入远程仓库地址(如 https://github.com/user/repo.git " - ) - if not ok: - # 用户取消,不做任何事 - return - url = url.strip() - if not url: - QMessageBox.warning(self, "输入无效", "远程仓库地址不能为空") - return - success, output = self.viewModel.set_remote_url(url) - if not success: - QMessageBox.critical(self, "远程仓库设置失败", output) - return - self.remote_label.setText(url) - self.setRemoteUrlButton.setVisible(False) - - def bulk_rewrite_author_time(self): - if self.current_branch is None: - QMessageBox.warning(self, "错误", "请打开本地的代码仓库") - return - dialog = BulkRewriteDialog(authors=self.viewModel.get_authors()) - - def callback(success, output): - if success: - self.refresh_remote_url() - self.load_commits() - self.show_done() - else: - QMessageBox.critical(self, "失败", output) - self.show_error(f"信息重写失败") - - if dialog.exec_(): - all_authors, authors, start, end = dialog.get_values() - self.viewModel.set_authors(all_authors) - if len(authors) == 0: - QMessageBox.warning(self, "输入无效", "请输入至少一个作者") - return - if not start or not end: - QMessageBox.warning(self, "输入无效", "请输入开始时间和结束时间") - return - if end < start: - QMessageBox.warning(self, "输入无效", "开始时间不能大于结束时间") - return - try: - self.viewModel.bulk_rewrite_author_time(self.current_branch, authors, start, end,callback) - self.show_busy(f"正在重写提交的作者及提交时间...") - except Exception as e: - logging.error(e) - QMessageBox.critical(self, "失败", str(e)) - - def show_busy(self, text="正在处理中...", color="blue"): - self.status_label.setText(text) - self.status_label.setStyleSheet(f"color: {color};") - self.progress_bar.show() - - def show_done(self, text="完成", color="green"): - self.status_label.setText(text) - self.status_label.setStyleSheet(f"color:{color}") - self.progress_bar.hide() - - def show_error(self, text="失败"): - self.status_label.setText(text) - self.status_label.setStyleSheet("color:red") - self.progress_bar.hide() - - def reveal_in_file_explorer(self): - path = os.path.abspath(self.viewModel.get_repo_path()) - if not os.path.exists(path): - QMessageBox.warning(self, "错误", "代码仓库不存在") - return - os.startfile( path) - -logging.basicConfig( - filename='app.log', # 日志文件名 - filemode='a', # 文件模式 ('w' 表示覆盖写入, 'a' 表示追加写入) - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # 日志格式 - level=logging.DEBUG # 日志级别 -) - -def init_logging(): - # 创建 logger - logger = logging.getLogger() - logger.setLevel(logging.DEBUG) # 设置最低日志级别 - - # 避免重复添加 handler(尤其在 Jupyter 或多次运行时) - if logger.hasHandlers(): - logger.handlers.clear() - - # 定义统一的日志格式 - formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - - # 1. 文件处理器(追加模式) - file_handler = logging.FileHandler('app.log', mode='a') - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(formatter) - - # 2. 控制台处理器(输出到 stdout) - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(logging.INFO) # 可以设不同级别,比如只在控制台显示 INFO 以上 - console_handler.setFormatter(formatter) - - # 添加处理器到 logger - logger.addHandler(file_handler) - logger.addHandler(console_handler) - -def log_exception(exc_type, exc_value, exc_traceback): - if issubclass(exc_type, KeyboardInterrupt): - sys.__excepthook__(exc_type, exc_value, exc_traceback) - return - logging.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) - QMessageBox.information(None, '未知错误', str(exc_type) + '\n' + str(exc_value) + '\n' + str(exc_traceback)) - - -sys.excepthook = log_exception - - -if __name__ == '__main__': - try: - init_logging() - app = QApplication(sys.argv) - main = MainWindowUI() - main.show() - logging.info("程序启动") - - sys.exit(app.exec_()) - except Exception as e: - logging.error(e) - sys.exit(1) diff --git a/src/test.py b/src/test.py deleted file mode 100644 index 5f34fef..0000000 --- a/src/test.py +++ /dev/null @@ -1,37 +0,0 @@ -from git_utils import RepoAnalyze,FilteringOptions,RepoFilter,Commit -import os - -if __name__ == '__main__': - # 指定程序的运行目录 - os.chdir(r'C:\envirment\code\adhd\code\virbox-dog-adapter') - # 确保当前在 Git 仓库中 - if not os.path.exists(".git"): - raise RuntimeError("Not a Git repository") - # 执行过滤 - argv = ['--analyze'] - args = FilteringOptions.parse_args(argv) - - def commit_callback(commit: Commit,meta): - commit_id = commit.original_id.decode()[:7] - author_name = commit.author_name.decode() - author_email = commit.author_email.decode() - commit_message = commit.message.decode() - type = commit.type - date = commit.author_date.decode() - commiter_date = commit.committer_date.decode() - commiter_email = commit.committer_email.decode() - commiter_name = commit.committer_name.decode() - print(f"commit_id: {commit_id}") - print(f"author_name: {author_name}") - print(f"author_email: {author_email}") - print(f"commit_message: {commit_message}") - print(f"type: {type}") - print(f"date: {date}") - print(f"commiter_date: {commiter_date}") - print(f"commiter_email: {commiter_email}") - print(f"commiter_name: {commiter_name}") - print('*'*50) - repo = RepoFilter(args, commit_callback = commit_callback) - repo.run() - -