shotgun-sh 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.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/__init__.py +5 -0
- shotgun/agents/__init__.py +1 -0
- shotgun/agents/agent_manager.py +651 -0
- shotgun/agents/common.py +549 -0
- shotgun/agents/config/__init__.py +13 -0
- shotgun/agents/config/constants.py +17 -0
- shotgun/agents/config/manager.py +294 -0
- shotgun/agents/config/models.py +185 -0
- shotgun/agents/config/provider.py +206 -0
- shotgun/agents/conversation_history.py +106 -0
- shotgun/agents/conversation_manager.py +105 -0
- shotgun/agents/export.py +96 -0
- shotgun/agents/history/__init__.py +5 -0
- shotgun/agents/history/compaction.py +85 -0
- shotgun/agents/history/constants.py +19 -0
- shotgun/agents/history/context_extraction.py +108 -0
- shotgun/agents/history/history_building.py +104 -0
- shotgun/agents/history/history_processors.py +426 -0
- shotgun/agents/history/message_utils.py +84 -0
- shotgun/agents/history/token_counting.py +429 -0
- shotgun/agents/history/token_estimation.py +138 -0
- shotgun/agents/messages.py +35 -0
- shotgun/agents/models.py +275 -0
- shotgun/agents/plan.py +98 -0
- shotgun/agents/research.py +108 -0
- shotgun/agents/specify.py +98 -0
- shotgun/agents/tasks.py +96 -0
- shotgun/agents/tools/__init__.py +34 -0
- shotgun/agents/tools/codebase/__init__.py +28 -0
- shotgun/agents/tools/codebase/codebase_shell.py +256 -0
- shotgun/agents/tools/codebase/directory_lister.py +141 -0
- shotgun/agents/tools/codebase/file_read.py +144 -0
- shotgun/agents/tools/codebase/models.py +252 -0
- shotgun/agents/tools/codebase/query_graph.py +67 -0
- shotgun/agents/tools/codebase/retrieve_code.py +81 -0
- shotgun/agents/tools/file_management.py +218 -0
- shotgun/agents/tools/user_interaction.py +37 -0
- shotgun/agents/tools/web_search/__init__.py +60 -0
- shotgun/agents/tools/web_search/anthropic.py +144 -0
- shotgun/agents/tools/web_search/gemini.py +85 -0
- shotgun/agents/tools/web_search/openai.py +98 -0
- shotgun/agents/tools/web_search/utils.py +20 -0
- shotgun/build_constants.py +20 -0
- shotgun/cli/__init__.py +1 -0
- shotgun/cli/codebase/__init__.py +5 -0
- shotgun/cli/codebase/commands.py +202 -0
- shotgun/cli/codebase/models.py +21 -0
- shotgun/cli/config.py +275 -0
- shotgun/cli/export.py +81 -0
- shotgun/cli/models.py +10 -0
- shotgun/cli/plan.py +73 -0
- shotgun/cli/research.py +85 -0
- shotgun/cli/specify.py +69 -0
- shotgun/cli/tasks.py +78 -0
- shotgun/cli/update.py +152 -0
- shotgun/cli/utils.py +25 -0
- shotgun/codebase/__init__.py +12 -0
- shotgun/codebase/core/__init__.py +46 -0
- shotgun/codebase/core/change_detector.py +358 -0
- shotgun/codebase/core/code_retrieval.py +243 -0
- shotgun/codebase/core/ingestor.py +1497 -0
- shotgun/codebase/core/language_config.py +297 -0
- shotgun/codebase/core/manager.py +1662 -0
- shotgun/codebase/core/nl_query.py +331 -0
- shotgun/codebase/core/parser_loader.py +128 -0
- shotgun/codebase/models.py +111 -0
- shotgun/codebase/service.py +206 -0
- shotgun/logging_config.py +227 -0
- shotgun/main.py +167 -0
- shotgun/posthog_telemetry.py +158 -0
- shotgun/prompts/__init__.py +5 -0
- shotgun/prompts/agents/__init__.py +1 -0
- shotgun/prompts/agents/export.j2 +350 -0
- shotgun/prompts/agents/partials/codebase_understanding.j2 +87 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +37 -0
- shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
- shotgun/prompts/agents/partials/interactive_mode.j2 +26 -0
- shotgun/prompts/agents/plan.j2 +144 -0
- shotgun/prompts/agents/research.j2 +69 -0
- shotgun/prompts/agents/specify.j2 +51 -0
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +19 -0
- shotgun/prompts/agents/state/system_state.j2 +31 -0
- shotgun/prompts/agents/tasks.j2 +143 -0
- shotgun/prompts/codebase/__init__.py +1 -0
- shotgun/prompts/codebase/cypher_query_patterns.j2 +223 -0
- shotgun/prompts/codebase/cypher_system.j2 +28 -0
- shotgun/prompts/codebase/enhanced_query_context.j2 +10 -0
- shotgun/prompts/codebase/partials/cypher_rules.j2 +24 -0
- shotgun/prompts/codebase/partials/graph_schema.j2 +30 -0
- shotgun/prompts/codebase/partials/temporal_context.j2 +21 -0
- shotgun/prompts/history/__init__.py +1 -0
- shotgun/prompts/history/incremental_summarization.j2 +53 -0
- shotgun/prompts/history/summarization.j2 +46 -0
- shotgun/prompts/loader.py +140 -0
- shotgun/py.typed +0 -0
- shotgun/sdk/__init__.py +13 -0
- shotgun/sdk/codebase.py +219 -0
- shotgun/sdk/exceptions.py +17 -0
- shotgun/sdk/models.py +189 -0
- shotgun/sdk/services.py +23 -0
- shotgun/sentry_telemetry.py +87 -0
- shotgun/telemetry.py +93 -0
- shotgun/tui/__init__.py +0 -0
- shotgun/tui/app.py +116 -0
- shotgun/tui/commands/__init__.py +76 -0
- shotgun/tui/components/prompt_input.py +69 -0
- shotgun/tui/components/spinner.py +86 -0
- shotgun/tui/components/splash.py +25 -0
- shotgun/tui/components/vertical_tail.py +13 -0
- shotgun/tui/screens/chat.py +782 -0
- shotgun/tui/screens/chat.tcss +43 -0
- shotgun/tui/screens/chat_screen/__init__.py +0 -0
- shotgun/tui/screens/chat_screen/command_providers.py +219 -0
- shotgun/tui/screens/chat_screen/hint_message.py +40 -0
- shotgun/tui/screens/chat_screen/history.py +221 -0
- shotgun/tui/screens/directory_setup.py +113 -0
- shotgun/tui/screens/provider_config.py +221 -0
- shotgun/tui/screens/splash.py +31 -0
- shotgun/tui/styles.tcss +10 -0
- shotgun/tui/utils/__init__.py +5 -0
- shotgun/tui/utils/mode_progress.py +257 -0
- shotgun/utils/__init__.py +5 -0
- shotgun/utils/env_utils.py +35 -0
- shotgun/utils/file_system_utils.py +36 -0
- shotgun/utils/update_checker.py +375 -0
- shotgun_sh-0.1.0.dist-info/METADATA +466 -0
- shotgun_sh-0.1.0.dist-info/RECORD +130 -0
- shotgun_sh-0.1.0.dist-info/WHEEL +4 -0
- shotgun_sh-0.1.0.dist-info/entry_points.txt +2 -0
- shotgun_sh-0.1.0.dist-info/licenses/LICENSE +21 -0
shotgun/cli/update.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Update command for shotgun CLI."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
8
|
+
|
|
9
|
+
from shotgun.logging_config import get_logger
|
|
10
|
+
from shotgun.utils.update_checker import (
|
|
11
|
+
detect_installation_method,
|
|
12
|
+
get_latest_version,
|
|
13
|
+
is_dev_version,
|
|
14
|
+
perform_update,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
console = Console()
|
|
19
|
+
app = typer.Typer()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.callback(invoke_without_command=True)
|
|
23
|
+
def update(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
force: Annotated[
|
|
26
|
+
bool,
|
|
27
|
+
typer.Option(
|
|
28
|
+
"--force",
|
|
29
|
+
"-f",
|
|
30
|
+
help="Force update even for development versions",
|
|
31
|
+
),
|
|
32
|
+
] = False,
|
|
33
|
+
check_only: Annotated[
|
|
34
|
+
bool,
|
|
35
|
+
typer.Option(
|
|
36
|
+
"--check",
|
|
37
|
+
"-c",
|
|
38
|
+
help="Check for updates without installing",
|
|
39
|
+
),
|
|
40
|
+
] = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Check for and install updates to shotgun-sh.
|
|
43
|
+
|
|
44
|
+
This command will:
|
|
45
|
+
- Check PyPI for the latest version
|
|
46
|
+
- Detect your installation method (pipx, pip, or venv)
|
|
47
|
+
- Perform the appropriate upgrade command
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
shotgun update # Check and install updates
|
|
51
|
+
shotgun update --check # Only check for updates
|
|
52
|
+
shotgun update --force # Force update (even for dev versions)
|
|
53
|
+
"""
|
|
54
|
+
if ctx.resilient_parsing:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Handle check-only mode
|
|
58
|
+
if check_only:
|
|
59
|
+
with Progress(
|
|
60
|
+
SpinnerColumn(),
|
|
61
|
+
TextColumn("[progress.description]{task.description}"),
|
|
62
|
+
console=console,
|
|
63
|
+
) as progress:
|
|
64
|
+
progress.add_task("Checking for updates...", total=None)
|
|
65
|
+
|
|
66
|
+
latest = get_latest_version()
|
|
67
|
+
if not latest:
|
|
68
|
+
console.print(
|
|
69
|
+
"[red]✗[/red] Failed to check for updates", style="bold red"
|
|
70
|
+
)
|
|
71
|
+
raise typer.Exit(1)
|
|
72
|
+
|
|
73
|
+
from shotgun import __version__
|
|
74
|
+
from shotgun.utils.update_checker import compare_versions
|
|
75
|
+
|
|
76
|
+
if compare_versions(__version__, latest):
|
|
77
|
+
console.print(
|
|
78
|
+
f"[green]✓[/green] Update available: [cyan]{__version__}[/cyan] → [green]{latest}[/green]",
|
|
79
|
+
style="bold",
|
|
80
|
+
)
|
|
81
|
+
console.print("Run 'shotgun update' to install the update")
|
|
82
|
+
else:
|
|
83
|
+
console.print(
|
|
84
|
+
f"[green]✓[/green] You're on the latest version ([cyan]{__version__}[/cyan])",
|
|
85
|
+
style="bold",
|
|
86
|
+
)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
# Check for dev version
|
|
90
|
+
if is_dev_version() and not force:
|
|
91
|
+
console.print(
|
|
92
|
+
"[yellow]⚠[/yellow] You're running a development version",
|
|
93
|
+
style="bold yellow",
|
|
94
|
+
)
|
|
95
|
+
console.print(
|
|
96
|
+
"Use --force to update anyway, or install the stable version with:\n"
|
|
97
|
+
" pipx install shotgun-sh\n"
|
|
98
|
+
" or\n"
|
|
99
|
+
" pip install shotgun-sh",
|
|
100
|
+
)
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
# Confirm if forcing dev version update
|
|
104
|
+
if is_dev_version() and force:
|
|
105
|
+
confirm = typer.confirm(
|
|
106
|
+
"⚠️ You're about to replace a development version. Continue?",
|
|
107
|
+
default=False,
|
|
108
|
+
)
|
|
109
|
+
if not confirm:
|
|
110
|
+
console.print("Update cancelled", style="dim")
|
|
111
|
+
raise typer.Exit(0)
|
|
112
|
+
|
|
113
|
+
# Detect installation method
|
|
114
|
+
method = detect_installation_method()
|
|
115
|
+
console.print(f"Installation method: [cyan]{method}[/cyan]", style="dim")
|
|
116
|
+
|
|
117
|
+
# Perform update
|
|
118
|
+
with Progress(
|
|
119
|
+
SpinnerColumn(),
|
|
120
|
+
TextColumn("[progress.description]{task.description}"),
|
|
121
|
+
console=console,
|
|
122
|
+
) as progress:
|
|
123
|
+
task = progress.add_task("Updating shotgun-sh...", total=None)
|
|
124
|
+
|
|
125
|
+
success, message = perform_update(force=force)
|
|
126
|
+
|
|
127
|
+
if success:
|
|
128
|
+
progress.update(task, description="[green]✓[/green] Update complete!")
|
|
129
|
+
console.print(f"\n[green]✓[/green] {message}", style="bold green")
|
|
130
|
+
console.print(
|
|
131
|
+
"\n[dim]Restart your terminal or run 'shotgun --version' to verify the update[/dim]"
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
progress.update(task, description="[red]✗[/red] Update failed")
|
|
135
|
+
console.print(f"\n[red]✗[/red] {message}", style="bold red")
|
|
136
|
+
|
|
137
|
+
# Provide manual update instructions
|
|
138
|
+
if method == "pipx":
|
|
139
|
+
console.print(
|
|
140
|
+
"\n[yellow]Try updating manually:[/yellow]\n"
|
|
141
|
+
" pipx upgrade shotgun-sh"
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
console.print(
|
|
145
|
+
"\n[yellow]Try updating manually:[/yellow]\n"
|
|
146
|
+
" pip install --upgrade shotgun-sh"
|
|
147
|
+
)
|
|
148
|
+
raise typer.Exit(1)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
app()
|
shotgun/cli/utils.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Common utilities for CLI commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from .models import OutputFormat
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_result_json(result: Any) -> str:
|
|
12
|
+
"""Format result object as JSON using Pydantic serialization."""
|
|
13
|
+
if isinstance(result, BaseModel):
|
|
14
|
+
return result.model_dump_json(indent=2)
|
|
15
|
+
else:
|
|
16
|
+
# Fallback for non-Pydantic objects
|
|
17
|
+
return json.dumps({"result": str(result)}, indent=2)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def output_result(result: Any, format_type: OutputFormat = OutputFormat.TEXT) -> None:
|
|
21
|
+
"""Output result in specified format."""
|
|
22
|
+
if format_type == OutputFormat.JSON:
|
|
23
|
+
print(format_result_json(result))
|
|
24
|
+
else: # Default to text
|
|
25
|
+
print(str(result))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Shotgun codebase analysis and graph management."""
|
|
2
|
+
|
|
3
|
+
from shotgun.codebase.models import CodebaseGraph, GraphStatus, QueryResult, QueryType
|
|
4
|
+
from shotgun.codebase.service import CodebaseService
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CodebaseService",
|
|
8
|
+
"CodebaseGraph",
|
|
9
|
+
"GraphStatus",
|
|
10
|
+
"QueryResult",
|
|
11
|
+
"QueryType",
|
|
12
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Core components for codebase understanding."""
|
|
2
|
+
|
|
3
|
+
from shotgun.codebase.core.code_retrieval import (
|
|
4
|
+
CodeSnippet,
|
|
5
|
+
retrieve_code_by_cypher,
|
|
6
|
+
retrieve_code_by_qualified_name,
|
|
7
|
+
)
|
|
8
|
+
from shotgun.codebase.core.ingestor import (
|
|
9
|
+
CodebaseIngestor,
|
|
10
|
+
Ingestor,
|
|
11
|
+
SimpleGraphBuilder,
|
|
12
|
+
)
|
|
13
|
+
from shotgun.codebase.core.language_config import (
|
|
14
|
+
LANGUAGE_CONFIGS,
|
|
15
|
+
LanguageConfig,
|
|
16
|
+
get_language_config,
|
|
17
|
+
)
|
|
18
|
+
from shotgun.codebase.core.manager import CodebaseGraphManager
|
|
19
|
+
from shotgun.codebase.core.nl_query import (
|
|
20
|
+
clean_cypher_response,
|
|
21
|
+
generate_cypher,
|
|
22
|
+
generate_cypher_openai_async,
|
|
23
|
+
)
|
|
24
|
+
from shotgun.codebase.core.parser_loader import load_parsers
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Ingestor classes
|
|
28
|
+
"CodebaseIngestor",
|
|
29
|
+
"Ingestor",
|
|
30
|
+
"SimpleGraphBuilder",
|
|
31
|
+
"CodebaseGraphManager",
|
|
32
|
+
# Language configuration
|
|
33
|
+
"LanguageConfig",
|
|
34
|
+
"LANGUAGE_CONFIGS",
|
|
35
|
+
"get_language_config",
|
|
36
|
+
# Parser loading
|
|
37
|
+
"load_parsers",
|
|
38
|
+
# Natural language query
|
|
39
|
+
"generate_cypher",
|
|
40
|
+
"generate_cypher_openai_async",
|
|
41
|
+
"clean_cypher_response",
|
|
42
|
+
# Code retrieval
|
|
43
|
+
"CodeSnippet",
|
|
44
|
+
"retrieve_code_by_qualified_name",
|
|
45
|
+
"retrieve_code_by_cypher",
|
|
46
|
+
]
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Change detection for incremental graph updates."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import kuzu
|
|
10
|
+
|
|
11
|
+
from shotgun.logging_config import get_logger
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChangeType(Enum):
|
|
17
|
+
"""Types of file changes."""
|
|
18
|
+
|
|
19
|
+
ADDED = "added"
|
|
20
|
+
MODIFIED = "modified"
|
|
21
|
+
DELETED = "deleted"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChangeDetector:
|
|
25
|
+
"""Detects changes in the codebase by comparing with FileMetadata nodes."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, connection: kuzu.Connection, repo_path: Path):
|
|
28
|
+
"""Initialize change detector.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
connection: Kuzu database connection
|
|
32
|
+
repo_path: Root path of the repository
|
|
33
|
+
"""
|
|
34
|
+
self.conn = connection
|
|
35
|
+
self.repo_path = Path(repo_path).resolve()
|
|
36
|
+
|
|
37
|
+
# Validate that repo path exists
|
|
38
|
+
if not self.repo_path.exists():
|
|
39
|
+
raise ValueError(f"Repository path does not exist: {self.repo_path}")
|
|
40
|
+
if not self.repo_path.is_dir():
|
|
41
|
+
raise ValueError(f"Repository path is not a directory: {self.repo_path}")
|
|
42
|
+
|
|
43
|
+
logger.info(f"ChangeDetector initialized with repo_path: {self.repo_path}")
|
|
44
|
+
|
|
45
|
+
def detect_changes(
|
|
46
|
+
self,
|
|
47
|
+
languages: list[str] | None = None,
|
|
48
|
+
exclude_patterns: list[str] | None = None,
|
|
49
|
+
) -> dict[str, ChangeType]:
|
|
50
|
+
"""Detect all changes since last update.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
languages: Optional list of languages to include
|
|
54
|
+
exclude_patterns: Optional patterns to exclude
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Dictionary mapping file paths to change types
|
|
58
|
+
"""
|
|
59
|
+
changes = {}
|
|
60
|
+
current_files = set()
|
|
61
|
+
|
|
62
|
+
# Get supported file extensions
|
|
63
|
+
from shotgun.codebase.core.language_config import get_language_config
|
|
64
|
+
|
|
65
|
+
supported_extensions = set()
|
|
66
|
+
if languages:
|
|
67
|
+
# Map language names to their primary extensions
|
|
68
|
+
lang_to_ext = {
|
|
69
|
+
"python": ".py",
|
|
70
|
+
"javascript": ".js",
|
|
71
|
+
"typescript": ".ts",
|
|
72
|
+
"java": ".java",
|
|
73
|
+
"cpp": ".cpp",
|
|
74
|
+
"c": ".c",
|
|
75
|
+
"csharp": ".cs",
|
|
76
|
+
"go": ".go",
|
|
77
|
+
"rust": ".rs",
|
|
78
|
+
"ruby": ".rb",
|
|
79
|
+
"php": ".php",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for lang in languages:
|
|
83
|
+
# Try to get config using the language's primary extension
|
|
84
|
+
primary_ext = lang_to_ext.get(lang.lower())
|
|
85
|
+
if primary_ext:
|
|
86
|
+
config = get_language_config(primary_ext)
|
|
87
|
+
if config:
|
|
88
|
+
supported_extensions.update(config.file_extensions)
|
|
89
|
+
else:
|
|
90
|
+
# Get all supported extensions
|
|
91
|
+
for ext in [
|
|
92
|
+
".py",
|
|
93
|
+
".js",
|
|
94
|
+
".ts",
|
|
95
|
+
".tsx",
|
|
96
|
+
".jsx",
|
|
97
|
+
".java",
|
|
98
|
+
".cpp",
|
|
99
|
+
".c",
|
|
100
|
+
".hpp",
|
|
101
|
+
".h",
|
|
102
|
+
".cs",
|
|
103
|
+
".go",
|
|
104
|
+
".rs",
|
|
105
|
+
".rb",
|
|
106
|
+
".php",
|
|
107
|
+
]:
|
|
108
|
+
if get_language_config(ext):
|
|
109
|
+
supported_extensions.add(ext)
|
|
110
|
+
|
|
111
|
+
# Walk through all source files
|
|
112
|
+
logger.debug(
|
|
113
|
+
f"Walking source files in {self.repo_path} with extensions {supported_extensions}"
|
|
114
|
+
)
|
|
115
|
+
for filepath in self._walk_source_files(supported_extensions, exclude_patterns):
|
|
116
|
+
# Normalize the relative path to use forward slashes consistently
|
|
117
|
+
relative_path = str(filepath.relative_to(self.repo_path)).replace(
|
|
118
|
+
os.sep, "/"
|
|
119
|
+
)
|
|
120
|
+
current_files.add(relative_path)
|
|
121
|
+
|
|
122
|
+
file_info = self._get_file_info(relative_path)
|
|
123
|
+
if not file_info:
|
|
124
|
+
# New file
|
|
125
|
+
changes[relative_path] = ChangeType.ADDED
|
|
126
|
+
logger.debug(f"Detected new file: {relative_path}")
|
|
127
|
+
else:
|
|
128
|
+
# Check if modified
|
|
129
|
+
mtime = int(filepath.stat().st_mtime)
|
|
130
|
+
if mtime > file_info["mtime"]:
|
|
131
|
+
# Check hash to confirm actual change
|
|
132
|
+
current_hash = self._calculate_file_hash(filepath)
|
|
133
|
+
if current_hash != file_info.get("hash", ""):
|
|
134
|
+
changes[relative_path] = ChangeType.MODIFIED
|
|
135
|
+
logger.debug(f"Detected modified file: {relative_path}")
|
|
136
|
+
|
|
137
|
+
# Check for deleted files
|
|
138
|
+
tracked_files = self._get_tracked_files()
|
|
139
|
+
logger.debug(
|
|
140
|
+
f"Found {len(tracked_files)} tracked files, {len(current_files)} current files"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Log sample of files for debugging
|
|
144
|
+
if tracked_files and current_files:
|
|
145
|
+
logger.debug(f"Sample tracked files: {list(tracked_files)[:5]}")
|
|
146
|
+
logger.debug(f"Sample current files: {list(current_files)[:5]}")
|
|
147
|
+
|
|
148
|
+
for tracked_file in tracked_files:
|
|
149
|
+
if tracked_file not in current_files:
|
|
150
|
+
changes[tracked_file] = ChangeType.DELETED
|
|
151
|
+
logger.debug(f"Detected deleted file: {tracked_file}")
|
|
152
|
+
|
|
153
|
+
# Warn if detecting large number of deletions
|
|
154
|
+
deleted_count = sum(1 for c in changes.values() if c == ChangeType.DELETED)
|
|
155
|
+
if deleted_count > 100:
|
|
156
|
+
logger.warning(
|
|
157
|
+
f"Detected {deleted_count} file deletions - this may indicate a path resolution issue"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
logger.info(
|
|
161
|
+
f"Detected {len(changes)} file changes: "
|
|
162
|
+
f"{sum(1 for c in changes.values() if c == ChangeType.ADDED)} added, "
|
|
163
|
+
f"{sum(1 for c in changes.values() if c == ChangeType.MODIFIED)} modified, "
|
|
164
|
+
f"{sum(1 for c in changes.values() if c == ChangeType.DELETED)} deleted"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return changes
|
|
168
|
+
|
|
169
|
+
def _get_file_info(self, filepath: str) -> dict[str, Any] | None:
|
|
170
|
+
"""Get FileMetadata info for a file.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
filepath: Relative file path
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Dictionary with file metadata or None if not found
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
result = self.conn.execute(
|
|
180
|
+
"MATCH (f:FileMetadata {filepath: $path}) RETURN f.mtime, f.hash",
|
|
181
|
+
{"path": filepath},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Handle the QueryResult properly - cast to proper type
|
|
185
|
+
if hasattr(result, "has_next"):
|
|
186
|
+
query_result = cast(Any, result)
|
|
187
|
+
if query_result.has_next():
|
|
188
|
+
row = query_result.get_next()
|
|
189
|
+
if isinstance(row, list | tuple) and len(row) >= 2:
|
|
190
|
+
return {"mtime": row[0], "hash": row[1]}
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.error(f"Failed to get file info for {filepath}: {e}")
|
|
193
|
+
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def _get_tracked_files(self) -> list[str]:
|
|
197
|
+
"""Get all tracked file paths from database.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
List of relative file paths
|
|
201
|
+
"""
|
|
202
|
+
files = []
|
|
203
|
+
try:
|
|
204
|
+
result = self.conn.execute("MATCH (f:FileMetadata) RETURN f.filepath")
|
|
205
|
+
# Handle the QueryResult properly - cast to proper type
|
|
206
|
+
if hasattr(result, "has_next"):
|
|
207
|
+
query_result = cast(Any, result)
|
|
208
|
+
while query_result.has_next():
|
|
209
|
+
# Normalize path separators to forward slashes
|
|
210
|
+
row = query_result.get_next()
|
|
211
|
+
if isinstance(row, list | tuple) and len(row) > 0:
|
|
212
|
+
filepath = row[0]
|
|
213
|
+
if filepath:
|
|
214
|
+
normalized_path = filepath.replace(os.sep, "/")
|
|
215
|
+
files.append(normalized_path)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error(f"Failed to get tracked files: {e}")
|
|
218
|
+
return files
|
|
219
|
+
|
|
220
|
+
def _walk_source_files(
|
|
221
|
+
self, supported_extensions: set[str], exclude_patterns: list[str] | None = None
|
|
222
|
+
) -> list[Path]:
|
|
223
|
+
"""Walk repository and find all source files.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
supported_extensions: Set of file extensions to include
|
|
227
|
+
exclude_patterns: Optional patterns to exclude
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
List of absolute file paths
|
|
231
|
+
"""
|
|
232
|
+
source_files = []
|
|
233
|
+
ignore_dirs = {
|
|
234
|
+
".git",
|
|
235
|
+
"__pycache__",
|
|
236
|
+
"node_modules",
|
|
237
|
+
".venv",
|
|
238
|
+
"venv",
|
|
239
|
+
".env",
|
|
240
|
+
"dist",
|
|
241
|
+
"build",
|
|
242
|
+
".pytest_cache",
|
|
243
|
+
".mypy_cache",
|
|
244
|
+
".tox",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
logger.debug(f"Walking files from: {self.repo_path}")
|
|
248
|
+
logger.debug(f"Current working directory: {Path.cwd()}")
|
|
249
|
+
|
|
250
|
+
# Add custom exclude patterns
|
|
251
|
+
if exclude_patterns:
|
|
252
|
+
for pattern in exclude_patterns:
|
|
253
|
+
if pattern.startswith("*/"):
|
|
254
|
+
ignore_dirs.add(pattern[2:])
|
|
255
|
+
|
|
256
|
+
for root, dirs, files in os.walk(self.repo_path):
|
|
257
|
+
root_path = Path(root)
|
|
258
|
+
|
|
259
|
+
# Filter out ignored directories
|
|
260
|
+
dirs[:] = [d for d in dirs if d not in ignore_dirs]
|
|
261
|
+
|
|
262
|
+
# Skip if any parent directory should be ignored
|
|
263
|
+
if any(part in ignore_dirs for part in root_path.parts):
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
for file in files:
|
|
267
|
+
filepath = root_path / file
|
|
268
|
+
|
|
269
|
+
# Check if it's a supported source file
|
|
270
|
+
if filepath.suffix in supported_extensions:
|
|
271
|
+
# Check exclude patterns
|
|
272
|
+
if exclude_patterns:
|
|
273
|
+
relative_path = str(filepath.relative_to(self.repo_path))
|
|
274
|
+
if any(
|
|
275
|
+
self._matches_pattern(relative_path, pattern)
|
|
276
|
+
for pattern in exclude_patterns
|
|
277
|
+
):
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
source_files.append(filepath)
|
|
281
|
+
|
|
282
|
+
return source_files
|
|
283
|
+
|
|
284
|
+
def _matches_pattern(self, filepath: str, pattern: str) -> bool:
|
|
285
|
+
"""Check if filepath matches an exclude pattern.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
filepath: File path to check
|
|
289
|
+
pattern: Pattern to match against
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
True if matches
|
|
293
|
+
"""
|
|
294
|
+
# Simple pattern matching
|
|
295
|
+
if "*" in pattern:
|
|
296
|
+
# Convert simple glob to regex-like matching
|
|
297
|
+
import fnmatch
|
|
298
|
+
|
|
299
|
+
return fnmatch.fnmatch(filepath, pattern)
|
|
300
|
+
else:
|
|
301
|
+
# Direct substring match
|
|
302
|
+
return pattern in filepath
|
|
303
|
+
|
|
304
|
+
def _calculate_file_hash(self, filepath: Path) -> str:
|
|
305
|
+
"""Calculate hash of file contents.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
filepath: Path to file
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
SHA256 hash of file contents
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
with open(filepath, "rb") as f:
|
|
315
|
+
return hashlib.sha256(f.read()).hexdigest()
|
|
316
|
+
except Exception as e:
|
|
317
|
+
logger.error(f"Failed to calculate hash for {filepath}: {e}")
|
|
318
|
+
return ""
|
|
319
|
+
|
|
320
|
+
def get_file_nodes(self, filepath: str) -> set[str]:
|
|
321
|
+
"""Get all nodes tracked by a FileMetadata.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
filepath: Relative file path
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Set of qualified names of nodes in the file
|
|
328
|
+
"""
|
|
329
|
+
nodes = set()
|
|
330
|
+
|
|
331
|
+
# Query each TRACKS relationship type
|
|
332
|
+
for node_type, rel_type in [
|
|
333
|
+
("Module", "TRACKS_Module"),
|
|
334
|
+
("Class", "TRACKS_Class"),
|
|
335
|
+
("Function", "TRACKS_Function"),
|
|
336
|
+
("Method", "TRACKS_Method"),
|
|
337
|
+
]:
|
|
338
|
+
try:
|
|
339
|
+
result = self.conn.execute(
|
|
340
|
+
f"""
|
|
341
|
+
MATCH (f:FileMetadata {{filepath: $path}})-[:{rel_type}]->(n:{node_type})
|
|
342
|
+
RETURN n.qualified_name
|
|
343
|
+
""",
|
|
344
|
+
{"path": filepath},
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Handle the QueryResult properly - cast to proper type
|
|
348
|
+
if hasattr(result, "has_next"):
|
|
349
|
+
query_result = cast(Any, result)
|
|
350
|
+
while query_result.has_next():
|
|
351
|
+
row = query_result.get_next()
|
|
352
|
+
if isinstance(row, list | tuple) and len(row) > 0:
|
|
353
|
+
nodes.add(row[0])
|
|
354
|
+
except Exception as e:
|
|
355
|
+
# Ignore if relationship doesn't exist - this is expected when tables aren't created yet
|
|
356
|
+
logger.debug(f"No {rel_type} relationship found for {filepath}: {e}")
|
|
357
|
+
|
|
358
|
+
return nodes
|