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/__init__.py +1 -0
- whygit/__main__.py +5 -0
- whygit/cli.py +109 -0
- whygit/engine.py +179 -0
- whygit/errors.py +1108 -0
- whygit/findings.py +88 -0
- whygit/gitcmd.py +183 -0
- whygit/render.py +185 -0
- whygit/rules.py +1252 -0
- whygit/server.py +77 -0
- whygit/state.py +486 -0
- whygit-0.1.0.dist-info/METADATA +226 -0
- whygit-0.1.0.dist-info/RECORD +16 -0
- whygit-0.1.0.dist-info/WHEEL +4 -0
- whygit-0.1.0.dist-info/entry_points.txt +3 -0
- whygit-0.1.0.dist-info/licenses/LICENSE +21 -0
whygit/rules.py
ADDED
|
@@ -0,0 +1,1252 @@
|
|
|
1
|
+
"""Diagnosis rules. Each rule looks at a RepoState and returns a Finding or None."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from .findings import BOOK, DOCS, Finding, FixOption, Step, q
|
|
9
|
+
from .state import GITHUB_HARD_LIMIT, MB, RepoState
|
|
10
|
+
|
|
11
|
+
Detect = Callable[[RepoState], "Finding | None"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Rule:
|
|
16
|
+
id: str
|
|
17
|
+
severity: str
|
|
18
|
+
summary: str
|
|
19
|
+
detect: Detect
|
|
20
|
+
quiet: bool = False # hidden when something more urgent is reported
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
RULES: list[Rule] = []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def rule(rule_id: str, severity: str, summary: str, quiet: bool = False):
|
|
27
|
+
def deco(fn: Detect) -> Detect:
|
|
28
|
+
RULES.append(Rule(rule_id, severity, summary, fn, quiet))
|
|
29
|
+
return fn
|
|
30
|
+
|
|
31
|
+
return deco
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def rule_order() -> dict[str, int]:
|
|
35
|
+
return {r.id: i for i, r in enumerate(RULES)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_rule(rule_id: str) -> Rule:
|
|
39
|
+
for r in RULES:
|
|
40
|
+
if r.id == rule_id:
|
|
41
|
+
return r
|
|
42
|
+
raise KeyError(rule_id)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _mb(n: int) -> str:
|
|
46
|
+
return f"{n / MB:.1f} MB"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _plural(n: int, word: str, many: str | None = None) -> str:
|
|
50
|
+
return f"{n} {word if n == 1 else (many or word + 's')}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _paths(paths: list[str], limit: int = 6) -> str:
|
|
54
|
+
shown = " ".join(q(p) for p in paths[:limit])
|
|
55
|
+
return shown if len(paths) <= limit else shown + " ..."
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _branch_or(st: RepoState, fallback: str = "<branch>") -> str:
|
|
59
|
+
return st.branch or fallback
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------- environment
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@rule("git_not_installed", "blocking", "Git itself is not installed or not on PATH")
|
|
66
|
+
def git_not_installed(st: RepoState):
|
|
67
|
+
if st.git_available:
|
|
68
|
+
return None
|
|
69
|
+
return Finding(
|
|
70
|
+
"git_not_installed",
|
|
71
|
+
"blocking",
|
|
72
|
+
"Git is not installed (or your terminal can't find it)",
|
|
73
|
+
"whygit asks the `git` program about your repository, and no `git` command could be started. "
|
|
74
|
+
"Install Git, then open a new terminal window so it is picked up.",
|
|
75
|
+
fix=[
|
|
76
|
+
FixOption(
|
|
77
|
+
"Install Git",
|
|
78
|
+
[
|
|
79
|
+
Step("On macOS, installs Apple's command line tools, which include Git", "xcode-select --install"),
|
|
80
|
+
Step("On Windows, download and run the installer from https://git-scm.com/downloads/win"),
|
|
81
|
+
Step("Checks that Git now works (open a new terminal first)", "git --version"),
|
|
82
|
+
],
|
|
83
|
+
)
|
|
84
|
+
],
|
|
85
|
+
learn_more=BOOK + "Getting-Started-Installing-Git",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@rule("dubious_ownership", "blocking", "Git refuses a repository owned by another user")
|
|
90
|
+
def dubious_ownership(st: RepoState):
|
|
91
|
+
if st.not_repo_reason != "dubious":
|
|
92
|
+
return None
|
|
93
|
+
return dubious_finding(str(st.path))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def dubious_finding(path: str) -> Finding:
|
|
97
|
+
return Finding(
|
|
98
|
+
"dubious_ownership",
|
|
99
|
+
"blocking",
|
|
100
|
+
"Git doesn't trust this folder because a different user account owns it",
|
|
101
|
+
"As a safety check, Git refuses to work in a repository whose folder belongs to another user. "
|
|
102
|
+
"This often happens with folders copied from a USB drive, a shared drive, OneDrive, or made with `sudo`.",
|
|
103
|
+
fix=[
|
|
104
|
+
FixOption(
|
|
105
|
+
"If this is your project and you trust it",
|
|
106
|
+
[
|
|
107
|
+
Step(
|
|
108
|
+
"Tells Git this one folder is safe to use (it changes only your own Git settings)",
|
|
109
|
+
f"git config --global --add safe.directory {q(path)}",
|
|
110
|
+
)
|
|
111
|
+
],
|
|
112
|
+
),
|
|
113
|
+
FixOption(
|
|
114
|
+
"Or make the folder yours again",
|
|
115
|
+
[
|
|
116
|
+
Step(
|
|
117
|
+
"On macOS or Linux, gives your account ownership of the folder",
|
|
118
|
+
f"sudo chown -R $(whoami) {q(path)}",
|
|
119
|
+
)
|
|
120
|
+
],
|
|
121
|
+
),
|
|
122
|
+
],
|
|
123
|
+
learn_more=DOCS + "git-config#Documentation/git-config.txt-safedirectory",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@rule("not_a_repo", "blocking", "The folder is not inside a Git repository")
|
|
128
|
+
def not_a_repo(st: RepoState):
|
|
129
|
+
if not st.git_available or st.not_repo_reason != "not_found":
|
|
130
|
+
return None
|
|
131
|
+
from .state import is_under, same_path
|
|
132
|
+
|
|
133
|
+
kids = _child_repos(st)
|
|
134
|
+
at_home = same_path(st.path, st.home)
|
|
135
|
+
details = kids if len(kids) > 1 else []
|
|
136
|
+
if at_home:
|
|
137
|
+
expl = (
|
|
138
|
+
"You're in your home folder, which is not a Git repository (that's normal and how it should be). "
|
|
139
|
+
"Git commands only work inside a project folder that has been cloned or set up with `git init`."
|
|
140
|
+
)
|
|
141
|
+
elif is_under(st.path, st.home):
|
|
142
|
+
expl = (
|
|
143
|
+
"This folder isn't inside a Git repository, so Git has nothing to work with here. "
|
|
144
|
+
"You are probably one folder too high or in the wrong place; move into your project folder first."
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
expl = "This folder isn't inside a Git repository, so Git has nothing to work with here."
|
|
148
|
+
fixes: list[FixOption] = []
|
|
149
|
+
if kids:
|
|
150
|
+
if len(kids) == 1:
|
|
151
|
+
step = Step(f"Moves into {kids[0]}", f"cd {q(kids[0])}")
|
|
152
|
+
else:
|
|
153
|
+
step = Step("Moves into one of the repositories listed above", "cd <folder>")
|
|
154
|
+
fixes.append(FixOption("Move into the project folder", [step]))
|
|
155
|
+
fixes.append(
|
|
156
|
+
FixOption(
|
|
157
|
+
"Or get the project from GitHub",
|
|
158
|
+
[
|
|
159
|
+
Step(
|
|
160
|
+
"Downloads the repository into a new folder and sets up the link to GitHub",
|
|
161
|
+
"git clone <repository-url>",
|
|
162
|
+
)
|
|
163
|
+
],
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
init_step = Step("Starts a brand-new repository in the current folder", "git init")
|
|
167
|
+
if at_home:
|
|
168
|
+
init_step.warning = "Don't run this in your home folder: it would make your whole home folder one repository."
|
|
169
|
+
init_step.destructive = False
|
|
170
|
+
fixes.append(FixOption("Or start a new repository here (only for a new project)", [init_step]))
|
|
171
|
+
return Finding(
|
|
172
|
+
"not_a_repo",
|
|
173
|
+
"blocking",
|
|
174
|
+
"This folder is not a Git repository",
|
|
175
|
+
expl,
|
|
176
|
+
fix=fixes,
|
|
177
|
+
details=details,
|
|
178
|
+
details_label="Repositories found just below this folder",
|
|
179
|
+
learn_more=DOCS + "git-init",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _child_repos(st: RepoState) -> list[str]:
|
|
184
|
+
import os
|
|
185
|
+
|
|
186
|
+
found: list[str] = []
|
|
187
|
+
try:
|
|
188
|
+
for c in sorted(os.scandir(st.path), key=lambda d: d.name.lower()):
|
|
189
|
+
if c.is_dir() and not c.name.startswith("."):
|
|
190
|
+
if os.path.exists(os.path.join(c.path, ".git")):
|
|
191
|
+
found.append(c.name)
|
|
192
|
+
elif len(found) < 5:
|
|
193
|
+
try:
|
|
194
|
+
for g in os.scandir(c.path):
|
|
195
|
+
if (
|
|
196
|
+
g.is_dir()
|
|
197
|
+
and not g.name.startswith(".")
|
|
198
|
+
and os.path.exists(os.path.join(g.path, ".git"))
|
|
199
|
+
):
|
|
200
|
+
found.append(f"{c.name}/{g.name}")
|
|
201
|
+
except OSError:
|
|
202
|
+
pass
|
|
203
|
+
if len(found) >= 8:
|
|
204
|
+
break
|
|
205
|
+
except OSError:
|
|
206
|
+
pass
|
|
207
|
+
return found
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@rule("inside_git_dir", "warning", "The current folder is the hidden .git folder itself")
|
|
211
|
+
def inside_git_dir(st: RepoState):
|
|
212
|
+
if st.not_repo_reason != "inside_git_dir":
|
|
213
|
+
return None
|
|
214
|
+
return Finding(
|
|
215
|
+
"inside_git_dir",
|
|
216
|
+
"warning",
|
|
217
|
+
"You're inside the hidden .git folder",
|
|
218
|
+
"The .git folder is where Git keeps its own records; your project files are in the folder above it. "
|
|
219
|
+
"Most commands won't work from in here.",
|
|
220
|
+
fix=[FixOption("Go back up to the project", [Step("Moves up to the folder that contains .git", "cd ..")])],
|
|
221
|
+
learn_more=DOCS + "gitrepository-layout",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@rule("bare_repo", "info", "The folder is a bare repository (storage only, no working files)")
|
|
226
|
+
def bare_repo(st: RepoState):
|
|
227
|
+
if st.not_repo_reason != "bare":
|
|
228
|
+
return None
|
|
229
|
+
return Finding(
|
|
230
|
+
"bare_repo",
|
|
231
|
+
"info",
|
|
232
|
+
"This is a bare repository, not a working folder",
|
|
233
|
+
"A bare repository holds only Git's history (like the copy on a server) and has no files to edit. "
|
|
234
|
+
"To work on the project, clone it into a normal folder.",
|
|
235
|
+
fix=[
|
|
236
|
+
FixOption(
|
|
237
|
+
"Make a working copy",
|
|
238
|
+
[
|
|
239
|
+
Step(
|
|
240
|
+
"Creates a normal folder you can edit, linked to this one",
|
|
241
|
+
f"git clone {q(str(st.path))} my-project",
|
|
242
|
+
)
|
|
243
|
+
],
|
|
244
|
+
)
|
|
245
|
+
],
|
|
246
|
+
learn_more=DOCS + "git-clone",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@rule("home_repo", "blocking", "The home directory itself is a Git repository")
|
|
251
|
+
def home_repo(st: RepoState):
|
|
252
|
+
if not st.is_repo:
|
|
253
|
+
return None
|
|
254
|
+
from .state import same_path
|
|
255
|
+
|
|
256
|
+
top_is_home = st.home_is_repo_root
|
|
257
|
+
outer_is_home = st.outer_repo is not None and same_path(st.outer_repo, st.home)
|
|
258
|
+
if not (top_is_home or outer_is_home):
|
|
259
|
+
return None
|
|
260
|
+
if top_is_home:
|
|
261
|
+
sev = "blocking"
|
|
262
|
+
expl = (
|
|
263
|
+
"The repository you're in starts at your home folder, so Git treats everything in your home folder "
|
|
264
|
+
"(documents, downloads, other projects) as part of one project. This almost always comes from running "
|
|
265
|
+
"`git init` in the wrong place, and it can lead to private files being committed or pushed."
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
sev = "warning"
|
|
269
|
+
expl = (
|
|
270
|
+
"Your project is fine, but your home folder is also a Git repository, which usually comes from running "
|
|
271
|
+
"`git init` in the wrong place. It makes other folders behave strangely and risks committing private files."
|
|
272
|
+
)
|
|
273
|
+
return Finding(
|
|
274
|
+
"home_repo",
|
|
275
|
+
sev,
|
|
276
|
+
"Your home folder has been turned into a Git repository",
|
|
277
|
+
expl,
|
|
278
|
+
fix=[
|
|
279
|
+
FixOption(
|
|
280
|
+
"Check, then switch it off without deleting anything",
|
|
281
|
+
[
|
|
282
|
+
Step(
|
|
283
|
+
"Shows any commits in the home-folder repository; 'does not have any commits yet' means it was an accident",
|
|
284
|
+
"git -C ~ log --oneline -n 5",
|
|
285
|
+
),
|
|
286
|
+
Step(
|
|
287
|
+
"On macOS or Linux, renames the hidden .git folder so your home folder stops being a repository (you can rename it back)",
|
|
288
|
+
"mv ~/.git ~/git-home-backup",
|
|
289
|
+
),
|
|
290
|
+
Step("On Windows PowerShell, does the same rename", "Rename-Item ~\\.git git-home-backup"),
|
|
291
|
+
Step("Once you're sure you didn't need it, you can delete the git-home-backup folder yourself."),
|
|
292
|
+
],
|
|
293
|
+
)
|
|
294
|
+
],
|
|
295
|
+
learn_more=DOCS + "git-init",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@rule("index_lock", "warning", "An index.lock file is left in .git")
|
|
300
|
+
def index_lock(st: RepoState):
|
|
301
|
+
if not st.is_repo or not st.index_lock:
|
|
302
|
+
return None
|
|
303
|
+
return index_lock_finding(str(st.git_dir / "index.lock") if st.git_dir else ".git/index.lock")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def index_lock_finding(lock_path: str) -> Finding:
|
|
307
|
+
return Finding(
|
|
308
|
+
"index_lock",
|
|
309
|
+
"warning",
|
|
310
|
+
"Git thinks another Git command is still running here",
|
|
311
|
+
"Git creates a file called index.lock while it updates the staging area (the list of changes for the next commit), "
|
|
312
|
+
"and deletes it when done. If the file is still there, either another Git program is running right now "
|
|
313
|
+
"(an editor like VS Code, GitHub Desktop, or another terminal), or an earlier command crashed and left it behind.",
|
|
314
|
+
fix=[
|
|
315
|
+
FixOption(
|
|
316
|
+
"Wait, then check",
|
|
317
|
+
[
|
|
318
|
+
Step("Close or wait for other Git tools: VS Code, GitHub Desktop, other terminals running git."),
|
|
319
|
+
Step(
|
|
320
|
+
"On macOS or Linux, lists running git processes (no output apart from the grep line means none)",
|
|
321
|
+
"ps aux | grep -i git",
|
|
322
|
+
),
|
|
323
|
+
Step("On Windows, open Task Manager and look for git.exe."),
|
|
324
|
+
],
|
|
325
|
+
),
|
|
326
|
+
FixOption(
|
|
327
|
+
"Only if no Git process is running",
|
|
328
|
+
[
|
|
329
|
+
Step(
|
|
330
|
+
"Deletes the stale lock file (macOS/Linux; on Windows use: del "
|
|
331
|
+
+ lock_path.replace("/", "\\")
|
|
332
|
+
+ ")",
|
|
333
|
+
f"rm {q(lock_path)}",
|
|
334
|
+
destructive=True,
|
|
335
|
+
warning="Deleting the lock while Git is still running can corrupt the staging area. Check first.",
|
|
336
|
+
)
|
|
337
|
+
],
|
|
338
|
+
),
|
|
339
|
+
],
|
|
340
|
+
learn_more=DOCS + "gitrepository-layout",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------- operations in progress
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
CONFLICT_LABELS = {
|
|
348
|
+
"UU": "both changed",
|
|
349
|
+
"AA": "both added",
|
|
350
|
+
"DD": "both deleted",
|
|
351
|
+
"AU": "added by you",
|
|
352
|
+
"UA": "added by them",
|
|
353
|
+
"DU": "deleted by you, changed by them",
|
|
354
|
+
"UD": "changed by you, deleted by them",
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _conflict_details(st: RepoState) -> list[str]:
|
|
359
|
+
return [f"{e.path} ({CONFLICT_LABELS.get(e.xy, e.xy)})" for e in st.unmerged]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _edit_step() -> Step:
|
|
363
|
+
return Step(
|
|
364
|
+
"Open each file and find the blocks between <<<<<<< and >>>>>>>. The part above ======= is one version, "
|
|
365
|
+
"the part below is the other. Keep what you want, and delete the three marker lines."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _notebook_note(st: RepoState) -> list[Step]:
|
|
370
|
+
if any(e.path.lower().endswith(".ipynb") for e in st.unmerged):
|
|
371
|
+
return [Step("For .ipynb notebooks, don't edit the markers by hand; see the notebook advice below.")]
|
|
372
|
+
return []
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _deleted_note(st: RepoState) -> list[Step]:
|
|
376
|
+
if any(e.xy in ("DU", "UD") for e in st.unmerged):
|
|
377
|
+
return [
|
|
378
|
+
Step(
|
|
379
|
+
"For a file one side deleted: keep it with `git add <file>`, or accept the deletion with `git rm <file>`."
|
|
380
|
+
)
|
|
381
|
+
]
|
|
382
|
+
return []
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@rule("merge_conflict", "blocking", "Merge stopped with conflicts")
|
|
386
|
+
def merge_conflict(st: RepoState):
|
|
387
|
+
if "merge" not in st.in_progress or not st.unmerged:
|
|
388
|
+
return None
|
|
389
|
+
files = [e.path for e in st.unmerged]
|
|
390
|
+
return Finding(
|
|
391
|
+
"merge_conflict",
|
|
392
|
+
"blocking",
|
|
393
|
+
f"Merge in progress with conflicts in {_plural(len(files), 'file')}",
|
|
394
|
+
"Git tried to combine two lines of work, but both changed the same part of these files, so it stopped "
|
|
395
|
+
"and is waiting for you to choose what the result should be. Nothing is lost while you decide.",
|
|
396
|
+
details=_conflict_details(st),
|
|
397
|
+
details_label="Conflicted files",
|
|
398
|
+
fix=[
|
|
399
|
+
FixOption(
|
|
400
|
+
"Finish the merge",
|
|
401
|
+
[
|
|
402
|
+
_edit_step(),
|
|
403
|
+
*_notebook_note(st),
|
|
404
|
+
*_deleted_note(st),
|
|
405
|
+
Step("Marks the files as resolved", f"git add {_paths(files)}"),
|
|
406
|
+
Step("Records the merge and finishes it", "git commit --no-edit"),
|
|
407
|
+
],
|
|
408
|
+
),
|
|
409
|
+
FixOption(
|
|
410
|
+
"Or cancel the merge",
|
|
411
|
+
[Step("Puts everything back the way it was before the merge started", "git merge --abort")],
|
|
412
|
+
),
|
|
413
|
+
],
|
|
414
|
+
learn_more=DOCS + "git-merge#_how_to_resolve_conflicts",
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@rule("merge_resolved", "blocking", "Merge resolved but not yet committed")
|
|
419
|
+
def merge_resolved(st: RepoState):
|
|
420
|
+
if "merge" not in st.in_progress or st.unmerged:
|
|
421
|
+
return None
|
|
422
|
+
steps = []
|
|
423
|
+
note = ""
|
|
424
|
+
if st.leftover_markers:
|
|
425
|
+
note = "Some staged files still contain conflict markers (<<<<<<<): " + ", ".join(st.leftover_markers[:5])
|
|
426
|
+
steps.append(
|
|
427
|
+
Step(
|
|
428
|
+
"Remove the leftover <<<<<<< / ======= / >>>>>>> lines from those files, then add them again",
|
|
429
|
+
f"git add {_paths(st.leftover_markers)}",
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
steps.append(Step("Records the merge and finishes it", "git commit --no-edit"))
|
|
433
|
+
return Finding(
|
|
434
|
+
"merge_resolved",
|
|
435
|
+
"blocking",
|
|
436
|
+
"Merge is waiting for you to commit it",
|
|
437
|
+
"All conflicts are marked as resolved, but the merge isn't finished until you make the merge commit. "
|
|
438
|
+
"Until then Git treats the merge as still in progress and many commands will refuse to run.",
|
|
439
|
+
note=note,
|
|
440
|
+
fix=[
|
|
441
|
+
FixOption("Finish the merge", steps),
|
|
442
|
+
FixOption(
|
|
443
|
+
"Or cancel the merge",
|
|
444
|
+
[Step("Throws away the merge result and goes back to before the merge", "git merge --abort")],
|
|
445
|
+
),
|
|
446
|
+
],
|
|
447
|
+
learn_more=DOCS + "git-merge",
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@rule("rebase_in_progress", "blocking", "Rebase stopped partway (conflict, edit, or paused)")
|
|
452
|
+
def rebase_in_progress(st: RepoState):
|
|
453
|
+
if "rebase" not in st.in_progress:
|
|
454
|
+
return None
|
|
455
|
+
where = f" (step {st.rebase_step})" if st.rebase_step else ""
|
|
456
|
+
branch = f"branch '{st.rebase_head_name}'" if st.rebase_head_name else "your branch"
|
|
457
|
+
abort = FixOption(
|
|
458
|
+
"Or cancel the whole rebase",
|
|
459
|
+
[Step(f"Puts {branch} back exactly as it was before the rebase started", "git rebase --abort")],
|
|
460
|
+
)
|
|
461
|
+
if st.unmerged:
|
|
462
|
+
files = [e.path for e in st.unmerged]
|
|
463
|
+
return Finding(
|
|
464
|
+
"rebase_in_progress",
|
|
465
|
+
"blocking",
|
|
466
|
+
f"Rebase stopped on a conflict{where}",
|
|
467
|
+
f"A rebase replays the commits of {branch} one at a time on top of another commit. One of them changed "
|
|
468
|
+
"the same lines as the new base, so Git paused and is waiting for you to combine them. "
|
|
469
|
+
"During a rebase, 'ours' means the base you're rebasing onto and 'theirs' means your own commit.",
|
|
470
|
+
details=_conflict_details(st),
|
|
471
|
+
details_label="Conflicted files",
|
|
472
|
+
fix=[
|
|
473
|
+
FixOption(
|
|
474
|
+
"Resolve and continue",
|
|
475
|
+
[
|
|
476
|
+
_edit_step(),
|
|
477
|
+
*_deleted_note(st),
|
|
478
|
+
Step("Marks the files as resolved", f"git add {_paths(files)}"),
|
|
479
|
+
Step("Continues replaying the remaining commits", "git rebase --continue"),
|
|
480
|
+
],
|
|
481
|
+
),
|
|
482
|
+
abort,
|
|
483
|
+
FixOption(
|
|
484
|
+
"Or drop just this commit",
|
|
485
|
+
[
|
|
486
|
+
Step(
|
|
487
|
+
"Skips the commit that conflicted and carries on",
|
|
488
|
+
"git rebase --skip",
|
|
489
|
+
destructive=True,
|
|
490
|
+
warning="The changes in the skipped commit are left out of your branch.",
|
|
491
|
+
)
|
|
492
|
+
],
|
|
493
|
+
),
|
|
494
|
+
],
|
|
495
|
+
learn_more=DOCS + "git-rebase",
|
|
496
|
+
)
|
|
497
|
+
if st.rebase_edit_stop:
|
|
498
|
+
return Finding(
|
|
499
|
+
"rebase_in_progress",
|
|
500
|
+
"blocking",
|
|
501
|
+
f"Rebase paused so you can edit a commit{where}",
|
|
502
|
+
f"You (or a script) asked the rebase of {branch} to stop at this commit so it could be changed. "
|
|
503
|
+
"Git is waiting for you to make the change and then continue.",
|
|
504
|
+
fix=[
|
|
505
|
+
FixOption(
|
|
506
|
+
"Edit and continue",
|
|
507
|
+
[
|
|
508
|
+
Step("Make your changes to the files, then stage them", "git add <file>"),
|
|
509
|
+
Step("Folds the staged changes into the paused commit", "git commit --amend --no-edit"),
|
|
510
|
+
Step("Continues with the remaining commits", "git rebase --continue"),
|
|
511
|
+
],
|
|
512
|
+
),
|
|
513
|
+
abort,
|
|
514
|
+
],
|
|
515
|
+
learn_more=DOCS + "git-rebase",
|
|
516
|
+
)
|
|
517
|
+
return Finding(
|
|
518
|
+
"rebase_in_progress",
|
|
519
|
+
"blocking",
|
|
520
|
+
f"Rebase is paused{where}",
|
|
521
|
+
f"A rebase of {branch} is still in progress: conflicts are resolved (or it was paused on purpose), "
|
|
522
|
+
"but Git is waiting for you to continue it.",
|
|
523
|
+
fix=[
|
|
524
|
+
FixOption("Continue", [Step("Carries on replaying the remaining commits", "git rebase --continue")]),
|
|
525
|
+
abort,
|
|
526
|
+
],
|
|
527
|
+
learn_more=DOCS + "git-rebase",
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _sequencer_finding(st: RepoState, op: str, verb: str, doc: str) -> Finding:
|
|
532
|
+
if st.unmerged:
|
|
533
|
+
files = [e.path for e in st.unmerged]
|
|
534
|
+
return Finding(
|
|
535
|
+
f"{op.replace('-', '_')}_in_progress",
|
|
536
|
+
"blocking",
|
|
537
|
+
f"{op.capitalize()} stopped on a conflict",
|
|
538
|
+
f"You asked Git to {verb}, and it changes the same lines as your current work, so Git paused and is "
|
|
539
|
+
"waiting for you to combine them.",
|
|
540
|
+
details=_conflict_details(st),
|
|
541
|
+
details_label="Conflicted files",
|
|
542
|
+
fix=[
|
|
543
|
+
FixOption(
|
|
544
|
+
"Resolve and continue",
|
|
545
|
+
[
|
|
546
|
+
_edit_step(),
|
|
547
|
+
Step("Marks the files as resolved", f"git add {_paths(files)}"),
|
|
548
|
+
Step(f"Finishes the {op}", f"git {op} --continue"),
|
|
549
|
+
],
|
|
550
|
+
),
|
|
551
|
+
FixOption("Or cancel it", [Step(f"Goes back to before the {op} started", f"git {op} --abort")]),
|
|
552
|
+
],
|
|
553
|
+
learn_more=DOCS + doc,
|
|
554
|
+
)
|
|
555
|
+
return Finding(
|
|
556
|
+
f"{op.replace('-', '_')}_in_progress",
|
|
557
|
+
"blocking",
|
|
558
|
+
f"{op.capitalize()} is in progress",
|
|
559
|
+
f"A {op} (asking Git to {verb}) has started but isn't finished, so Git is waiting for you to continue or cancel it.",
|
|
560
|
+
fix=[
|
|
561
|
+
FixOption("Finish it", [Step(f"Finishes the {op}", f"git {op} --continue")]),
|
|
562
|
+
FixOption("Or cancel it", [Step(f"Goes back to before the {op} started", f"git {op} --abort")]),
|
|
563
|
+
],
|
|
564
|
+
learn_more=DOCS + doc,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@rule("cherry_pick_in_progress", "blocking", "Cherry-pick in progress")
|
|
569
|
+
def cherry_pick_in_progress(st: RepoState):
|
|
570
|
+
if "cherry-pick" not in st.in_progress:
|
|
571
|
+
return None
|
|
572
|
+
return _sequencer_finding(st, "cherry-pick", "copy a commit from another branch onto this one", "git-cherry-pick")
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@rule("revert_in_progress", "blocking", "Revert in progress")
|
|
576
|
+
def revert_in_progress(st: RepoState):
|
|
577
|
+
if "revert" not in st.in_progress:
|
|
578
|
+
return None
|
|
579
|
+
return _sequencer_finding(st, "revert", "undo an earlier commit with a new commit", "git-revert")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@rule("am_in_progress", "blocking", "Applying patches (git am) stopped")
|
|
583
|
+
def am_in_progress(st: RepoState):
|
|
584
|
+
if "am" not in st.in_progress:
|
|
585
|
+
return None
|
|
586
|
+
return Finding(
|
|
587
|
+
"am_in_progress",
|
|
588
|
+
"blocking",
|
|
589
|
+
"Applying a patch (git am) stopped partway",
|
|
590
|
+
"Git was applying commits from patch files or emails and one of them didn't apply cleanly. "
|
|
591
|
+
"It's waiting for you to fix it, skip it, or cancel.",
|
|
592
|
+
details=[e.path for e in st.unmerged],
|
|
593
|
+
details_label="Conflicted files",
|
|
594
|
+
fix=[
|
|
595
|
+
FixOption(
|
|
596
|
+
"Fix and continue",
|
|
597
|
+
[
|
|
598
|
+
Step("Fix the files, then stage them", "git add <file>"),
|
|
599
|
+
Step("Continues with the next patch", "git am --continue"),
|
|
600
|
+
],
|
|
601
|
+
),
|
|
602
|
+
FixOption("Or cancel", [Step("Goes back to before git am started", "git am --abort")]),
|
|
603
|
+
FixOption(
|
|
604
|
+
"Or skip this patch",
|
|
605
|
+
[
|
|
606
|
+
Step(
|
|
607
|
+
"Leaves this patch out",
|
|
608
|
+
"git am --skip",
|
|
609
|
+
destructive=True,
|
|
610
|
+
warning="The skipped patch's changes are not applied.",
|
|
611
|
+
)
|
|
612
|
+
],
|
|
613
|
+
),
|
|
614
|
+
],
|
|
615
|
+
learn_more=DOCS + "git-am",
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@rule("bisect_in_progress", "warning", "A bisect session is active")
|
|
620
|
+
def bisect_in_progress(st: RepoState):
|
|
621
|
+
if "bisect" not in st.in_progress:
|
|
622
|
+
return None
|
|
623
|
+
return Finding(
|
|
624
|
+
"bisect_in_progress",
|
|
625
|
+
"warning",
|
|
626
|
+
"You're in the middle of a git bisect",
|
|
627
|
+
"Bisect is a search through your history for the commit that introduced a bug, and it checks out old "
|
|
628
|
+
"commits as it goes. Until you finish it, you are not on your branch.",
|
|
629
|
+
fix=[
|
|
630
|
+
FixOption(
|
|
631
|
+
"Continue the search",
|
|
632
|
+
[
|
|
633
|
+
Step("Test the code, then tell Git if this commit works", "git bisect good"),
|
|
634
|
+
Step("...or if it has the bug", "git bisect bad"),
|
|
635
|
+
],
|
|
636
|
+
),
|
|
637
|
+
FixOption(
|
|
638
|
+
"Or stop and go back to your branch",
|
|
639
|
+
[Step("Ends the search and returns to where you started", "git bisect reset")],
|
|
640
|
+
),
|
|
641
|
+
],
|
|
642
|
+
learn_more=DOCS + "git-bisect",
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@rule("notebook_conflict", "warning", "A Jupyter notebook has merge conflicts")
|
|
647
|
+
def notebook_conflict(st: RepoState):
|
|
648
|
+
nbs = [e.path for e in st.unmerged if e.path.lower().endswith(".ipynb")]
|
|
649
|
+
if not nbs:
|
|
650
|
+
return None
|
|
651
|
+
rebasing = "rebase" in st.in_progress
|
|
652
|
+
mine, theirs = ("--theirs", "--ours") if rebasing else ("--ours", "--theirs")
|
|
653
|
+
return Finding(
|
|
654
|
+
"notebook_conflict",
|
|
655
|
+
"warning",
|
|
656
|
+
f"{_plural(len(nbs), 'notebook')} with conflicts",
|
|
657
|
+
"A .ipynb file is really a large JSON text file with code, outputs and metadata mixed together, so the "
|
|
658
|
+
"conflict markers land in the middle of JSON and Jupyter may refuse to open the file. It's usually easier "
|
|
659
|
+
"to keep one whole version and then redo the other side's changes by hand.",
|
|
660
|
+
details=nbs,
|
|
661
|
+
details_label="Notebooks",
|
|
662
|
+
fix=[
|
|
663
|
+
FixOption(
|
|
664
|
+
"Keep your version of each notebook",
|
|
665
|
+
[
|
|
666
|
+
Step(
|
|
667
|
+
"Replaces the conflicted notebook with your version",
|
|
668
|
+
f"git restore {mine} {_paths(nbs)}",
|
|
669
|
+
destructive=True,
|
|
670
|
+
warning="Their changes to the notebook are dropped; copy anything you need from it first.",
|
|
671
|
+
),
|
|
672
|
+
Step("Marks the notebook as resolved", f"git add {_paths(nbs)}"),
|
|
673
|
+
],
|
|
674
|
+
),
|
|
675
|
+
FixOption(
|
|
676
|
+
"Or keep their version",
|
|
677
|
+
[
|
|
678
|
+
Step(
|
|
679
|
+
"Replaces the conflicted notebook with their version",
|
|
680
|
+
f"git restore {theirs} {_paths(nbs)}",
|
|
681
|
+
destructive=True,
|
|
682
|
+
warning="Your changes to the notebook are dropped; copy anything you need from it first.",
|
|
683
|
+
),
|
|
684
|
+
Step("Marks the notebook as resolved", f"git add {_paths(nbs)}"),
|
|
685
|
+
],
|
|
686
|
+
),
|
|
687
|
+
FixOption(
|
|
688
|
+
"Or merge them properly with nbdime (optional tool)",
|
|
689
|
+
[
|
|
690
|
+
Step("Installs nbdime, a notebook-aware diff and merge tool", "pip install nbdime"),
|
|
691
|
+
Step("Opens a side-by-side merge of the notebook in your browser", f"nbdime mergetool {q(nbs[0])}"),
|
|
692
|
+
],
|
|
693
|
+
),
|
|
694
|
+
],
|
|
695
|
+
learn_more=DOCS + "git-merge#_how_to_resolve_conflicts",
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# ---------------------------------------------------------------- where HEAD is
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@rule("detached_head", "warning", "HEAD is detached (not on a branch)")
|
|
703
|
+
def detached_head(st: RepoState):
|
|
704
|
+
if not st.is_repo or not st.detached or {"rebase", "bisect", "am"} & set(st.in_progress):
|
|
705
|
+
return None
|
|
706
|
+
sha = (st.head_oid or "")[:7]
|
|
707
|
+
if st.orphan_commits:
|
|
708
|
+
expl = (
|
|
709
|
+
f"You're on commit {sha} directly instead of on a branch (a 'detached HEAD'), and you have made "
|
|
710
|
+
f"{_plural(st.orphan_commits, 'commit')} here that no branch points to. If you switch away now, "
|
|
711
|
+
"those commits become very hard to find, so give them a branch first."
|
|
712
|
+
)
|
|
713
|
+
fix = [
|
|
714
|
+
FixOption(
|
|
715
|
+
"Keep your work on a new branch",
|
|
716
|
+
[
|
|
717
|
+
Step(
|
|
718
|
+
"Creates a branch here so your commits are saved, and moves you onto it",
|
|
719
|
+
"git switch -c <new-branch-name>",
|
|
720
|
+
),
|
|
721
|
+
Step(
|
|
722
|
+
"Later, to bring the work into main: switch to main and merge the new branch",
|
|
723
|
+
"git switch main && git merge <new-branch-name>",
|
|
724
|
+
),
|
|
725
|
+
],
|
|
726
|
+
)
|
|
727
|
+
]
|
|
728
|
+
else:
|
|
729
|
+
expl = (
|
|
730
|
+
f"You're looking at commit {sha} directly instead of being on a branch (a 'detached HEAD'). "
|
|
731
|
+
"That's fine for looking around, but new commits made here won't belong to any branch."
|
|
732
|
+
)
|
|
733
|
+
fix = [
|
|
734
|
+
FixOption(
|
|
735
|
+
"Go back to your branch",
|
|
736
|
+
[
|
|
737
|
+
Step(
|
|
738
|
+
"Returns to the main branch",
|
|
739
|
+
f"git switch {'main' if 'main' in st.local_branches else (st.local_branches[0] if st.local_branches else 'main')}",
|
|
740
|
+
)
|
|
741
|
+
],
|
|
742
|
+
),
|
|
743
|
+
FixOption(
|
|
744
|
+
"Or start a branch here",
|
|
745
|
+
[Step("Creates a new branch at this commit and moves onto it", "git switch -c <new-branch-name>")],
|
|
746
|
+
),
|
|
747
|
+
]
|
|
748
|
+
return Finding(
|
|
749
|
+
"detached_head",
|
|
750
|
+
"warning",
|
|
751
|
+
"You're not on a branch (detached HEAD)",
|
|
752
|
+
expl,
|
|
753
|
+
fix=fix,
|
|
754
|
+
learn_more=DOCS + "git-checkout#_detached_head",
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
@rule("unborn_branch", "info", "No commits yet")
|
|
759
|
+
def unborn_branch(st: RepoState):
|
|
760
|
+
if not st.is_repo or not st.unborn or st.home_is_repo_root:
|
|
761
|
+
return None
|
|
762
|
+
b = _branch_or(st, "main")
|
|
763
|
+
staged = bool(st.staged)
|
|
764
|
+
return Finding(
|
|
765
|
+
"unborn_branch",
|
|
766
|
+
"info",
|
|
767
|
+
"Nothing has been committed yet",
|
|
768
|
+
f"This repository has no commits, so the branch '{b}' doesn't really exist yet and there is nothing to push. "
|
|
769
|
+
"A commit is a saved snapshot of your files; make the first one to get started.",
|
|
770
|
+
fix=[
|
|
771
|
+
FixOption(
|
|
772
|
+
"Make the first commit",
|
|
773
|
+
(
|
|
774
|
+
[]
|
|
775
|
+
if staged
|
|
776
|
+
else [
|
|
777
|
+
Step("Stages every file in this folder for the commit (check `git status` first)", "git add .")
|
|
778
|
+
]
|
|
779
|
+
)
|
|
780
|
+
+ [Step("Saves the snapshot with a message", 'git commit -m "First commit"')],
|
|
781
|
+
)
|
|
782
|
+
],
|
|
783
|
+
learn_more=DOCS + "git-commit",
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
# ---------------------------------------------------------------- remote and upstream
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@rule("diverged", "warning", "Branch and its upstream have both moved on")
|
|
791
|
+
def diverged(st: RepoState):
|
|
792
|
+
if not st.ahead or not st.behind:
|
|
793
|
+
return None
|
|
794
|
+
return Finding(
|
|
795
|
+
"diverged",
|
|
796
|
+
"warning",
|
|
797
|
+
f"Your branch and {st.upstream} have diverged",
|
|
798
|
+
f"You have {_plural(st.ahead, 'commit')} that {st.upstream} doesn't, and it has {_plural(st.behind, 'commit')} "
|
|
799
|
+
"you don't (someone pushed while you were working, or you committed on another computer). "
|
|
800
|
+
"Git won't let you push until you combine them.",
|
|
801
|
+
note=st.fetch_note(),
|
|
802
|
+
fix=[
|
|
803
|
+
FixOption(
|
|
804
|
+
"Combine them with a merge (safest)",
|
|
805
|
+
[
|
|
806
|
+
Step(
|
|
807
|
+
"Downloads their commits and merges them into yours; fix any conflicts it reports",
|
|
808
|
+
"git pull --no-rebase",
|
|
809
|
+
),
|
|
810
|
+
Step("Uploads the combined result", "git push"),
|
|
811
|
+
],
|
|
812
|
+
),
|
|
813
|
+
FixOption(
|
|
814
|
+
"Or replay your commits on top of theirs",
|
|
815
|
+
[
|
|
816
|
+
Step("Same as above but keeps history in a straight line", "git pull --rebase"),
|
|
817
|
+
Step("Uploads the result", "git push"),
|
|
818
|
+
],
|
|
819
|
+
),
|
|
820
|
+
FixOption(
|
|
821
|
+
"Only if their commits really should be thrown away",
|
|
822
|
+
[
|
|
823
|
+
Step(
|
|
824
|
+
"Overwrites the remote branch with yours",
|
|
825
|
+
"git push --force-with-lease",
|
|
826
|
+
destructive=True,
|
|
827
|
+
warning="This deletes the other commits from GitHub for everyone. Don't do this on a shared branch.",
|
|
828
|
+
)
|
|
829
|
+
],
|
|
830
|
+
),
|
|
831
|
+
],
|
|
832
|
+
learn_more=DOCS + "git-pull",
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
@rule("behind", "info", "Branch is behind its upstream")
|
|
837
|
+
def behind(st: RepoState):
|
|
838
|
+
if not st.behind or st.ahead:
|
|
839
|
+
return None
|
|
840
|
+
return Finding(
|
|
841
|
+
"behind",
|
|
842
|
+
"info",
|
|
843
|
+
f"Your branch is {_plural(st.behind, 'commit')} behind {st.upstream}",
|
|
844
|
+
f"{st.upstream} has new commits that you don't have yet. Pull them before you start new work to avoid conflicts later.",
|
|
845
|
+
note=st.fetch_note(),
|
|
846
|
+
fix=[FixOption("Update", [Step("Downloads and adds the new commits to your branch", "git pull")])],
|
|
847
|
+
learn_more=DOCS + "git-pull",
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
@rule("ahead", "info", "Branch has commits not pushed yet")
|
|
852
|
+
def ahead(st: RepoState):
|
|
853
|
+
if not st.ahead or st.behind:
|
|
854
|
+
return None
|
|
855
|
+
held = any(b.where == "unpushed" for b in st.big_files + st.sensitive)
|
|
856
|
+
return Finding(
|
|
857
|
+
"ahead",
|
|
858
|
+
"info",
|
|
859
|
+
f"You have {_plural(st.ahead, 'commit')} not pushed to {st.upstream} yet",
|
|
860
|
+
"These commits exist only on this computer. Push them to back them up and share them."
|
|
861
|
+
+ (" Sort out the file warnings above before you push." if held else ""),
|
|
862
|
+
note=st.fetch_note(),
|
|
863
|
+
fix=[FixOption("Upload", [Step(f"Sends your commits to {st.upstream}", "git push")])],
|
|
864
|
+
learn_more=DOCS + "git-push",
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
@rule("upstream_gone", "info", "The upstream branch was deleted on the remote")
|
|
869
|
+
def upstream_gone(st: RepoState):
|
|
870
|
+
if not st.upstream_gone:
|
|
871
|
+
return None
|
|
872
|
+
return Finding(
|
|
873
|
+
"upstream_gone",
|
|
874
|
+
"info",
|
|
875
|
+
f"The branch this tracks ({st.upstream}) no longer exists on the remote",
|
|
876
|
+
"Someone deleted the branch on GitHub, often after its pull request was merged. Your local copy is untouched.",
|
|
877
|
+
fix=[
|
|
878
|
+
FixOption(
|
|
879
|
+
"If the work was merged",
|
|
880
|
+
[
|
|
881
|
+
Step("Moves to main", "git switch main"),
|
|
882
|
+
Step("Gets the merged work", "git pull"),
|
|
883
|
+
Step(
|
|
884
|
+
"Deletes the old local branch (refuses if it has unmerged work)",
|
|
885
|
+
f"git branch -d {_branch_or(st)}",
|
|
886
|
+
),
|
|
887
|
+
],
|
|
888
|
+
),
|
|
889
|
+
FixOption(
|
|
890
|
+
"If you want to keep working on it",
|
|
891
|
+
[Step("Pushes it again and tracks it", f"git push -u origin {_branch_or(st)}")],
|
|
892
|
+
),
|
|
893
|
+
],
|
|
894
|
+
learn_more=DOCS + "git-branch",
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
@rule("no_upstream", "info", "Branch has no upstream (first push needs -u)", quiet=True)
|
|
899
|
+
def no_upstream(st: RepoState):
|
|
900
|
+
if not st.is_repo or st.unborn or st.detached or st.upstream or not st.remotes:
|
|
901
|
+
return None
|
|
902
|
+
remote = "origin" if "origin" in st.remotes else next(iter(st.remotes))
|
|
903
|
+
b = _branch_or(st)
|
|
904
|
+
if f"{remote}/{b}" in st.remote_branches:
|
|
905
|
+
steps = [
|
|
906
|
+
Step(
|
|
907
|
+
f"Links {b} to {remote}/{b} so plain `git pull` and `git push` work",
|
|
908
|
+
f"git branch --set-upstream-to={remote}/{b}",
|
|
909
|
+
)
|
|
910
|
+
]
|
|
911
|
+
else:
|
|
912
|
+
steps = [
|
|
913
|
+
Step(
|
|
914
|
+
f"Uploads {b} and remembers {remote}/{b} as its upstream, so later you can just type `git push`",
|
|
915
|
+
f"git push -u {remote} {b}",
|
|
916
|
+
)
|
|
917
|
+
]
|
|
918
|
+
return Finding(
|
|
919
|
+
"no_upstream",
|
|
920
|
+
"info",
|
|
921
|
+
f"Branch '{b}' isn't linked to a branch on {remote} yet",
|
|
922
|
+
"The 'upstream' is the branch on GitHub that this one pushes to and pulls from. Until it's set, "
|
|
923
|
+
"`git push` and `git pull` don't know where to go.",
|
|
924
|
+
fix=[FixOption("Set it up", steps)],
|
|
925
|
+
learn_more=DOCS + "git-push",
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
@rule("no_remote", "info", "Repository has no remote", quiet=True)
|
|
930
|
+
def no_remote(st: RepoState):
|
|
931
|
+
if not st.is_repo or st.unborn or st.remotes:
|
|
932
|
+
return None
|
|
933
|
+
b = _branch_or(st, "main")
|
|
934
|
+
return Finding(
|
|
935
|
+
"no_remote",
|
|
936
|
+
"info",
|
|
937
|
+
"This repository isn't connected to GitHub (no remote)",
|
|
938
|
+
"A remote is the address of the copy of your repository on a server such as GitHub. "
|
|
939
|
+
"Without one, your commits exist only on this computer.",
|
|
940
|
+
fix=[
|
|
941
|
+
FixOption(
|
|
942
|
+
"Connect it",
|
|
943
|
+
[
|
|
944
|
+
Step(
|
|
945
|
+
"Create an empty repository on GitHub. Leave 'Add a README' unticked, or the first push will be rejected."
|
|
946
|
+
),
|
|
947
|
+
Step("Saves GitHub's address under the name origin", "git remote add origin <repository-url>"),
|
|
948
|
+
Step("Uploads your branch and links it", f"git push -u origin {b}"),
|
|
949
|
+
],
|
|
950
|
+
)
|
|
951
|
+
],
|
|
952
|
+
learn_more=DOCS + "git-remote",
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
# ---------------------------------------------------------------- repository layout and identity
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
@rule("nested_repo", "warning", "A folder inside the repository is its own repository")
|
|
960
|
+
def nested_repo(st: RepoState):
|
|
961
|
+
if not st.is_repo:
|
|
962
|
+
return None
|
|
963
|
+
from .state import same_path
|
|
964
|
+
|
|
965
|
+
inner = st.nested_repos + [p for p in st.unregistered_gitlinks if p not in st.nested_repos]
|
|
966
|
+
outer = st.outer_repo if (st.outer_repo is not None and not same_path(st.outer_repo, st.home)) else None
|
|
967
|
+
if not inner and not outer:
|
|
968
|
+
return None
|
|
969
|
+
if inner:
|
|
970
|
+
target = inner[0]
|
|
971
|
+
backup = "../" + target.replace("/", "-") + "-git-backup"
|
|
972
|
+
expl = (
|
|
973
|
+
"A folder inside your project has its own hidden .git folder, so it is a separate repository. "
|
|
974
|
+
"The outer repository stores it as a single link instead of its files, so on GitHub it shows up as an "
|
|
975
|
+
"empty or unclickable folder. This usually happens with downloaded or cloned starter code."
|
|
976
|
+
)
|
|
977
|
+
fix = [
|
|
978
|
+
FixOption(
|
|
979
|
+
"Make its files part of your project",
|
|
980
|
+
[
|
|
981
|
+
*(
|
|
982
|
+
[
|
|
983
|
+
Step(
|
|
984
|
+
"Removes the link from the staging area without touching the files",
|
|
985
|
+
f"git rm --cached {q(target)}",
|
|
986
|
+
)
|
|
987
|
+
]
|
|
988
|
+
if target in st.unregistered_gitlinks
|
|
989
|
+
else []
|
|
990
|
+
),
|
|
991
|
+
Step(
|
|
992
|
+
"On macOS or Linux, moves the inner .git folder out of your project, so the folder becomes ordinary files "
|
|
993
|
+
"(Windows PowerShell: Move-Item "
|
|
994
|
+
+ target.replace("/", "\\")
|
|
995
|
+
+ "\\.git "
|
|
996
|
+
+ backup.replace("/", "\\")
|
|
997
|
+
+ ")",
|
|
998
|
+
f"mv {q(target + '/.git')} {q(backup)}",
|
|
999
|
+
),
|
|
1000
|
+
Step("Stages the folder's files normally", f"git add {q(target)}"),
|
|
1001
|
+
Step(
|
|
1002
|
+
f"The moved folder ({backup}) holds that folder's old history; delete it once you're sure you don't need it."
|
|
1003
|
+
),
|
|
1004
|
+
],
|
|
1005
|
+
)
|
|
1006
|
+
]
|
|
1007
|
+
if len(inner) > 1:
|
|
1008
|
+
fix.append(
|
|
1009
|
+
FixOption(
|
|
1010
|
+
"Repeat for each folder listed", [Step("Do the same steps for every folder in the list above.")]
|
|
1011
|
+
)
|
|
1012
|
+
)
|
|
1013
|
+
return Finding(
|
|
1014
|
+
"nested_repo",
|
|
1015
|
+
"warning",
|
|
1016
|
+
f"{_plural(len(inner), 'folder')} inside this repository {'is its' if len(inner) == 1 else 'are their'} own repository",
|
|
1017
|
+
expl,
|
|
1018
|
+
details=inner,
|
|
1019
|
+
details_label="Nested repositories",
|
|
1020
|
+
fix=fix,
|
|
1021
|
+
learn_more=DOCS + "gitsubmodules",
|
|
1022
|
+
)
|
|
1023
|
+
return Finding(
|
|
1024
|
+
"nested_repo",
|
|
1025
|
+
"warning",
|
|
1026
|
+
"This repository is inside another repository",
|
|
1027
|
+
f"The folder {outer} is also a Git repository and contains this one. Git commands run in the outer folder "
|
|
1028
|
+
"will see this project as a single link, not as files, which is confusing and usually not intended.",
|
|
1029
|
+
details=[str(outer)],
|
|
1030
|
+
details_label="Outer repository",
|
|
1031
|
+
fix=[
|
|
1032
|
+
FixOption(
|
|
1033
|
+
"Check whether the outer repository was made by mistake",
|
|
1034
|
+
[Step("Shows the outer repository's recent commits", f"git -C {q(str(outer))} log --oneline -n 5")],
|
|
1035
|
+
)
|
|
1036
|
+
],
|
|
1037
|
+
learn_more=DOCS + "gitsubmodules",
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
@rule("missing_identity", "warning", "user.name or user.email is not set")
|
|
1042
|
+
def missing_identity(st: RepoState):
|
|
1043
|
+
if not st.is_repo or (st.user_name and st.user_email):
|
|
1044
|
+
return None
|
|
1045
|
+
missing = [k for k, v in (("user.name", st.user_name), ("user.email", st.user_email)) if not v]
|
|
1046
|
+
return identity_finding(missing)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def identity_finding(missing: list[str] | None = None) -> Finding:
|
|
1050
|
+
missing = missing or ["user.name", "user.email"]
|
|
1051
|
+
steps = []
|
|
1052
|
+
if "user.name" in missing:
|
|
1053
|
+
steps.append(Step("Sets the name shown on your commits", 'git config --global user.name "Your Name"'))
|
|
1054
|
+
if "user.email" in missing:
|
|
1055
|
+
steps.append(
|
|
1056
|
+
Step(
|
|
1057
|
+
"Sets the email on your commits; use the one on your GitHub account so commits link to you",
|
|
1058
|
+
'git config --global user.email "you@example.com"',
|
|
1059
|
+
)
|
|
1060
|
+
)
|
|
1061
|
+
return Finding(
|
|
1062
|
+
"missing_identity",
|
|
1063
|
+
"warning",
|
|
1064
|
+
"Git doesn't know your name and email yet",
|
|
1065
|
+
"Every commit records who made it. Git needs your name and email set once per computer before it will "
|
|
1066
|
+
"commit (or it guesses an address GitHub won't recognise).",
|
|
1067
|
+
details=missing,
|
|
1068
|
+
details_label="Not set",
|
|
1069
|
+
fix=[FixOption("Introduce yourself to Git (once per computer)", steps)],
|
|
1070
|
+
learn_more=BOOK + "Getting-Started-First-Time-Git-Setup",
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
# ---------------------------------------------------------------- what is about to be pushed
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _reset_soft_target(st: RepoState) -> str | None:
|
|
1078
|
+
if st.upstream and not st.upstream_gone:
|
|
1079
|
+
return "@{u}"
|
|
1080
|
+
for cand in ("origin/main", "origin/master"):
|
|
1081
|
+
if cand in st.remote_branches:
|
|
1082
|
+
return cand
|
|
1083
|
+
return None
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _remove_from_commit_steps(st: RepoState, paths: list[str], gitignore_hint: str) -> list[FixOption]:
|
|
1087
|
+
staged = [p for p in paths if p in {e.path for e in st.staged}]
|
|
1088
|
+
opts: list[FixOption] = []
|
|
1089
|
+
target = _reset_soft_target(st)
|
|
1090
|
+
unpushed = [p for p in paths if p not in staged]
|
|
1091
|
+
if staged:
|
|
1092
|
+
opts.append(
|
|
1093
|
+
FixOption(
|
|
1094
|
+
"Take it out of the next commit",
|
|
1095
|
+
[
|
|
1096
|
+
Step("Unstages the file but keeps it on your computer", f"git rm --cached {_paths(staged)}"),
|
|
1097
|
+
Step(gitignore_hint),
|
|
1098
|
+
],
|
|
1099
|
+
)
|
|
1100
|
+
)
|
|
1101
|
+
if unpushed:
|
|
1102
|
+
if target:
|
|
1103
|
+
opts.append(
|
|
1104
|
+
FixOption(
|
|
1105
|
+
"It's already in a commit you haven't pushed: rebuild those commits without it",
|
|
1106
|
+
[
|
|
1107
|
+
Step(
|
|
1108
|
+
"Undoes the unpushed commits but keeps all their changes staged (your files are not touched)",
|
|
1109
|
+
f"git reset --soft {target}",
|
|
1110
|
+
),
|
|
1111
|
+
Step(
|
|
1112
|
+
"Takes the file out of what will be committed, keeping it on disk",
|
|
1113
|
+
f"git rm --cached {_paths(unpushed)}",
|
|
1114
|
+
),
|
|
1115
|
+
Step(gitignore_hint),
|
|
1116
|
+
Step("Commits everything else again", 'git commit -m "Your message"'),
|
|
1117
|
+
],
|
|
1118
|
+
)
|
|
1119
|
+
)
|
|
1120
|
+
else:
|
|
1121
|
+
opts.append(
|
|
1122
|
+
FixOption(
|
|
1123
|
+
"It's already in a commit you haven't pushed",
|
|
1124
|
+
[
|
|
1125
|
+
Step("Finds the commit before the file was added", f"git log --oneline -- {q(unpushed[0])}"),
|
|
1126
|
+
Step(
|
|
1127
|
+
"Undoes the commits after that point but keeps their changes staged (your files are not touched)",
|
|
1128
|
+
"git reset --soft <commit-before-it>",
|
|
1129
|
+
),
|
|
1130
|
+
Step("Takes the file out, keeping it on disk", f"git rm --cached {_paths(unpushed)}"),
|
|
1131
|
+
Step(gitignore_hint),
|
|
1132
|
+
Step("Commits everything else again", 'git commit -m "Your message"'),
|
|
1133
|
+
],
|
|
1134
|
+
)
|
|
1135
|
+
)
|
|
1136
|
+
return opts
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
@rule("large_file_over_limit", "blocking", "A staged or unpushed file is over GitHub's 100 MB limit")
|
|
1140
|
+
def large_file_over_limit(st: RepoState):
|
|
1141
|
+
big = [b for b in st.big_files if b.size >= GITHUB_HARD_LIMIT]
|
|
1142
|
+
if not big:
|
|
1143
|
+
return None
|
|
1144
|
+
paths = [b.path for b in big]
|
|
1145
|
+
fixes = _remove_from_commit_steps(
|
|
1146
|
+
st, paths, "Add the file (or a pattern like data/*.csv) to .gitignore so it isn't added again"
|
|
1147
|
+
)
|
|
1148
|
+
fixes.append(
|
|
1149
|
+
FixOption(
|
|
1150
|
+
"Or store it with Git LFS (if you really need it on GitHub)",
|
|
1151
|
+
[
|
|
1152
|
+
Step(
|
|
1153
|
+
"Sets up Git Large File Storage, a GitHub add-on for big files (install it from git-lfs.com first)",
|
|
1154
|
+
"git lfs install",
|
|
1155
|
+
),
|
|
1156
|
+
Step("Tells LFS to handle files like this one", f'git lfs track "*.{paths[0].rsplit(".", 1)[-1]}"'),
|
|
1157
|
+
],
|
|
1158
|
+
)
|
|
1159
|
+
)
|
|
1160
|
+
return Finding(
|
|
1161
|
+
"large_file_over_limit",
|
|
1162
|
+
"blocking",
|
|
1163
|
+
f"{_plural(len(big), 'file')} over GitHub's 100 MB limit",
|
|
1164
|
+
"GitHub rejects any push that contains a file over 100 MB, even if you delete the file in a later commit. "
|
|
1165
|
+
"Large data files (CSVs, models, zips) are better kept out of Git and shared another way.",
|
|
1166
|
+
details=[
|
|
1167
|
+
f"{b.path} ({_mb(b.size)}, {'staged' if b.where == 'staged' else 'in a commit not pushed yet'})"
|
|
1168
|
+
for b in big
|
|
1169
|
+
],
|
|
1170
|
+
details_label="Files",
|
|
1171
|
+
fix=fixes,
|
|
1172
|
+
learn_more=DOCS + "git-rm",
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
@rule("large_file_warning", "warning", "A staged or unpushed file is over 50 MB")
|
|
1177
|
+
def large_file_warning(st: RepoState):
|
|
1178
|
+
big = [b for b in st.big_files if b.size < GITHUB_HARD_LIMIT]
|
|
1179
|
+
if not big:
|
|
1180
|
+
return None
|
|
1181
|
+
return Finding(
|
|
1182
|
+
"large_file_warning",
|
|
1183
|
+
"warning",
|
|
1184
|
+
f"{_plural(len(big), 'large file')} (over 50 MB) about to go to GitHub",
|
|
1185
|
+
"GitHub accepts files up to 100 MB but warns above 50 MB, and big files make every clone slow. "
|
|
1186
|
+
"Data files usually belong in .gitignore instead.",
|
|
1187
|
+
details=[
|
|
1188
|
+
f"{b.path} ({_mb(b.size)}, {'staged' if b.where == 'staged' else 'in a commit not pushed yet'})"
|
|
1189
|
+
for b in big
|
|
1190
|
+
],
|
|
1191
|
+
details_label="Files",
|
|
1192
|
+
fix=_remove_from_commit_steps(st, [b.path for b in big], "Add the file to .gitignore so it isn't added again"),
|
|
1193
|
+
learn_more=DOCS + "git-rm",
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
@rule("secret_staged", "warning", "A file that often holds secrets or keys is staged or unpushed")
|
|
1198
|
+
def secret_staged(st: RepoState):
|
|
1199
|
+
if not st.sensitive:
|
|
1200
|
+
return None
|
|
1201
|
+
paths = [s.path for s in st.sensitive]
|
|
1202
|
+
return Finding(
|
|
1203
|
+
"secret_staged",
|
|
1204
|
+
"warning",
|
|
1205
|
+
"A file that may contain passwords or keys is about to be committed or pushed",
|
|
1206
|
+
"Files like .env, private keys (.pem, id_rsa) and credentials JSON usually hold secrets. Once pushed to "
|
|
1207
|
+
"GitHub, assume anyone can read them, even if you delete them later.",
|
|
1208
|
+
details=[
|
|
1209
|
+
f"{s.path} ({'staged' if s.where == 'staged' else 'in a commit not pushed yet'})" for s in st.sensitive
|
|
1210
|
+
],
|
|
1211
|
+
details_label="Files",
|
|
1212
|
+
fix=_remove_from_commit_steps(st, paths, "Add the file name to .gitignore so it isn't added again")
|
|
1213
|
+
+ [
|
|
1214
|
+
FixOption(
|
|
1215
|
+
"If it was already pushed",
|
|
1216
|
+
[Step("Treat the secret as leaked: create a new key or password and revoke the old one.")],
|
|
1217
|
+
)
|
|
1218
|
+
],
|
|
1219
|
+
learn_more=DOCS + "gitignore",
|
|
1220
|
+
)
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
@rule("notebook_outputs", "warning", "A staged notebook is large, probably from saved outputs")
|
|
1224
|
+
def notebook_outputs(st: RepoState):
|
|
1225
|
+
if not st.big_notebooks:
|
|
1226
|
+
return None
|
|
1227
|
+
nbs = [b.path for b in st.big_notebooks]
|
|
1228
|
+
return Finding(
|
|
1229
|
+
"notebook_outputs",
|
|
1230
|
+
"warning",
|
|
1231
|
+
f"{_plural(len(nbs), 'staged notebook')} with large saved outputs",
|
|
1232
|
+
"These notebooks are over 1 MB, which usually means they contain saved outputs: plots, tables or printed data. "
|
|
1233
|
+
"That makes diffs unreadable and conflicts likely, and can publish data you didn't mean to share.",
|
|
1234
|
+
details=[f"{b.path} ({_mb(b.size)})" for b in st.big_notebooks],
|
|
1235
|
+
details_label="Notebooks",
|
|
1236
|
+
fix=[
|
|
1237
|
+
FixOption(
|
|
1238
|
+
"Clear outputs before committing",
|
|
1239
|
+
[
|
|
1240
|
+
Step("In Jupyter or VS Code, use 'Clear All Outputs' and save, or run:"),
|
|
1241
|
+
Step(
|
|
1242
|
+
"Removes the outputs from the notebook file",
|
|
1243
|
+
f"jupyter nbconvert --clear-output --inplace {_paths(nbs)}",
|
|
1244
|
+
destructive=True,
|
|
1245
|
+
warning="The saved outputs are deleted from the file; re-run the notebook to get them back.",
|
|
1246
|
+
),
|
|
1247
|
+
Step("Stages the cleaned notebook", f"git add {_paths(nbs)}"),
|
|
1248
|
+
],
|
|
1249
|
+
)
|
|
1250
|
+
],
|
|
1251
|
+
learn_more=DOCS + "gitattributes",
|
|
1252
|
+
)
|