-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit_commit_date_editor.py
More file actions
237 lines (193 loc) · 7.34 KB
/
Copy pathgit_commit_date_editor.py
File metadata and controls
237 lines (193 loc) · 7.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
#!/usr/bin/env python3
import os
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime
from typing import List, Optional, Tuple
@dataclass
class CommitInfo:
sha: str
short_sha: str
author_date: str
committer_date: str
subject: str
DATE_FORMAT_HINT = "YYYY-MM-DD HH:MM:SS"
def run_git(args: List[str], cwd: str, check: bool = True, env: Optional[dict] = None) -> subprocess.CompletedProcess:
merged_env = os.environ.copy()
if env:
merged_env.update(env)
return subprocess.run(
["git", *args],
cwd=cwd,
env=merged_env,
text=True,
capture_output=True,
check=check,
)
def is_git_repo(path: str) -> bool:
try:
result = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
return result.stdout.strip() == "true"
except subprocess.CalledProcessError:
return False
def ensure_clean_worktree(path: str) -> Tuple[bool, str]:
result = run_git(["status", "--porcelain"], cwd=path, check=False)
if result.returncode != 0:
return False, result.stderr.strip() or "无法检查工作区状态"
if result.stdout.strip():
return False, "工作区存在未提交变更。请先提交或暂存,否则 rebase/amend 风险较高。"
return True, ""
def get_commits(path: str, count: int = 30) -> List[CommitInfo]:
fmt = "%H%x1f%h%x1f%ad%x1f%cd%x1f%s"
result = run_git(
[
"log",
f"--max-count={count}",
f"--pretty=format:{fmt}",
"--date=format:%Y-%m-%d %H:%M:%S",
],
cwd=path,
)
commits: List[CommitInfo] = []
for line in result.stdout.splitlines():
parts = line.split("\x1f")
if len(parts) != 5:
continue
commits.append(
CommitInfo(
sha=parts[0],
short_sha=parts[1],
author_date=parts[2],
committer_date=parts[3],
subject=parts[4],
)
)
return commits
def prompt(msg: str) -> str:
try:
return input(msg).strip()
except KeyboardInterrupt:
print("\n已取消。")
sys.exit(1)
def choose_repo_path() -> str:
raw = prompt("Git 仓库路径(直接回车表示当前目录): ")
path = os.path.abspath(raw or os.getcwd())
if not os.path.isdir(path):
print(f"路径不存在: {path}")
sys.exit(1)
if not is_git_repo(path):
print(f"不是 Git 仓库: {path}")
sys.exit(1)
return path
def choose_commit(commits: List[CommitInfo]) -> CommitInfo:
if not commits:
print("未找到任何 commit。")
sys.exit(1)
print("\n最近的提交:")
print("-" * 100)
for idx, c in enumerate(commits, start=1):
print(f"[{idx:>2}] {c.short_sha} | A:{c.author_date} | C:{c.committer_date} | {c.subject}")
print("-" * 100)
while True:
raw = prompt("请选择要修改的 commit 编号: ")
if not raw.isdigit():
print("请输入数字编号。")
continue
idx = int(raw)
if 1 <= idx <= len(commits):
return commits[idx - 1]
print("编号超出范围。")
def parse_datetime_input(label: str, default_value: Optional[str] = None) -> str:
suffix = f"(留空使用 {default_value})" if default_value else ""
while True:
raw = prompt(f"请输入{label} {DATE_FORMAT_HINT} {suffix}: ")
value = raw or default_value
if not value:
print("不能为空。")
continue
try:
dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
return dt.strftime("%Y-%m-%dT%H:%M:%S")
except ValueError:
print("日期格式错误,请按 YYYY-MM-DD HH:MM:SS 输入。")
def count_commits_from_head_to_target(path: str, sha: str) -> int:
result = run_git(["rev-list", "--count", f"{sha}..HEAD"], cwd=path)
return int(result.stdout.strip())
def amend_head_dates(path: str, author_dt: str, committer_dt: str) -> None:
env = {"GIT_COMMITTER_DATE": committer_dt}
run_git(["commit", "--amend", "--no-edit", f"--date={author_dt}"], cwd=path, env=env)
def rewrite_non_head_commit(path: str, target_sha: str, author_dt: str, committer_dt: str) -> None:
ahead_count = count_commits_from_head_to_target(path, target_sha)
if ahead_count <= 0:
raise RuntimeError("目标 commit 不是 HEAD,计数却为 0,状态异常。")
base = f"HEAD~{ahead_count + 1}"
env = os.environ.copy()
env["GIT_SEQUENCE_EDITOR"] = "sed -i.bak '1s/^pick /edit /'"
try:
subprocess.run(
["git", "rebase", "-i", base],
cwd=path,
env=env,
text=True,
capture_output=True,
check=True,
)
except subprocess.CalledProcessError as e:
msg = e.stderr.strip() or e.stdout.strip() or "interactive rebase 启动失败"
raise RuntimeError(msg) from e
try:
amend_head_dates(path, author_dt, committer_dt)
run_git(["rebase", "--continue"], cwd=path)
except Exception:
subprocess.run(["git", "rebase", "--abort"], cwd=path, text=True, capture_output=True)
raise
def main() -> None:
print("Git Commit 时间修改器")
print("=" * 40)
print("说明:只建议在未 push 到远端,且工作区干净时使用。")
repo = choose_repo_path()
clean, msg = ensure_clean_worktree(repo)
if not clean:
print(f"\n拒绝执行:{msg}")
sys.exit(1)
commits = get_commits(repo, count=30)
target = choose_commit(commits)
print("\n已选择:")
print(f"SHA: {target.sha}")
print(f"Subject: {target.subject}")
print(f"当前 Author Date: {target.author_date}")
print(f"当前 Committer Date: {target.committer_date}")
mode = prompt("\n是否同时修改 author/committer 时间?[Y/n]: ").lower() or "y"
if mode == "n":
print("当前版本仍要求输入两者时间,但你可以填相同或分别指定。")
author_dt = parse_datetime_input("Author Date", target.author_date)
committer_dt = parse_datetime_input("Committer Date", target.committer_date)
print("\n即将执行:")
print(f"仓库: {repo}")
print(f"提交: {target.short_sha} {target.subject}")
print(f"新 Author Date: {author_dt}")
print(f"新 Committer Date: {committer_dt}")
print("注意:这会重写历史并改变该 commit 及其后续 commit 的 SHA。")
confirm = prompt("确认继续?请输入 YES: ")
if confirm != "YES":
print("已取消。")
sys.exit(0)
try:
head_sha = run_git(["rev-parse", "HEAD"], cwd=repo).stdout.strip()
if target.sha == head_sha:
amend_head_dates(repo, author_dt, committer_dt)
else:
rewrite_non_head_commit(repo, target.sha, author_dt, committer_dt)
except subprocess.CalledProcessError as e:
msg = e.stderr.strip() or e.stdout.strip() or str(e)
print(f"\n执行失败:{msg}")
sys.exit(1)
except Exception as e:
print(f"\n执行失败:{e}")
sys.exit(1)
print("\n修改完成。新的最近提交记录如下:\n")
for c in get_commits(repo, count=10):
print(f"{c.short_sha} | A:{c.author_date} | C:{c.committer_date} | {c.subject}")
if __name__ == "__main__":
main()