codetour-cli 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.
- codetour_cli/__init__.py +9 -0
- codetour_cli/cli.py +2087 -0
- codetour_cli/config.py +144 -0
- codetour_cli/description_validator.py +197 -0
- codetour_cli/detection.py +91 -0
- codetour_cli/discovery.py +226 -0
- codetour_cli/lint.py +334 -0
- codetour_cli/logging/__init__.py +6 -0
- codetour_cli/logging/setup.py +63 -0
- codetour_cli/migration/__init__.py +6 -0
- codetour_cli/migration/direct.py +385 -0
- codetour_cli/migration/git_diff.py +418 -0
- codetour_cli/migration/stepwise.py +400 -0
- codetour_cli/review_parser.py +348 -0
- codetour_cli/schema_models.py +203 -0
- codetour_cli/tour/__init__.py +6 -0
- codetour_cli/tour/schema.py +255 -0
- codetour_cli/tour/updater.py +428 -0
- codetour_cli-0.1.0.dist-info/METADATA +47 -0
- codetour_cli-0.1.0.dist-info/RECORD +24 -0
- codetour_cli-0.1.0.dist-info/WHEEL +5 -0
- codetour_cli-0.1.0.dist-info/entry_points.txt +2 -0
- codetour_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- codetour_cli-0.1.0.dist-info/top_level.txt +1 -0
codetour_cli/config.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for CodeTour CLI.
|
|
3
|
+
|
|
4
|
+
Handles loading, merging, and saving configuration files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, Dict, Any
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
log = structlog.get_logger()
|
|
13
|
+
|
|
14
|
+
# Default configuration
|
|
15
|
+
DEFAULT_CONFIG = {
|
|
16
|
+
"tours_dir": ".tours",
|
|
17
|
+
"migration": {
|
|
18
|
+
"add_ref": True,
|
|
19
|
+
"backup": True,
|
|
20
|
+
"threshold": 0.7,
|
|
21
|
+
"diff_algorithm": "histogram",
|
|
22
|
+
"interactive": False,
|
|
23
|
+
},
|
|
24
|
+
"display": {
|
|
25
|
+
"color": True,
|
|
26
|
+
"verbosity": "normal",
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
CONFIG_TEMPLATE = """# CodeTour CLI Configuration
|
|
31
|
+
# Generated: {date}
|
|
32
|
+
|
|
33
|
+
# Tours location
|
|
34
|
+
tours_dir: .tours
|
|
35
|
+
|
|
36
|
+
# Migration behavior
|
|
37
|
+
migration:
|
|
38
|
+
# Auto-inject 'ref' field after migration
|
|
39
|
+
add_ref: true
|
|
40
|
+
|
|
41
|
+
# Create backup files
|
|
42
|
+
backup: true
|
|
43
|
+
|
|
44
|
+
# Confidence threshold for auto-applying changes
|
|
45
|
+
threshold: 0.7
|
|
46
|
+
|
|
47
|
+
# Git diff algorithm (myers, minimal, patience, histogram)
|
|
48
|
+
diff_algorithm: histogram
|
|
49
|
+
|
|
50
|
+
# Interactive mode by default
|
|
51
|
+
interactive: false
|
|
52
|
+
|
|
53
|
+
# Display preferences
|
|
54
|
+
display:
|
|
55
|
+
# Use colors and emojis in output
|
|
56
|
+
color: true
|
|
57
|
+
|
|
58
|
+
# Verbosity level (quiet, normal, verbose)
|
|
59
|
+
verbosity: normal
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def generate_config_file(path: Path) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Generate a default configuration file.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
path: Path where to save the config file
|
|
69
|
+
"""
|
|
70
|
+
from datetime import datetime
|
|
71
|
+
|
|
72
|
+
config_content = CONFIG_TEMPLATE.format(date=datetime.now().strftime("%Y-%m-%d"))
|
|
73
|
+
|
|
74
|
+
path.write_text(config_content)
|
|
75
|
+
log.info("generated_config", path=str(path))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_config(config_path: Optional[Path] = None) -> Dict[str, Any]:
|
|
79
|
+
"""
|
|
80
|
+
Load configuration from file.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
config_path: Path to config file (default: search for codetour.yaml)
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Configuration dictionary (merged with defaults)
|
|
87
|
+
"""
|
|
88
|
+
config = DEFAULT_CONFIG.copy()
|
|
89
|
+
|
|
90
|
+
# Find config file if not specified
|
|
91
|
+
if config_path is None:
|
|
92
|
+
# Try .tours directory first (most discrete, project-specific)
|
|
93
|
+
tours_config = Path.cwd() / ".tours" / "codetour.yaml"
|
|
94
|
+
if tours_config.exists():
|
|
95
|
+
config_path = tours_config
|
|
96
|
+
else:
|
|
97
|
+
# Try current directory
|
|
98
|
+
config_path = Path.cwd() / "codetour.yaml"
|
|
99
|
+
if not config_path.exists():
|
|
100
|
+
# Try user config
|
|
101
|
+
user_config = Path.home() / ".config" / "codetour-cli" / "config.yaml"
|
|
102
|
+
if user_config.exists():
|
|
103
|
+
config_path = user_config
|
|
104
|
+
else:
|
|
105
|
+
# No config file found, use defaults
|
|
106
|
+
return config
|
|
107
|
+
|
|
108
|
+
if not config_path.exists():
|
|
109
|
+
return config
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
with open(config_path) as f:
|
|
113
|
+
user_config = yaml.safe_load(f) or {}
|
|
114
|
+
|
|
115
|
+
# Merge with defaults (user config takes precedence)
|
|
116
|
+
config = merge_config(config, user_config)
|
|
117
|
+
|
|
118
|
+
log.info("loaded_config", path=str(config_path))
|
|
119
|
+
except Exception as e:
|
|
120
|
+
log.warning("failed_to_load_config", path=str(config_path), error=str(e))
|
|
121
|
+
|
|
122
|
+
return config
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
126
|
+
"""
|
|
127
|
+
Merge configuration dictionaries recursively.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
base: Base configuration
|
|
131
|
+
override: Override configuration (takes precedence)
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Merged configuration
|
|
135
|
+
"""
|
|
136
|
+
result = base.copy()
|
|
137
|
+
|
|
138
|
+
for key, value in override.items():
|
|
139
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
140
|
+
result[key] = merge_config(result[key], value)
|
|
141
|
+
else:
|
|
142
|
+
result[key] = value
|
|
143
|
+
|
|
144
|
+
return result
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Description edit validation for review reports.
|
|
3
|
+
|
|
4
|
+
Provides safety checks for edited descriptions to catch copy-paste errors,
|
|
5
|
+
placeholder text, and suspicious changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import difflib
|
|
10
|
+
from typing import Tuple, List
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ValidationResult:
|
|
16
|
+
"""Result of description validation."""
|
|
17
|
+
is_valid: bool # False if hard errors found
|
|
18
|
+
errors: List[str] # Tier 1: Hard errors (prevent apply)
|
|
19
|
+
warnings: List[str] # Tier 2: Warnings (prompt user, allow with --yes)
|
|
20
|
+
info: List[str] # Tier 3: Info (just log)
|
|
21
|
+
similarity: float # 0.0 to 1.0
|
|
22
|
+
len_ratio: float # edited_len / original_len
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def calculate_similarity(text1: str, text2: str) -> float:
|
|
26
|
+
"""
|
|
27
|
+
Calculate text similarity using SequenceMatcher.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
text1: First text
|
|
31
|
+
text2: Second text
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Similarity ratio from 0.0 (completely different) to 1.0 (identical)
|
|
35
|
+
"""
|
|
36
|
+
if not text1 and not text2:
|
|
37
|
+
return 1.0
|
|
38
|
+
if not text1 or not text2:
|
|
39
|
+
return 0.0
|
|
40
|
+
|
|
41
|
+
return difflib.SequenceMatcher(None, text1, text2).ratio()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check_structure_preserved(original: str, edited: str) -> List[str]:
|
|
45
|
+
"""
|
|
46
|
+
Check if structural markdown elements are preserved.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
original: Original description
|
|
50
|
+
edited: Edited description
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
List of warning messages about missing structural elements
|
|
54
|
+
"""
|
|
55
|
+
warnings = []
|
|
56
|
+
|
|
57
|
+
# Code blocks
|
|
58
|
+
orig_code = original.count('```')
|
|
59
|
+
edit_code = edited.count('```')
|
|
60
|
+
if orig_code > 0 and edit_code == 0:
|
|
61
|
+
warnings.append("Original had code blocks, edited version doesn't")
|
|
62
|
+
|
|
63
|
+
# Bullet lists (significant number) - handle indented bullets
|
|
64
|
+
orig_lists = len(re.findall(r'\n\s*[-*] ', original))
|
|
65
|
+
edit_lists = len(re.findall(r'\n\s*[-*] ', edited))
|
|
66
|
+
if orig_lists > 3 and edit_lists == 0:
|
|
67
|
+
warnings.append("Original had bullet lists, edited version doesn't")
|
|
68
|
+
|
|
69
|
+
# Markdown links
|
|
70
|
+
orig_links = original.count('](')
|
|
71
|
+
edit_links = edited.count('](')
|
|
72
|
+
if orig_links > 0 and edit_links == 0:
|
|
73
|
+
warnings.append("Original had markdown links, edited version doesn't")
|
|
74
|
+
|
|
75
|
+
# Headers - handle indented headers
|
|
76
|
+
orig_headers = len(re.findall(r'\n\s*##+ ', original))
|
|
77
|
+
edit_headers = len(re.findall(r'\n\s*##+ ', edited))
|
|
78
|
+
if orig_headers > 0 and edit_headers == 0:
|
|
79
|
+
warnings.append("Original had section headers, edited version doesn't")
|
|
80
|
+
|
|
81
|
+
return warnings
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_edited_description(
|
|
85
|
+
edited_desc: str,
|
|
86
|
+
original_desc: str
|
|
87
|
+
) -> ValidationResult:
|
|
88
|
+
"""
|
|
89
|
+
Validate edited description for common errors and suspicious changes.
|
|
90
|
+
|
|
91
|
+
Implements 3-tier validation:
|
|
92
|
+
- Tier 1 (Errors): Hard failures that prevent apply
|
|
93
|
+
- Tier 2 (Warnings): Suspicious changes that prompt user
|
|
94
|
+
- Tier 3 (Info): Informational metrics (just logged)
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
edited_desc: User-edited description from review report
|
|
98
|
+
original_desc: Original tour step description
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
ValidationResult with errors, warnings, and metrics
|
|
102
|
+
"""
|
|
103
|
+
errors = []
|
|
104
|
+
warnings = []
|
|
105
|
+
info = []
|
|
106
|
+
|
|
107
|
+
# Calculate metrics
|
|
108
|
+
edited_clean = edited_desc.strip() if edited_desc else ""
|
|
109
|
+
original_clean = original_desc.strip() if original_desc else ""
|
|
110
|
+
|
|
111
|
+
similarity = calculate_similarity(original_clean, edited_clean)
|
|
112
|
+
len_ratio = len(edited_clean) / max(len(original_clean), 1)
|
|
113
|
+
|
|
114
|
+
# Tier 1: Hard errors (prevent apply)
|
|
115
|
+
if not edited_clean:
|
|
116
|
+
errors.append("Edited description is empty")
|
|
117
|
+
|
|
118
|
+
if "Edit the step description here" in edited_desc:
|
|
119
|
+
errors.append("Edited description contains placeholder text")
|
|
120
|
+
|
|
121
|
+
if "(Edit " in edited_desc and " above)" in edited_desc:
|
|
122
|
+
errors.append("Edited description contains template instructions")
|
|
123
|
+
|
|
124
|
+
if edited_clean and len(edited_clean) < 10:
|
|
125
|
+
errors.append(f"Edited description is too short ({len(edited_clean)} chars)")
|
|
126
|
+
|
|
127
|
+
# Tier 2: Warnings (prompt user, allow with --yes)
|
|
128
|
+
if similarity < 0.3:
|
|
129
|
+
warnings.append(
|
|
130
|
+
f"Description changed significantly ({similarity:.0%} similarity)\n"
|
|
131
|
+
f" Original: {len(original_clean)} chars, Edited: {len(edited_clean)} chars\n"
|
|
132
|
+
f" This might indicate a copy-paste error"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if len_ratio > 3.0:
|
|
136
|
+
warnings.append(
|
|
137
|
+
f"Edited description is 3x longer than original\n"
|
|
138
|
+
f" This might indicate accidental content inclusion"
|
|
139
|
+
)
|
|
140
|
+
elif len_ratio < 0.3 and len(original_clean) > 50:
|
|
141
|
+
warnings.append(
|
|
142
|
+
f"Edited description is much shorter than original\n"
|
|
143
|
+
f" This might indicate truncated content"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Check structural elements
|
|
147
|
+
struct_warnings = check_structure_preserved(original_clean, edited_clean)
|
|
148
|
+
if struct_warnings:
|
|
149
|
+
warnings.append(
|
|
150
|
+
"Structural elements changed:\n " +
|
|
151
|
+
"\n ".join(struct_warnings)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Tier 3: Info (moderate changes, just log)
|
|
155
|
+
if 0.3 <= similarity < 0.7:
|
|
156
|
+
info.append(f"Moderate change ({similarity:.0%} similarity)")
|
|
157
|
+
|
|
158
|
+
word_delta = len(edited_clean.split()) - len(original_clean.split())
|
|
159
|
+
if abs(word_delta) > 10:
|
|
160
|
+
info.append(f"Word count delta: {word_delta:+d}")
|
|
161
|
+
|
|
162
|
+
is_valid = len(errors) == 0
|
|
163
|
+
|
|
164
|
+
return ValidationResult(
|
|
165
|
+
is_valid=is_valid,
|
|
166
|
+
errors=errors,
|
|
167
|
+
warnings=warnings,
|
|
168
|
+
info=info,
|
|
169
|
+
similarity=similarity,
|
|
170
|
+
len_ratio=len_ratio
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def format_validation_message(step_num: int, result: ValidationResult) -> str:
|
|
175
|
+
"""
|
|
176
|
+
Format validation result as user-friendly message.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
step_num: Step number being validated
|
|
180
|
+
result: ValidationResult from validate_edited_description
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Formatted message string for display
|
|
184
|
+
"""
|
|
185
|
+
lines = []
|
|
186
|
+
|
|
187
|
+
if result.errors:
|
|
188
|
+
lines.append(f" ❌ Step {step_num}: Description edit failed validation:")
|
|
189
|
+
for error in result.errors:
|
|
190
|
+
lines.append(f" - {error}")
|
|
191
|
+
|
|
192
|
+
if result.warnings:
|
|
193
|
+
lines.append(f" ⚠️ Step {step_num}: Description edit warnings:")
|
|
194
|
+
for warning in result.warnings:
|
|
195
|
+
lines.append(f" {warning}")
|
|
196
|
+
|
|
197
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tour source commit detection.
|
|
3
|
+
|
|
4
|
+
Determines which commit a tour should be migrated from.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from git import Repo, GitCommandError
|
|
10
|
+
|
|
11
|
+
from codetour_cli.tour.schema import Tour
|
|
12
|
+
|
|
13
|
+
log = structlog.get_logger()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def detect_source_commit(repo: Repo, tour_path: str | Path) -> tuple[str, float, str]:
|
|
17
|
+
"""
|
|
18
|
+
Detect the commit where this tour was last valid.
|
|
19
|
+
|
|
20
|
+
Strategy:
|
|
21
|
+
1. If tour has explicit 'ref' field → use it (user intent)
|
|
22
|
+
2. If tour file is in git → last commit that touched it
|
|
23
|
+
3. If file not committed → use HEAD (assume current)
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
repo: GitPython Repo object
|
|
27
|
+
tour_path: Path to .tour file (relative to repo root)
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
tuple of (commit_sha, confidence, method)
|
|
31
|
+
- commit_sha: The detected source commit
|
|
32
|
+
- confidence: How confident we are (0.0-1.0)
|
|
33
|
+
- method: How we detected it ('explicit_ref', 'git_log', 'uncommitted')
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
>>> detect_source_commit(repo, ".tours/main.tour")
|
|
37
|
+
('abc123...', 0.95, 'git_log')
|
|
38
|
+
"""
|
|
39
|
+
tour_path = Path(tour_path)
|
|
40
|
+
|
|
41
|
+
# Construct absolute path if relative (relative to repo root)
|
|
42
|
+
if not tour_path.is_absolute():
|
|
43
|
+
tour_path = Path(repo.working_dir) / tour_path
|
|
44
|
+
|
|
45
|
+
# Load tour to check for explicit ref field
|
|
46
|
+
try:
|
|
47
|
+
tour = Tour.from_file(tour_path)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
log.error("failed_to_load_tour", path=str(tour_path), error=str(e))
|
|
50
|
+
# Fallback: assume current HEAD
|
|
51
|
+
return repo.head.commit.hexsha, 0.3, "load_failed"
|
|
52
|
+
|
|
53
|
+
# 1. Explicit ref takes priority (user intent)
|
|
54
|
+
if tour.ref:
|
|
55
|
+
log.info(
|
|
56
|
+
"using_explicit_ref",
|
|
57
|
+
path=str(tour_path),
|
|
58
|
+
ref=tour.ref[:8] if len(tour.ref) >= 8 else tour.ref
|
|
59
|
+
)
|
|
60
|
+
return tour.ref, 1.0, "explicit_ref"
|
|
61
|
+
|
|
62
|
+
# 2. Most recent commit that touched this file
|
|
63
|
+
try:
|
|
64
|
+
# Get the most recent commit for this file
|
|
65
|
+
# --follow tracks through renames
|
|
66
|
+
commits = list(repo.iter_commits(paths=str(tour_path), max_count=1))
|
|
67
|
+
|
|
68
|
+
if commits:
|
|
69
|
+
last_commit = commits[0]
|
|
70
|
+
log.info(
|
|
71
|
+
"detected_via_git_log",
|
|
72
|
+
path=str(tour_path),
|
|
73
|
+
commit=last_commit.hexsha[:8],
|
|
74
|
+
date=last_commit.committed_datetime.isoformat()
|
|
75
|
+
)
|
|
76
|
+
return last_commit.hexsha, 0.95, "git_log"
|
|
77
|
+
|
|
78
|
+
except GitCommandError as e:
|
|
79
|
+
log.warning(
|
|
80
|
+
"git_log_failed",
|
|
81
|
+
path=str(tour_path),
|
|
82
|
+
error=str(e)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# 3. File not in git (new/uncommitted)
|
|
86
|
+
log.warning(
|
|
87
|
+
"tour_not_in_git",
|
|
88
|
+
path=str(tour_path),
|
|
89
|
+
fallback="HEAD"
|
|
90
|
+
)
|
|
91
|
+
return repo.head.commit.hexsha, 0.5, "uncommitted"
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tour discovery and repository scanning.
|
|
3
|
+
|
|
4
|
+
Auto-discovers tours in the current repository.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional, Tuple
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from git import Repo, InvalidGitRepositoryError, GitCommandError
|
|
14
|
+
|
|
15
|
+
from codetour_cli.tour.schema import Tour
|
|
16
|
+
from codetour_cli.detection import detect_source_commit
|
|
17
|
+
|
|
18
|
+
log = structlog.get_logger()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TourInfo:
|
|
23
|
+
"""
|
|
24
|
+
Information about a discovered tour.
|
|
25
|
+
"""
|
|
26
|
+
path: Path # Absolute path to tour file
|
|
27
|
+
tour: Tour # Loaded tour object
|
|
28
|
+
source_commit: str # Detected source commit
|
|
29
|
+
source_confidence: float # Confidence in source detection
|
|
30
|
+
source_method: str # How source was detected
|
|
31
|
+
commits_behind: int # Number of commits since source
|
|
32
|
+
last_modified: datetime # When tour file was last modified in git
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RepositoryInfo:
|
|
37
|
+
"""
|
|
38
|
+
Information about the repository containing tours.
|
|
39
|
+
"""
|
|
40
|
+
repo_path: Path # Repository root
|
|
41
|
+
tours_dir: Path # Tours directory (.tours/)
|
|
42
|
+
tours: List[TourInfo] # Discovered tours
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def find_repo_root(start_path: Optional[Path] = None) -> Path:
|
|
46
|
+
"""
|
|
47
|
+
Find the git repository root, starting from start_path.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
start_path: Directory to start searching from (default: current directory)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Path to repository root
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
InvalidGitRepositoryError: If not in a git repository
|
|
57
|
+
"""
|
|
58
|
+
start_path = start_path or Path.cwd()
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
repo = Repo(start_path, search_parent_directories=True)
|
|
62
|
+
repo_root = Path(repo.working_dir)
|
|
63
|
+
log.debug("found_repo_root", path=str(repo_root))
|
|
64
|
+
return repo_root
|
|
65
|
+
except InvalidGitRepositoryError as e:
|
|
66
|
+
log.error("not_a_git_repo", start_path=str(start_path))
|
|
67
|
+
raise InvalidGitRepositoryError(
|
|
68
|
+
f"Not a git repository (or any parent up to mount point)\n"
|
|
69
|
+
f"CodeTour CLI requires git to track tour drift.\n"
|
|
70
|
+
f"Initialize git with: git init"
|
|
71
|
+
) from e
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_tours_directory(repo_root: Path) -> Optional[Path]:
|
|
75
|
+
"""
|
|
76
|
+
Find the .tours directory in the repository.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
repo_root: Repository root path
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Path to tours directory, or None if not found
|
|
83
|
+
"""
|
|
84
|
+
tours_dir = repo_root / ".tours"
|
|
85
|
+
|
|
86
|
+
if tours_dir.exists() and tours_dir.is_dir():
|
|
87
|
+
log.debug("found_tours_directory", path=str(tours_dir))
|
|
88
|
+
return tours_dir
|
|
89
|
+
|
|
90
|
+
log.warning("tours_directory_not_found", repo_root=str(repo_root))
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def scan_tours(tours_dir: Path) -> List[Path]:
|
|
95
|
+
"""
|
|
96
|
+
Scan for .tour files in the tours directory.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
tours_dir: Path to tours directory
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of paths to tour files
|
|
103
|
+
"""
|
|
104
|
+
tour_files = sorted(tours_dir.glob("*.tour"))
|
|
105
|
+
log.info("scanned_tours", num_tours=len(tour_files), tours_dir=str(tours_dir))
|
|
106
|
+
return tour_files
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_tour_info(repo: Repo, tour_path: Path) -> Optional[TourInfo]:
|
|
110
|
+
"""
|
|
111
|
+
Load full information about a tour.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
repo: GitPython Repo object
|
|
115
|
+
tour_path: Path to tour file
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
TourInfo object, or None if tour couldn't be loaded
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
# Load tour
|
|
122
|
+
tour = Tour.from_file(tour_path)
|
|
123
|
+
|
|
124
|
+
# Detect source commit
|
|
125
|
+
tour_path_rel = tour_path.relative_to(repo.working_dir)
|
|
126
|
+
source_commit, confidence, method = detect_source_commit(repo, tour_path_rel)
|
|
127
|
+
|
|
128
|
+
# Count commits since source
|
|
129
|
+
try:
|
|
130
|
+
commits = list(repo.iter_commits(f"{source_commit}..HEAD"))
|
|
131
|
+
commits_behind = len(commits)
|
|
132
|
+
except GitCommandError:
|
|
133
|
+
# Source commit might not be reachable
|
|
134
|
+
commits_behind = 0
|
|
135
|
+
|
|
136
|
+
# Get last modified time from git
|
|
137
|
+
try:
|
|
138
|
+
last_commit = list(repo.iter_commits(paths=str(tour_path_rel), max_count=1))
|
|
139
|
+
if last_commit:
|
|
140
|
+
last_modified = last_commit[0].committed_datetime
|
|
141
|
+
else:
|
|
142
|
+
# File not in git, use filesystem mtime
|
|
143
|
+
last_modified = datetime.fromtimestamp(tour_path.stat().st_mtime)
|
|
144
|
+
except (GitCommandError, OSError):
|
|
145
|
+
last_modified = datetime.now()
|
|
146
|
+
|
|
147
|
+
return TourInfo(
|
|
148
|
+
path=tour_path,
|
|
149
|
+
tour=tour,
|
|
150
|
+
source_commit=source_commit,
|
|
151
|
+
source_confidence=confidence,
|
|
152
|
+
source_method=method,
|
|
153
|
+
commits_behind=commits_behind,
|
|
154
|
+
last_modified=last_modified
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
log.error("failed_to_load_tour", path=str(tour_path), error=str(e))
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def discover_repository(start_path: Optional[Path] = None) -> RepositoryInfo:
|
|
163
|
+
"""
|
|
164
|
+
Discover repository and all tours within it.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
start_path: Directory to start searching from (default: current directory)
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
RepositoryInfo with all discovered tours
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
InvalidGitRepositoryError: If not in a git repository
|
|
174
|
+
ValueError: If no .tours directory found
|
|
175
|
+
"""
|
|
176
|
+
# Find repo root
|
|
177
|
+
repo_root = find_repo_root(start_path)
|
|
178
|
+
repo = Repo(repo_root)
|
|
179
|
+
|
|
180
|
+
# Find tours directory
|
|
181
|
+
tours_dir = find_tours_directory(repo_root)
|
|
182
|
+
if tours_dir is None:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"No .tours directory found in {repo_root}\n"
|
|
185
|
+
f"Create it with: mkdir .tours\n"
|
|
186
|
+
f"Or run: codetour init"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Scan for tours
|
|
190
|
+
tour_paths = scan_tours(tours_dir)
|
|
191
|
+
if not tour_paths:
|
|
192
|
+
log.warning("no_tours_found", tours_dir=str(tours_dir))
|
|
193
|
+
|
|
194
|
+
# Load tour information
|
|
195
|
+
tours = []
|
|
196
|
+
for tour_path in tour_paths:
|
|
197
|
+
tour_info = load_tour_info(repo, tour_path)
|
|
198
|
+
if tour_info:
|
|
199
|
+
tours.append(tour_info)
|
|
200
|
+
|
|
201
|
+
log.info(
|
|
202
|
+
"discovered_repository",
|
|
203
|
+
repo_root=str(repo_root),
|
|
204
|
+
tours_dir=str(tours_dir),
|
|
205
|
+
num_tours=len(tours)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return RepositoryInfo(
|
|
209
|
+
repo_path=repo_root,
|
|
210
|
+
tours_dir=tours_dir,
|
|
211
|
+
tours=tours
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def is_tour_up_to_date(tour_info: TourInfo, threshold: int = 0) -> bool:
|
|
216
|
+
"""
|
|
217
|
+
Check if a tour is up to date.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
tour_info: Tour information
|
|
221
|
+
threshold: Maximum commits behind to be considered up to date
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if tour is up to date
|
|
225
|
+
"""
|
|
226
|
+
return tour_info.commits_behind <= threshold
|