-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
339 lines (299 loc) · 12.4 KB
/
Copy pathmain.py
File metadata and controls
339 lines (299 loc) · 12.4 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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
"""
main.py — GitHub Commit Art
Run with no arguments for interactive mode (recommended):
python main.py
Or use flags directly:
python main.py --dry-run # preview default word
python main.py --word "HELLO WORLD" --dry-run # preview a custom phrase
python main.py --word "HI" --start-date 2026-01-04 # generate + push
Flags:
--word TEXT Text to spell (A-Z, 0-9, symbols; default from config.py)
--start-date DATE Start date YYYY-MM-DD; must be a Sunday
--commits N Commits per active day (default 20)
--letter-spacing N Empty columns between letters (default 2)
--word-spacing N Columns for a space between words (default 4)
--repo-path PATH Path to git repo (default: current directory)
--author-name TEXT Override git author name
--author-email EMAIL Override git author email (must be verified on GitHub)
--no-push Skip the automatic git push after generating commits
--dry-run Preview commit pattern without writing anything
"""
import argparse
import subprocess
import sys
from datetime import date
from pathlib import Path
import config
from calendar_mapper import word_to_commit_dates, preview_calendar, get_total_weeks
from commit_generator import generate_commits
def _parse_date(value: str) -> date:
try:
return date.fromisoformat(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(
f"Invalid date '{value}'. Expected YYYY-MM-DD."
) from exc
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="main.py",
description="Spell a word in your GitHub contributions calendar via backdated commits.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--word",
type=str,
default=config.WORD,
metavar="TEXT",
help=f"Word to display (default: '{config.WORD}'). Supports A-Z and spaces.",
)
parser.add_argument(
"--start-date",
type=_parse_date,
default=config.START_DATE,
metavar="YYYY-MM-DD",
help=f"Calendar start date — must be a Sunday (default: {config.START_DATE}).",
)
parser.add_argument(
"--commits",
type=int,
default=config.COMMITS_PER_DAY,
metavar="N",
help=f"Commits per active day (default: {config.COMMITS_PER_DAY}). 10+ = darkest green.",
)
parser.add_argument(
"--letter-spacing",
type=int,
default=config.LETTER_SPACING,
metavar="N",
help=f"Empty columns between consecutive letters (default: {config.LETTER_SPACING}).",
)
parser.add_argument(
"--word-spacing",
type=int,
default=config.WORD_SPACING,
metavar="N",
help=f"Columns consumed by a space character between words (default: {config.WORD_SPACING}).",
)
parser.add_argument(
"--repo-path",
type=str,
default=".",
metavar="PATH",
help="Path to the local git repository (default: current directory).",
)
parser.add_argument(
"--author-name",
type=str,
default=config.AUTHOR_NAME,
metavar="TEXT",
help="Explicit git author name for generated commits (default: current git config).",
)
parser.add_argument(
"--author-email",
type=str,
default=config.AUTHOR_EMAIL,
metavar="EMAIL",
help="Explicit git author email for generated commits (default: current git config). Must be verified on GitHub.",
)
parser.add_argument(
"--no-push",
action="store_true",
help="Skip the automatic git push after generating commits.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview the commit pattern without creating any commits.",
)
parser.add_argument(
"--yes",
action="store_true",
help="Skip the confirmation prompt (useful for CI / GitHub Actions).",
)
return parser
def _get_git_identity(repo_path: str) -> tuple[str | None, str | None]:
def _read(key: str) -> str | None:
result = subprocess.run(
["git", "config", key],
cwd=repo_path,
capture_output=True,
text=True,
check=False,
)
value = result.stdout.strip()
return value or None
return _read("user.name"), _read("user.email")
def _supported_chars_hint() -> str:
specials = sorted(k for k in config.LETTERS if not k.isalpha())
return f"A-Z, 0-9, and: {' '.join(specials)}"
def _interactive_prompt() -> tuple[str, date]:
"""Ask the user for the text and start date interactively."""
print()
print(" GitHub Commit Art — Interactive Setup")
print(f" Supported characters: {_supported_chars_hint()}")
print()
while True:
raw_word = input(" Text to display: ").strip()
if not raw_word:
print(" Text cannot be empty.")
continue
missing = [ch for ch in raw_word.upper() if ch != " " and ch not in config.LETTERS]
if missing:
print(f" Unsupported characters: {sorted(set(missing))}")
print(f" Supported: {_supported_chars_hint()}")
continue
break
print()
while True:
raw_date = input(" Start date (YYYY-MM-DD, must be a Sunday): ").strip()
try:
d = date.fromisoformat(raw_date)
except ValueError:
print(" Invalid format. Use YYYY-MM-DD (e.g. 2026-01-04).")
continue
if d.weekday() != 6:
print(f" {d} is a {d.strftime('%A')}, not a Sunday. Please pick a Sunday.")
continue
break
print()
return raw_word, d
def _current_branch(repo_path: str) -> str:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=repo_path,
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip() or "master"
def _git_push(repo_path: str) -> None:
branch = _current_branch(repo_path)
print(f" Pushing to GitHub (origin/{branch})…")
result = subprocess.run(
["git", "push", "origin", branch],
cwd=repo_path,
capture_output=True,
text=True,
check=False,
)
output = (result.stdout + result.stderr).strip()
if output:
for line in output.splitlines():
print(f" {line}")
if result.returncode == 0:
print(" Pushed successfully! GitHub will update within 10–15 minutes.")
else:
print(f" [Warning] Push failed. Run manually: git push origin {branch}")
def main() -> None:
interactive = len(sys.argv) == 1
args = _build_parser().parse_args()
if interactive:
word, start_date = _interactive_prompt()
word = word.upper()
else:
word = args.word.upper()
start_date: date = args.start_date
commits_per_day: int = args.commits
letter_spacing: int = args.letter_spacing
word_spacing: int = args.word_spacing
repo_path = str(Path(args.repo_path).resolve())
author_name: str | None = args.author_name
author_email: str | None = args.author_email
git_name, git_email = _get_git_identity(repo_path)
effective_author_name = author_name or git_name
effective_author_email = author_email or git_email
# ── Validate unsupported characters ──────────────────────────────────────
missing = [ch for ch in word if ch != " " and ch not in config.LETTERS]
if missing:
unique = sorted(set(missing))
print(f" [Error] Unsupported characters: {unique}")
print(f" Supported: {_supported_chars_hint()}")
sys.exit(1)
# ── Warn if start date is not a Sunday ────────────────────────────────────
if start_date.weekday() != 6: # Python: Monday=0 … Sunday=6
day_name = start_date.strftime("%A")
print(f" [Warning] {start_date} is a {day_name}, not a Sunday.")
print(" GitHub's calendar starts each column on Sunday.")
print(" The art may appear shifted by one or more rows.")
print()
# ── Compute target dates ──────────────────────────────────────────────────
commit_dates = word_to_commit_dates(
word,
start_date,
letter_width=config.LETTER_WIDTH,
letter_spacing=letter_spacing,
word_spacing=word_spacing,
)
if not commit_dates:
print(" No commit dates generated. Check your word or configuration.")
sys.exit(1)
total_weeks = get_total_weeks(word, config.LETTER_WIDTH, letter_spacing, word_spacing)
end_date = max(commit_dates.keys())
total_commits = len(commit_dates) * commits_per_day
# ── ASCII preview ─────────────────────────────────────────────────────────
preview_calendar(word, start_date, config.LETTER_WIDTH, letter_spacing, word_spacing)
# ── Summary ───────────────────────────────────────────────────────────────
print(f" Word : '{word}'")
print(f" Start date : {start_date} ({start_date.strftime('%A')})")
print(f" End date : {end_date} ({end_date.strftime('%A')})")
print(f" Calendar span : ~{total_weeks} weeks")
print(f" Active days : {len(commit_dates)}")
print(f" Commits/day : {commits_per_day}")
print(f" Total commits : {total_commits}")
print(f" Author name : {effective_author_name or '[not set]'}")
print(f" Author email : {effective_author_email or '[not set]'}")
print()
# ── Dry-run mode ──────────────────────────────────────────────────────────
if args.dry_run:
print(" [DRY RUN] Planned commit dates:")
print()
for d in sorted(commit_dates.keys()):
print(f" {d} {d.strftime('%A'):<9} {commit_dates[d]}")
print()
print(f" [DRY RUN] {total_commits} commits would be created. Nothing written.")
return
# ── Confirm before writing ────────────────────────────────────────────────
# In interactive mode the preview was already shown above; ask if they want
# to proceed or restart. In CLI mode just show the repository and confirm.
print(f" Repository : {repo_path}")
print()
if args.yes:
print(" --yes flag set; skipping confirmation prompt.")
elif interactive:
try:
confirm = input(" Looks good? Proceed with generating commits? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("\n Aborted.")
sys.exit(0)
if confirm != "y":
print(" Aborted.")
sys.exit(0)
else:
try:
confirm = input(" This will create commits with backdated timestamps. Proceed? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("\n Aborted.")
sys.exit(0)
if confirm != "y":
print(" Aborted.")
sys.exit(0)
print()
# ── Generate commits ──────────────────────────────────────────────────────
generate_commits(
commit_dates=commit_dates,
commits_per_day=commits_per_day,
repo_path=repo_path,
author_name=effective_author_name,
author_email=effective_author_email,
verbose=True,
)
print()
print(" All commits created successfully!")
print()
# ── Push to GitHub ────────────────────────────────────────────────────────
if not args.no_push:
_git_push(repo_path)
else:
print(" Skipping push (--no-push). Run: git push origin master")
if __name__ == "__main__":
main()