doit-toolkit-cli 0.1.10__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.
Potentially problematic release.
This version of doit-toolkit-cli might be problematic. Click here for more details.
- doit_cli/__init__.py +1356 -0
- doit_cli/cli/__init__.py +26 -0
- doit_cli/cli/analytics_command.py +616 -0
- doit_cli/cli/context_command.py +213 -0
- doit_cli/cli/diagram_command.py +304 -0
- doit_cli/cli/fixit_command.py +641 -0
- doit_cli/cli/hooks_command.py +211 -0
- doit_cli/cli/init_command.py +613 -0
- doit_cli/cli/memory_command.py +293 -0
- doit_cli/cli/roadmapit_command.py +10 -0
- doit_cli/cli/status_command.py +117 -0
- doit_cli/cli/sync_prompts_command.py +248 -0
- doit_cli/cli/validate_command.py +196 -0
- doit_cli/cli/verify_command.py +204 -0
- doit_cli/cli/workflow_mixin.py +224 -0
- doit_cli/cli/xref_command.py +555 -0
- doit_cli/formatters/__init__.py +8 -0
- doit_cli/formatters/base.py +38 -0
- doit_cli/formatters/json_formatter.py +126 -0
- doit_cli/formatters/markdown_formatter.py +97 -0
- doit_cli/formatters/rich_formatter.py +257 -0
- doit_cli/main.py +51 -0
- doit_cli/models/__init__.py +139 -0
- doit_cli/models/agent.py +74 -0
- doit_cli/models/analytics_models.py +384 -0
- doit_cli/models/context_config.py +464 -0
- doit_cli/models/crossref_models.py +182 -0
- doit_cli/models/diagram_models.py +363 -0
- doit_cli/models/fixit_models.py +355 -0
- doit_cli/models/hook_config.py +125 -0
- doit_cli/models/project.py +91 -0
- doit_cli/models/results.py +121 -0
- doit_cli/models/search_models.py +228 -0
- doit_cli/models/status_models.py +195 -0
- doit_cli/models/sync_models.py +146 -0
- doit_cli/models/template.py +77 -0
- doit_cli/models/validation_models.py +175 -0
- doit_cli/models/workflow_models.py +319 -0
- doit_cli/prompts/__init__.py +5 -0
- doit_cli/prompts/fixit_prompts.py +344 -0
- doit_cli/prompts/interactive.py +390 -0
- doit_cli/rules/__init__.py +5 -0
- doit_cli/rules/builtin_rules.py +160 -0
- doit_cli/services/__init__.py +79 -0
- doit_cli/services/agent_detector.py +168 -0
- doit_cli/services/analytics_service.py +218 -0
- doit_cli/services/architecture_generator.py +290 -0
- doit_cli/services/backup_service.py +204 -0
- doit_cli/services/config_loader.py +113 -0
- doit_cli/services/context_loader.py +1123 -0
- doit_cli/services/coverage_calculator.py +142 -0
- doit_cli/services/crossref_service.py +237 -0
- doit_cli/services/cycle_time_calculator.py +134 -0
- doit_cli/services/date_inferrer.py +349 -0
- doit_cli/services/diagram_service.py +337 -0
- doit_cli/services/drift_detector.py +109 -0
- doit_cli/services/entity_parser.py +301 -0
- doit_cli/services/er_diagram_generator.py +197 -0
- doit_cli/services/fixit_service.py +699 -0
- doit_cli/services/github_service.py +192 -0
- doit_cli/services/hook_manager.py +258 -0
- doit_cli/services/hook_validator.py +528 -0
- doit_cli/services/input_validator.py +322 -0
- doit_cli/services/memory_search.py +527 -0
- doit_cli/services/mermaid_validator.py +334 -0
- doit_cli/services/prompt_transformer.py +91 -0
- doit_cli/services/prompt_writer.py +133 -0
- doit_cli/services/query_interpreter.py +428 -0
- doit_cli/services/report_exporter.py +219 -0
- doit_cli/services/report_generator.py +256 -0
- doit_cli/services/requirement_parser.py +112 -0
- doit_cli/services/roadmap_summarizer.py +209 -0
- doit_cli/services/rule_engine.py +443 -0
- doit_cli/services/scaffolder.py +215 -0
- doit_cli/services/score_calculator.py +172 -0
- doit_cli/services/section_parser.py +204 -0
- doit_cli/services/spec_scanner.py +327 -0
- doit_cli/services/state_manager.py +355 -0
- doit_cli/services/status_reporter.py +143 -0
- doit_cli/services/task_parser.py +347 -0
- doit_cli/services/template_manager.py +710 -0
- doit_cli/services/template_reader.py +158 -0
- doit_cli/services/user_journey_generator.py +214 -0
- doit_cli/services/user_story_parser.py +232 -0
- doit_cli/services/validation_service.py +188 -0
- doit_cli/services/validator.py +232 -0
- doit_cli/services/velocity_tracker.py +173 -0
- doit_cli/services/workflow_engine.py +405 -0
- doit_cli/templates/agent-file-template.md +28 -0
- doit_cli/templates/checklist-template.md +39 -0
- doit_cli/templates/commands/doit.checkin.md +363 -0
- doit_cli/templates/commands/doit.constitution.md +187 -0
- doit_cli/templates/commands/doit.documentit.md +485 -0
- doit_cli/templates/commands/doit.fixit.md +181 -0
- doit_cli/templates/commands/doit.implementit.md +265 -0
- doit_cli/templates/commands/doit.planit.md +262 -0
- doit_cli/templates/commands/doit.reviewit.md +355 -0
- doit_cli/templates/commands/doit.roadmapit.md +389 -0
- doit_cli/templates/commands/doit.scaffoldit.md +458 -0
- doit_cli/templates/commands/doit.specit.md +521 -0
- doit_cli/templates/commands/doit.taskit.md +304 -0
- doit_cli/templates/commands/doit.testit.md +277 -0
- doit_cli/templates/config/context.yaml +134 -0
- doit_cli/templates/config/hooks.yaml +93 -0
- doit_cli/templates/config/validation-rules.yaml +64 -0
- doit_cli/templates/github-issue-templates/epic.yml +78 -0
- doit_cli/templates/github-issue-templates/feature.yml +116 -0
- doit_cli/templates/github-issue-templates/task.yml +129 -0
- doit_cli/templates/hooks/.gitkeep +0 -0
- doit_cli/templates/hooks/post-commit.sh +25 -0
- doit_cli/templates/hooks/post-merge.sh +75 -0
- doit_cli/templates/hooks/pre-commit.sh +17 -0
- doit_cli/templates/hooks/pre-push.sh +18 -0
- doit_cli/templates/memory/completed_roadmap.md +50 -0
- doit_cli/templates/memory/constitution.md +125 -0
- doit_cli/templates/memory/roadmap.md +61 -0
- doit_cli/templates/plan-template.md +146 -0
- doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
- doit_cli/templates/scripts/bash/common.sh +156 -0
- doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
- doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
- doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
- doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
- doit_cli/templates/scripts/powershell/common.ps1 +137 -0
- doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
- doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
- doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
- doit_cli/templates/spec-template.md +159 -0
- doit_cli/templates/tasks-template.md +313 -0
- doit_cli/templates/vscode-settings.json +14 -0
- doit_toolkit_cli-0.1.10.dist-info/METADATA +324 -0
- doit_toolkit_cli-0.1.10.dist-info/RECORD +135 -0
- doit_toolkit_cli-0.1.10.dist-info/WHEEL +4 -0
- doit_toolkit_cli-0.1.10.dist-info/entry_points.txt +2 -0
- doit_toolkit_cli-0.1.10.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Service for GitHub issue operations via gh CLI.
|
|
2
|
+
|
|
3
|
+
This module provides the GitHubService class for interacting
|
|
4
|
+
with GitHub issues using the gh CLI tool.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from ..models.fixit_models import GitHubIssue
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitHubServiceError(Exception):
|
|
15
|
+
"""Error raised when GitHub operations fail."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GitHubService:
|
|
21
|
+
"""Manages GitHub issue operations via gh CLI."""
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
"""Initialize the GitHub service."""
|
|
25
|
+
self._verify_gh_cli()
|
|
26
|
+
|
|
27
|
+
def _verify_gh_cli(self) -> None:
|
|
28
|
+
"""Verify that gh CLI is available and authenticated."""
|
|
29
|
+
try:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
["gh", "auth", "status"],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
)
|
|
35
|
+
if result.returncode != 0:
|
|
36
|
+
# gh CLI not authenticated, but might still work for public repos
|
|
37
|
+
pass
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
raise GitHubServiceError(
|
|
40
|
+
"GitHub CLI (gh) not found. Install it from https://cli.github.com/"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def is_available(self) -> bool:
|
|
44
|
+
"""Check if GitHub API is available."""
|
|
45
|
+
try:
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["gh", "api", "rate_limit"],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
timeout=5,
|
|
51
|
+
)
|
|
52
|
+
return result.returncode == 0
|
|
53
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
def get_issue(self, issue_id: int) -> Optional[GitHubIssue]:
|
|
57
|
+
"""Fetch a GitHub issue by ID.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
issue_id: The GitHub issue number.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
GitHubIssue if found, None otherwise.
|
|
64
|
+
"""
|
|
65
|
+
result = subprocess.run(
|
|
66
|
+
[
|
|
67
|
+
"gh", "issue", "view", str(issue_id),
|
|
68
|
+
"--json", "number,title,body,state,labels"
|
|
69
|
+
],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
)
|
|
73
|
+
if result.returncode != 0:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(result.stdout)
|
|
78
|
+
return GitHubIssue.from_dict(data)
|
|
79
|
+
except (json.JSONDecodeError, KeyError):
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def list_bugs(self, label: str = "bug", limit: int = 20) -> list[GitHubIssue]:
|
|
83
|
+
"""List open issues with specified label.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
label: Label to filter by (default: "bug").
|
|
87
|
+
limit: Maximum number of issues to return.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of GitHubIssue objects.
|
|
91
|
+
"""
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
[
|
|
94
|
+
"gh", "issue", "list",
|
|
95
|
+
"--label", label,
|
|
96
|
+
"--state", "open",
|
|
97
|
+
"--limit", str(limit),
|
|
98
|
+
"--json", "number,title,body,state,labels"
|
|
99
|
+
],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
)
|
|
103
|
+
if result.returncode != 0:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(result.stdout)
|
|
108
|
+
return [GitHubIssue.from_dict(item) for item in data]
|
|
109
|
+
except (json.JSONDecodeError, KeyError):
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
def close_issue(self, issue_id: int, comment: str = "") -> bool:
|
|
113
|
+
"""Close a GitHub issue.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
issue_id: The GitHub issue number.
|
|
117
|
+
comment: Optional comment to add when closing.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if successful, False otherwise.
|
|
121
|
+
"""
|
|
122
|
+
cmd = ["gh", "issue", "close", str(issue_id)]
|
|
123
|
+
if comment:
|
|
124
|
+
cmd.extend(["--comment", comment])
|
|
125
|
+
|
|
126
|
+
result = subprocess.run(cmd, capture_output=True)
|
|
127
|
+
return result.returncode == 0
|
|
128
|
+
|
|
129
|
+
def add_comment(self, issue_id: int, comment: str) -> bool:
|
|
130
|
+
"""Add a comment to a GitHub issue.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
issue_id: The GitHub issue number.
|
|
134
|
+
comment: The comment text.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
True if successful, False otherwise.
|
|
138
|
+
"""
|
|
139
|
+
result = subprocess.run(
|
|
140
|
+
["gh", "issue", "comment", str(issue_id), "--body", comment],
|
|
141
|
+
capture_output=True,
|
|
142
|
+
)
|
|
143
|
+
return result.returncode == 0
|
|
144
|
+
|
|
145
|
+
def check_branch_exists(self, branch_name: str) -> tuple[bool, bool]:
|
|
146
|
+
"""Check if a branch exists locally or remotely.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
branch_name: The branch name to check.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Tuple of (local_exists, remote_exists).
|
|
153
|
+
"""
|
|
154
|
+
# Check local
|
|
155
|
+
local_result = subprocess.run(
|
|
156
|
+
["git", "show-ref", "--verify", f"refs/heads/{branch_name}"],
|
|
157
|
+
capture_output=True,
|
|
158
|
+
)
|
|
159
|
+
local_exists = local_result.returncode == 0
|
|
160
|
+
|
|
161
|
+
# Check remote
|
|
162
|
+
remote_result = subprocess.run(
|
|
163
|
+
["git", "ls-remote", "--heads", "origin", branch_name],
|
|
164
|
+
capture_output=True,
|
|
165
|
+
text=True,
|
|
166
|
+
)
|
|
167
|
+
remote_exists = bool(remote_result.stdout.strip())
|
|
168
|
+
|
|
169
|
+
return local_exists, remote_exists
|
|
170
|
+
|
|
171
|
+
def create_branch(self, branch_name: str, from_branch: str = "main") -> bool:
|
|
172
|
+
"""Create a new git branch.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
branch_name: Name of the new branch.
|
|
176
|
+
from_branch: Base branch to create from.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
True if successful, False otherwise.
|
|
180
|
+
"""
|
|
181
|
+
# First try to checkout the base branch
|
|
182
|
+
subprocess.run(
|
|
183
|
+
["git", "checkout", from_branch],
|
|
184
|
+
capture_output=True,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Create and checkout the new branch
|
|
188
|
+
result = subprocess.run(
|
|
189
|
+
["git", "checkout", "-b", branch_name],
|
|
190
|
+
capture_output=True,
|
|
191
|
+
)
|
|
192
|
+
return result.returncode == 0
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Hook manager service for installing and managing Git hooks."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import stat
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from importlib import resources
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HookManager:
|
|
14
|
+
"""Manages Git hook installation, backup, and restoration."""
|
|
15
|
+
|
|
16
|
+
HOOK_NAMES = ["pre-commit", "pre-push", "post-commit", "post-merge"]
|
|
17
|
+
BACKUP_DIR = ".doit/backups/hooks"
|
|
18
|
+
MANIFEST_FILE = "manifest.json"
|
|
19
|
+
|
|
20
|
+
def __init__(self, project_root: Optional[Path] = None):
|
|
21
|
+
"""Initialize the hook manager.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
project_root: Root directory of the project. Defaults to current directory.
|
|
25
|
+
"""
|
|
26
|
+
self.project_root = project_root or Path.cwd()
|
|
27
|
+
self.git_hooks_dir = self.project_root / ".git" / "hooks"
|
|
28
|
+
self.backup_dir = self.project_root / self.BACKUP_DIR
|
|
29
|
+
|
|
30
|
+
def is_git_repo(self) -> bool:
|
|
31
|
+
"""Check if the project is a Git repository."""
|
|
32
|
+
return (self.project_root / ".git").is_dir()
|
|
33
|
+
|
|
34
|
+
def get_template_path(self, hook_name: str) -> Optional[Path]:
|
|
35
|
+
"""Get the path to a hook template.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
hook_name: Name of the hook (e.g., 'pre-commit').
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Path to the template file, or None if not found.
|
|
42
|
+
"""
|
|
43
|
+
# Try to get from package resources
|
|
44
|
+
try:
|
|
45
|
+
with resources.files("doit_cli.templates.hooks") as templates_dir:
|
|
46
|
+
template_path = Path(templates_dir) / f"{hook_name}.sh"
|
|
47
|
+
if template_path.exists():
|
|
48
|
+
return template_path
|
|
49
|
+
except (TypeError, FileNotFoundError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
# Fallback: try relative path from this file
|
|
53
|
+
fallback_path = (
|
|
54
|
+
Path(__file__).parent.parent / "templates" / "hooks" / f"{hook_name}.sh"
|
|
55
|
+
)
|
|
56
|
+
if fallback_path.exists():
|
|
57
|
+
return fallback_path
|
|
58
|
+
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def install_hooks(
|
|
62
|
+
self, backup: bool = False, force: bool = False
|
|
63
|
+
) -> tuple[list[str], list[str]]:
|
|
64
|
+
"""Install Git hooks.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
backup: Whether to backup existing hooks before installing.
|
|
68
|
+
force: Whether to overwrite existing hooks without prompting.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Tuple of (installed hooks, skipped hooks).
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
RuntimeError: If not in a Git repository.
|
|
75
|
+
"""
|
|
76
|
+
if not self.is_git_repo():
|
|
77
|
+
raise RuntimeError("Not a Git repository. Run 'git init' first.")
|
|
78
|
+
|
|
79
|
+
# Ensure hooks directory exists
|
|
80
|
+
self.git_hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
|
|
82
|
+
installed = []
|
|
83
|
+
skipped = []
|
|
84
|
+
|
|
85
|
+
for hook_name in self.HOOK_NAMES:
|
|
86
|
+
template_path = self.get_template_path(hook_name)
|
|
87
|
+
if template_path is None:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
hook_path = self.git_hooks_dir / hook_name
|
|
91
|
+
|
|
92
|
+
# Check if hook already exists
|
|
93
|
+
if hook_path.exists():
|
|
94
|
+
if backup:
|
|
95
|
+
self._backup_hook(hook_name)
|
|
96
|
+
elif not force:
|
|
97
|
+
skipped.append(hook_name)
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
# Copy template to hooks directory
|
|
101
|
+
shutil.copy2(template_path, hook_path)
|
|
102
|
+
|
|
103
|
+
# Make executable
|
|
104
|
+
hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
105
|
+
|
|
106
|
+
installed.append(hook_name)
|
|
107
|
+
|
|
108
|
+
return installed, skipped
|
|
109
|
+
|
|
110
|
+
def uninstall_hooks(self) -> list[str]:
|
|
111
|
+
"""Remove installed Git hooks.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
List of removed hook names.
|
|
115
|
+
"""
|
|
116
|
+
removed = []
|
|
117
|
+
|
|
118
|
+
for hook_name in self.HOOK_NAMES:
|
|
119
|
+
hook_path = self.git_hooks_dir / hook_name
|
|
120
|
+
if hook_path.exists():
|
|
121
|
+
# Check if it's our hook by looking for the marker comment
|
|
122
|
+
content = hook_path.read_text()
|
|
123
|
+
if "installed by doit" in content.lower():
|
|
124
|
+
hook_path.unlink()
|
|
125
|
+
removed.append(hook_name)
|
|
126
|
+
|
|
127
|
+
return removed
|
|
128
|
+
|
|
129
|
+
def _backup_hook(self, hook_name: str) -> Optional[Path]:
|
|
130
|
+
"""Backup an existing hook.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
hook_name: Name of the hook to backup.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Path to the backup file, or None if hook doesn't exist.
|
|
137
|
+
"""
|
|
138
|
+
hook_path = self.git_hooks_dir / hook_name
|
|
139
|
+
if not hook_path.exists():
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
# Create backup directory
|
|
143
|
+
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
|
|
145
|
+
# Generate timestamp for backup filename
|
|
146
|
+
timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
|
147
|
+
backup_name = f"{hook_name}.{timestamp}.bak"
|
|
148
|
+
backup_path = self.backup_dir / backup_name
|
|
149
|
+
|
|
150
|
+
# Copy the hook
|
|
151
|
+
shutil.copy2(hook_path, backup_path)
|
|
152
|
+
|
|
153
|
+
# Update manifest
|
|
154
|
+
self._update_manifest(hook_name, timestamp)
|
|
155
|
+
|
|
156
|
+
return backup_path
|
|
157
|
+
|
|
158
|
+
def _update_manifest(self, hook_name: str, timestamp: str) -> None:
|
|
159
|
+
"""Update the backup manifest file.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
hook_name: Name of the backed up hook.
|
|
163
|
+
timestamp: Timestamp of the backup.
|
|
164
|
+
"""
|
|
165
|
+
manifest_path = self.backup_dir / self.MANIFEST_FILE
|
|
166
|
+
|
|
167
|
+
if manifest_path.exists():
|
|
168
|
+
with open(manifest_path, encoding="utf-8") as f:
|
|
169
|
+
manifest = json.load(f)
|
|
170
|
+
else:
|
|
171
|
+
manifest = {"latest_backup": None, "backups": []}
|
|
172
|
+
|
|
173
|
+
# Add new backup entry
|
|
174
|
+
manifest["latest_backup"] = timestamp
|
|
175
|
+
|
|
176
|
+
# Find or create entry for this timestamp
|
|
177
|
+
existing_entry = next(
|
|
178
|
+
(b for b in manifest["backups"] if b["timestamp"] == timestamp),
|
|
179
|
+
None
|
|
180
|
+
)
|
|
181
|
+
if existing_entry:
|
|
182
|
+
if hook_name not in existing_entry["hooks"]:
|
|
183
|
+
existing_entry["hooks"].append(hook_name)
|
|
184
|
+
else:
|
|
185
|
+
manifest["backups"].append({
|
|
186
|
+
"timestamp": timestamp,
|
|
187
|
+
"hooks": [hook_name]
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
with open(manifest_path, "w", encoding="utf-8") as f:
|
|
191
|
+
json.dump(manifest, f, indent=2)
|
|
192
|
+
|
|
193
|
+
def restore_hooks(self, timestamp: Optional[str] = None) -> list[str]:
|
|
194
|
+
"""Restore hooks from backup.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
timestamp: Specific backup timestamp to restore. Defaults to latest.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
List of restored hook names.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
RuntimeError: If no backups exist.
|
|
204
|
+
"""
|
|
205
|
+
manifest_path = self.backup_dir / self.MANIFEST_FILE
|
|
206
|
+
|
|
207
|
+
if not manifest_path.exists():
|
|
208
|
+
raise RuntimeError("No hook backups found.")
|
|
209
|
+
|
|
210
|
+
with open(manifest_path, encoding="utf-8") as f:
|
|
211
|
+
manifest = json.load(f)
|
|
212
|
+
|
|
213
|
+
if not manifest.get("backups"):
|
|
214
|
+
raise RuntimeError("No hook backups found.")
|
|
215
|
+
|
|
216
|
+
# Use specified timestamp or latest
|
|
217
|
+
target_timestamp = timestamp or manifest.get("latest_backup")
|
|
218
|
+
if not target_timestamp:
|
|
219
|
+
raise RuntimeError("No backup timestamp available.")
|
|
220
|
+
|
|
221
|
+
# Find the backup entry
|
|
222
|
+
backup_entry = next(
|
|
223
|
+
(b for b in manifest["backups"] if b["timestamp"] == target_timestamp),
|
|
224
|
+
None
|
|
225
|
+
)
|
|
226
|
+
if not backup_entry:
|
|
227
|
+
raise RuntimeError(f"No backup found for timestamp: {target_timestamp}")
|
|
228
|
+
|
|
229
|
+
restored = []
|
|
230
|
+
for hook_name in backup_entry["hooks"]:
|
|
231
|
+
backup_path = self.backup_dir / f"{hook_name}.{target_timestamp}.bak"
|
|
232
|
+
if backup_path.exists():
|
|
233
|
+
hook_path = self.git_hooks_dir / hook_name
|
|
234
|
+
shutil.copy2(backup_path, hook_path)
|
|
235
|
+
# Make executable
|
|
236
|
+
hook_path.chmod(
|
|
237
|
+
hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
238
|
+
)
|
|
239
|
+
restored.append(hook_name)
|
|
240
|
+
|
|
241
|
+
return restored
|
|
242
|
+
|
|
243
|
+
def get_installed_hooks(self) -> list[str]:
|
|
244
|
+
"""Get list of currently installed doit hooks.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of installed hook names.
|
|
248
|
+
"""
|
|
249
|
+
installed = []
|
|
250
|
+
|
|
251
|
+
for hook_name in self.HOOK_NAMES:
|
|
252
|
+
hook_path = self.git_hooks_dir / hook_name
|
|
253
|
+
if hook_path.exists():
|
|
254
|
+
content = hook_path.read_text()
|
|
255
|
+
if "installed by doit" in content.lower():
|
|
256
|
+
installed.append(hook_name)
|
|
257
|
+
|
|
258
|
+
return installed
|