whygit 0.1.0__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.
whygit/errors.py ADDED
@@ -0,0 +1,1108 @@
1
+ """Matching pasted git and GitHub error text to explanations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import Callable
9
+
10
+ from . import rules as R
11
+ from .findings import BOOK, DOCS, Finding, FixOption, Step, q
12
+ from .gitcmd import redact
13
+ from .state import RepoState
14
+
15
+ Build = Callable[[re.Match, str, "RepoState | None"], Finding]
16
+
17
+
18
+ @dataclass
19
+ class ErrorRule:
20
+ id: str
21
+ family: str
22
+ patterns: list[re.Pattern]
23
+ build: Build
24
+
25
+
26
+ ERROR_RULES: list[ErrorRule] = []
27
+
28
+
29
+ def error_rule(rule_id: str, *patterns: str, family: str | None = None):
30
+ def deco(fn: Build) -> Build:
31
+ ERROR_RULES.append(ErrorRule(rule_id, family or rule_id, [re.compile(p, re.I | re.M) for p in patterns], fn))
32
+ return fn
33
+
34
+ return deco
35
+
36
+
37
+ def _repo(st: RepoState | None) -> RepoState | None:
38
+ return st if st is not None and st.is_repo else None
39
+
40
+
41
+ def _branch(st: RepoState | None, default: str = "main") -> str:
42
+ st = _repo(st)
43
+ return (st.branch if st and st.branch else None) or default
44
+
45
+
46
+ def _remote(st: RepoState | None) -> str:
47
+ st = _repo(st)
48
+ if st and st.remotes and "origin" not in st.remotes:
49
+ return next(iter(st.remotes))
50
+ return "origin"
51
+
52
+
53
+ def _tab_list(text: str, start: int) -> list[str]:
54
+ """Files git lists on tab-indented lines after a message."""
55
+ out = []
56
+ for line in text[start:].splitlines()[1:]:
57
+ if line.startswith("\t") or line.startswith(" "):
58
+ out.append(line.strip())
59
+ elif out or line.strip():
60
+ break
61
+ return out
62
+
63
+
64
+ def _no_force_step() -> Step:
65
+ return Step(
66
+ "Don't use `git push --force` to get past this: it would delete the other commits from GitHub.",
67
+ )
68
+
69
+
70
+ # ---------------------------------------------------------------- authentication and access
71
+
72
+
73
+ @error_rule(
74
+ "auth_failed",
75
+ r"Support for password authentication was removed",
76
+ r"Authentication failed for '[^']+'",
77
+ r"Invalid username or (?:password|token)",
78
+ r"could not read Username for '[^']+'",
79
+ )
80
+ def _auth(m, text, st):
81
+ return Finding(
82
+ "auth_failed",
83
+ "blocking",
84
+ "GitHub didn't accept your login",
85
+ "GitHub stopped accepting your account password for Git in 2021. When Git asks for a password it now needs "
86
+ "a personal access token (a long generated password), or you can let the GitHub CLI or SSH keys handle "
87
+ "logging in. Never paste a token into a chat, a file, or your code.",
88
+ fix=[
89
+ FixOption(
90
+ "Easiest: log in with the GitHub CLI",
91
+ [
92
+ Step("Install the GitHub CLI from https://cli.github.com (on macOS: brew install gh)"),
93
+ Step(
94
+ "Logs in through your browser and sets Git up to use that login; choose HTTPS when asked",
95
+ "gh auth login",
96
+ ),
97
+ Step("Run the command that failed again, for example", "git push"),
98
+ ],
99
+ ),
100
+ FixOption(
101
+ "Or use a personal access token",
102
+ [
103
+ Step(
104
+ "On GitHub go to Settings > Developer settings > Personal access tokens and create one with access to this repository."
105
+ ),
106
+ Step(
107
+ "Run your command again and, when Git asks for a password, paste the token (it won't show as you type)."
108
+ ),
109
+ Step(
110
+ "If Git doesn't ask and fails straight away, an old password is saved: on macOS remove the github.com entry in "
111
+ "Keychain Access; on Windows remove git:https://github.com in Credential Manager > Windows Credentials."
112
+ ),
113
+ ],
114
+ ),
115
+ ],
116
+ learn_more=DOCS + "gitcredentials",
117
+ )
118
+
119
+
120
+ def _https_url(st: RepoState | None) -> str:
121
+ st = _repo(st)
122
+ url = (st.remotes.get(_remote(st)) if st else "") or ""
123
+ m = re.match(r"^(?:ssh://)?git@github\.com[:/](.+?)(?:\.git)?/?$", url)
124
+ if m:
125
+ return f"https://github.com/{m.group(1)}.git"
126
+ return "https://github.com/<owner>/<repo>.git"
127
+
128
+
129
+ @error_rule("ssh_publickey", r"Permission denied \(publickey")
130
+ def _publickey(m, text, st):
131
+ return Finding(
132
+ "ssh_publickey",
133
+ "blocking",
134
+ "GitHub rejected your SSH key",
135
+ "This repository's address uses SSH (it starts with git@github.com), and GitHub didn't accept any SSH key from "
136
+ "this computer. An SSH key is a pair of files that proves who you are; either none exists yet, or it isn't "
137
+ "added to your GitHub account.",
138
+ fix=[
139
+ FixOption(
140
+ "Simplest: switch this repository to HTTPS and log in with the GitHub CLI",
141
+ [
142
+ Step(
143
+ "Changes the address Git uses for GitHub to the HTTPS form",
144
+ f"git remote set-url {_remote(st)} {_https_url(st)}",
145
+ ),
146
+ Step("Logs in through your browser (install from https://cli.github.com)", "gh auth login"),
147
+ ],
148
+ ),
149
+ FixOption(
150
+ "Or set up an SSH key",
151
+ [
152
+ Step(
153
+ "Creates a new key pair; press Enter to accept the defaults",
154
+ 'ssh-keygen -t ed25519 -C "you@example.com"',
155
+ ),
156
+ Step(
157
+ "Copy the contents of the .pub file it created (~/.ssh/id_ed25519.pub) into GitHub > Settings > SSH and GPG keys."
158
+ ),
159
+ Step("Checks that GitHub now recognises you", "ssh -T git@github.com"),
160
+ ],
161
+ ),
162
+ ],
163
+ learn_more=BOOK + "Git-on-the-Server-Generating-Your-SSH-Public-Key",
164
+ )
165
+
166
+
167
+ @error_rule("repo_not_found", r"remote: Repository not found", r"fatal: repository '[^']+' not found")
168
+ def _repo_not_found(m, text, st):
169
+ st2 = _repo(st)
170
+ details = [f"{k}: {v}" for k, v in st2.remotes.items()] if st2 else []
171
+ return Finding(
172
+ "repo_not_found",
173
+ "blocking",
174
+ "GitHub can't find that repository (or won't show it to you)",
175
+ "Either the address has a typo, the repository was renamed or deleted, or it is private and the account you're "
176
+ "logged in as doesn't have access. GitHub says 'not found' rather than 'forbidden' for private repositories.",
177
+ details=details,
178
+ details_label="Remote addresses",
179
+ fix=[
180
+ FixOption(
181
+ "Check the address",
182
+ [
183
+ Step("Shows the address this repository uses", "git remote -v"),
184
+ Step(
185
+ "If it's wrong, replaces it with the address from the repository's green Code button on GitHub",
186
+ f"git remote set-url {_remote(st)} <correct-url>",
187
+ ),
188
+ ],
189
+ ),
190
+ FixOption(
191
+ "Check access",
192
+ [
193
+ Step(
194
+ "Make sure you're logged in as the right account and have accepted the invitation to the repository (for a class, accept the assignment link first)."
195
+ )
196
+ ],
197
+ ),
198
+ ],
199
+ learn_more=DOCS + "git-remote",
200
+ )
201
+
202
+
203
+ @error_rule("push_forbidden", r"The requested URL returned error: 403", r"Permission to \S+ denied to \S+")
204
+ def _forbidden(m, text, st):
205
+ return Finding(
206
+ "push_forbidden",
207
+ "blocking",
208
+ "You're logged in, but not allowed to push to this repository",
209
+ "GitHub knows who you are, but that account can't write to this repository. Usually the remote points at someone "
210
+ "else's repository (for example the course's starter repository instead of your own copy), or your token "
211
+ "doesn't have write access.",
212
+ fix=[
213
+ FixOption(
214
+ "Point at your own repository",
215
+ [
216
+ Step("Shows where Git is pushing to", "git remote -v"),
217
+ Step(
218
+ "Changes it to your own repository's address",
219
+ f"git remote set-url {_remote(st)} <your-repository-url>",
220
+ ),
221
+ Step("Pushes again", f"git push -u {_remote(st)} {_branch(st)}"),
222
+ ],
223
+ ),
224
+ FixOption("Or log in as the right account", [Step("Logs in again through your browser", "gh auth login")]),
225
+ ],
226
+ learn_more=DOCS + "git-push",
227
+ )
228
+
229
+
230
+ # ---------------------------------------------------------------- push and pull
231
+
232
+
233
+ @error_rule(
234
+ "push_rejected_remote_ahead",
235
+ r"\[rejected\].*\((?:fetch first|non-fast-forward)\)",
236
+ r"Updates were rejected because the remote contains work that you do",
237
+ r"Updates were rejected because the tip of your current branch is behind",
238
+ r"Updates were rejected because a pushed branch tip is behind",
239
+ family="push",
240
+ )
241
+ def _push_rejected(m, text, st):
242
+ st2 = _repo(st)
243
+ expl = (
244
+ "GitHub has commits on this branch that you don't have yet (a teammate pushed, or you pushed from another "
245
+ "computer or edited on the website). Git won't let you push until you've combined their work with yours."
246
+ )
247
+ note = ""
248
+ if st2 and st2.ahead is not None and st2.behind is not None and st2.upstream:
249
+ note = f"Right now you are {st2.ahead} ahead and {st2.behind} behind {st2.upstream}. " + st2.fetch_note()
250
+ return Finding(
251
+ "push_rejected_remote_ahead",
252
+ "blocking",
253
+ "Push rejected: GitHub has commits you don't have",
254
+ expl,
255
+ note=note,
256
+ fix=[
257
+ FixOption(
258
+ "Get their commits, then push",
259
+ [
260
+ Step("Downloads their commits and merges them with yours", "git pull --no-rebase"),
261
+ Step(
262
+ "If it reports conflicts, fix them (whygit will explain the state), then `git add` and `git commit`."
263
+ ),
264
+ Step("Uploads the combined result", "git push"),
265
+ _no_force_step(),
266
+ ],
267
+ )
268
+ ],
269
+ learn_more=DOCS + "git-push#_note_about_fast_forwards",
270
+ )
271
+
272
+
273
+ @error_rule(
274
+ "divergent_pull",
275
+ r"You have divergent branches and need to specify how to reconcile them",
276
+ r"Need to specify how to reconcile divergent branches",
277
+ r"Not possible to fast-forward, aborting",
278
+ family="pull",
279
+ )
280
+ def _divergent(m, text, st):
281
+ return Finding(
282
+ "divergent_pull",
283
+ "blocking",
284
+ "Pull stopped: Git wants you to choose how to combine your work",
285
+ "Both you and GitHub have new commits on this branch. Git can combine them with a merge (adds one commit that "
286
+ "joins the two) or a rebase (replays your commits on top of theirs), and it's asking which you want.",
287
+ fix=[
288
+ FixOption(
289
+ "Merge (simplest, recommended if unsure)",
290
+ [
291
+ Step("Pulls and merges this time", "git pull --no-rebase"),
292
+ Step(
293
+ "Optional: makes merging the default so you aren't asked again",
294
+ "git config --global pull.rebase false",
295
+ ),
296
+ ],
297
+ ),
298
+ FixOption("Or rebase", [Step("Pulls and replays your commits on top of theirs", "git pull --rebase")]),
299
+ ],
300
+ learn_more=DOCS + "git-pull",
301
+ )
302
+
303
+
304
+ @error_rule("unrelated_histories", r"refusing to merge unrelated histories", family="pull")
305
+ def _unrelated(m, text, st):
306
+ b = _branch(st)
307
+ r = _remote(st)
308
+ return Finding(
309
+ "unrelated_histories",
310
+ "blocking",
311
+ "Git refuses to combine two unrelated projects",
312
+ "Your local repository and the one on GitHub don't share any starting commit. This almost always happens when "
313
+ "the GitHub repository was created with a README or license, and you also ran `git init` and committed on your computer.",
314
+ fix=[
315
+ FixOption(
316
+ "Combine them once",
317
+ [
318
+ Step(
319
+ "Pulls the GitHub history and allows joining it to yours this one time",
320
+ f"git pull {r} {b} --no-rebase --allow-unrelated-histories",
321
+ ),
322
+ Step("If README.md or another file conflicts, fix it, then `git add` it and `git commit`."),
323
+ Step("Uploads the joined history and links the branch", f"git push -u {r} {b}"),
324
+ _no_force_step(),
325
+ ],
326
+ )
327
+ ],
328
+ learn_more=DOCS + "git-merge#Documentation/git-merge.txt---allow-unrelated-histories",
329
+ )
330
+
331
+
332
+ @error_rule("remote_ref_missing", r"couldn't find remote ref (\S+)", family="pull")
333
+ def _remote_ref_missing(m, text, st):
334
+ name = m.group(1)
335
+ st2 = _repo(st)
336
+ have = [b.split("/", 1)[1] for b in st2.remote_branches] if st2 else []
337
+ alt = "main" if name == "master" else ("master" if name == "main" else "")
338
+ guess = alt if (alt and (not have or alt in have)) else ""
339
+ steps = [Step("Lists the branches the remote has (as of your last fetch)", "git branch -r")]
340
+ if guess:
341
+ steps.append(Step("Pulls the branch that probably exists instead", f"git pull {_remote(st)} {guess}"))
342
+ return Finding(
343
+ "remote_ref_missing",
344
+ "blocking",
345
+ f"The remote has no branch called '{name}'",
346
+ "You asked for a branch that doesn't exist on the remote. A common cause: GitHub names the first branch "
347
+ "'main' while older tutorials say 'master'.",
348
+ fix=[FixOption("Use the right branch name", steps)],
349
+ learn_more=DOCS + "git-pull",
350
+ )
351
+
352
+
353
+ @error_rule("no_upstream_push", r"The current branch (\S+) has no upstream branch", family="upstream")
354
+ def _no_upstream_push(m, text, st):
355
+ b = m.group(1)
356
+ return Finding(
357
+ "no_upstream",
358
+ "blocking",
359
+ f"Git doesn't know where to push '{b}' yet",
360
+ "This branch isn't linked to a branch on GitHub (its 'upstream'), which is normal the first time you push it.",
361
+ fix=[
362
+ FixOption(
363
+ "Push and link it",
364
+ [
365
+ Step(
366
+ f"Uploads {b} and remembers where it goes, so next time `git push` is enough",
367
+ f"git push -u {_remote(st)} {b}",
368
+ )
369
+ ],
370
+ )
371
+ ],
372
+ learn_more=DOCS + "git-push",
373
+ )
374
+
375
+
376
+ @error_rule("no_tracking_pull", r"There is no tracking information for the current branch", family="upstream")
377
+ def _no_tracking(m, text, st):
378
+ b = _branch(st, "<branch>")
379
+ r = _remote(st)
380
+ st2 = _repo(st)
381
+ if st2 and f"{r}/{b}" not in st2.remote_branches:
382
+ steps = [
383
+ Step(
384
+ f"The remote doesn't have {b} yet (as of your last fetch); push it first and link it",
385
+ f"git push -u {r} {b}",
386
+ )
387
+ ]
388
+ else:
389
+ steps = [
390
+ Step(f"Links this branch to {r}/{b}", f"git branch --set-upstream-to={r}/{b}"),
391
+ Step("Pulls again", "git pull"),
392
+ ]
393
+ return Finding(
394
+ "no_upstream",
395
+ "blocking",
396
+ "Git doesn't know which branch to pull from",
397
+ "This branch isn't linked to a branch on the remote (its 'upstream'), so plain `git pull` doesn't know what to fetch.",
398
+ fix=[FixOption("Link it", steps)],
399
+ learn_more=DOCS + "git-branch#Documentation/git-branch.txt---set-upstream-toltupstreamgt",
400
+ )
401
+
402
+
403
+ @error_rule("src_refspec", r"src refspec (\S+) does not match any", family="push")
404
+ def _src_refspec(m, text, st):
405
+ name = m.group(1)
406
+ st2 = _repo(st)
407
+ r = _remote(st)
408
+ if st2 and st2.unborn:
409
+ return Finding(
410
+ "src_refspec",
411
+ "blocking",
412
+ "There's nothing to push yet: no commits",
413
+ f"Git can't push '{name}' because you haven't made any commits, so the branch doesn't exist yet.",
414
+ fix=[
415
+ FixOption(
416
+ "Commit, then push",
417
+ [
418
+ Step("Stages your files (check `git status` first)", "git add ."),
419
+ Step("Makes the first commit", 'git commit -m "First commit"'),
420
+ Step("Uploads it", f"git push -u {r} {st2.branch or name}"),
421
+ ],
422
+ )
423
+ ],
424
+ learn_more=DOCS + "git-push",
425
+ )
426
+ if st2 and st2.branch and st2.branch != name:
427
+ cur = st2.branch
428
+ return Finding(
429
+ "src_refspec",
430
+ "blocking",
431
+ f"You don't have a branch called '{name}'; yours is '{cur}'",
432
+ f"You asked Git to push '{name}', but your branch is named '{cur}' (older Git versions call the first branch "
433
+ "'master', GitHub calls it 'main').",
434
+ fix=[
435
+ FixOption("Push the branch you have", [Step(f"Uploads {cur}", f"git push -u {r} {cur}")]),
436
+ FixOption(
437
+ f"Or rename it to {name} first",
438
+ [
439
+ Step(f"Renames {cur} to {name}", f"git branch -m {cur} {name}"),
440
+ Step("Uploads it", f"git push -u {r} {name}"),
441
+ ],
442
+ ),
443
+ ],
444
+ learn_more=DOCS + "git-branch",
445
+ )
446
+ return Finding(
447
+ "src_refspec",
448
+ "blocking",
449
+ f"There's no local branch called '{name}' to push",
450
+ "Either you haven't made a commit yet (so no branch exists), or your branch has a different name, such as 'master' instead of 'main'.",
451
+ fix=[
452
+ FixOption("Check", [Step("Shows your branch name and whether anything is committed", "git status")]),
453
+ FixOption(
454
+ "If nothing is committed",
455
+ [
456
+ Step("Stages your files", "git add ."),
457
+ Step("Makes the first commit", 'git commit -m "First commit"'),
458
+ Step("Pushes", f"git push -u {r} {name}"),
459
+ ],
460
+ ),
461
+ FixOption(
462
+ "If your branch has another name",
463
+ [
464
+ Step("Renames the current branch", f"git branch -m {name}"),
465
+ Step("Pushes", f"git push -u {r} {name}"),
466
+ ],
467
+ ),
468
+ ],
469
+ learn_more=DOCS + "git-push",
470
+ )
471
+
472
+
473
+ @error_rule("push_too_large", r"RPC failed; HTTP 413", r"pack exceeds maximum allowed size", family="large")
474
+ def _too_large(m, text, st):
475
+ st2 = _repo(st)
476
+ if st2:
477
+ f = R.large_file_over_limit(st2) or R.large_file_warning(st2)
478
+ if f:
479
+ return f
480
+ return Finding(
481
+ "push_too_large",
482
+ "blocking",
483
+ "The push was too big for the server",
484
+ "The server stopped the upload because it was too large, usually because big data files were committed.",
485
+ fix=[
486
+ FixOption(
487
+ "Find the big files",
488
+ [Step("Run whygit in the repository to list large files waiting to be pushed", "whygit")],
489
+ )
490
+ ],
491
+ learn_more=DOCS + "git-push",
492
+ )
493
+
494
+
495
+ @error_rule(
496
+ "large_file_rejected",
497
+ r"GH001: Large files detected",
498
+ r"exceeds GitHub's file size limit",
499
+ family="large",
500
+ )
501
+ def _gh001(m, text, st):
502
+ st2 = _repo(st)
503
+ if st2:
504
+ f = R.large_file_over_limit(st2)
505
+ if f:
506
+ return f
507
+ files = [(g.group(1), g.group(2)) for g in re.finditer(r"File (.+?) is ([\d.]+ MB); this exceeds", text)]
508
+ paths = [p for p, _ in files]
509
+ b = _branch(st)
510
+ return Finding(
511
+ "large_file_over_limit",
512
+ "blocking",
513
+ "GitHub rejected the push: a file is over 100 MB",
514
+ "GitHub refuses any push containing a file over 100 MB, and deleting the file in a new commit isn't enough "
515
+ "because it is still inside an earlier commit. You need to rebuild your unpushed commits without it.",
516
+ details=[f"{p} ({s})" for p, s in files],
517
+ details_label="Files GitHub rejected",
518
+ fix=[
519
+ FixOption(
520
+ "Rebuild the unpushed commits without the file",
521
+ [
522
+ Step(
523
+ "Undoes the commits GitHub hasn't accepted but keeps all their changes staged (your files are not touched)",
524
+ f"git reset --soft {_remote(st)}/{b}",
525
+ ),
526
+ Step(
527
+ "Takes the big file out of what will be committed, keeping it on disk",
528
+ f"git rm --cached {' '.join(q(p) for p in paths) or '<file>'}",
529
+ ),
530
+ Step("Add the file (or a pattern like data/*.csv) to .gitignore so it isn't added again."),
531
+ Step("Commits everything else again", 'git commit -m "Your message"'),
532
+ Step("Pushes", "git push"),
533
+ ],
534
+ )
535
+ ],
536
+ learn_more=DOCS + "git-rm",
537
+ )
538
+
539
+
540
+ @error_rule("everything_up_to_date", r"^Everything up-to-date", family="push")
541
+ def _up_to_date(m, text, st):
542
+ return Finding(
543
+ "everything_up_to_date",
544
+ "info",
545
+ "Nothing new to push",
546
+ "Every commit you have is already on the remote. If a change is missing on GitHub, it probably hasn't been "
547
+ "committed yet: pushing only sends commits, not edited files.",
548
+ fix=[
549
+ FixOption(
550
+ "Commit your changes first",
551
+ [
552
+ Step("Shows changed files that aren't committed", "git status"),
553
+ Step("Stages them", "git add <file>"),
554
+ Step("Commits", 'git commit -m "Describe the change"'),
555
+ Step("Pushes", "git push"),
556
+ ],
557
+ )
558
+ ],
559
+ learn_more=DOCS + "git-push",
560
+ )
561
+
562
+
563
+ # ---------------------------------------------------------------- local changes and paths
564
+
565
+
566
+ @error_rule(
567
+ "local_changes_overwritten",
568
+ r"Your local changes to the following files would be overwritten by ([\w-]+)",
569
+ r"cannot (?:pull with rebase|rebase): You have unstaged changes",
570
+ r"Your local changes would be overwritten by ([\w-]+)",
571
+ )
572
+ def _overwritten(m, text, st):
573
+ files = _tab_list(text, m.start())
574
+ st2 = _repo(st)
575
+ if not files and st2:
576
+ files = [e.path for e in st2.unstaged]
577
+ op = m.group(1) if m.groups() and m.group(1) else "the command"
578
+ paths = " ".join(q(f) for f in files[:6]) or "<file>"
579
+ return Finding(
580
+ "local_changes_overwritten",
581
+ "blocking",
582
+ "You have uncommitted changes that would be overwritten",
583
+ f"You've edited files that aren't committed yet, and {op} would replace them. Git stopped so your edits aren't lost.",
584
+ details=files,
585
+ details_label="Files with uncommitted changes",
586
+ fix=[
587
+ FixOption(
588
+ "Keep the changes by committing them",
589
+ [
590
+ Step("Stages your edits", f"git add {paths}"),
591
+ Step("Commits them", 'git commit -m "Work in progress"'),
592
+ Step("Then run your command again."),
593
+ ],
594
+ ),
595
+ FixOption(
596
+ "Or set them aside for a moment",
597
+ [
598
+ Step("Saves your edits away and cleans the files (nothing is lost)", "git stash"),
599
+ Step("Run your command again."),
600
+ Step("Brings your edits back", "git stash pop"),
601
+ ],
602
+ ),
603
+ FixOption(
604
+ "Or throw the edits away",
605
+ [
606
+ Step(
607
+ "Restores the files to the last commit",
608
+ f"git restore {paths}",
609
+ destructive=True,
610
+ warning="Your uncommitted edits to these files are permanently lost.",
611
+ )
612
+ ],
613
+ ),
614
+ ],
615
+ learn_more=DOCS + "git-stash",
616
+ )
617
+
618
+
619
+ @error_rule("untracked_overwritten", r"untracked working tree files would be (?:overwritten|removed) by ([\w-]+)")
620
+ def _untracked_overwritten(m, text, st):
621
+ files = _tab_list(text, m.start())
622
+ paths = " ".join(q(f) for f in files[:6]) or "<file>"
623
+ return Finding(
624
+ "untracked_overwritten",
625
+ "blocking",
626
+ "Files you haven't committed are in the way",
627
+ f"You have files that Git isn't tracking yet, and the {m.group(1)} would put tracked files with the same names in their place.",
628
+ details=files,
629
+ details_label="Files in the way",
630
+ fix=[
631
+ FixOption(
632
+ "Move them aside",
633
+ [
634
+ Step("Makes a backup folder next to the project", "mkdir ../whygit-backup"),
635
+ Step("Moves the files there (macOS/Linux; on Windows use move)", f"mv {paths} ../whygit-backup/"),
636
+ Step("Run your command again, then compare with the backup."),
637
+ ],
638
+ )
639
+ ],
640
+ learn_more=DOCS + "git-checkout",
641
+ )
642
+
643
+
644
+ @error_rule(
645
+ "pathspec_no_match",
646
+ r"pathspec '([^']+)' did not match any file\(s\) known to git",
647
+ r"fatal: pathspec '([^']+)' did not match any files",
648
+ r"fatal: invalid reference: (\S+)",
649
+ )
650
+ def _pathspec(m, text, st):
651
+ name = m.group(1)
652
+ st2 = _repo(st)
653
+ is_add = "did not match any files" in m.group(0)
654
+ if is_add:
655
+ return Finding(
656
+ "pathspec_no_match",
657
+ "blocking",
658
+ f"There's no file called '{name}' here",
659
+ "Git couldn't find that file. Check the spelling and capital letters, which folder you're in, and put quotes "
660
+ "around names with spaces.",
661
+ fix=[
662
+ FixOption(
663
+ "Check the name",
664
+ [
665
+ Step("Lists changed and new files with their exact names", "git status"),
666
+ Step("Adds the file using the exact name shown", f'git add "{name}"'),
667
+ ],
668
+ )
669
+ ],
670
+ learn_more=DOCS + "git-add",
671
+ )
672
+ candidates: list[str] = []
673
+ remote_match = ""
674
+ if st2:
675
+ names = st2.local_branches + [b.split("/", 1)[1] for b in st2.remote_branches if "/" in b]
676
+ candidates = difflib.get_close_matches(name, sorted(set(names)), n=3, cutoff=0.6)
677
+ if any(b.split("/", 1)[-1] == name for b in st2.remote_branches):
678
+ remote_match = name
679
+ steps = [Step("Lists all branches, including ones on the remote", "git branch -a")]
680
+ if remote_match:
681
+ steps.append(Step("Creates a local copy of the remote branch and switches to it", f"git switch {name}"))
682
+ else:
683
+ steps.insert(
684
+ 0,
685
+ Step(
686
+ "Downloads the latest list of branches (it only updates your local copy of GitHub's branches)",
687
+ "git fetch",
688
+ ),
689
+ )
690
+ steps.append(Step("Switches to the branch once it shows up", f"git switch {name}"))
691
+ for c in candidates:
692
+ if c != name:
693
+ steps.append(Step(f"Did you mean '{c}'?", f"git switch {c}"))
694
+ return Finding(
695
+ "pathspec_no_match",
696
+ "blocking",
697
+ f"Git doesn't know a branch or file called '{name}'",
698
+ "There's no branch with that name on this computer yet. Check the spelling, or if the branch was just made on GitHub, "
699
+ "fetch first so your computer hears about it.",
700
+ fix=[FixOption("Find the branch", steps)],
701
+ learn_more=DOCS + "git-switch",
702
+ )
703
+
704
+
705
+ @error_rule("remote_exists", r"remote (\S+) already exists")
706
+ def _remote_exists(m, text, st):
707
+ name = m.group(1).rstrip(".")
708
+ st2 = _repo(st)
709
+ details = [f"{name}: {st2.remotes[name]}"] if st2 and name in st2.remotes else []
710
+ return Finding(
711
+ "remote_exists",
712
+ "info",
713
+ f"A remote called '{name}' is already set",
714
+ "This repository already has an address saved under that name, usually because it was cloned or you ran "
715
+ "`git remote add` before. You can keep it or change where it points.",
716
+ details=details,
717
+ details_label="Current address",
718
+ fix=[
719
+ FixOption(
720
+ "Check or change it",
721
+ [
722
+ Step("Shows the saved addresses", "git remote -v"),
723
+ Step("Changes the address if it's wrong", f"git remote set-url {name} <new-url>"),
724
+ ],
725
+ )
726
+ ],
727
+ learn_more=DOCS + "git-remote",
728
+ )
729
+
730
+
731
+ @error_rule("no_such_remote", r"fatal: '([^']+)' does not appear to be a git repository")
732
+ def _no_such_remote(m, text, st):
733
+ name = m.group(1)
734
+ st2 = _repo(st)
735
+ if "/" not in name and ":" not in name and "\\" not in name:
736
+ has = ", ".join(st2.remotes) if st2 and st2.remotes else "none"
737
+ return Finding(
738
+ "no_remote",
739
+ "blocking",
740
+ f"There's no remote called '{name}'",
741
+ f"Git doesn't have an address saved under the name '{name}' (remotes set here: {has}). Add one before pushing or pulling.",
742
+ fix=[
743
+ FixOption(
744
+ "Add the remote",
745
+ [
746
+ Step("Saves GitHub's address under that name", f"git remote add {name} <repository-url>"),
747
+ Step("Pushes and links your branch", f"git push -u {name} {_branch(st)}"),
748
+ ],
749
+ )
750
+ ],
751
+ learn_more=DOCS + "git-remote",
752
+ )
753
+ return Finding(
754
+ "repo_not_found",
755
+ "blocking",
756
+ "That address isn't a Git repository",
757
+ "Git looked at the address you gave and found no repository there. Check the address for typos.",
758
+ fix=[FixOption("Check the address", [Step("Shows the addresses this repository uses", "git remote -v")])],
759
+ learn_more=DOCS + "git-remote",
760
+ )
761
+
762
+
763
+ @error_rule("clone_dest_exists", r"destination path '([^']+)' already exists and is not an empty directory")
764
+ def _clone_exists(m, text, st):
765
+ d = m.group(1)
766
+ return Finding(
767
+ "clone_dest_exists",
768
+ "info",
769
+ f"A folder called '{d}' already exists",
770
+ "Clone makes a new folder and won't put a repository into one that already has files. You may have cloned it already.",
771
+ fix=[
772
+ FixOption(
773
+ "If it's the same project",
774
+ [Step("Moves into it", f"cd {q(d)}"), Step("Gets the latest changes", "git pull")],
775
+ ),
776
+ FixOption(
777
+ "Or clone into a new folder name",
778
+ [Step("Clones into a different folder", f"git clone <repository-url> {q(d + '-2')}")],
779
+ ),
780
+ ],
781
+ learn_more=DOCS + "git-clone",
782
+ )
783
+
784
+
785
+ # ---------------------------------------------------------------- state-backed errors
786
+
787
+
788
+ @error_rule("not_a_repo", r"not a git repository")
789
+ def _not_repo(m, text, st):
790
+ if st is not None and not st.is_repo:
791
+ f = R.not_a_repo(st) or R.inside_git_dir(st) or R.bare_repo(st)
792
+ if f:
793
+ return f
794
+ return Finding(
795
+ "not_a_repo",
796
+ "blocking",
797
+ "That folder is not a Git repository",
798
+ "Git commands only work inside a project folder that was cloned or set up with `git init`. You're probably in "
799
+ "the wrong folder (often one level above the project).",
800
+ fix=[
801
+ FixOption(
802
+ "Move into the project",
803
+ [Step("Shows where you are", "pwd"), Step("Moves into the project folder", "cd <project-folder>")],
804
+ ),
805
+ FixOption(
806
+ "Or get the project", [Step("Downloads the repository into a new folder", "git clone <repository-url>")]
807
+ ),
808
+ ],
809
+ learn_more=DOCS + "git-init",
810
+ )
811
+
812
+
813
+ @error_rule("dubious_ownership", r"detected dubious ownership in repository at '([^']+)'")
814
+ def _dubious(m, text, st):
815
+ return R.dubious_finding(m.group(1))
816
+
817
+
818
+ @error_rule(
819
+ "missing_identity",
820
+ r"Please tell me who you are",
821
+ r"Author identity unknown",
822
+ r"unable to auto-detect email address",
823
+ r"empty ident name",
824
+ )
825
+ def _identity(m, text, st):
826
+ st2 = _repo(st)
827
+ missing = [k for k, v in (("user.name", st2.user_name), ("user.email", st2.user_email)) if not v] if st2 else None
828
+ f = R.identity_finding(missing or None)
829
+ f.severity = "blocking"
830
+ return f
831
+
832
+
833
+ @error_rule("index_lock", r"Unable to create '([^']+index\.lock)': File exists")
834
+ def _lock(m, text, st):
835
+ return R.index_lock_finding(m.group(1))
836
+
837
+
838
+ def _state_conflict(st: RepoState | None) -> Finding | None:
839
+ st2 = _repo(st)
840
+ if not st2:
841
+ return None
842
+ for fn in (
843
+ R.rebase_in_progress,
844
+ R.cherry_pick_in_progress,
845
+ R.revert_in_progress,
846
+ R.am_in_progress,
847
+ R.merge_conflict,
848
+ R.merge_resolved,
849
+ ):
850
+ f = fn(st2)
851
+ if f:
852
+ return f
853
+ return None
854
+
855
+
856
+ @error_rule(
857
+ "merge_conflict",
858
+ r"CONFLICT \([^)]+\): Merge conflict in (.+)",
859
+ r"Automatic merge failed; fix conflicts",
860
+ r"you need to resolve your current index first",
861
+ r"(?:Pulling|Committing|Merging|Reverting|Cherry-picking) is not possible because you have unmerged files",
862
+ r"could not apply [0-9a-f]+",
863
+ family="conflict",
864
+ )
865
+ def _conflict(m, text, st):
866
+ f = _state_conflict(st)
867
+ if f:
868
+ return f
869
+ files = [g.group(1).strip() for g in re.finditer(r"CONFLICT \([^)]+\): Merge conflict in (.+)", text)]
870
+ return Finding(
871
+ "merge_conflict",
872
+ "blocking",
873
+ "Git stopped because of a conflict",
874
+ "Two sets of changes touched the same lines, and Git needs you to decide what the result should be. "
875
+ "Nothing is lost while you decide.",
876
+ details=files,
877
+ details_label="Conflicted files",
878
+ fix=[
879
+ FixOption(
880
+ "Resolve it",
881
+ [
882
+ R._edit_step(),
883
+ Step("Marks the files as resolved", f"git add {' '.join(q(f) for f in files[:6]) or '<file>'}"),
884
+ Step("Finishes (for a rebase use `git rebase --continue` instead)", "git commit --no-edit"),
885
+ ],
886
+ ),
887
+ FixOption(
888
+ "Or cancel",
889
+ [Step("Goes back to before the merge (or use git rebase --abort for a rebase)", "git merge --abort")],
890
+ ),
891
+ ],
892
+ learn_more=DOCS + "git-merge#_how_to_resolve_conflicts",
893
+ )
894
+
895
+
896
+ @error_rule("merge_unfinished", r"You have not concluded your merge \(MERGE_HEAD exists\)", family="conflict")
897
+ def _merge_unfinished(m, text, st):
898
+ f = _state_conflict(st)
899
+ if f:
900
+ return f
901
+ return Finding(
902
+ "merge_resolved",
903
+ "blocking",
904
+ "An earlier merge isn't finished",
905
+ "You started a merge and haven't made the merge commit yet, so Git won't start anything new.",
906
+ fix=[
907
+ FixOption("Finish it", [Step("Records the merge", "git commit --no-edit")]),
908
+ FixOption("Or cancel it", [Step("Goes back to before the merge", "git merge --abort")]),
909
+ ],
910
+ learn_more=DOCS + "git-merge",
911
+ )
912
+
913
+
914
+ @error_rule(
915
+ "rebase_exists",
916
+ r"already a rebase-(?:merge|apply) directory",
917
+ r"It seems that there is already a rebase",
918
+ family="conflict",
919
+ )
920
+ def _rebase_exists(m, text, st):
921
+ f = _state_conflict(st)
922
+ if f:
923
+ return f
924
+ return Finding(
925
+ "rebase_in_progress",
926
+ "blocking",
927
+ "A rebase is already in progress",
928
+ "An earlier rebase was never finished or cancelled, so Git won't start another.",
929
+ fix=[
930
+ FixOption("Continue it", [Step("Carries on with the earlier rebase", "git rebase --continue")]),
931
+ FixOption("Or cancel it", [Step("Goes back to before it started", "git rebase --abort")]),
932
+ ],
933
+ learn_more=DOCS + "git-rebase",
934
+ )
935
+
936
+
937
+ @error_rule("detached_head", r"You are in 'detached HEAD' state", r"HEAD detached at", r"Not currently on any branch")
938
+ def _detached(m, text, st):
939
+ st2 = _repo(st)
940
+ if st2:
941
+ f = R.detached_head(st2)
942
+ if f:
943
+ return f
944
+ return Finding(
945
+ "detached_head",
946
+ "warning",
947
+ "You're not on a branch (detached HEAD)",
948
+ "You're looking at a specific commit rather than a branch. That's fine for looking around, but commits made here "
949
+ "don't belong to any branch and are easy to lose.",
950
+ fix=[
951
+ FixOption(
952
+ "Keep any work you did here",
953
+ [Step("Creates a branch at this point and moves onto it", "git switch -c <new-branch-name>")],
954
+ ),
955
+ FixOption("Or go back to your branch", [Step("Returns to main", "git switch main")]),
956
+ ],
957
+ learn_more=DOCS + "git-checkout#_detached_head",
958
+ )
959
+
960
+
961
+ @error_rule("diverged", r"have diverged,\s*and have (\d+) and (\d+) different commits", family="status")
962
+ def _diverged(m, text, st):
963
+ st2 = _repo(st)
964
+ if st2:
965
+ f = R.diverged(st2)
966
+ if f:
967
+ return f
968
+ return Finding(
969
+ "diverged",
970
+ "warning",
971
+ "Your branch and the remote have diverged",
972
+ f"You have {m.group(1)} commit(s) the remote doesn't, and it has {m.group(2)} you don't. Combine them before pushing.",
973
+ fix=[
974
+ FixOption(
975
+ "Combine them",
976
+ [
977
+ Step("Downloads and merges their commits", "git pull --no-rebase"),
978
+ Step("Uploads the result", "git push"),
979
+ ],
980
+ )
981
+ ],
982
+ learn_more=DOCS + "git-pull",
983
+ )
984
+
985
+
986
+ @error_rule("nested_repo", r"adding embedded git repository: (.+)")
987
+ def _embedded(m, text, st):
988
+ st2 = _repo(st)
989
+ if st2:
990
+ f = R.nested_repo(st2)
991
+ if f:
992
+ return f
993
+ p = m.group(1).strip()
994
+ return Finding(
995
+ "nested_repo",
996
+ "warning",
997
+ f"'{p}' is a separate repository inside yours",
998
+ "That folder has its own .git folder, so Git added it as a single link rather than its files. On GitHub it will "
999
+ "show as an empty folder.",
1000
+ fix=[
1001
+ FixOption(
1002
+ "Add its files normally",
1003
+ [
1004
+ Step("Undoes the add without touching the files", f"git rm --cached {q(p)}"),
1005
+ Step(
1006
+ "Renames the inner .git folder (macOS/Linux) so the files become ordinary files",
1007
+ f"mv {q(p + '/.git')} {q(p + '/git-backup')}",
1008
+ ),
1009
+ Step("Adds the files", f"git add {q(p)}"),
1010
+ ],
1011
+ )
1012
+ ],
1013
+ learn_more=DOCS + "gitsubmodules",
1014
+ )
1015
+
1016
+
1017
+ @error_rule("line_endings", r"\b(?:LF|CRLF) will be replaced by (?:CRLF|LF)")
1018
+ def _line_endings(m, text, st):
1019
+ return Finding(
1020
+ "line_endings",
1021
+ "info",
1022
+ "Just a line-ending warning, nothing is broken",
1023
+ "Windows and macOS end lines of text differently (CRLF vs LF), and Git is telling you it will convert them. "
1024
+ "It's harmless; your command still worked.",
1025
+ fix=[
1026
+ FixOption(
1027
+ "Optional: make it consistent for the whole team",
1028
+ [
1029
+ Step("Create a file called .gitattributes in the project root containing the line: * text=auto"),
1030
+ Step("Commits it", 'git add .gitattributes && git commit -m "Normalise line endings"'),
1031
+ ],
1032
+ )
1033
+ ],
1034
+ learn_more=DOCS + "gitattributes#_end_of_line_conversion",
1035
+ )
1036
+
1037
+
1038
+ @error_rule(
1039
+ "network",
1040
+ r"Could not resolve host",
1041
+ r"Failed to connect to \S+",
1042
+ r"Connection timed out",
1043
+ r"Could not resolve hostname",
1044
+ )
1045
+ def _network(m, text, st):
1046
+ return Finding(
1047
+ "network",
1048
+ "blocking",
1049
+ "Git couldn't reach the server",
1050
+ "This is a network problem, not a problem with your repository: no internet connection, a typo in the address, "
1051
+ "or a network (some campus or public Wi-Fi) blocking the connection.",
1052
+ fix=[
1053
+ FixOption(
1054
+ "Check the connection",
1055
+ [
1056
+ Step("Open https://github.com in a browser to check you're online."),
1057
+ Step("Shows the address Git is trying to reach", "git remote -v"),
1058
+ Step("Try again on another network if it still fails."),
1059
+ ],
1060
+ )
1061
+ ],
1062
+ learn_more=DOCS + "git-remote",
1063
+ )
1064
+
1065
+
1066
+ @error_rule("push_failed", r"failed to push some refs", family="push")
1067
+ def _push_failed(m, text, st):
1068
+ st2 = _repo(st)
1069
+ if st2:
1070
+ for fn in (R.large_file_over_limit, R.diverged, R.behind, R.unborn_branch):
1071
+ f = fn(st2)
1072
+ if f:
1073
+ return f
1074
+ return Finding(
1075
+ "push_failed",
1076
+ "blocking",
1077
+ "The push failed",
1078
+ "Git couldn't upload your commits. The lines just before 'failed to push some refs' say why; paste the whole "
1079
+ "output to get a specific explanation.",
1080
+ fix=[FixOption("Look closer", [Step("Shows your branch and whether you're ahead or behind", "git status")])],
1081
+ learn_more=DOCS + "git-push",
1082
+ )
1083
+
1084
+
1085
+ # ---------------------------------------------------------------- matching
1086
+
1087
+
1088
+ def match_errors(text: str, st: RepoState | None = None) -> list[tuple[Finding, str]]:
1089
+ """Return (finding, matched line) for every rule the text matches, one per family."""
1090
+ out: list[tuple[Finding, str]] = []
1091
+ families: set[str] = set()
1092
+ for er in ERROR_RULES:
1093
+ if er.family in families:
1094
+ continue
1095
+ for pat in er.patterns:
1096
+ m = pat.search(text)
1097
+ if not m:
1098
+ continue
1099
+ if er.id == "push_failed" and out:
1100
+ break
1101
+ finding = er.build(m, text, st)
1102
+ line_start = text.rfind("\n", 0, m.start()) + 1
1103
+ line_end = text.find("\n", m.end())
1104
+ line = text[line_start : line_end if line_end != -1 else len(text)].strip()
1105
+ out.append((finding, redact(line)[:300]))
1106
+ families.add(er.family)
1107
+ break
1108
+ return out