diffsage 1.2.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.
- diffsage/__init__.py +0 -0
- diffsage/__main__.py +4 -0
- diffsage/cli.py +38 -0
- diffsage/commands/__init__.py +0 -0
- diffsage/commands/ask.py +62 -0
- diffsage/commands/auth.py +153 -0
- diffsage/commands/commit.py +125 -0
- diffsage/commands/config.py +216 -0
- diffsage/commands/doctor.py +18 -0
- diffsage/config/__init__.py +0 -0
- diffsage/config/defaults.py +20 -0
- diffsage/config/file_loader.py +21 -0
- diffsage/config/loader.py +8 -0
- diffsage/config/paths.py +61 -0
- diffsage/config/resolver.py +118 -0
- diffsage/config/schema.py +53 -0
- diffsage/config/scope.py +8 -0
- diffsage/config/settings.py +9 -0
- diffsage/exceptions/__init__.py +34 -0
- diffsage/exceptions/base.py +32 -0
- diffsage/exceptions/credentials.py +8 -0
- diffsage/exceptions/git.py +9 -0
- diffsage/exceptions/provider.py +17 -0
- diffsage/git/__init__.py +0 -0
- diffsage/git/client.py +155 -0
- diffsage/logging/__init__.py +0 -0
- diffsage/logging/formatter.py +0 -0
- diffsage/logging/logger.py +41 -0
- diffsage/models/__init__.py +0 -0
- diffsage/models/commit_message.py +9 -0
- diffsage/models/config.py +16 -0
- diffsage/models/credentials.py +8 -0
- diffsage/models/doctor.py +13 -0
- diffsage/models/git.py +32 -0
- diffsage/models/provider.py +20 -0
- diffsage/parsers/commit_message_parser.py +43 -0
- diffsage/prompts/__init__.py +0 -0
- diffsage/providers/__init__.py +0 -0
- diffsage/providers/base.py +8 -0
- diffsage/providers/factory.py +14 -0
- diffsage/providers/gemini_provider.py +77 -0
- diffsage/services/__init__.py +0 -0
- diffsage/services/ai_service.py +63 -0
- diffsage/services/commit_service.py +38 -0
- diffsage/services/config_service.py +109 -0
- diffsage/services/credentials_service.py +61 -0
- diffsage/services/doctor_service.py +47 -0
- diffsage/services/editor_service.py +75 -0
- diffsage/services/git_service.py +20 -0
- diffsage/services/prompt_service.py +60 -0
- diffsage/storage/__init__.py +0 -0
- diffsage/storage/base.py +0 -0
- diffsage/storage/config_repository.py +85 -0
- diffsage/storage/credentials_repository.py +121 -0
- diffsage/ui/__init__.py +0 -0
- diffsage/ui/ask_view.py +10 -0
- diffsage/ui/auth_view.py +51 -0
- diffsage/ui/base.py +15 -0
- diffsage/ui/commit_view.py +100 -0
- diffsage/ui/config_view.py +58 -0
- diffsage/ui/doctor_view.py +39 -0
- diffsage-1.2.0.dist-info/METADATA +471 -0
- diffsage-1.2.0.dist-info/RECORD +67 -0
- diffsage-1.2.0.dist-info/WHEEL +5 -0
- diffsage-1.2.0.dist-info/entry_points.txt +2 -0
- diffsage-1.2.0.dist-info/licenses/LICENSE +21 -0
- diffsage-1.2.0.dist-info/top_level.txt +1 -0
diffsage/config/paths.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from platformdirs import user_config_path, user_log_path
|
|
4
|
+
|
|
5
|
+
APP_NAME = "DiffSage"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_global_config_path() -> Path:
|
|
9
|
+
"""Return the global DiffSage configuration file path."""
|
|
10
|
+
|
|
11
|
+
return user_config_path("DiffSage") / "config.toml"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_repository_root() -> Path | None:
|
|
15
|
+
"""Return the Git repository root or None if not inside a repository."""
|
|
16
|
+
current = Path.cwd()
|
|
17
|
+
|
|
18
|
+
while True:
|
|
19
|
+
if (current / ".git").is_dir():
|
|
20
|
+
return current
|
|
21
|
+
|
|
22
|
+
if current.parent == current:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
current = current.parent
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_local_config_path() -> Path | None:
|
|
29
|
+
"""Return the local repository configuration file path."""
|
|
30
|
+
|
|
31
|
+
root = find_repository_root()
|
|
32
|
+
|
|
33
|
+
if root is None:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
return root / ".diffsage.toml"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_log_directory() -> Path:
|
|
40
|
+
path = user_log_path(APP_NAME)
|
|
41
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_log_file_path() -> Path:
|
|
46
|
+
return get_log_directory() / "diffsage.log"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def ensure_global_config_dir() -> None:
|
|
50
|
+
"""Create the global configuration directory if it does not exist."""
|
|
51
|
+
|
|
52
|
+
get_global_config_path().parent.mkdir(
|
|
53
|
+
parents=True,
|
|
54
|
+
exist_ok=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_credentials_path() -> Path:
|
|
59
|
+
"""Return the global DiffSage credentials file path."""
|
|
60
|
+
|
|
61
|
+
return user_config_path(APP_NAME) / "credentials.toml"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
from diffsage.config.defaults import DEFAULT_CONFIG
|
|
8
|
+
from diffsage.config.file_loader import load_config
|
|
9
|
+
from diffsage.config.paths import get_global_config_path, get_local_config_path
|
|
10
|
+
from diffsage.config.schema import DiffSageConfig, PartialDiffSageConfig
|
|
11
|
+
from diffsage.config.scope import ConfigScope
|
|
12
|
+
from diffsage.config.settings import Settings
|
|
13
|
+
from diffsage.exceptions import ConfigError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _merge_dict(
|
|
17
|
+
base: dict,
|
|
18
|
+
override: dict,
|
|
19
|
+
) -> dict:
|
|
20
|
+
"""Recursively merge two dictionaries."""
|
|
21
|
+
|
|
22
|
+
merged = base.copy()
|
|
23
|
+
|
|
24
|
+
for key, value in override.items():
|
|
25
|
+
if key in merged and isinstance(merged[key], Mapping) and isinstance(value, Mapping):
|
|
26
|
+
merged[key] = _merge_dict(merged[key], value)
|
|
27
|
+
else:
|
|
28
|
+
merged[key] = value
|
|
29
|
+
|
|
30
|
+
return merged
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _merge_config(
|
|
34
|
+
base: DiffSageConfig,
|
|
35
|
+
override: PartialDiffSageConfig,
|
|
36
|
+
) -> DiffSageConfig:
|
|
37
|
+
"""Merge two configuration models."""
|
|
38
|
+
|
|
39
|
+
merged = _merge_dict(
|
|
40
|
+
base.model_dump(),
|
|
41
|
+
override.model_dump(exclude_unset=True),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return DiffSageConfig.model_validate(merged)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _to_settings(config: DiffSageConfig) -> Settings:
|
|
48
|
+
"""Convert a configuration model into runtime settings."""
|
|
49
|
+
|
|
50
|
+
return Settings(
|
|
51
|
+
provider=config.ai.provider,
|
|
52
|
+
ai_model=config.ai.model,
|
|
53
|
+
timeout=config.network.timeout,
|
|
54
|
+
max_retries=config.network.max_retries,
|
|
55
|
+
log_level=config.logging.level,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_settings() -> Settings:
|
|
60
|
+
"""Resolve application settings from all configuration sources."""
|
|
61
|
+
|
|
62
|
+
load_dotenv()
|
|
63
|
+
|
|
64
|
+
config = DEFAULT_CONFIG
|
|
65
|
+
global_config_path = get_global_config_path()
|
|
66
|
+
|
|
67
|
+
if global_config_path.exists():
|
|
68
|
+
config = _merge_config(
|
|
69
|
+
config,
|
|
70
|
+
load_config(global_config_path),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
local_config_path = get_local_config_path()
|
|
74
|
+
|
|
75
|
+
if local_config_path is not None and local_config_path.exists():
|
|
76
|
+
config = _merge_config(config, load_config(local_config_path))
|
|
77
|
+
|
|
78
|
+
settings = _to_settings(config)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
return Settings(
|
|
82
|
+
provider=os.getenv(
|
|
83
|
+
"DIFFSAGE_PROVIDER",
|
|
84
|
+
settings.provider,
|
|
85
|
+
),
|
|
86
|
+
ai_model=os.getenv(
|
|
87
|
+
"DIFFSAGE_AI_MODEL",
|
|
88
|
+
settings.ai_model,
|
|
89
|
+
),
|
|
90
|
+
timeout=int(
|
|
91
|
+
os.getenv(
|
|
92
|
+
"DIFFSAGE_TIMEOUT",
|
|
93
|
+
str(settings.timeout),
|
|
94
|
+
)
|
|
95
|
+
),
|
|
96
|
+
max_retries=int(
|
|
97
|
+
os.getenv(
|
|
98
|
+
"DIFFSAGE_MAX_RETRIES",
|
|
99
|
+
str(settings.max_retries),
|
|
100
|
+
)
|
|
101
|
+
),
|
|
102
|
+
log_level=os.getenv(
|
|
103
|
+
"DIFFSAGE_LOG_LEVEL",
|
|
104
|
+
settings.log_level,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
except ValueError as error:
|
|
109
|
+
raise ConfigError("Invalid configuration value.") from error
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve_config_path(scope: ConfigScope) -> Path:
|
|
113
|
+
"""Return the configuration path for the given scope"""
|
|
114
|
+
|
|
115
|
+
if scope is ConfigScope.GLOBAL:
|
|
116
|
+
return get_global_config_path()
|
|
117
|
+
|
|
118
|
+
return get_local_config_path()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AIConfig(BaseModel):
|
|
5
|
+
provider: str
|
|
6
|
+
model: str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NetworkConfig(BaseModel):
|
|
10
|
+
timeout: int
|
|
11
|
+
max_retries: int
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LoggingConfig(BaseModel):
|
|
15
|
+
level: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Reserved for future multi-provider support.
|
|
19
|
+
#
|
|
20
|
+
# class ProviderCredentials(BaseModel):
|
|
21
|
+
# api_key: str | None = None
|
|
22
|
+
#
|
|
23
|
+
#
|
|
24
|
+
# class ProvidersConfig(BaseModel):
|
|
25
|
+
# gemini: ProviderCredentials
|
|
26
|
+
# openai: ProviderCredentials
|
|
27
|
+
# anthropic: ProviderCredentials
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DiffSageConfig(BaseModel):
|
|
31
|
+
ai: AIConfig
|
|
32
|
+
network: NetworkConfig
|
|
33
|
+
logging: LoggingConfig
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PartialAIConfig(BaseModel):
|
|
37
|
+
provider: str | None = None
|
|
38
|
+
model: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PartialNetworkConfig(BaseModel):
|
|
42
|
+
timeout: int | None = None
|
|
43
|
+
max_retries: int | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PartialLoggingConfig(BaseModel):
|
|
47
|
+
level: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PartialDiffSageConfig(BaseModel):
|
|
51
|
+
ai: PartialAIConfig | None = None
|
|
52
|
+
network: PartialNetworkConfig | None = None
|
|
53
|
+
logging: PartialLoggingConfig | None = None
|
diffsage/config/scope.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .base import (
|
|
2
|
+
ConfigError,
|
|
3
|
+
DiffSageError,
|
|
4
|
+
GitError,
|
|
5
|
+
InvalidCommitMessageError,
|
|
6
|
+
InvalidConfigurationValueError,
|
|
7
|
+
ProviderError,
|
|
8
|
+
UnknownConfigurationKeyError,
|
|
9
|
+
)
|
|
10
|
+
from .credentials import CredentialNotFoundError
|
|
11
|
+
from .git import NoStagedChangesError, NotGitRepositoryError
|
|
12
|
+
from .provider import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
ModelNotFoundError,
|
|
15
|
+
ProviderUnavailableError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DiffSageError",
|
|
21
|
+
"ProviderError",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"ModelNotFoundError",
|
|
25
|
+
"ProviderUnavailableError",
|
|
26
|
+
"ConfigError",
|
|
27
|
+
"GitError",
|
|
28
|
+
"UnknownConfigurationKeyError",
|
|
29
|
+
"InvalidConfigurationValueError",
|
|
30
|
+
"InvalidCommitMessageError",
|
|
31
|
+
"NoStagedChangesError",
|
|
32
|
+
"NotGitRepositoryError",
|
|
33
|
+
"CredentialNotFoundError",
|
|
34
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class DiffSageError(Exception):
|
|
2
|
+
"""Base exception for all DiffSage errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ProviderError(DiffSageError):
|
|
6
|
+
"""Raised when an AI provider fails."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitError(DiffSageError):
|
|
10
|
+
"""Raised when git operations fail."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InvalidCommitMessageError(DiffSageError):
|
|
14
|
+
"""Raised when an AI-generated commit message cannot be parsed."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(DiffSageError):
|
|
18
|
+
"""Raised when configuration is invalid."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnknownConfigurationKeyError(ConfigError):
|
|
22
|
+
"""Raised when configuration key does not exist."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, key: str) -> None:
|
|
25
|
+
super().__init__(f"Unknown configuration key: '{key}'")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidConfigurationValueError(ConfigError):
|
|
29
|
+
"""Raised when configuration value is of invalid type."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, value: str) -> None:
|
|
32
|
+
super().__init__(f"Invalid configuration value: '{value}'")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from diffsage.exceptions.base import DiffSageError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CredentialNotFoundError(DiffSageError):
|
|
5
|
+
"""Raised when a required provider credential cannot be found."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, provider: str, name: str = "default") -> None:
|
|
8
|
+
super().__init__(f"Credential not found for provider '{provider}' and profile '{name}'.")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from diffsage.exceptions.base import ProviderError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuthenticationError(ProviderError):
|
|
5
|
+
"""Raised when authentication with a provider fails."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RateLimitError(ProviderError):
|
|
9
|
+
"""Raised when a provider rate limit is exceeded."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ModelNotFoundError(ProviderError):
|
|
13
|
+
"""Raised when the requested model is unavailable."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProviderUnavailableError(ProviderError):
|
|
17
|
+
"""Raised when the provider service is unavailable."""
|
diffsage/git/__init__.py
ADDED
|
File without changes
|
diffsage/git/client.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from diffsage.models.git import GitCommit, GitStatus
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GitClient:
|
|
9
|
+
"""Low-level client for executing Git commands."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, repo_path: Path | str | None = None) -> None:
|
|
12
|
+
self._repo_path = Path(repo_path) if repo_path else Path.cwd()
|
|
13
|
+
|
|
14
|
+
def _run_git_command(self, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
15
|
+
"""Execute a Git command and return the completed process."""
|
|
16
|
+
|
|
17
|
+
return subprocess.run(
|
|
18
|
+
["git", *args],
|
|
19
|
+
cwd=self._repo_path,
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
check=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def is_git_repository(self) -> bool:
|
|
26
|
+
"""Return True if the repository path is inside a Git working tree."""
|
|
27
|
+
try:
|
|
28
|
+
result = self._run_git_command(["rev-parse", "--is-inside-work-tree"])
|
|
29
|
+
return result.stdout.strip() == "true"
|
|
30
|
+
|
|
31
|
+
except subprocess.CalledProcessError:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
def repository_root(self) -> Path:
|
|
35
|
+
"""Return the root directory of the Git repository."""
|
|
36
|
+
|
|
37
|
+
result = self._run_git_command(["rev-parse", "--show-toplevel"])
|
|
38
|
+
return Path(result.stdout.strip())
|
|
39
|
+
|
|
40
|
+
def current_branch(self) -> str:
|
|
41
|
+
"""Return the name of the current Git branch."""
|
|
42
|
+
|
|
43
|
+
result = self._run_git_command(
|
|
44
|
+
["branch", "--show-current"],
|
|
45
|
+
)
|
|
46
|
+
return result.stdout.strip()
|
|
47
|
+
|
|
48
|
+
def current_commit(self) -> str:
|
|
49
|
+
"""Return the hash of the current commit."""
|
|
50
|
+
|
|
51
|
+
result = self._run_git_command(
|
|
52
|
+
["rev-parse", "HEAD"],
|
|
53
|
+
)
|
|
54
|
+
return result.stdout.strip()
|
|
55
|
+
|
|
56
|
+
def status(self) -> GitStatus:
|
|
57
|
+
"""Return the current repository status."""
|
|
58
|
+
|
|
59
|
+
result = self._run_git_command(
|
|
60
|
+
["status", "--porcelain"],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
untracked = []
|
|
64
|
+
modified = []
|
|
65
|
+
added = []
|
|
66
|
+
deleted = []
|
|
67
|
+
|
|
68
|
+
# Parse Git porcelain status codes.
|
|
69
|
+
for line in result.stdout.splitlines():
|
|
70
|
+
status = line[:2]
|
|
71
|
+
path = line[3:]
|
|
72
|
+
|
|
73
|
+
match status:
|
|
74
|
+
case "??":
|
|
75
|
+
untracked.append(path)
|
|
76
|
+
case " M":
|
|
77
|
+
modified.append(path)
|
|
78
|
+
case "A ":
|
|
79
|
+
added.append(path)
|
|
80
|
+
case " D":
|
|
81
|
+
deleted.append(path)
|
|
82
|
+
|
|
83
|
+
return GitStatus(
|
|
84
|
+
modified=modified,
|
|
85
|
+
added=added,
|
|
86
|
+
deleted=deleted,
|
|
87
|
+
untracked=untracked,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def staged_diff(self) -> str:
|
|
91
|
+
"""Returns the staged diff"""
|
|
92
|
+
|
|
93
|
+
result = self._run_git_command(
|
|
94
|
+
["diff", "--staged"],
|
|
95
|
+
)
|
|
96
|
+
return result.stdout.strip()
|
|
97
|
+
|
|
98
|
+
def unstaged_diff(self) -> str:
|
|
99
|
+
"""Returns the unstaged diff"""
|
|
100
|
+
|
|
101
|
+
result = self._run_git_command(
|
|
102
|
+
["diff"],
|
|
103
|
+
)
|
|
104
|
+
return result.stdout.strip()
|
|
105
|
+
|
|
106
|
+
def recent_commits(self, limit: int = 10) -> list[GitCommit]:
|
|
107
|
+
"""Return the most recent commits."""
|
|
108
|
+
|
|
109
|
+
result = self._run_git_command(
|
|
110
|
+
["log", f"-{limit}", "--pretty=format:%H%x09%an%x09%s%x09%aI"],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
commits: list[GitCommit] = []
|
|
114
|
+
|
|
115
|
+
for line in result.stdout.splitlines():
|
|
116
|
+
hash_, author, message, date = line.split("\t")
|
|
117
|
+
|
|
118
|
+
commit = GitCommit(
|
|
119
|
+
hash=hash_, author=author, message=message, date=datetime.fromisoformat(date)
|
|
120
|
+
)
|
|
121
|
+
commits.append(commit)
|
|
122
|
+
|
|
123
|
+
return commits
|
|
124
|
+
|
|
125
|
+
def branches(self) -> list[str]:
|
|
126
|
+
"""Return local branch names."""
|
|
127
|
+
|
|
128
|
+
result = self._run_git_command(
|
|
129
|
+
["branch", "--format=%(refname:short)"],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return result.stdout.splitlines()
|
|
133
|
+
|
|
134
|
+
def tags(self) -> list[str]:
|
|
135
|
+
"""Return tag names."""
|
|
136
|
+
|
|
137
|
+
result = self._run_git_command(
|
|
138
|
+
["tag", "--format=%(refname:short)"],
|
|
139
|
+
)
|
|
140
|
+
return result.stdout.splitlines()
|
|
141
|
+
|
|
142
|
+
def commit(self, message: str) -> None:
|
|
143
|
+
"""Create a git commit with the provided commit message."""
|
|
144
|
+
|
|
145
|
+
lines = message.split("\n", 1)
|
|
146
|
+
|
|
147
|
+
subject = lines[0]
|
|
148
|
+
body = lines[1].strip() if len(lines) > 1 else ""
|
|
149
|
+
|
|
150
|
+
command = ["commit", "-m", subject]
|
|
151
|
+
|
|
152
|
+
if body:
|
|
153
|
+
command.extend(["-m", body])
|
|
154
|
+
|
|
155
|
+
self._run_git_command(command)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from diffsage.config.paths import get_log_directory
|
|
4
|
+
|
|
5
|
+
_CONFIGURED = False
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def configure_logging(level: int = logging.INFO) -> None:
|
|
9
|
+
"""Configure the application's logging."""
|
|
10
|
+
|
|
11
|
+
global _CONFIGURED
|
|
12
|
+
|
|
13
|
+
if _CONFIGURED:
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
root_logger = logging.getLogger()
|
|
17
|
+
root_logger.setLevel(level)
|
|
18
|
+
|
|
19
|
+
log_file = get_log_directory() / "diffsage.log"
|
|
20
|
+
|
|
21
|
+
# console_handler = logging.StreamHandler()
|
|
22
|
+
file_handler = logging.FileHandler(log_file)
|
|
23
|
+
|
|
24
|
+
formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s")
|
|
25
|
+
|
|
26
|
+
# console_handler.setFormatter(formatter)
|
|
27
|
+
file_handler.setFormatter(formatter)
|
|
28
|
+
|
|
29
|
+
# root_logger.addHandler(console_handler)
|
|
30
|
+
root_logger.addHandler(file_handler)
|
|
31
|
+
|
|
32
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
33
|
+
logging.getLogger("google_genai").setLevel(logging.WARNING)
|
|
34
|
+
|
|
35
|
+
_CONFIGURED = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_logger(name: str) -> logging.Logger:
|
|
39
|
+
"""Return a logger."""
|
|
40
|
+
|
|
41
|
+
return logging.getLogger(name)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(slots=True)
|
|
5
|
+
class ConfigReport:
|
|
6
|
+
provider: str | None
|
|
7
|
+
model: str | None
|
|
8
|
+
timeout: int | None
|
|
9
|
+
max_retries: int | None
|
|
10
|
+
log_level: str | None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class ConfigValueReport:
|
|
15
|
+
key: str
|
|
16
|
+
value: str | int | None
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DoctorReport(BaseModel):
|
|
5
|
+
python_version: str
|
|
6
|
+
git_installed: bool
|
|
7
|
+
git_version: str | None
|
|
8
|
+
provider: str
|
|
9
|
+
timeout: int
|
|
10
|
+
max_retries: int
|
|
11
|
+
log_level: str
|
|
12
|
+
virtual_environment: bool
|
|
13
|
+
configuration_loaded: bool
|
diffsage/models/git.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(slots=True)
|
|
6
|
+
class GitStatus:
|
|
7
|
+
"""Represents the current status of a Git repository."""
|
|
8
|
+
|
|
9
|
+
modified: list[str]
|
|
10
|
+
added: list[str]
|
|
11
|
+
deleted: list[str]
|
|
12
|
+
untracked: list[str]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class GitCommit:
|
|
17
|
+
"""Represents a commit history in a Git repository"""
|
|
18
|
+
|
|
19
|
+
hash: str
|
|
20
|
+
author: str
|
|
21
|
+
message: str
|
|
22
|
+
date: datetime
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class CommitContext:
|
|
27
|
+
"""Represents the information needed to generate a commit message."""
|
|
28
|
+
|
|
29
|
+
branch: str
|
|
30
|
+
staged_diff: str
|
|
31
|
+
unstaged_diff: str
|
|
32
|
+
recent_commits: list[GitCommit]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(slots=True)
|
|
5
|
+
class ProviderRequest:
|
|
6
|
+
prompt: str
|
|
7
|
+
model: str
|
|
8
|
+
temperature: float
|
|
9
|
+
max_tokens: int
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class ProviderResponse:
|
|
14
|
+
content: str
|
|
15
|
+
provider: str
|
|
16
|
+
model: str
|
|
17
|
+
input_tokens: int
|
|
18
|
+
output_tokens: int
|
|
19
|
+
finish_reason: str
|
|
20
|
+
latency_ms: int
|