codedd-cli 0.1.1__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.
- codedd_cli/__init__.py +3 -0
- codedd_cli/__main__.py +19 -0
- codedd_cli/api/__init__.py +4 -0
- codedd_cli/api/client.py +120 -0
- codedd_cli/api/endpoints.py +44 -0
- codedd_cli/api/exceptions.py +24 -0
- codedd_cli/auditor/__init__.py +6 -0
- codedd_cli/auditor/architecture_analyzer.py +1251 -0
- codedd_cli/auditor/architecture_prompts.py +173 -0
- codedd_cli/auditor/complexity_analyzer.py +1739 -0
- codedd_cli/auditor/dependency_scanner.py +2485 -0
- codedd_cli/auditor/file_auditor.py +578 -0
- codedd_cli/auditor/git_stats_collector.py +417 -0
- codedd_cli/auditor/response_parser.py +484 -0
- codedd_cli/auditor/vulnerability_validator.py +323 -0
- codedd_cli/auth/__init__.py +4 -0
- codedd_cli/auth/session.py +40 -0
- codedd_cli/auth/token_manager.py +86 -0
- codedd_cli/cli.py +69 -0
- codedd_cli/commands/__init__.py +1 -0
- codedd_cli/commands/audit_cmd.py +1987 -0
- codedd_cli/commands/audits_cmd.py +276 -0
- codedd_cli/commands/auth_cmd.py +235 -0
- codedd_cli/commands/config_cmd.py +421 -0
- codedd_cli/commands/scope_cmd.py +1016 -0
- codedd_cli/config/__init__.py +4 -0
- codedd_cli/config/constants.py +22 -0
- codedd_cli/config/settings.py +389 -0
- codedd_cli/llm/__init__.py +1 -0
- codedd_cli/llm/key_manager.py +267 -0
- codedd_cli/models/__init__.py +5 -0
- codedd_cli/models/account.py +13 -0
- codedd_cli/models/audit.py +31 -0
- codedd_cli/models/local_directory.py +25 -0
- codedd_cli/scanner/__init__.py +18 -0
- codedd_cli/scanner/file_classifier.py +752 -0
- codedd_cli/scanner/file_walker.py +213 -0
- codedd_cli/scanner/line_counter.py +80 -0
- codedd_cli/utils/__init__.py +1 -0
- codedd_cli/utils/directory_validator.py +178 -0
- codedd_cli/utils/display.py +497 -0
- codedd_cli/utils/payload_inspector.py +178 -0
- codedd_cli/utils/security.py +14 -0
- codedd_cli/utils/validators.py +37 -0
- codedd_cli-0.1.1.dist-info/METADATA +306 -0
- codedd_cli-0.1.1.dist-info/RECORD +49 -0
- codedd_cli-0.1.1.dist-info/WHEEL +4 -0
- codedd_cli-0.1.1.dist-info/entry_points.txt +3 -0
- codedd_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local repository scanner.
|
|
3
|
+
|
|
4
|
+
Walks a Git repository directory, classifies each file, counts lines of code,
|
|
5
|
+
and produces a structured metadata payload ready for submission to the CodeDD API.
|
|
6
|
+
|
|
7
|
+
**No file contents are included** — only paths, types, and line counts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from codedd_cli.scanner.file_classifier import (
|
|
18
|
+
get_file_type,
|
|
19
|
+
should_exclude_directory,
|
|
20
|
+
should_exclude_file,
|
|
21
|
+
)
|
|
22
|
+
from codedd_cli.scanner.line_counter import count_lines
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class FileMetadata:
|
|
29
|
+
"""Metadata for a single scanned file (no content)."""
|
|
30
|
+
|
|
31
|
+
relative_path: str
|
|
32
|
+
file_type: str
|
|
33
|
+
lines_of_code: int
|
|
34
|
+
lines_of_doc: int
|
|
35
|
+
selected_for_audit: bool = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class FolderMetadata:
|
|
40
|
+
"""Aggregated metadata for a folder."""
|
|
41
|
+
|
|
42
|
+
relative_path: str
|
|
43
|
+
lines_of_code: int = 0
|
|
44
|
+
file_count: int = 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ScanResult:
|
|
49
|
+
"""Complete scan result for a single repository directory."""
|
|
50
|
+
|
|
51
|
+
root_path: str
|
|
52
|
+
repo_name: str
|
|
53
|
+
branch: str
|
|
54
|
+
commit_hash: str
|
|
55
|
+
files: list[FileMetadata] = field(default_factory=list)
|
|
56
|
+
folders: list[FolderMetadata] = field(default_factory=list)
|
|
57
|
+
total_files: int = 0
|
|
58
|
+
total_lines_of_code: int = 0
|
|
59
|
+
total_lines_of_doc: int = 0
|
|
60
|
+
errors: list[str] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
"""Serialise to a plain dict suitable for JSON encoding."""
|
|
64
|
+
return {
|
|
65
|
+
"root_path": self.root_path,
|
|
66
|
+
"repo_name": self.repo_name,
|
|
67
|
+
"branch": self.branch,
|
|
68
|
+
"commit_hash": self.commit_hash,
|
|
69
|
+
"total_files": self.total_files,
|
|
70
|
+
"total_lines_of_code": self.total_lines_of_code,
|
|
71
|
+
"total_lines_of_doc": self.total_lines_of_doc,
|
|
72
|
+
"files": [
|
|
73
|
+
{
|
|
74
|
+
"relative_path": f.relative_path,
|
|
75
|
+
"file_type": f.file_type,
|
|
76
|
+
"lines_of_code": f.lines_of_code,
|
|
77
|
+
"lines_of_doc": f.lines_of_doc,
|
|
78
|
+
"selected_for_audit": f.selected_for_audit,
|
|
79
|
+
}
|
|
80
|
+
for f in self.files
|
|
81
|
+
],
|
|
82
|
+
"folders": [
|
|
83
|
+
{
|
|
84
|
+
"relative_path": fd.relative_path,
|
|
85
|
+
"lines_of_code": fd.lines_of_code,
|
|
86
|
+
"file_count": fd.file_count,
|
|
87
|
+
}
|
|
88
|
+
for fd in self.folders
|
|
89
|
+
],
|
|
90
|
+
"errors": self.errors,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _git_info(repo_path: str) -> tuple[str, str]:
|
|
95
|
+
"""Return (branch, short_commit_hash) for a repo, or empty strings on error."""
|
|
96
|
+
try:
|
|
97
|
+
branch = subprocess.run(
|
|
98
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
99
|
+
cwd=repo_path,
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
timeout=10,
|
|
103
|
+
).stdout.strip()
|
|
104
|
+
|
|
105
|
+
commit = subprocess.run(
|
|
106
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
107
|
+
cwd=repo_path,
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
timeout=10,
|
|
111
|
+
).stdout.strip()
|
|
112
|
+
|
|
113
|
+
return branch, commit
|
|
114
|
+
except Exception:
|
|
115
|
+
return "", ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def scan_repository(
|
|
119
|
+
root_path: str,
|
|
120
|
+
progress_callback: Callable[[int, int, str], None] | None = None,
|
|
121
|
+
) -> ScanResult:
|
|
122
|
+
"""
|
|
123
|
+
Walk *root_path*, classify every file, and count lines of code.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
root_path: Absolute path to the repository root.
|
|
127
|
+
progress_callback: Optional callable(current, total, file_path) invoked
|
|
128
|
+
for each file processed. Useful for Rich progress bars.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
A ``ScanResult`` containing all file and folder metadata.
|
|
132
|
+
"""
|
|
133
|
+
root = Path(root_path).resolve()
|
|
134
|
+
repo_name = root.name
|
|
135
|
+
branch, commit = _git_info(str(root))
|
|
136
|
+
|
|
137
|
+
result = ScanResult(
|
|
138
|
+
root_path=str(root),
|
|
139
|
+
repo_name=repo_name,
|
|
140
|
+
branch=branch,
|
|
141
|
+
commit_hash=commit,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Phase 1: collect all eligible file paths
|
|
145
|
+
file_paths: list[str] = []
|
|
146
|
+
for dirpath, dirnames, filenames in os.walk(str(root)):
|
|
147
|
+
# Filter out excluded directories in-place so os.walk skips them
|
|
148
|
+
dirnames[:] = [d for d in dirnames if not should_exclude_directory(d)]
|
|
149
|
+
for fname in filenames:
|
|
150
|
+
full_path = os.path.join(dirpath, fname)
|
|
151
|
+
# Skip symlinks
|
|
152
|
+
if os.path.islink(full_path):
|
|
153
|
+
continue
|
|
154
|
+
file_paths.append(full_path)
|
|
155
|
+
|
|
156
|
+
total_files = len(file_paths)
|
|
157
|
+
folder_aggregates: dict[str, FolderMetadata] = {}
|
|
158
|
+
|
|
159
|
+
# Phase 2: classify and count
|
|
160
|
+
for idx, full_path in enumerate(file_paths):
|
|
161
|
+
try:
|
|
162
|
+
rel_path = os.path.relpath(full_path, start=str(root)).replace("\\", "/")
|
|
163
|
+
file_type = get_file_type(full_path)
|
|
164
|
+
|
|
165
|
+
# Count LoC (returns (0, 0) for excluded files)
|
|
166
|
+
loc, doc = count_lines(full_path)
|
|
167
|
+
|
|
168
|
+
fm = FileMetadata(
|
|
169
|
+
relative_path=rel_path,
|
|
170
|
+
file_type=file_type,
|
|
171
|
+
lines_of_code=loc,
|
|
172
|
+
lines_of_doc=doc,
|
|
173
|
+
selected_for_audit=(not should_exclude_file(full_path) and loc > 0),
|
|
174
|
+
)
|
|
175
|
+
result.files.append(fm)
|
|
176
|
+
|
|
177
|
+
if fm.selected_for_audit:
|
|
178
|
+
result.total_files += 1
|
|
179
|
+
result.total_lines_of_code += loc
|
|
180
|
+
result.total_lines_of_doc += doc
|
|
181
|
+
|
|
182
|
+
# Aggregate into parent folder and ensure all ancestor folders exist.
|
|
183
|
+
# The server-side import (import_file_list.py) discovers ALL directories
|
|
184
|
+
# via os.scandir. The CLI must produce the same set so the folder
|
|
185
|
+
# hierarchy can be reconstructed on the server when building TypeDB
|
|
186
|
+
# directory_content relations (folder → parent_folder → root).
|
|
187
|
+
parent_rel = os.path.dirname(rel_path).replace("\\", "/")
|
|
188
|
+
if parent_rel and parent_rel != ".":
|
|
189
|
+
# Direct parent gets LoC / file_count
|
|
190
|
+
if parent_rel not in folder_aggregates:
|
|
191
|
+
folder_aggregates[parent_rel] = FolderMetadata(relative_path=parent_rel)
|
|
192
|
+
folder_aggregates[parent_rel].lines_of_code += loc
|
|
193
|
+
folder_aggregates[parent_rel].file_count += 1
|
|
194
|
+
|
|
195
|
+
# Ensure every ancestor folder exists (with 0 direct metrics)
|
|
196
|
+
ancestor = os.path.dirname(parent_rel).replace("\\", "/")
|
|
197
|
+
while ancestor and ancestor != ".":
|
|
198
|
+
if ancestor not in folder_aggregates:
|
|
199
|
+
folder_aggregates[ancestor] = FolderMetadata(relative_path=ancestor)
|
|
200
|
+
ancestor = os.path.dirname(ancestor).replace("\\", "/")
|
|
201
|
+
|
|
202
|
+
if progress_callback:
|
|
203
|
+
progress_callback(idx + 1, total_files, rel_path)
|
|
204
|
+
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
error_msg = f"Error scanning {full_path}: {exc}"
|
|
207
|
+
logger.warning(error_msg)
|
|
208
|
+
result.errors.append(error_msg)
|
|
209
|
+
|
|
210
|
+
# Build sorted folder list
|
|
211
|
+
result.folders = sorted(folder_aggregates.values(), key=lambda f: f.relative_path)
|
|
212
|
+
|
|
213
|
+
return result
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local line-of-code counter.
|
|
3
|
+
|
|
4
|
+
Counts non-empty lines of code and comment/doc lines for a single file.
|
|
5
|
+
This is a pure-Python implementation that mirrors the server-side
|
|
6
|
+
``get_code_and_doc_lines`` fallback logic — no external ``linecounter``
|
|
7
|
+
binary is required.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from codedd_cli.scanner.file_classifier import (
|
|
13
|
+
get_file_type,
|
|
14
|
+
should_exclude_file,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# File types eligible for LoC counting
|
|
18
|
+
_COUNTABLE_TYPES = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"Source Code",
|
|
21
|
+
"Configuration",
|
|
22
|
+
"Documentation",
|
|
23
|
+
"System",
|
|
24
|
+
"Security",
|
|
25
|
+
"Other",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Single-line comment prefixes by convention
|
|
30
|
+
_COMMENT_PREFIXES = ("#", "//", "/*", "*", "*/", "--", ";", "%", "REM ")
|
|
31
|
+
|
|
32
|
+
# Maximum file size (in bytes) to attempt reading — skip very large binaries
|
|
33
|
+
_MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def count_lines(file_path: str) -> tuple[int, int]:
|
|
37
|
+
"""
|
|
38
|
+
Count lines of code and lines of documentation/comments in *file_path*.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
file_path: Absolute path to the file.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Tuple of (lines_of_code, lines_of_doc).
|
|
45
|
+
Returns ``(0, 0)`` for excluded or unreadable files.
|
|
46
|
+
"""
|
|
47
|
+
if should_exclude_file(file_path):
|
|
48
|
+
return 0, 0
|
|
49
|
+
|
|
50
|
+
file_type = get_file_type(file_path)
|
|
51
|
+
if file_type not in _COUNTABLE_TYPES:
|
|
52
|
+
return 0, 0
|
|
53
|
+
|
|
54
|
+
# Safety: skip extremely large files
|
|
55
|
+
try:
|
|
56
|
+
size = os.path.getsize(file_path)
|
|
57
|
+
if size > _MAX_FILE_SIZE:
|
|
58
|
+
return 0, 0
|
|
59
|
+
except OSError:
|
|
60
|
+
return 0, 0
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with open(file_path, encoding="utf-8", errors="replace") as fh:
|
|
64
|
+
lines = fh.readlines()
|
|
65
|
+
except (OSError, PermissionError):
|
|
66
|
+
return 1, 0 # Minimum fallback
|
|
67
|
+
|
|
68
|
+
non_empty = 0
|
|
69
|
+
comment_lines = 0
|
|
70
|
+
|
|
71
|
+
for line in lines:
|
|
72
|
+
stripped = line.strip()
|
|
73
|
+
if not stripped:
|
|
74
|
+
continue
|
|
75
|
+
non_empty += 1
|
|
76
|
+
if stripped.startswith(_COMMENT_PREFIXES):
|
|
77
|
+
comment_lines += 1
|
|
78
|
+
|
|
79
|
+
code_lines = max(non_empty - comment_lines, 1) if non_empty > 0 else 0
|
|
80
|
+
return code_lines, comment_lines
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local directory validation for audit scope.
|
|
3
|
+
|
|
4
|
+
Validates that a user-provided path:
|
|
5
|
+
1. Exists on the filesystem
|
|
6
|
+
2. Is a directory (not a file)
|
|
7
|
+
3. Is readable / accessible by the current user
|
|
8
|
+
4. Is the root of a Git repository (contains ``.git/``)
|
|
9
|
+
5. Has at least one commit (valid HEAD)
|
|
10
|
+
|
|
11
|
+
Uses the **default branch** (main or master) for the audit, not the current
|
|
12
|
+
checkout. Fetches the default branch from origin when a remote exists so
|
|
13
|
+
the scope is tied to the canonical branch state.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from codedd_cli.models.local_directory import LocalDirectory
|
|
21
|
+
|
|
22
|
+
# Default branch names to try, in order of preference.
|
|
23
|
+
DEFAULT_BRANCH_ORDER = ("main", "master")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_directory(raw_path: str) -> LocalDirectory:
|
|
27
|
+
"""
|
|
28
|
+
Run all validation checks on *raw_path* and return a ``LocalDirectory``.
|
|
29
|
+
|
|
30
|
+
The returned object's ``is_valid`` flag indicates whether all checks
|
|
31
|
+
passed. When ``is_valid`` is False, the ``error`` field explains why.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
raw_path: A path string as entered by the user. May be relative;
|
|
35
|
+
it is resolved to an absolute path internally.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A populated ``LocalDirectory`` instance.
|
|
39
|
+
"""
|
|
40
|
+
# Normalise and resolve to an absolute path
|
|
41
|
+
try:
|
|
42
|
+
resolved = Path(raw_path).expanduser().resolve()
|
|
43
|
+
abs_path = str(resolved)
|
|
44
|
+
except (OSError, ValueError) as exc:
|
|
45
|
+
return LocalDirectory(
|
|
46
|
+
path=raw_path,
|
|
47
|
+
is_valid=False,
|
|
48
|
+
error=f"Invalid path: {exc}",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# 1. Existence check
|
|
52
|
+
if not resolved.exists():
|
|
53
|
+
return LocalDirectory(
|
|
54
|
+
path=abs_path,
|
|
55
|
+
is_valid=False,
|
|
56
|
+
error="Directory does not exist",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# 2. Must be a directory (not a file or symlink to file)
|
|
60
|
+
if not resolved.is_dir():
|
|
61
|
+
return LocalDirectory(
|
|
62
|
+
path=abs_path,
|
|
63
|
+
is_valid=False,
|
|
64
|
+
error="Path is not a directory",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# 3. Accessibility — try listing the directory contents
|
|
68
|
+
if not os.access(abs_path, os.R_OK):
|
|
69
|
+
return LocalDirectory(
|
|
70
|
+
path=abs_path,
|
|
71
|
+
is_valid=False,
|
|
72
|
+
error="Directory is not readable (permission denied)",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# 4. Must be a Git repository root
|
|
76
|
+
git_dir = resolved / ".git"
|
|
77
|
+
if not git_dir.exists():
|
|
78
|
+
return LocalDirectory(
|
|
79
|
+
path=abs_path,
|
|
80
|
+
repo_name=resolved.name,
|
|
81
|
+
is_valid=False,
|
|
82
|
+
error="Not a Git repository (no .git directory found)",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# 5. Use default branch (main/master) and its commit for the audit
|
|
86
|
+
branch, commit_hash, git_error = _default_branch_metadata(abs_path)
|
|
87
|
+
if git_error:
|
|
88
|
+
return LocalDirectory(
|
|
89
|
+
path=abs_path,
|
|
90
|
+
repo_name=resolved.name,
|
|
91
|
+
is_valid=False,
|
|
92
|
+
error=git_error,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return LocalDirectory(
|
|
96
|
+
path=abs_path,
|
|
97
|
+
repo_name=resolved.name,
|
|
98
|
+
branch=branch,
|
|
99
|
+
commit_hash=commit_hash,
|
|
100
|
+
is_valid=True,
|
|
101
|
+
error="",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _run_git(repo_path: str, args: list[str], timeout: int = 15) -> tuple[int, str, str]:
|
|
106
|
+
"""Run a git command; returns (returncode, stdout, stderr)."""
|
|
107
|
+
try:
|
|
108
|
+
result = subprocess.run(
|
|
109
|
+
["git"] + args,
|
|
110
|
+
cwd=repo_path,
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
timeout=timeout,
|
|
114
|
+
)
|
|
115
|
+
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
|
116
|
+
except FileNotFoundError:
|
|
117
|
+
return -1, "", "Git is not installed or not in PATH"
|
|
118
|
+
except subprocess.TimeoutExpired:
|
|
119
|
+
return -1, "", "Git command timed out"
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
return -1, "", str(exc)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_default_branch_name(repo_path: str) -> str:
|
|
125
|
+
"""
|
|
126
|
+
Determine the default branch: origin/HEAD, then local main, then local master.
|
|
127
|
+
|
|
128
|
+
Returns the branch name (e.g. "main") or empty string if none found.
|
|
129
|
+
"""
|
|
130
|
+
# Prefer remote default (e.g. origin/HEAD -> origin/main)
|
|
131
|
+
code, out, _ = _run_git(repo_path, ["rev-parse", "--abbrev-ref", "origin/HEAD"], timeout=5)
|
|
132
|
+
if code == 0 and out and out != "origin/HEAD":
|
|
133
|
+
# "origin/HEAD" -> "origin/main" -> we want "main"
|
|
134
|
+
if out.startswith("origin/"):
|
|
135
|
+
return out[7:]
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
# Fallback: which of main/master exists (local or remote)
|
|
139
|
+
for branch in DEFAULT_BRANCH_ORDER:
|
|
140
|
+
code, _, _ = _run_git(repo_path, ["rev-parse", "--verify", branch], timeout=5)
|
|
141
|
+
if code == 0:
|
|
142
|
+
return branch
|
|
143
|
+
code, _, _ = _run_git(repo_path, ["rev-parse", "--verify", f"origin/{branch}"], timeout=5)
|
|
144
|
+
if code == 0:
|
|
145
|
+
return branch
|
|
146
|
+
return ""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _default_branch_metadata(repo_path: str) -> tuple[str, str, str]:
|
|
150
|
+
"""
|
|
151
|
+
Resolve the default branch (main/master), fetch it from origin if possible,
|
|
152
|
+
and return its name and short commit hash for use in the audit scope.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
repo_path: Absolute path to the repository root.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Tuple of (branch_name, short_commit_hash, error_message).
|
|
159
|
+
On success ``error_message`` is empty.
|
|
160
|
+
"""
|
|
161
|
+
branch_name = _get_default_branch_name(repo_path)
|
|
162
|
+
if not branch_name:
|
|
163
|
+
# Last resort: use current HEAD so we don't fail local-only repos
|
|
164
|
+
code, head_branch, err = _run_git(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
165
|
+
if code != 0:
|
|
166
|
+
return "", "", f"Git error: no main/master branch and {err or 'unable to read HEAD'}"
|
|
167
|
+
branch_name = head_branch
|
|
168
|
+
|
|
169
|
+
# Fetch the default branch from origin (best-effort; ignore failures for offline/local repos)
|
|
170
|
+
_run_git(repo_path, ["fetch", "origin", branch_name], timeout=30)
|
|
171
|
+
|
|
172
|
+
# Resolve commit: prefer origin/<branch>, then local <branch>
|
|
173
|
+
for ref in (f"origin/{branch_name}", branch_name):
|
|
174
|
+
code, commit, err = _run_git(repo_path, ["rev-parse", "--short", ref])
|
|
175
|
+
if code == 0 and commit:
|
|
176
|
+
return branch_name, commit, ""
|
|
177
|
+
|
|
178
|
+
return branch_name, "", f"Git error: could not resolve commit for branch '{branch_name}'"
|