scc-cli 1.5.3__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 scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +960 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +383 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Git services - data operations without UI dependencies.
|
|
2
|
+
|
|
3
|
+
This package contains pure data functions for git operations.
|
|
4
|
+
UI rendering is handled separately in ui/git_render.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .branch import (
|
|
8
|
+
PROTECTED_BRANCHES,
|
|
9
|
+
get_current_branch,
|
|
10
|
+
get_default_branch,
|
|
11
|
+
get_display_branch,
|
|
12
|
+
get_uncommitted_files,
|
|
13
|
+
is_protected_branch,
|
|
14
|
+
list_branches_without_worktrees,
|
|
15
|
+
sanitize_branch_name,
|
|
16
|
+
)
|
|
17
|
+
from .core import (
|
|
18
|
+
check_git_available,
|
|
19
|
+
check_git_installed,
|
|
20
|
+
create_empty_initial_commit,
|
|
21
|
+
detect_workspace_root,
|
|
22
|
+
get_git_version,
|
|
23
|
+
has_commits,
|
|
24
|
+
has_remote,
|
|
25
|
+
init_repo,
|
|
26
|
+
is_file_ignored,
|
|
27
|
+
is_git_repo,
|
|
28
|
+
)
|
|
29
|
+
from .hooks import (
|
|
30
|
+
SCC_HOOK_MARKER,
|
|
31
|
+
install_pre_push_hook,
|
|
32
|
+
is_scc_hook,
|
|
33
|
+
)
|
|
34
|
+
from .worktree import (
|
|
35
|
+
WorktreeInfo,
|
|
36
|
+
find_main_worktree,
|
|
37
|
+
find_worktree_by_query,
|
|
38
|
+
get_workspace_mount_path,
|
|
39
|
+
get_worktree_main_repo,
|
|
40
|
+
get_worktree_status,
|
|
41
|
+
get_worktrees_data,
|
|
42
|
+
is_worktree,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# Core
|
|
47
|
+
"check_git_available",
|
|
48
|
+
"check_git_installed",
|
|
49
|
+
"get_git_version",
|
|
50
|
+
"is_git_repo",
|
|
51
|
+
"is_file_ignored",
|
|
52
|
+
"has_commits",
|
|
53
|
+
"init_repo",
|
|
54
|
+
"create_empty_initial_commit",
|
|
55
|
+
"has_remote",
|
|
56
|
+
"detect_workspace_root",
|
|
57
|
+
# Branch
|
|
58
|
+
"PROTECTED_BRANCHES",
|
|
59
|
+
"is_protected_branch",
|
|
60
|
+
"get_current_branch",
|
|
61
|
+
"get_default_branch",
|
|
62
|
+
"sanitize_branch_name",
|
|
63
|
+
"get_display_branch",
|
|
64
|
+
"get_uncommitted_files",
|
|
65
|
+
"list_branches_without_worktrees",
|
|
66
|
+
# Worktree
|
|
67
|
+
"WorktreeInfo",
|
|
68
|
+
"get_worktrees_data",
|
|
69
|
+
"get_worktree_status",
|
|
70
|
+
"is_worktree",
|
|
71
|
+
"get_worktree_main_repo",
|
|
72
|
+
"get_workspace_mount_path",
|
|
73
|
+
"find_worktree_by_query",
|
|
74
|
+
"find_main_worktree",
|
|
75
|
+
# Hooks
|
|
76
|
+
"SCC_HOOK_MARKER",
|
|
77
|
+
"is_scc_hook",
|
|
78
|
+
"install_pre_push_hook",
|
|
79
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Branch operations - safety checks, naming, queries.
|
|
2
|
+
|
|
3
|
+
Pure functions with no UI dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ...core.constants import WORKTREE_BRANCH_PREFIX
|
|
10
|
+
from ...subprocess_utils import run_command, run_command_bool, run_command_lines
|
|
11
|
+
|
|
12
|
+
PROTECTED_BRANCHES = ("main", "master", "develop", "production", "staging")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_protected_branch(branch: str) -> bool:
|
|
16
|
+
"""Check if branch is protected.
|
|
17
|
+
|
|
18
|
+
Protected branches are: main, master, develop, production, staging.
|
|
19
|
+
"""
|
|
20
|
+
return branch in PROTECTED_BRANCHES
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_current_branch(path: Path) -> str | None:
|
|
24
|
+
"""Get the current branch name."""
|
|
25
|
+
return run_command(["git", "-C", str(path), "branch", "--show-current"], timeout=5)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_default_branch(path: Path) -> str:
|
|
29
|
+
"""Get the default branch for worktree creation.
|
|
30
|
+
|
|
31
|
+
Resolution order:
|
|
32
|
+
1. Current branch (respects user's git init.defaultBranch config)
|
|
33
|
+
2. Remote origin HEAD (for cloned repositories)
|
|
34
|
+
3. Check if main or master exists locally
|
|
35
|
+
4. Fallback to "main"
|
|
36
|
+
|
|
37
|
+
This order ensures freshly initialized repos use their actual branch
|
|
38
|
+
name rather than assuming "main".
|
|
39
|
+
"""
|
|
40
|
+
# Priority 1: Use current branch (best for local-only repos)
|
|
41
|
+
current = get_current_branch(path)
|
|
42
|
+
if current:
|
|
43
|
+
return current
|
|
44
|
+
|
|
45
|
+
# Priority 2: Try to get from remote HEAD (for cloned repos)
|
|
46
|
+
output = run_command(
|
|
47
|
+
["git", "-C", str(path), "symbolic-ref", "refs/remotes/origin/HEAD"],
|
|
48
|
+
timeout=5,
|
|
49
|
+
)
|
|
50
|
+
if output:
|
|
51
|
+
return output.split("/")[-1]
|
|
52
|
+
|
|
53
|
+
# Priority 3: Check if main or master exists locally
|
|
54
|
+
for branch in ["main", "master"]:
|
|
55
|
+
if run_command_bool(
|
|
56
|
+
["git", "-C", str(path), "rev-parse", "--verify", branch],
|
|
57
|
+
timeout=5,
|
|
58
|
+
):
|
|
59
|
+
return branch
|
|
60
|
+
|
|
61
|
+
return "main"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sanitize_branch_name(name: str) -> str:
|
|
65
|
+
"""Sanitize a name for use as a branch/directory name.
|
|
66
|
+
|
|
67
|
+
Converts input to a safe format for git branch names and filesystem directories.
|
|
68
|
+
Path separators (/ and \\) are replaced with hyphens to prevent collisions.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
>>> sanitize_branch_name("feature/auth")
|
|
72
|
+
'feature-auth'
|
|
73
|
+
>>> sanitize_branch_name("Feature Auth")
|
|
74
|
+
'feature-auth'
|
|
75
|
+
"""
|
|
76
|
+
safe = name.lower()
|
|
77
|
+
# Replace path separators with hyphens FIRST (collision fix)
|
|
78
|
+
safe = safe.replace("/", "-").replace("\\", "-")
|
|
79
|
+
# Replace spaces with hyphens
|
|
80
|
+
safe = safe.replace(" ", "-")
|
|
81
|
+
# Remove invalid characters (only a-z, 0-9, - allowed)
|
|
82
|
+
safe = re.sub(r"[^a-z0-9-]", "", safe)
|
|
83
|
+
# Collapse multiple hyphens
|
|
84
|
+
safe = re.sub(r"-+", "-", safe)
|
|
85
|
+
# Strip leading/trailing hyphens
|
|
86
|
+
return safe.strip("-")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_display_branch(branch: str) -> str:
|
|
90
|
+
"""Get user-friendly branch name (strip worktree prefixes if present).
|
|
91
|
+
|
|
92
|
+
Strips both `scc/` (current) and `claude/` (legacy) prefixes for cleaner display.
|
|
93
|
+
This is display-only; matching rules still require `scc/` prefix for new branches.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
branch: The full branch name.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Branch name with worktree prefix stripped for display.
|
|
100
|
+
"""
|
|
101
|
+
# Strip both current (scc/) and legacy (claude/) prefixes for display
|
|
102
|
+
for prefix in (WORKTREE_BRANCH_PREFIX, "claude/"):
|
|
103
|
+
if branch.startswith(prefix):
|
|
104
|
+
return branch[len(prefix) :]
|
|
105
|
+
return branch
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_uncommitted_files(path: Path) -> list[str]:
|
|
109
|
+
"""Get list of uncommitted files in a repository."""
|
|
110
|
+
lines = run_command_lines(
|
|
111
|
+
["git", "-C", str(path), "status", "--porcelain"],
|
|
112
|
+
timeout=5,
|
|
113
|
+
)
|
|
114
|
+
# Each line is "XY filename" where XY is 2-char status code
|
|
115
|
+
return [line[3:] for line in lines if len(line) > 3]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def list_branches_without_worktrees(repo_path: Path) -> list[str]:
|
|
119
|
+
"""List remote branches that don't have local worktrees.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
repo_path: Path to the repository.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
List of branch names (without origin/ prefix) that have no worktrees.
|
|
126
|
+
"""
|
|
127
|
+
from .worktree import get_worktrees_data
|
|
128
|
+
|
|
129
|
+
# Get all remote branches
|
|
130
|
+
remote_output = run_command(
|
|
131
|
+
["git", "-C", str(repo_path), "branch", "-r", "--format", "%(refname:short)"],
|
|
132
|
+
timeout=10,
|
|
133
|
+
)
|
|
134
|
+
if not remote_output:
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
remote_branches = set()
|
|
138
|
+
for line in remote_output.strip().split("\n"):
|
|
139
|
+
line = line.strip()
|
|
140
|
+
if line and not line.endswith("/HEAD"):
|
|
141
|
+
# Remove origin/ prefix
|
|
142
|
+
if "/" in line:
|
|
143
|
+
branch = line.split("/", 1)[1]
|
|
144
|
+
remote_branches.add(branch)
|
|
145
|
+
|
|
146
|
+
# Get worktree branches
|
|
147
|
+
worktrees = get_worktrees_data(repo_path)
|
|
148
|
+
worktree_branches = {wt.branch for wt in worktrees if wt.branch}
|
|
149
|
+
|
|
150
|
+
# Return branches without worktrees
|
|
151
|
+
return sorted(remote_branches - worktree_branches)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Core git operations - detection, initialization, repository management.
|
|
2
|
+
|
|
3
|
+
Pure functions with no UI dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ...core.errors import GitNotFoundError
|
|
10
|
+
from ...subprocess_utils import run_command, run_command_bool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_git_available() -> None:
|
|
14
|
+
"""Check if Git is installed and available.
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
GitNotFoundError: Git is not installed or not in PATH
|
|
18
|
+
"""
|
|
19
|
+
if shutil.which("git") is None:
|
|
20
|
+
raise GitNotFoundError()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_git_installed() -> bool:
|
|
24
|
+
"""Check if Git is installed (boolean for doctor command)."""
|
|
25
|
+
return shutil.which("git") is not None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_git_version() -> str | None:
|
|
29
|
+
"""Get Git version string for display."""
|
|
30
|
+
# Returns something like "git version 2.40.0"
|
|
31
|
+
return run_command(["git", "--version"], timeout=5)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_git_repo(path: Path) -> bool:
|
|
35
|
+
"""Check if path is inside a git repository."""
|
|
36
|
+
return run_command_bool(["git", "-C", str(path), "rev-parse", "--git-dir"], timeout=5)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def has_commits(path: Path) -> bool:
|
|
40
|
+
"""Check if the git repository has at least one commit.
|
|
41
|
+
|
|
42
|
+
This is important for worktree operations, which require at least
|
|
43
|
+
one commit to function properly.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
path: Path to the git repository.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
True if the repository has at least one commit, False otherwise.
|
|
50
|
+
"""
|
|
51
|
+
if not is_git_repo(path):
|
|
52
|
+
return False
|
|
53
|
+
# rev-parse HEAD fails if there are no commits
|
|
54
|
+
return run_command_bool(["git", "-C", str(path), "rev-parse", "HEAD"], timeout=5)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def init_repo(path: Path) -> bool:
|
|
58
|
+
"""Initialize a new git repository.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
path: Path where to initialize the repository.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
True if initialization succeeded, False otherwise.
|
|
65
|
+
"""
|
|
66
|
+
result = run_command(["git", "-C", str(path), "init"], timeout=10)
|
|
67
|
+
return result is not None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def create_empty_initial_commit(path: Path) -> tuple[bool, str | None]:
|
|
71
|
+
"""Create an empty initial commit to enable worktree operations.
|
|
72
|
+
|
|
73
|
+
Worktrees require at least one commit to function. This creates a
|
|
74
|
+
minimal empty commit without staging any files, following the
|
|
75
|
+
principle of not mutating user files without consent.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
path: Path to the git repository.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Tuple of (success, error_message). If success is False,
|
|
82
|
+
error_message contains details (e.g., git identity not configured).
|
|
83
|
+
"""
|
|
84
|
+
result = run_command(
|
|
85
|
+
[
|
|
86
|
+
"git",
|
|
87
|
+
"-C",
|
|
88
|
+
str(path),
|
|
89
|
+
"commit",
|
|
90
|
+
"--allow-empty",
|
|
91
|
+
"-m",
|
|
92
|
+
"Initial commit",
|
|
93
|
+
],
|
|
94
|
+
timeout=10,
|
|
95
|
+
)
|
|
96
|
+
if result is None:
|
|
97
|
+
# Check if it's a git identity issue
|
|
98
|
+
name_check = run_command(["git", "-C", str(path), "config", "user.name"], timeout=5)
|
|
99
|
+
email_check = run_command(["git", "-C", str(path), "config", "user.email"], timeout=5)
|
|
100
|
+
if not name_check or not email_check:
|
|
101
|
+
return (
|
|
102
|
+
False,
|
|
103
|
+
"Git identity not configured. Run:\n"
|
|
104
|
+
" git config --global user.name 'Your Name'\n"
|
|
105
|
+
" git config --global user.email 'you@example.com'",
|
|
106
|
+
)
|
|
107
|
+
return (False, "Failed to create initial commit")
|
|
108
|
+
return (True, None)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def has_remote(path: Path, remote_name: str = "origin") -> bool:
|
|
112
|
+
"""Check if the repository has a specific remote configured.
|
|
113
|
+
|
|
114
|
+
This is used to determine whether fetch operations should be attempted.
|
|
115
|
+
Freshly initialized repositories have no remotes.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
path: Path to the git repository.
|
|
119
|
+
remote_name: Name of the remote to check (default: "origin").
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True if the remote exists, False otherwise.
|
|
123
|
+
"""
|
|
124
|
+
if not is_git_repo(path):
|
|
125
|
+
return False
|
|
126
|
+
result = run_command(
|
|
127
|
+
["git", "-C", str(path), "remote", "get-url", remote_name],
|
|
128
|
+
timeout=5,
|
|
129
|
+
)
|
|
130
|
+
return result is not None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def detect_workspace_root(start_dir: Path) -> tuple[Path | None, Path]:
|
|
134
|
+
"""Detect the workspace root from a starting directory.
|
|
135
|
+
|
|
136
|
+
This function implements smart workspace detection for use cases where
|
|
137
|
+
the user runs `scc start` from a subdirectory or git worktree.
|
|
138
|
+
|
|
139
|
+
Resolution order:
|
|
140
|
+
1) git rev-parse --show-toplevel (works for subdirs + worktrees)
|
|
141
|
+
2) Parent-walk for .scc.yaml (repo root config marker)
|
|
142
|
+
3) Parent-walk for .git (directory OR file - worktree-safe)
|
|
143
|
+
4) None (no workspace detected)
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
start_dir: The directory to start detection from (usually cwd).
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Tuple of (root, start_cwd) where:
|
|
150
|
+
- root: The detected workspace root, or None if not found
|
|
151
|
+
- start_cwd: The original start_dir (preserved for container cwd)
|
|
152
|
+
"""
|
|
153
|
+
start_dir = start_dir.resolve()
|
|
154
|
+
|
|
155
|
+
# Priority 1: Use git rev-parse --show-toplevel (handles subdirs + worktrees)
|
|
156
|
+
if check_git_installed():
|
|
157
|
+
toplevel = run_command(
|
|
158
|
+
["git", "-C", str(start_dir), "rev-parse", "--show-toplevel"],
|
|
159
|
+
timeout=5,
|
|
160
|
+
)
|
|
161
|
+
if toplevel:
|
|
162
|
+
return (Path(toplevel.strip()), start_dir)
|
|
163
|
+
|
|
164
|
+
# Priority 2: Parent-walk for .scc.yaml (SCC project marker)
|
|
165
|
+
current = start_dir
|
|
166
|
+
while current != current.parent:
|
|
167
|
+
scc_config = current / ".scc.yaml"
|
|
168
|
+
if scc_config.is_file():
|
|
169
|
+
return (current, start_dir)
|
|
170
|
+
current = current.parent
|
|
171
|
+
|
|
172
|
+
# Priority 3: Parent-walk for .git (directory OR file - worktree-safe)
|
|
173
|
+
current = start_dir
|
|
174
|
+
while current != current.parent:
|
|
175
|
+
git_marker = current / ".git"
|
|
176
|
+
if git_marker.exists(): # Works for both directory and file
|
|
177
|
+
return (current, start_dir)
|
|
178
|
+
current = current.parent
|
|
179
|
+
|
|
180
|
+
# No workspace detected
|
|
181
|
+
return (None, start_dir)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def is_file_ignored(file_path: str | Path, repo_root: Path | None = None) -> bool:
|
|
185
|
+
"""Check if a file path is ignored by git.
|
|
186
|
+
|
|
187
|
+
Uses git check-ignore to determine if the file would be ignored.
|
|
188
|
+
Returns False if git is not available or not in a git repo (fail-open).
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
file_path: The file path to check.
|
|
192
|
+
repo_root: The repository root. If None, uses current directory.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
True if the file is ignored by git, False otherwise.
|
|
196
|
+
"""
|
|
197
|
+
import subprocess
|
|
198
|
+
|
|
199
|
+
cwd = str(repo_root) if repo_root else None
|
|
200
|
+
|
|
201
|
+
# Check if we're actually in a git repo
|
|
202
|
+
if repo_root and not (repo_root / ".git").exists():
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
result = subprocess.run(
|
|
207
|
+
["git", "check-ignore", "-q", str(file_path)],
|
|
208
|
+
capture_output=True,
|
|
209
|
+
cwd=cwd,
|
|
210
|
+
timeout=5,
|
|
211
|
+
)
|
|
212
|
+
# Exit code 0 means file IS ignored
|
|
213
|
+
return result.returncode == 0
|
|
214
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
215
|
+
# git not available or other error - fail silently (fail-open)
|
|
216
|
+
return False
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Git hooks management - installation and detection.
|
|
2
|
+
|
|
3
|
+
Pure functions with no UI dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SCC_HOOK_MARKER = "# SCC-MANAGED-HOOK" # Identifies hooks we can safely update
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_scc_hook(hook_path: Path) -> bool:
|
|
12
|
+
"""Check if hook file is managed by SCC (has SCC marker).
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
True if hook exists and contains SCC_HOOK_MARKER, False otherwise.
|
|
16
|
+
"""
|
|
17
|
+
if not hook_path.exists():
|
|
18
|
+
return False
|
|
19
|
+
try:
|
|
20
|
+
content = hook_path.read_text()
|
|
21
|
+
return SCC_HOOK_MARKER in content
|
|
22
|
+
except (OSError, PermissionError):
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def install_pre_push_hook(repo_path: Path) -> tuple[bool, str]:
|
|
27
|
+
"""Install repo-local pre-push hook with strict rules.
|
|
28
|
+
|
|
29
|
+
Installation conditions:
|
|
30
|
+
1. User said yes in `scc setup` (hooks.enabled=true in config)
|
|
31
|
+
2. Repo is recognized (has .git directory)
|
|
32
|
+
|
|
33
|
+
Never:
|
|
34
|
+
- Modify global git config
|
|
35
|
+
- Overwrite existing non-SCC hooks
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
repo_path: Path to the git repository root
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Tuple of (success, message) describing the outcome
|
|
42
|
+
"""
|
|
43
|
+
from ...config import load_user_config
|
|
44
|
+
|
|
45
|
+
# Condition 1: Check if hooks are enabled in user config
|
|
46
|
+
config = load_user_config()
|
|
47
|
+
if not config.get("hooks", {}).get("enabled", False):
|
|
48
|
+
return (False, "Hooks not enabled in config")
|
|
49
|
+
|
|
50
|
+
# Condition 2: Check if repo is recognized (has .git directory)
|
|
51
|
+
git_dir = repo_path / ".git"
|
|
52
|
+
if not git_dir.exists():
|
|
53
|
+
return (False, "Not a git repository")
|
|
54
|
+
|
|
55
|
+
# Determine hooks directory (repo-local, NOT global)
|
|
56
|
+
hooks_dir = git_dir / "hooks"
|
|
57
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
hook_path = hooks_dir / "pre-push"
|
|
59
|
+
|
|
60
|
+
# Check for existing hook
|
|
61
|
+
if hook_path.exists():
|
|
62
|
+
if is_scc_hook(hook_path):
|
|
63
|
+
# Safe to update our own hook
|
|
64
|
+
_write_scc_hook(hook_path)
|
|
65
|
+
return (True, "Updated existing SCC hook")
|
|
66
|
+
else:
|
|
67
|
+
# DON'T overwrite user's hook
|
|
68
|
+
return (
|
|
69
|
+
False,
|
|
70
|
+
f"Will not overwrite existing user hook at {hook_path}. "
|
|
71
|
+
f"To manually add SCC protection, add '{SCC_HOOK_MARKER}' marker to your hook.",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# No existing hook - safe to create
|
|
75
|
+
_write_scc_hook(hook_path)
|
|
76
|
+
return (True, "Installed new SCC hook")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _write_scc_hook(hook_path: Path) -> None:
|
|
80
|
+
"""Write SCC pre-push hook content.
|
|
81
|
+
|
|
82
|
+
The hook blocks pushes to protected branches (main, master, develop, production, staging).
|
|
83
|
+
"""
|
|
84
|
+
hook_content = f"""#!/bin/bash
|
|
85
|
+
{SCC_HOOK_MARKER}
|
|
86
|
+
# SCC pre-push hook - blocks pushes to protected branches
|
|
87
|
+
# This hook is managed by SCC. You can safely delete it to remove protection.
|
|
88
|
+
|
|
89
|
+
branch=$(git rev-parse --abbrev-ref HEAD)
|
|
90
|
+
protected_branches="main master develop production staging"
|
|
91
|
+
|
|
92
|
+
for protected in $protected_branches; do
|
|
93
|
+
if [ "$branch" = "$protected" ]; then
|
|
94
|
+
echo ""
|
|
95
|
+
echo "❌ Direct push to '$branch' blocked by SCC"
|
|
96
|
+
echo ""
|
|
97
|
+
echo "Create a feature branch first:"
|
|
98
|
+
echo " git checkout -b scc/your-feature"
|
|
99
|
+
echo " git push -u origin scc/your-feature"
|
|
100
|
+
echo ""
|
|
101
|
+
exit 1
|
|
102
|
+
fi
|
|
103
|
+
done
|
|
104
|
+
|
|
105
|
+
exit 0
|
|
106
|
+
"""
|
|
107
|
+
hook_path.write_text(hook_content)
|
|
108
|
+
hook_path.chmod(0o755)
|