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.

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. 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)