git-panic 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.
- git_panic/__init__.py +3 -0
- git_panic/__main__.py +5 -0
- git_panic/cli.py +107 -0
- git_panic/diagnosis.py +125 -0
- git_panic/git.py +217 -0
- git_panic/models.py +77 -0
- git_panic/safety.py +67 -0
- git_panic/workflows.py +333 -0
- git_panic-0.1.0.dist-info/METADATA +100 -0
- git_panic-0.1.0.dist-info/RECORD +13 -0
- git_panic-0.1.0.dist-info/WHEEL +4 -0
- git_panic-0.1.0.dist-info/entry_points.txt +2 -0
- git_panic-0.1.0.dist-info/licenses/LICENSE +674 -0
git_panic/workflows.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
|
|
6
|
+
from git_panic.git import GitRepository
|
|
7
|
+
from git_panic.models import FileAppend, GitCommand, RecoveryPlan, SafetyError, WorkflowKind
|
|
8
|
+
from git_panic.safety import SafetyValidator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RecoveryPlanner:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
repository: GitRepository,
|
|
15
|
+
validator: SafetyValidator,
|
|
16
|
+
*,
|
|
17
|
+
create_safety_branch: bool = True,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.repository = repository
|
|
20
|
+
self.validator = validator
|
|
21
|
+
self.create_safety_branch = create_safety_branch
|
|
22
|
+
|
|
23
|
+
def _backup_name(self, workflow: WorkflowKind) -> str:
|
|
24
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
25
|
+
return f"git-panic-rescue/{timestamp}-{workflow.value}"
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _normalize_path(raw_path: str) -> str:
|
|
29
|
+
path = PurePosixPath(raw_path.strip())
|
|
30
|
+
if not raw_path.strip() or path.is_absolute() or ".." in path.parts:
|
|
31
|
+
raise SafetyError("Enter a repository-relative file path without '..'.")
|
|
32
|
+
return path.as_posix()
|
|
33
|
+
|
|
34
|
+
def _backup_command(self, backup: str, head: str) -> GitCommand:
|
|
35
|
+
return GitCommand(
|
|
36
|
+
("branch", backup, head),
|
|
37
|
+
f"Create safety branch {backup} at the current commit",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _base(self, workflow: WorkflowKind) -> tuple[str | None, tuple[GitCommand, ...]]:
|
|
41
|
+
if not self.create_safety_branch:
|
|
42
|
+
return None, ()
|
|
43
|
+
head = self.repository.head()
|
|
44
|
+
backup = self._backup_name(workflow)
|
|
45
|
+
return backup, (self._backup_command(backup, head),)
|
|
46
|
+
|
|
47
|
+
def wrong_branch(self, destination: str) -> RecoveryPlan:
|
|
48
|
+
current = self.validator.require_attached_head()
|
|
49
|
+
self.validator.require_clean_worktree("Wrong Branch")
|
|
50
|
+
self.validator.refuse_published_head()
|
|
51
|
+
if not destination or not self.repository.valid_branch_name(destination):
|
|
52
|
+
raise SafetyError(f"{destination!r} is not a valid Git branch name.")
|
|
53
|
+
if self.repository.branch_exists(destination):
|
|
54
|
+
raise SafetyError(
|
|
55
|
+
"The destination branch already exists. Git-Panic only moves a commit to a new branch "
|
|
56
|
+
"to avoid an automatic cherry-pick and possible conflicts."
|
|
57
|
+
)
|
|
58
|
+
head = self.repository.head()
|
|
59
|
+
parent = self.repository.head_parent()
|
|
60
|
+
backup, backup_commands = self._base(WorkflowKind.WRONG_BRANCH)
|
|
61
|
+
return RecoveryPlan(
|
|
62
|
+
workflow=WorkflowKind.WRONG_BRANCH,
|
|
63
|
+
title="Move the last commit to a new branch",
|
|
64
|
+
summary=(
|
|
65
|
+
f"Preserve {head[:10]}, create and switch to {destination!r} at that commit, then move "
|
|
66
|
+
f"{current!r} back to its previous commit. Your working tree remains on {destination!r}."
|
|
67
|
+
),
|
|
68
|
+
backup_ref=backup,
|
|
69
|
+
commands=(
|
|
70
|
+
*backup_commands,
|
|
71
|
+
GitCommand(("switch", "-c", destination, head), f"Create and switch to {destination}"),
|
|
72
|
+
GitCommand(("branch", "-f", current, parent), f"Move {current} back one commit"),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def amend_changes(self) -> RecoveryPlan:
|
|
77
|
+
self.validator.require_attached_head()
|
|
78
|
+
self.validator.refuse_published_head()
|
|
79
|
+
if not self.repository.has_staged_changes():
|
|
80
|
+
raise SafetyError(
|
|
81
|
+
"No staged changes were found. Stage only the intended correction, then run this workflow again."
|
|
82
|
+
)
|
|
83
|
+
backup, backup_commands = self._base(WorkflowKind.AMEND_CHANGES)
|
|
84
|
+
return RecoveryPlan(
|
|
85
|
+
workflow=WorkflowKind.AMEND_CHANGES,
|
|
86
|
+
title="Add staged corrections to the last commit",
|
|
87
|
+
summary=(
|
|
88
|
+
"Amend the last unpublished commit with the currently staged changes while preserving its "
|
|
89
|
+
"message. Unstaged and untracked changes are not included."
|
|
90
|
+
),
|
|
91
|
+
backup_ref=backup,
|
|
92
|
+
commands=(
|
|
93
|
+
*backup_commands,
|
|
94
|
+
GitCommand(("commit", "--amend", "--no-edit"), "Add staged changes without changing the message"),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def sensitive_file(self, raw_path: str, *, published: bool) -> RecoveryPlan:
|
|
99
|
+
self.validator.require_attached_head()
|
|
100
|
+
normalized = self._normalize_path(raw_path)
|
|
101
|
+
if not self.repository.tracked(normalized):
|
|
102
|
+
raise SafetyError(f"{normalized!r} is not tracked by the current commit.")
|
|
103
|
+
|
|
104
|
+
ignore_source = self.repository.repository_ignore_source(normalized)
|
|
105
|
+
ignore_commands: tuple[GitCommand | FileAppend, ...] = ()
|
|
106
|
+
if ignore_source is None:
|
|
107
|
+
ignore_source = ".gitignore"
|
|
108
|
+
if self.repository.path_has_changes(ignore_source):
|
|
109
|
+
raise SafetyError(
|
|
110
|
+
".gitignore already has changes. Commit or set those changes aside before allowing "
|
|
111
|
+
"Git-Panic to add the sensitive-file rule."
|
|
112
|
+
)
|
|
113
|
+
ignore_commands = (
|
|
114
|
+
FileAppend(ignore_source, normalized, f"Add {normalized} to .gitignore"),
|
|
115
|
+
GitCommand(("add", "--", ":(literal).gitignore"), "Stage the new ignore rule"),
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
if not self.repository.index_tracks(ignore_source):
|
|
119
|
+
raise SafetyError(
|
|
120
|
+
f"{ignore_source!r} is not tracked. Commit it before continuing so collaborators "
|
|
121
|
+
"receive its existing ignore rule."
|
|
122
|
+
)
|
|
123
|
+
if self.repository.has_unstaged_changes(ignore_source):
|
|
124
|
+
raise SafetyError(f"{ignore_source!r} has unstaged changes. Stage or set them aside first.")
|
|
125
|
+
|
|
126
|
+
unrelated_staged = self.repository.staged_paths() - {ignore_source}
|
|
127
|
+
if unrelated_staged:
|
|
128
|
+
paths = ", ".join(sorted(unrelated_staged))
|
|
129
|
+
raise SafetyError(
|
|
130
|
+
f"Unstage unrelated changes before continuing. These paths would enter the commit: {paths}"
|
|
131
|
+
)
|
|
132
|
+
if not published and self.repository.head_is_published():
|
|
133
|
+
raise SafetyError(
|
|
134
|
+
"The current commit exists on the configured upstream. Treat the credential as published "
|
|
135
|
+
"and choose the shared/pushed option."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
command = (
|
|
139
|
+
*ignore_commands,
|
|
140
|
+
GitCommand(
|
|
141
|
+
("rm", "--cached", "--", f":(literal){normalized}"),
|
|
142
|
+
f"Stop tracking {normalized} while keeping the local file",
|
|
143
|
+
),
|
|
144
|
+
GitCommand(
|
|
145
|
+
("commit", "-m", "Stop tracking sensitive file")
|
|
146
|
+
if published
|
|
147
|
+
else ("commit", "--amend", "--no-edit"),
|
|
148
|
+
"Create a removal commit without rewriting history"
|
|
149
|
+
if published
|
|
150
|
+
else "Remove the file from the latest unpublished commit",
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
warnings = [
|
|
154
|
+
"Rotate or revoke the exposed credential immediately. Removing it from Git does not invalidate it.",
|
|
155
|
+
"No safety branch will be created because that branch would intentionally preserve the sensitive commit.",
|
|
156
|
+
]
|
|
157
|
+
if published:
|
|
158
|
+
warnings.append(
|
|
159
|
+
"The sensitive content remains in published history. Coordinate any git-filter-repo cleanup "
|
|
160
|
+
"and subsequent force-push with repository owners and every collaborator."
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
warnings.append(
|
|
164
|
+
"The old commit may remain in your local reflog and object database, but it will no longer be "
|
|
165
|
+
"part of the branch you push."
|
|
166
|
+
)
|
|
167
|
+
return RecoveryPlan(
|
|
168
|
+
workflow=WorkflowKind.SENSITIVE_FILE,
|
|
169
|
+
title="Stop tracking a sensitive file",
|
|
170
|
+
summary=(
|
|
171
|
+
f"Keep {normalized!r} on disk, remove it from Git's index, and "
|
|
172
|
+
+ (
|
|
173
|
+
"create a new commit that does not rewrite published history."
|
|
174
|
+
if published
|
|
175
|
+
else "amend the latest unpublished commit without changing its message."
|
|
176
|
+
)
|
|
177
|
+
),
|
|
178
|
+
backup_ref=None,
|
|
179
|
+
commands=command,
|
|
180
|
+
warnings=tuple(warnings),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def undo_keep_changes(self, *, staged: bool = False) -> RecoveryPlan:
|
|
184
|
+
self.validator.require_attached_head()
|
|
185
|
+
self.validator.require_clean_worktree("Undo Commit, Keep Changes")
|
|
186
|
+
self.validator.refuse_published_head()
|
|
187
|
+
parent = self.repository.head_parent()
|
|
188
|
+
backup, backup_commands = self._base(WorkflowKind.UNDO_KEEP_CHANGES)
|
|
189
|
+
mode = "--soft" if staged else "--mixed"
|
|
190
|
+
resulting_state = "staged" if staged else "unstaged"
|
|
191
|
+
return RecoveryPlan(
|
|
192
|
+
workflow=WorkflowKind.UNDO_KEEP_CHANGES,
|
|
193
|
+
title="Undo the last commit and keep its changes",
|
|
194
|
+
summary=(
|
|
195
|
+
f"Move the current branch to the previous commit with a {mode.removeprefix('--')} reset. "
|
|
196
|
+
f"The former commit's content remains in the working tree as {resulting_state} changes."
|
|
197
|
+
),
|
|
198
|
+
backup_ref=backup,
|
|
199
|
+
commands=(
|
|
200
|
+
*backup_commands,
|
|
201
|
+
GitCommand(("reset", mode, parent), f"Move HEAD back and keep the changes {resulting_state}"),
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def deleted_file(self, raw_path: str) -> RecoveryPlan:
|
|
206
|
+
normalized = self._normalize_path(raw_path)
|
|
207
|
+
absolute = self.repository.root.joinpath(*PurePosixPath(normalized).parts)
|
|
208
|
+
if not self.repository.tracked(normalized):
|
|
209
|
+
raise SafetyError(f"{normalized!r} is not tracked in HEAD and cannot be restored from it.")
|
|
210
|
+
if absolute.exists() or absolute.is_symlink():
|
|
211
|
+
raise SafetyError(f"{normalized!r} still exists. This workflow only restores a deleted path.")
|
|
212
|
+
backup, backup_commands = self._base(WorkflowKind.DELETED_FILE)
|
|
213
|
+
return RecoveryPlan(
|
|
214
|
+
workflow=WorkflowKind.DELETED_FILE,
|
|
215
|
+
title="Restore a deleted tracked file",
|
|
216
|
+
summary=f"Restore {normalized!r} in both the index and working tree from the current commit.",
|
|
217
|
+
backup_ref=backup,
|
|
218
|
+
commands=(
|
|
219
|
+
*backup_commands,
|
|
220
|
+
GitCommand(
|
|
221
|
+
(
|
|
222
|
+
"restore",
|
|
223
|
+
"--source=HEAD",
|
|
224
|
+
"--staged",
|
|
225
|
+
"--worktree",
|
|
226
|
+
"--",
|
|
227
|
+
f":(literal){normalized}",
|
|
228
|
+
),
|
|
229
|
+
f"Restore {normalized} from HEAD",
|
|
230
|
+
),
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def discard_changes(self, raw_path: str | None = None) -> RecoveryPlan:
|
|
235
|
+
normalized = self._normalize_path(raw_path) if raw_path is not None else None
|
|
236
|
+
if normalized is not None:
|
|
237
|
+
if not self.repository.path_has_changes(normalized):
|
|
238
|
+
raise SafetyError(f"{normalized!r} has no staged, unstaged, or untracked changes to discard.")
|
|
239
|
+
elif not self.repository.is_dirty():
|
|
240
|
+
raise SafetyError("The working tree has no staged, unstaged, or untracked changes to discard.")
|
|
241
|
+
|
|
242
|
+
backup, backup_commands = self._base(WorkflowKind.DISCARD_CHANGES)
|
|
243
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
244
|
+
stash_message = f"git-panic discarded changes {timestamp}"
|
|
245
|
+
scope = repr(normalized) if normalized is not None else "the entire working tree"
|
|
246
|
+
pathspec = ("--", f":(literal){normalized}") if normalized is not None else ()
|
|
247
|
+
return RecoveryPlan(
|
|
248
|
+
workflow=WorkflowKind.DISCARD_CHANGES,
|
|
249
|
+
title="Set uncommitted changes aside safely",
|
|
250
|
+
summary=(
|
|
251
|
+
f"Return {scope} to the committed state by storing its staged, unstaged, and untracked "
|
|
252
|
+
"changes in Git's stash. The discarded work remains recoverable with `git stash list` "
|
|
253
|
+
"and `git stash apply`."
|
|
254
|
+
),
|
|
255
|
+
backup_ref=backup,
|
|
256
|
+
commands=(
|
|
257
|
+
*backup_commands,
|
|
258
|
+
GitCommand(
|
|
259
|
+
("stash", "push", "--include-untracked", "--message", stash_message, *pathspec),
|
|
260
|
+
f"Stash changes from {scope} instead of deleting them",
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def fix_message(self, new_message: str) -> RecoveryPlan:
|
|
266
|
+
self.validator.require_attached_head()
|
|
267
|
+
self.validator.refuse_published_head()
|
|
268
|
+
if self.repository.has_staged_changes():
|
|
269
|
+
raise SafetyError(
|
|
270
|
+
"The index contains staged changes. Amending now would add them to the commit; unstage them first."
|
|
271
|
+
)
|
|
272
|
+
if not new_message.strip():
|
|
273
|
+
raise SafetyError("The new commit message cannot be empty.")
|
|
274
|
+
backup, backup_commands = self._base(WorkflowKind.FIX_MESSAGE)
|
|
275
|
+
return RecoveryPlan(
|
|
276
|
+
workflow=WorkflowKind.FIX_MESSAGE,
|
|
277
|
+
title="Replace the last commit message",
|
|
278
|
+
summary="Amend only the last commit message. Unstaged working-tree changes are left untouched.",
|
|
279
|
+
backup_ref=backup,
|
|
280
|
+
commands=(
|
|
281
|
+
*backup_commands,
|
|
282
|
+
GitCommand(("commit", "--amend", "--only", "-m", new_message.strip()), "Rewrite the message"),
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def revert_published_head(self) -> RecoveryPlan:
|
|
287
|
+
self.validator.require_attached_head()
|
|
288
|
+
self.validator.require_clean_worktree("Revert Published Commit")
|
|
289
|
+
self.validator.require_published_head()
|
|
290
|
+
if self.repository.head_parent_count() != 1:
|
|
291
|
+
raise SafetyError(
|
|
292
|
+
"The current commit is a root or merge commit. Git-Panic will not guess merge-parent semantics."
|
|
293
|
+
)
|
|
294
|
+
head = self.repository.head()
|
|
295
|
+
backup, backup_commands = self._base(WorkflowKind.REVERT_PUBLISHED)
|
|
296
|
+
return RecoveryPlan(
|
|
297
|
+
workflow=WorkflowKind.REVERT_PUBLISHED,
|
|
298
|
+
title="Revert the latest published commit",
|
|
299
|
+
summary=(
|
|
300
|
+
f"Create a new commit that reverses published commit {head[:10]}. Existing history remains "
|
|
301
|
+
"intact, so collaborators can pull the correction normally."
|
|
302
|
+
),
|
|
303
|
+
backup_ref=backup,
|
|
304
|
+
commands=(
|
|
305
|
+
*backup_commands,
|
|
306
|
+
GitCommand(("revert", "--no-edit", head), f"Create an inverse commit for {head[:10]}"),
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def reflog_rescue(self, commit: str, destination: str) -> RecoveryPlan:
|
|
311
|
+
self.validator.require_clean_worktree("Reflog Rescue")
|
|
312
|
+
if not destination or not self.repository.valid_branch_name(destination):
|
|
313
|
+
raise SafetyError(f"{destination!r} is not a valid Git branch name.")
|
|
314
|
+
if self.repository.branch_exists(destination):
|
|
315
|
+
raise SafetyError("The rescue destination must be a new branch.")
|
|
316
|
+
allowed = {entry[0] for entry in self.repository.reflog()}
|
|
317
|
+
if commit not in allowed:
|
|
318
|
+
raise SafetyError("Select a commit from the displayed reflog entries.")
|
|
319
|
+
backup, backup_commands = self._base(WorkflowKind.REFLOG_RESCUE)
|
|
320
|
+
backup_summary = "Keep a backup at the current HEAD, then " if backup else ""
|
|
321
|
+
return RecoveryPlan(
|
|
322
|
+
workflow=WorkflowKind.REFLOG_RESCUE,
|
|
323
|
+
title="Create a branch from a reflog entry",
|
|
324
|
+
summary=(
|
|
325
|
+
f"{backup_summary}create and switch to {destination!r} at "
|
|
326
|
+
f"reflog commit {commit[:10]}. No existing branch is moved."
|
|
327
|
+
),
|
|
328
|
+
backup_ref=backup,
|
|
329
|
+
commands=(
|
|
330
|
+
*backup_commands,
|
|
331
|
+
GitCommand(("switch", "-c", destination, commit), f"Recover {commit[:10]} on {destination}"),
|
|
332
|
+
),
|
|
333
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-panic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A safety-first interactive assistant for recovering from common local Git mistakes
|
|
5
|
+
Project-URL: Homepage, https://github.com/usemoslinux/git-panic
|
|
6
|
+
Project-URL: Repository, https://github.com/usemoslinux/git-panic
|
|
7
|
+
Project-URL: Issues, https://github.com/usemoslinux/git-panic/issues
|
|
8
|
+
Author: Git-Panic contributors
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: rich<15,>=13.7
|
|
13
|
+
Requires-Dist: typer<1,>=0.12
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: build<2,>=1.2; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest<9,>=8.2; extra == 'dev'
|
|
17
|
+
Requires-Dist: twine<7,>=5.1; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Git-Panic
|
|
21
|
+
|
|
22
|
+
Git-Panic is an interactive, safety-first terminal assistant for common local Git recovery scenarios. It diagnoses the problem, explains an exact command plan, creates a rescue branch by default, and asks for explicit confirmation before changing repository state.
|
|
23
|
+
|
|
24
|
+
## Recovery workflows
|
|
25
|
+
|
|
26
|
+
- Move the last unpublished commit from the current branch to a new branch.
|
|
27
|
+
- Add staged corrections to the last unpublished commit without changing its message.
|
|
28
|
+
- Stop tracking a committed sensitive file while keeping the local ignored copy.
|
|
29
|
+
- Undo the last unpublished commit while preserving its content as staged or unstaged changes.
|
|
30
|
+
- Replace the last unpublished commit message without including staged changes.
|
|
31
|
+
- Set aside one path or all uncommitted changes in a recoverable stash.
|
|
32
|
+
- Restore a locally deleted tracked file from `HEAD`.
|
|
33
|
+
- Revert the latest published commit by creating an inverse commit.
|
|
34
|
+
- Inspect the reflog and recover a selected state onto a new branch.
|
|
35
|
+
|
|
36
|
+
Git-Panic refuses to proceed during merge, rebase, cherry-pick, revert, or bisect operations; with unresolved conflicts; or when the local branch has diverged from its upstream. History-rewriting workflows also refuse to alter a commit already present upstream.
|
|
37
|
+
|
|
38
|
+
## Install and run
|
|
39
|
+
|
|
40
|
+
Python 3.10 or newer and Git are required.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
python -m venv .venv
|
|
44
|
+
. .venv/bin/activate
|
|
45
|
+
python -m pip install -e '.[dev]'
|
|
46
|
+
git-panic
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Inspect a repository without executing recovery commands:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git-panic --repo /path/to/repository --dry-run
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Safety model
|
|
56
|
+
|
|
57
|
+
By default, every plan starts with a branch named `git-panic-rescue/<timestamp>-<workflow>`. The full plan is displayed before execution and defaults to cancellation at the confirmation prompt. Commands are passed directly to Git without a shell.
|
|
58
|
+
|
|
59
|
+
The backup protects committed history. It does not snapshot arbitrary uncommitted files, so workflows that move branches require a clean working tree. The discard workflow uses `git stash push` instead of `git restore`, ensuring discarded changes remain recoverable. Deleted-file recovery is safe because the deleted file's current version remains in `HEAD`.
|
|
60
|
+
|
|
61
|
+
Git-Panic does not offer `git reset --hard` or force-push workflows. Published history is undone with `git revert`, preserving a reviewable record and avoiding disruption for collaborators.
|
|
62
|
+
|
|
63
|
+
### Sensitive files
|
|
64
|
+
|
|
65
|
+
The sensitive-file workflow adds a selected path to the root repository `.gitignore` and stages that file when no repository ignore rule already applies. The planned file edit and `git add` are shown before confirmation. Existing uncommitted `.gitignore` changes block the workflow so Git-Panic cannot accidentally commit them. Global excludes and `.git/info/exclude` are rejected because collaborators would not receive those rules.
|
|
66
|
+
|
|
67
|
+
For an unpublished latest commit, Git-Panic stops tracking the file and amends the commit. If the file was pushed or otherwise shared, it creates a normal removal commit without rewriting shared history. A safety branch is intentionally omitted because it would preserve another named reference to the sensitive commit.
|
|
68
|
+
|
|
69
|
+
Credential rotation or revocation is always recommended and is displayed prominently before execution. Removing a file does not invalidate exposed credentials. Published content also remains in historical commits; repository-wide cleanup with `git filter-repo` and coordinated force-pushing is left to repository owners and collaborators.
|
|
70
|
+
|
|
71
|
+
Safety branch creation can be explicitly disabled for one invocation. Git-Panic displays a warning and still requires confirmation before executing the recovery commands:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
git-panic --no-safety-branch
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python -m pip install -e '.[dev]'
|
|
81
|
+
pytest
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Publishing
|
|
85
|
+
|
|
86
|
+
Releases publish to PyPI through GitHub Actions OpenID Connect trusted publishing. No PyPI token is stored in this repository.
|
|
87
|
+
|
|
88
|
+
Before the first release, configure a pending trusted publisher at <https://pypi.org/manage/account/publishing/>:
|
|
89
|
+
|
|
90
|
+
| PyPI field | Value |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| PyPI Project Name | `git-panic` |
|
|
93
|
+
| Owner | `usemoslinux` |
|
|
94
|
+
| Repository name | `git-panic` |
|
|
95
|
+
| Workflow name | `publish.yml` |
|
|
96
|
+
| Environment name | `pypi` |
|
|
97
|
+
|
|
98
|
+
In the GitHub repository, create an environment named `pypi` under **Settings > Environments**. Restrict its deployment access as appropriate. Then publish a GitHub release with a version tag matching `pyproject.toml`, such as `v0.1.0`. The `Publish to PyPI` workflow builds the source and wheel distributions, validates their metadata with Twine, and publishes them through the configured trusted publisher.
|
|
99
|
+
|
|
100
|
+
The pending publisher does not reserve the PyPI name. Configure it before publishing, and confirm that `git-panic` is available on PyPI.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
git_panic/__init__.py,sha256=WIQhfdeKlv33ktwBINiJ1AmMirmvf3_X2KzpaxCeE-g,75
|
|
2
|
+
git_panic/__main__.py,sha256=ViGymMqeYi6m0_ffJoh7ihlT0bxX7FfhEDvFzJg13vs,69
|
|
3
|
+
git_panic/cli.py,sha256=EvT6pwLanwNF_zHrhjbsuGR-0mhY_BHb_Uektw-x4Qs,3760
|
|
4
|
+
git_panic/diagnosis.py,sha256=-t3mMxnjJsa6WziWbEYayYainQ9qEoIB46M62Lz7UXc,5552
|
|
5
|
+
git_panic/git.py,sha256=uRGgi9dJaEM2VVbAJFlvBh7Qc9M-kk7yeYR7qm1hnjw,8319
|
|
6
|
+
git_panic/models.py,sha256=IZfX9k_QcBxj8BovDDL4SQWXsYVzcb1hcWRimT2pkys,1833
|
|
7
|
+
git_panic/safety.py,sha256=Wet0M6qZZ0SIEVtPdZ4iG9FDq-ok6ikjCHpnZPM2BRY,2687
|
|
8
|
+
git_panic/workflows.py,sha256=o8Lq9EllCtrobcLpfJjHfX9MIMoprxTbPrfOxTlEYqU,15801
|
|
9
|
+
git_panic-0.1.0.dist-info/METADATA,sha256=_O9esY1E5MVaZy5OjoU1DbCY3xGFQq-4a7nGu0NKKa8,5440
|
|
10
|
+
git_panic-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
git_panic-0.1.0.dist-info/entry_points.txt,sha256=9xmqWKbWfkRC_ogmBo74l-L_xpe0xXy_qDOTUxfMGvc,48
|
|
12
|
+
git_panic-0.1.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
13
|
+
git_panic-0.1.0.dist-info/RECORD,,
|