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/__init__.py
ADDED
git_panic/__main__.py
ADDED
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
|
+
)
|