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 ADDED
@@ -0,0 +1,3 @@
1
+ """Git-Panic: guided, safety-first Git recovery."""
2
+
3
+ __version__ = "0.1.0"
git_panic/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from git_panic.cli import app
2
+
3
+
4
+ if __name__ == "__main__":
5
+ app()
git_panic/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.prompt import Confirm
9
+ from rich.table import Table
10
+
11
+ from git_panic.diagnosis import DiagnosisEngine
12
+ from git_panic.git import GitExecutor, GitRepository
13
+ from git_panic.models import GitPanicError, RecoveryPlan
14
+ from git_panic.safety import SafetyValidator
15
+ from git_panic.workflows import RecoveryPlanner
16
+
17
+ app = typer.Typer(
18
+ name="git-panic",
19
+ help="Safely recover from common local Git mistakes.",
20
+ no_args_is_help=False,
21
+ add_completion=False,
22
+ )
23
+ console = Console()
24
+
25
+
26
+ def _show_plan(plan: RecoveryPlan, dry_run: bool) -> None:
27
+ label = "DRY RUN: no commands will execute" if dry_run else "Review before execution"
28
+ console.print(Panel(plan.summary, title=plan.title, subtitle=label, border_style="cyan"))
29
+ for warning in plan.warnings:
30
+ console.print(Panel(warning, title="Important", border_style="bold red"))
31
+ table = Table(title="Recovery commands")
32
+ table.add_column("Step", justify="right", style="bold cyan")
33
+ table.add_column("Why")
34
+ table.add_column("Exact command", style="yellow")
35
+ for index, command in enumerate(plan.commands, start=1):
36
+ table.add_row(str(index), command.explanation, command.display)
37
+ console.print(table)
38
+ if plan.backup_ref:
39
+ console.print(f"Safety branch: [bold green]{plan.backup_ref}[/bold green]")
40
+ else:
41
+ console.print("[bold red]Warning: safety branch creation is disabled.[/bold red]")
42
+
43
+
44
+ @app.callback(invoke_without_command=True)
45
+ def main(
46
+ ctx: typer.Context,
47
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print commands without executing them."),
48
+ safety_branch: bool = typer.Option(
49
+ True,
50
+ "--safety-branch/--no-safety-branch",
51
+ help="Create a rescue branch before executing recovery commands.",
52
+ ),
53
+ repository: Path = typer.Option(
54
+ Path("."),
55
+ "--repo",
56
+ exists=True,
57
+ file_okay=False,
58
+ resolve_path=True,
59
+ help="Git working tree to inspect.",
60
+ ),
61
+ ) -> None:
62
+ if ctx.invoked_subcommand is not None:
63
+ return
64
+
65
+ try:
66
+ repo = GitRepository(repository)
67
+ validator = SafetyValidator(repo)
68
+ validator.validate_preflight()
69
+ planner = RecoveryPlanner(repo, validator, create_safety_branch=safety_branch)
70
+ plan = DiagnosisEngine(repo, planner, console).diagnose()
71
+ _show_plan(plan, dry_run)
72
+
73
+ executor = GitExecutor(repo)
74
+ if dry_run:
75
+ executor.execute(plan, confirmed=False, dry_run=True)
76
+ console.print("[bold cyan]Dry run complete. Repository state was not changed.[/bold cyan]")
77
+ return
78
+
79
+ confirmed = Confirm.ask(
80
+ "Execute every command above in order?",
81
+ default=False,
82
+ console=console,
83
+ )
84
+ if not confirmed:
85
+ console.print("[yellow]Cancelled. No commands were executed.[/yellow]")
86
+ raise typer.Exit(0)
87
+
88
+ executor.execute(plan, confirmed=True)
89
+ result_message = "Recovery completed."
90
+ if plan.backup_ref:
91
+ result_message += f" Keep [bold]{plan.backup_ref}[/bold] until you have verified the result."
92
+ console.print(
93
+ Panel(
94
+ result_message,
95
+ border_style="green",
96
+ )
97
+ )
98
+ except GitPanicError as error:
99
+ console.print(Panel(str(error), title="Git-Panic stopped safely", border_style="red"))
100
+ raise typer.Exit(1) from error
101
+ except KeyboardInterrupt:
102
+ console.print("\n[yellow]Cancelled. No further commands were executed.[/yellow]")
103
+ raise typer.Exit(130)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ app()
git_panic/diagnosis.py ADDED
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ from rich import box
4
+ from rich.console import Console
5
+ from rich.prompt import IntPrompt, Prompt
6
+ from rich.table import Table
7
+
8
+ from git_panic.git import GitRepository
9
+ from git_panic.models import RecoveryPlan, SafetyError, WorkflowKind
10
+ from git_panic.workflows import RecoveryPlanner
11
+
12
+
13
+ MENU_GROUPS: tuple[tuple[str, tuple[tuple[int, str, WorkflowKind, bool | None], ...]], ...] = (
14
+ (
15
+ "UNCOMMITTED WORK",
16
+ (
17
+ (1, "I want to discard all changes I have not committed yet", WorkflowKind.DISCARD_CHANGES, None),
18
+ (2, "I deleted a local file and want it back", WorkflowKind.DELETED_FILE, None),
19
+ ),
20
+ ),
21
+ (
22
+ "LAST COMMIT, NOT SHARED",
23
+ (
24
+ (3, "I committed on the wrong branch", WorkflowKind.WRONG_BRANCH, None),
25
+ (4, "I forgot a small change in my last commit", WorkflowKind.AMEND_CHANGES, None),
26
+ (5, "I need to change my last commit message", WorkflowKind.FIX_MESSAGE, None),
27
+ (6, "I committed sensitive information, but have not pushed it yet", WorkflowKind.SENSITIVE_FILE, False),
28
+ (7, "I want to undo my last commit but keep the changes", WorkflowKind.UNDO_KEEP_CHANGES, None),
29
+ ),
30
+ ),
31
+ (
32
+ "PUSHED OR PUBLISHED HISTORY",
33
+ (
34
+ (8, "I committed and pushed sensitive information", WorkflowKind.SENSITIVE_FILE, True),
35
+ (9, "I need to undo the latest published commit safely", WorkflowKind.REVERT_PUBLISHED, None),
36
+ ),
37
+ ),
38
+ (
39
+ "GO BACK IN TIME",
40
+ (
41
+ (10, "I need to recover an earlier state", WorkflowKind.REFLOG_RESCUE, None),
42
+ ),
43
+ ),
44
+ )
45
+
46
+
47
+ class DiagnosisEngine:
48
+ def __init__(self, repository: GitRepository, planner: RecoveryPlanner, console: Console) -> None:
49
+ self.repository = repository
50
+ self.planner = planner
51
+ self.console = console
52
+
53
+ def diagnose(self) -> RecoveryPlan:
54
+ choices = {
55
+ number: (workflow, published)
56
+ for _, scenarios in MENU_GROUPS
57
+ for number, _, workflow, published in scenarios
58
+ }
59
+ self.console.print("[bold]What happened?[/bold]")
60
+ self.console.print("Choose the situation that best matches your repository.\n")
61
+ for group, scenarios in MENU_GROUPS:
62
+ self.console.print(f"[bold cyan]{group}[/bold cyan]")
63
+ table = Table(show_header=False, box=box.SIMPLE, pad_edge=False)
64
+ table.add_column("Choice", style="bold cyan", justify="right", width=2)
65
+ table.add_column("Situation")
66
+ for number, label, _, _ in scenarios:
67
+ table.add_row(str(number), label)
68
+ self.console.print(table)
69
+ choice = IntPrompt.ask(
70
+ "Choose what happened",
71
+ choices=[str(number) for number in choices],
72
+ )
73
+ workflow, published = choices[choice]
74
+
75
+ if workflow is WorkflowKind.WRONG_BRANCH:
76
+ destination = Prompt.ask("Name for the new branch that should keep the commit")
77
+ return self.planner.wrong_branch(destination)
78
+ if workflow is WorkflowKind.AMEND_CHANGES:
79
+ return self.planner.amend_changes()
80
+ if workflow is WorkflowKind.SENSITIVE_FILE:
81
+ path = Prompt.ask("Repository-relative path of the sensitive file")
82
+ return self.planner.sensitive_file(path, published=bool(published))
83
+ if workflow is WorkflowKind.UNDO_KEEP_CHANGES:
84
+ state = IntPrompt.ask(
85
+ "Keep the former commit's changes as [1] staged or [2] unstaged",
86
+ choices=["1", "2"],
87
+ default=2,
88
+ )
89
+ return self.planner.undo_keep_changes(staged=state == 1)
90
+ if workflow is WorkflowKind.FIX_MESSAGE:
91
+ message = Prompt.ask("New commit message")
92
+ return self.planner.fix_message(message)
93
+ if workflow is WorkflowKind.DISCARD_CHANGES:
94
+ scope = IntPrompt.ask(
95
+ "Set aside changes from [1] one path or [2] the entire working tree",
96
+ choices=["1", "2"],
97
+ default=1,
98
+ )
99
+ path = Prompt.ask("Repository-relative path") if scope == 1 else None
100
+ return self.planner.discard_changes(path)
101
+ if workflow is WorkflowKind.DELETED_FILE:
102
+ path = Prompt.ask("Repository-relative path of the deleted file")
103
+ return self.planner.deleted_file(path)
104
+ if workflow is WorkflowKind.REVERT_PUBLISHED:
105
+ return self.planner.revert_published_head()
106
+ return self._reflog_plan()
107
+
108
+ def _reflog_plan(self) -> RecoveryPlan:
109
+ entries = self.repository.reflog()
110
+ if not entries:
111
+ raise SafetyError("No reflog entries are available for recovery.")
112
+ table = Table(title="Recent reflog entries")
113
+ table.add_column("Choice", style="bold cyan", justify="right")
114
+ table.add_column("Commit", style="yellow")
115
+ table.add_column("Selector")
116
+ table.add_column("Action")
117
+ for index, (commit, selector, subject) in enumerate(entries, start=1):
118
+ table.add_row(str(index), commit[:10], selector, subject)
119
+ self.console.print(table)
120
+ choice = IntPrompt.ask(
121
+ "Choose the state to recover",
122
+ choices=[str(i) for i in range(1, len(entries) + 1)],
123
+ )
124
+ destination = Prompt.ask("Name for the new recovery branch", default="recovered-work")
125
+ return self.planner.reflog_rescue(entries[choice - 1][0], destination)
git_panic/git.py ADDED
@@ -0,0 +1,217 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from git_panic.models import CommandError, FileAppend, GitCommand, RecoveryPlan
7
+
8
+
9
+ class GitRepository:
10
+ """Read-only queries for one Git working tree."""
11
+
12
+ def __init__(self, path: Path | str = ".") -> None:
13
+ requested = Path(path).resolve()
14
+ result = self._run_at(requested, ("rev-parse", "--show-toplevel"), check=False)
15
+ if result.returncode != 0:
16
+ raise CommandError(
17
+ "git rev-parse --show-toplevel",
18
+ result.stderr.strip() or "not inside a Git working tree",
19
+ result.returncode,
20
+ )
21
+ self.root = Path(result.stdout.strip()).resolve()
22
+
23
+ @staticmethod
24
+ def _run_at(
25
+ path: Path,
26
+ args: tuple[str, ...],
27
+ *,
28
+ check: bool = True,
29
+ ) -> subprocess.CompletedProcess[str]:
30
+ try:
31
+ result = subprocess.run(
32
+ ("git", *args),
33
+ cwd=path,
34
+ text=True,
35
+ capture_output=True,
36
+ check=False,
37
+ )
38
+ except OSError as error:
39
+ command = GitCommand(args, "Read repository state", state_changing=False)
40
+ raise CommandError(command.display, str(error), 127) from error
41
+ if check and result.returncode != 0:
42
+ command = GitCommand(args, "Read repository state", state_changing=False)
43
+ raise CommandError(
44
+ command.display,
45
+ result.stderr.strip() or result.stdout.strip() or "unknown Git error",
46
+ result.returncode,
47
+ )
48
+ return result
49
+
50
+ def run(
51
+ self,
52
+ *args: str,
53
+ check: bool = True,
54
+ ) -> subprocess.CompletedProcess[str]:
55
+ return self._run_at(self.root, tuple(args), check=check)
56
+
57
+ def output(self, *args: str) -> str:
58
+ return self.run(*args).stdout.strip()
59
+
60
+ def git_path(self, name: str) -> Path:
61
+ path = Path(self.output("rev-parse", "--git-path", name))
62
+ return path if path.is_absolute() else self.root / path
63
+
64
+ def head(self) -> str:
65
+ return self.output("rev-parse", "--verify", "HEAD")
66
+
67
+ def head_parent(self) -> str:
68
+ result = self.run("rev-parse", "--verify", "HEAD^", check=False)
69
+ if result.returncode != 0:
70
+ raise CommandError(
71
+ "git rev-parse --verify 'HEAD^'",
72
+ "the current commit has no parent",
73
+ result.returncode,
74
+ )
75
+ return result.stdout.strip()
76
+
77
+ def head_parent_count(self) -> int:
78
+ fields = self.output("rev-list", "--parents", "-n", "1", "HEAD").split()
79
+ return max(0, len(fields) - 1)
80
+
81
+ def current_branch(self) -> str | None:
82
+ result = self.run("symbolic-ref", "--quiet", "--short", "HEAD", check=False)
83
+ return result.stdout.strip() if result.returncode == 0 else None
84
+
85
+ def branch_exists(self, name: str) -> bool:
86
+ return self.run("show-ref", "--verify", "--quiet", f"refs/heads/{name}", check=False).returncode == 0
87
+
88
+ def valid_branch_name(self, name: str) -> bool:
89
+ return self.run("check-ref-format", "--branch", name, check=False).returncode == 0
90
+
91
+ def is_dirty(self) -> bool:
92
+ return bool(self.output("status", "--porcelain=v1", "--untracked-files=normal"))
93
+
94
+ def has_staged_changes(self) -> bool:
95
+ return self.run("diff", "--cached", "--quiet", check=False).returncode != 0
96
+
97
+ def staged_paths(self) -> set[str]:
98
+ output = self.run("diff", "--cached", "--name-only", "-z").stdout
99
+ return {path for path in output.split("\0") if path}
100
+
101
+ def has_unmerged_paths(self) -> bool:
102
+ return bool(self.output("diff", "--name-only", "--diff-filter=U"))
103
+
104
+ def upstream_divergence(self) -> tuple[int, int] | None:
105
+ status = self.output("status", "--porcelain=v2", "--branch")
106
+ for line in status.splitlines():
107
+ if line.startswith("# branch.ab "):
108
+ ahead_text, behind_text = line.removeprefix("# branch.ab ").split()
109
+ return int(ahead_text), abs(int(behind_text))
110
+ return None
111
+
112
+ def head_is_published(self) -> bool:
113
+ if self.current_branch() is None:
114
+ return False
115
+ upstream = self.run("rev-parse", "--verify", "@{upstream}", check=False)
116
+ if upstream.returncode != 0:
117
+ return False
118
+ return self.run("merge-base", "--is-ancestor", "HEAD", "@{upstream}", check=False).returncode == 0
119
+
120
+ def head_is_upstream_tip(self) -> bool:
121
+ upstream = self.run("rev-parse", "--verify", "@{upstream}", check=False)
122
+ return upstream.returncode == 0 and self.head() == upstream.stdout.strip()
123
+
124
+ def tracked(self, path: str) -> bool:
125
+ literal_path = f":(literal){path}"
126
+ result = self.output("ls-tree", "-r", "--name-only", "HEAD", "--", literal_path)
127
+ return path in result.splitlines()
128
+
129
+ def path_has_changes(self, path: str) -> bool:
130
+ literal_path = f":(literal){path}"
131
+ return bool(
132
+ self.output(
133
+ "status",
134
+ "--porcelain=v1",
135
+ "--untracked-files=normal",
136
+ "--",
137
+ literal_path,
138
+ )
139
+ )
140
+
141
+ def repository_ignore_source(self, path: str) -> str | None:
142
+ result = self.run("check-ignore", "--verbose", "--no-index", "--", path, check=False)
143
+ if result.returncode != 0 or not result.stdout:
144
+ return None
145
+ metadata = result.stdout.split("\t", 1)[0]
146
+ parts = metadata.rsplit(":", 2)
147
+ if len(parts) != 3:
148
+ return None
149
+ source = Path(parts[0])
150
+ candidate = source if source.is_absolute() else self.root / source
151
+ try:
152
+ relative = candidate.resolve().relative_to(self.root)
153
+ except ValueError:
154
+ return None
155
+ if relative.name != ".gitignore":
156
+ return None
157
+ return relative.as_posix()
158
+
159
+ def index_tracks(self, path: str) -> bool:
160
+ literal_path = f":(literal){path}"
161
+ return self.run("ls-files", "--error-unmatch", "--", literal_path, check=False).returncode == 0
162
+
163
+ def has_unstaged_changes(self, path: str) -> bool:
164
+ literal_path = f":(literal){path}"
165
+ return self.run("diff", "--quiet", "--", literal_path, check=False).returncode != 0
166
+
167
+ def reflog(self, limit: int = 15) -> list[tuple[str, str, str]]:
168
+ result = self.output(
169
+ "reflog",
170
+ "show",
171
+ f"-{limit}",
172
+ "--format=%H%x09%gd%x09%gs",
173
+ )
174
+ entries: list[tuple[str, str, str]] = []
175
+ for line in result.splitlines():
176
+ parts = line.split("\t", 2)
177
+ if len(parts) == 3:
178
+ entries.append((parts[0], parts[1], parts[2]))
179
+ return entries
180
+
181
+
182
+ class GitExecutor:
183
+ """The only component permitted to execute mutating Git commands."""
184
+
185
+ def __init__(self, repository: GitRepository) -> None:
186
+ self.repository = repository
187
+
188
+ def execute(
189
+ self,
190
+ plan: RecoveryPlan,
191
+ *,
192
+ confirmed: bool,
193
+ dry_run: bool = False,
194
+ ) -> list[str]:
195
+ displays = [command.display for command in plan.commands]
196
+ if dry_run:
197
+ return displays
198
+ if any(command.state_changing for command in plan.commands) and not confirmed:
199
+ from git_panic.models import ConfirmationRequired
200
+
201
+ raise ConfirmationRequired("Explicit confirmation is required before changing repository state.")
202
+
203
+ completed: list[str] = []
204
+ for command in plan.commands:
205
+ if isinstance(command, FileAppend):
206
+ target = self.repository.root / command.path
207
+ try:
208
+ existing = target.read_text(encoding="utf-8") if target.exists() else ""
209
+ prefix = "" if not existing or existing.endswith("\n") else "\n"
210
+ with target.open("a", encoding="utf-8") as stream:
211
+ stream.write(f"{prefix}{command.line}\n")
212
+ except OSError as error:
213
+ raise CommandError(command.display, str(error), 1) from error
214
+ else:
215
+ self.repository.run(*command.args)
216
+ completed.append(command.display)
217
+ return completed
git_panic/models.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+
7
+
8
+ class WorkflowKind(str, Enum):
9
+ WRONG_BRANCH = "wrong-branch"
10
+ AMEND_CHANGES = "amend-changes"
11
+ SENSITIVE_FILE = "sensitive-file"
12
+ UNDO_KEEP_CHANGES = "undo-keep-changes"
13
+ FIX_MESSAGE = "fix-message"
14
+ DISCARD_CHANGES = "discard-changes"
15
+ DELETED_FILE = "deleted-file"
16
+ REVERT_PUBLISHED = "revert-published"
17
+ REFLOG_RESCUE = "reflog-rescue"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class GitCommand:
22
+ args: tuple[str, ...]
23
+ explanation: str
24
+ state_changing: bool = True
25
+
26
+ @property
27
+ def display(self) -> str:
28
+ return shlex.join(("git", *self.args))
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class FileAppend:
33
+ path: str
34
+ line: str
35
+ explanation: str
36
+ state_changing: bool = True
37
+
38
+ @property
39
+ def display(self) -> str:
40
+ return f"append {self.line!r} to {self.path}"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class RecoveryPlan:
45
+ workflow: WorkflowKind
46
+ title: str
47
+ summary: str
48
+ backup_ref: str | None
49
+ commands: tuple[GitCommand | FileAppend, ...]
50
+ warnings: tuple[str, ...] = ()
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ReflogEntry:
55
+ commit: str
56
+ selector: str
57
+ subject: str
58
+
59
+
60
+ class GitPanicError(RuntimeError):
61
+ """Base error for errors that should be shown without a traceback."""
62
+
63
+
64
+ class SafetyError(GitPanicError):
65
+ """Raised when repository state makes an operation unsafe."""
66
+
67
+
68
+ class CommandError(GitPanicError):
69
+ def __init__(self, command: str, detail: str, returncode: int) -> None:
70
+ super().__init__(f"{command} failed ({returncode}): {detail}")
71
+ self.command = command
72
+ self.detail = detail
73
+ self.returncode = returncode
74
+
75
+
76
+ class ConfirmationRequired(GitPanicError):
77
+ """Raised when a mutating plan was not explicitly confirmed."""
git_panic/safety.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from git_panic.git import GitRepository
4
+ from git_panic.models import SafetyError
5
+
6
+
7
+ class SafetyValidator:
8
+ IN_PROGRESS_MARKERS = {
9
+ "MERGE_HEAD": "merge",
10
+ "CHERRY_PICK_HEAD": "cherry-pick",
11
+ "REVERT_HEAD": "revert",
12
+ "BISECT_LOG": "bisect",
13
+ "rebase-apply": "rebase",
14
+ "rebase-merge": "rebase",
15
+ }
16
+
17
+ def __init__(self, repository: GitRepository) -> None:
18
+ self.repository = repository
19
+
20
+ def validate_preflight(self) -> None:
21
+ active = {
22
+ label
23
+ for marker, label in self.IN_PROGRESS_MARKERS.items()
24
+ if self.repository.git_path(marker).exists()
25
+ }
26
+ if active:
27
+ operations = ", ".join(sorted(active))
28
+ raise SafetyError(
29
+ f"Git-Panic will not run during an active {operations} operation. "
30
+ "Finish or abort it manually, then retry."
31
+ )
32
+ if self.repository.has_unmerged_paths():
33
+ raise SafetyError(
34
+ "The repository has unresolved conflicts. Resolve them or abort the current operation first."
35
+ )
36
+ divergence = self.repository.upstream_divergence()
37
+ if divergence and divergence[0] > 0 and divergence[1] > 0:
38
+ raise SafetyError(
39
+ "The current branch has diverged from its upstream. Git-Panic's local-only workflows "
40
+ "cannot safely choose how to reconcile the remote history."
41
+ )
42
+
43
+ def require_attached_head(self) -> str:
44
+ branch = self.repository.current_branch()
45
+ if branch is None:
46
+ raise SafetyError("This workflow requires a named branch. Use Reflog Rescue for a detached HEAD.")
47
+ return branch
48
+
49
+ def require_clean_worktree(self, workflow: str) -> None:
50
+ if self.repository.is_dirty():
51
+ raise SafetyError(
52
+ f"{workflow} requires a clean working tree so existing changes cannot be mixed into recovery."
53
+ )
54
+
55
+ def refuse_published_head(self) -> None:
56
+ if self.repository.head_is_published():
57
+ raise SafetyError(
58
+ "The last commit appears to exist on the configured upstream. Git-Panic only rewrites "
59
+ "unpublished local history and will not alter shared history."
60
+ )
61
+
62
+ def require_published_head(self) -> None:
63
+ if not self.repository.head_is_upstream_tip():
64
+ raise SafetyError(
65
+ "The current commit is not the configured upstream tip. Update your remote-tracking state "
66
+ "and check out the latest published commit before creating a revert commit."
67
+ )