multi-forge 0.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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Git worktree creation utilities.
|
|
2
|
+
|
|
3
|
+
This module provides functions for creating git worktrees for session isolation.
|
|
4
|
+
Each session can have its own worktree, enabling parallel work without conflicts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..exceptions import (
|
|
15
|
+
BranchExistsError,
|
|
16
|
+
GitNotFoundError,
|
|
17
|
+
GitWorktreeError,
|
|
18
|
+
InvalidBranchNameError,
|
|
19
|
+
WorktreePathExistsError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class WorktreeResult:
|
|
25
|
+
"""Result of worktree creation."""
|
|
26
|
+
|
|
27
|
+
worktree_path: str
|
|
28
|
+
branch: str
|
|
29
|
+
created_branch: bool # True if a new branch was created
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_git_binary() -> str:
|
|
33
|
+
"""Find git binary in PATH.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Path to git binary.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
GitNotFoundError: If git is not found.
|
|
40
|
+
"""
|
|
41
|
+
git_path = shutil.which("git")
|
|
42
|
+
if git_path is None:
|
|
43
|
+
raise GitNotFoundError()
|
|
44
|
+
return git_path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_repo_root(cwd: Path | None = None) -> Path:
|
|
48
|
+
"""Get the root of the git repository or worktree.
|
|
49
|
+
|
|
50
|
+
For worktrees, this returns the worktree root, not the main repository.
|
|
51
|
+
Use get_main_repo_root() if you need the main repository.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
cwd: Starting directory (defaults to current).
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Path to repository/worktree root.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
GitWorktreeError: If not in a git repository.
|
|
61
|
+
"""
|
|
62
|
+
git = find_git_binary()
|
|
63
|
+
start = cwd or Path.cwd()
|
|
64
|
+
|
|
65
|
+
result = subprocess.run(
|
|
66
|
+
[git, "rev-parse", "--show-toplevel"],
|
|
67
|
+
cwd=str(start),
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if result.returncode != 0:
|
|
73
|
+
raise GitWorktreeError("rev-parse", "not in a git repository", result.returncode)
|
|
74
|
+
|
|
75
|
+
return Path(result.stdout.strip())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_main_repo_root(cwd: Path | None = None) -> Path:
|
|
79
|
+
"""Get the root of the main git repository.
|
|
80
|
+
|
|
81
|
+
For worktrees, this returns the main repository root, not the worktree.
|
|
82
|
+
Uses git-common-dir to find the shared .git directory.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
cwd: Starting directory (defaults to current).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Path to main repository root.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
GitWorktreeError: If not in a git repository.
|
|
92
|
+
"""
|
|
93
|
+
git = find_git_binary()
|
|
94
|
+
start = cwd or Path.cwd()
|
|
95
|
+
|
|
96
|
+
# Get the common git directory (shared by all worktrees)
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
[git, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
99
|
+
cwd=str(start),
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if result.returncode != 0:
|
|
105
|
+
raise GitWorktreeError("rev-parse", "not in a git repository", result.returncode)
|
|
106
|
+
|
|
107
|
+
common_dir = Path(result.stdout.strip())
|
|
108
|
+
if common_dir.name == ".git":
|
|
109
|
+
return common_dir.parent
|
|
110
|
+
|
|
111
|
+
# Handle edge case where common_dir might be .git/worktrees/name
|
|
112
|
+
while common_dir.name != ".git" and common_dir.parent != common_dir:
|
|
113
|
+
common_dir = common_dir.parent
|
|
114
|
+
|
|
115
|
+
if common_dir.name == ".git":
|
|
116
|
+
return common_dir.parent
|
|
117
|
+
|
|
118
|
+
return get_repo_root(cwd)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def branch_exists(branch: str, cwd: Path | None = None) -> bool:
|
|
122
|
+
"""Check if a git branch exists.
|
|
123
|
+
|
|
124
|
+
Uses refs/heads/ to specifically check for local branches,
|
|
125
|
+
avoiding false positives from tags or other refs.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
branch: Branch name to check.
|
|
129
|
+
cwd: Working directory.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
True if branch exists as a local branch.
|
|
133
|
+
"""
|
|
134
|
+
git = find_git_binary()
|
|
135
|
+
|
|
136
|
+
result = subprocess.run(
|
|
137
|
+
[git, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
|
|
138
|
+
cwd=str(cwd or Path.cwd()),
|
|
139
|
+
capture_output=True,
|
|
140
|
+
text=True,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return result.returncode == 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_worktree_for_branch(branch: str, cwd: Path | None = None) -> str | None:
|
|
147
|
+
"""Find the worktree path that has a branch checked out.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
branch: Branch name to look up.
|
|
151
|
+
cwd: Working directory.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Worktree path if the branch is checked out, None otherwise.
|
|
155
|
+
"""
|
|
156
|
+
git = find_git_binary()
|
|
157
|
+
|
|
158
|
+
result = subprocess.run(
|
|
159
|
+
[git, "worktree", "list", "--porcelain"],
|
|
160
|
+
cwd=str(cwd or Path.cwd()),
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if result.returncode != 0:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
# Porcelain format: blocks separated by blank lines, each has
|
|
169
|
+
# "worktree <path>" and "branch refs/heads/<name>"
|
|
170
|
+
current_path: str | None = None
|
|
171
|
+
for line in result.stdout.splitlines():
|
|
172
|
+
if line.startswith("worktree "):
|
|
173
|
+
current_path = line[len("worktree ") :]
|
|
174
|
+
elif line == f"branch refs/heads/{branch}":
|
|
175
|
+
return current_path
|
|
176
|
+
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def validate_branch_name(branch: str) -> None:
|
|
181
|
+
"""Validate a git branch name.
|
|
182
|
+
|
|
183
|
+
Uses git check-ref-format to validate the branch name.
|
|
184
|
+
This is called for explicit --branch values.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
branch: Branch name to validate.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
InvalidBranchNameError: If branch name is invalid.
|
|
191
|
+
"""
|
|
192
|
+
git = find_git_binary()
|
|
193
|
+
|
|
194
|
+
result = subprocess.run(
|
|
195
|
+
[git, "check-ref-format", "--branch", branch],
|
|
196
|
+
capture_output=True,
|
|
197
|
+
text=True,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if result.returncode != 0:
|
|
201
|
+
reason = result.stderr.strip() if result.stderr else "invalid format"
|
|
202
|
+
raise InvalidBranchNameError(branch, reason)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def sanitize_branch_name(session_name: str) -> str:
|
|
206
|
+
"""Convert session name to valid git branch name.
|
|
207
|
+
|
|
208
|
+
Session names are already strict (lowercase alphanumeric + hyphens),
|
|
209
|
+
which are valid git branch names. This is mainly a pass-through.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
session_name: The session name.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Valid git branch name.
|
|
216
|
+
"""
|
|
217
|
+
# Session names are validated as lowercase alphanumeric + hyphens
|
|
218
|
+
# which are valid git branch names - just pass through
|
|
219
|
+
return session_name
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def resolve_worktree_path(repo_root: Path, session_name: str) -> Path:
|
|
223
|
+
"""Compute the worktree path for a session.
|
|
224
|
+
|
|
225
|
+
Worktree path: ../<project-name>-<session-name>
|
|
226
|
+
Places worktrees as siblings to the main repo.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
repo_root: Path to the main repository.
|
|
230
|
+
session_name: The session name.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Absolute path for the worktree.
|
|
234
|
+
"""
|
|
235
|
+
project_name = repo_root.name
|
|
236
|
+
worktree_dir = f"{project_name}-{session_name}"
|
|
237
|
+
return (repo_root.parent / worktree_dir).resolve()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def create_worktree(
|
|
241
|
+
session_name: str,
|
|
242
|
+
branch: str | None = None,
|
|
243
|
+
cwd: Path | None = None,
|
|
244
|
+
*,
|
|
245
|
+
force: bool = False,
|
|
246
|
+
replace_owned_stale_state: bool = False,
|
|
247
|
+
) -> WorktreeResult:
|
|
248
|
+
"""Create a git worktree for a session.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
session_name: Session name (used for path and default branch).
|
|
252
|
+
branch: Override branch name (defaults to session_name).
|
|
253
|
+
cwd: Starting directory (defaults to current).
|
|
254
|
+
force: Replace conflicting branch/worktree state. Deletes a merged
|
|
255
|
+
branch and removes a clean registered worktree before recreating.
|
|
256
|
+
Hard constraints still apply: BranchInUseError (checked out
|
|
257
|
+
elsewhere), BranchNotMergedError (unmerged work), and non-worktree
|
|
258
|
+
paths (no .git file).
|
|
259
|
+
replace_owned_stale_state: Allow force recovery only when the caller
|
|
260
|
+
has already verified the target worktree/branch belong to the same
|
|
261
|
+
stale Forge session being replaced.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
WorktreeResult with path and branch info.
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
GitNotFoundError: If git is not found.
|
|
268
|
+
GitWorktreeError: If worktree creation fails.
|
|
269
|
+
InvalidBranchNameError: If explicit branch name is invalid.
|
|
270
|
+
BranchExistsError: If branch already exists (or force with explicit --branch).
|
|
271
|
+
WorktreePathExistsError: If worktree path already exists (or not a git worktree).
|
|
272
|
+
BranchInUseError: If branch is checked out in another worktree (force only).
|
|
273
|
+
BranchNotMergedError: If branch has unmerged work (force only).
|
|
274
|
+
"""
|
|
275
|
+
git = find_git_binary()
|
|
276
|
+
repo_root = get_repo_root(cwd)
|
|
277
|
+
|
|
278
|
+
if branch is not None:
|
|
279
|
+
validate_branch_name(branch)
|
|
280
|
+
target_branch = branch
|
|
281
|
+
else:
|
|
282
|
+
# Derive from session name (already valid)
|
|
283
|
+
target_branch = sanitize_branch_name(session_name)
|
|
284
|
+
|
|
285
|
+
worktree_path = resolve_worktree_path(repo_root, session_name)
|
|
286
|
+
|
|
287
|
+
# --force only replaces worktree state when the caller has proved the
|
|
288
|
+
# derived target belongs to the same stale Forge child being recovered.
|
|
289
|
+
# Worktree first (un-checks-out the branch), then branch.
|
|
290
|
+
if force and replace_owned_stale_state and worktree_path.exists():
|
|
291
|
+
git_file = worktree_path / ".git"
|
|
292
|
+
if not git_file.is_file():
|
|
293
|
+
# Not a registered git worktree — refuse to delete arbitrary dirs
|
|
294
|
+
raise WorktreePathExistsError(str(worktree_path))
|
|
295
|
+
from .cleanup import remove_worktree
|
|
296
|
+
|
|
297
|
+
main_root = get_main_repo_root(worktree_path)
|
|
298
|
+
remove_worktree(worktree_path, force=True, repo_root=main_root)
|
|
299
|
+
if worktree_path.exists():
|
|
300
|
+
raise WorktreePathExistsError(str(worktree_path))
|
|
301
|
+
elif worktree_path.exists():
|
|
302
|
+
raise WorktreePathExistsError(str(worktree_path))
|
|
303
|
+
|
|
304
|
+
if branch_exists(target_branch, repo_root):
|
|
305
|
+
if not force or not replace_owned_stale_state:
|
|
306
|
+
wt = get_worktree_for_branch(target_branch, repo_root)
|
|
307
|
+
raise BranchExistsError(target_branch, worktree=wt)
|
|
308
|
+
if branch is not None:
|
|
309
|
+
# Explicit --branch: refuse to destroy a user-chosen name
|
|
310
|
+
wt = get_worktree_for_branch(target_branch, repo_root)
|
|
311
|
+
raise BranchExistsError(target_branch, worktree=wt)
|
|
312
|
+
# --force with auto-derived branch: delete (respects git merge safety).
|
|
313
|
+
# BranchInUseError/BranchNotMergedError propagate as hard constraints.
|
|
314
|
+
from .cleanup import delete_branch as _delete_branch
|
|
315
|
+
|
|
316
|
+
_delete_branch(target_branch, cwd=repo_root, force=False)
|
|
317
|
+
|
|
318
|
+
result = subprocess.run(
|
|
319
|
+
[git, "worktree", "add", str(worktree_path), "-b", target_branch],
|
|
320
|
+
cwd=str(repo_root),
|
|
321
|
+
capture_output=True,
|
|
322
|
+
text=True,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if result.returncode != 0:
|
|
326
|
+
raise GitWorktreeError("add", result.stderr.strip(), result.returncode)
|
|
327
|
+
|
|
328
|
+
return WorktreeResult(
|
|
329
|
+
worktree_path=str(worktree_path),
|
|
330
|
+
branch=target_branch,
|
|
331
|
+
created_branch=True,
|
|
332
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Sidecar execution module for Multi-Forge.
|
|
2
|
+
|
|
3
|
+
Bundles proxy + Claude Code in a Docker container with lifecycle coupling,
|
|
4
|
+
port isolation, and version consistency. Not a security sandbox — Claude
|
|
5
|
+
Code's native sandbox (Seatbelt/bubblewrap) handles that.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from forge.sidecar.container import (
|
|
9
|
+
ContainerExistsError,
|
|
10
|
+
container_exists,
|
|
11
|
+
exec_in_container,
|
|
12
|
+
get_container_id,
|
|
13
|
+
parse_mounts,
|
|
14
|
+
run_sidecar_session,
|
|
15
|
+
)
|
|
16
|
+
from forge.sidecar.docker import is_container_running, is_docker_available
|
|
17
|
+
from forge.sidecar.secrets import get_secrets_for_template
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ContainerExistsError",
|
|
21
|
+
"container_exists",
|
|
22
|
+
"exec_in_container",
|
|
23
|
+
"get_container_id",
|
|
24
|
+
"get_secrets_for_template",
|
|
25
|
+
"is_container_running",
|
|
26
|
+
"is_docker_available",
|
|
27
|
+
"parse_mounts",
|
|
28
|
+
"run_sidecar_session",
|
|
29
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Container lifecycle management for sidecar Claude Code sessions.
|
|
2
|
+
|
|
3
|
+
Bundles proxy + Claude Code in a Docker container. The key function
|
|
4
|
+
`run_sidecar_session()` is the container equivalent of `invoke_claude()`
|
|
5
|
+
— it runs interactively with inherited stdin/stdout/stderr.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from forge.sidecar.docker import _docker_name_filter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ContainerExistsError(RuntimeError):
|
|
20
|
+
"""Raised when a container with the given name already exists."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, container_name: str) -> None:
|
|
23
|
+
self.container_name = container_name
|
|
24
|
+
super().__init__(f"Container '{container_name}' already exists. " f"Remove with: docker rm -f {container_name}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_container_id(container_name: str) -> str | None:
|
|
28
|
+
"""Get container ID by name (for running containers only)."""
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
["docker", "ps", "-q", "-f", _docker_name_filter(container_name)],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
)
|
|
34
|
+
return result.stdout.strip() or None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def container_exists(container_name: str) -> bool:
|
|
38
|
+
"""Check if a container exists by name (running OR stopped).
|
|
39
|
+
|
|
40
|
+
Uses `docker ps -a` to detect ALL containers, including stopped/exited ones.
|
|
41
|
+
"""
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
["docker", "ps", "-aq", "-f", _docker_name_filter(container_name)],
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
)
|
|
47
|
+
return bool(result.stdout.strip())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_sidecar_session(
|
|
51
|
+
*,
|
|
52
|
+
image: str,
|
|
53
|
+
template: str,
|
|
54
|
+
session_name: str,
|
|
55
|
+
project_dir: Path,
|
|
56
|
+
extra_mounts: list[tuple[str, str, str]] | None = None,
|
|
57
|
+
context_limit: int = 200000,
|
|
58
|
+
env_vars: dict[str, str] | None = None,
|
|
59
|
+
claude_args: list[str] | None = None,
|
|
60
|
+
) -> int:
|
|
61
|
+
"""Run Claude + proxy in a Docker container. Returns exit code.
|
|
62
|
+
|
|
63
|
+
Container lifecycle = Session lifecycle:
|
|
64
|
+
- Container starts when this function is called
|
|
65
|
+
- Container exits when Claude exits
|
|
66
|
+
- Container auto-cleaned via --rm flag
|
|
67
|
+
"""
|
|
68
|
+
container_name = f"forge-{session_name}"
|
|
69
|
+
|
|
70
|
+
# Collision guard: detect both running AND stopped containers
|
|
71
|
+
if container_exists(container_name):
|
|
72
|
+
raise ContainerExistsError(container_name)
|
|
73
|
+
|
|
74
|
+
cmd = [
|
|
75
|
+
"docker",
|
|
76
|
+
"run",
|
|
77
|
+
"-it",
|
|
78
|
+
"--rm",
|
|
79
|
+
"--name",
|
|
80
|
+
container_name,
|
|
81
|
+
"-v",
|
|
82
|
+
f"{project_dir}:/workspace",
|
|
83
|
+
"-e",
|
|
84
|
+
f"FORGE_TEMPLATE={template}",
|
|
85
|
+
"-e",
|
|
86
|
+
f"CLAUDE_CODE_AUTO_COMPACT_WINDOW={context_limit}",
|
|
87
|
+
"-e",
|
|
88
|
+
f"FORGE_SESSION={session_name}",
|
|
89
|
+
"-e",
|
|
90
|
+
"FORGE_SIDECAR=1",
|
|
91
|
+
"-e",
|
|
92
|
+
"FORGE_LAUNCH_MODE=sidecar",
|
|
93
|
+
"-w",
|
|
94
|
+
"/workspace",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
if sys.platform == "linux":
|
|
98
|
+
uid, gid = os.getuid(), os.getgid()
|
|
99
|
+
cmd.extend(["--user", f"{uid}:{gid}"])
|
|
100
|
+
|
|
101
|
+
if extra_mounts:
|
|
102
|
+
for host_path, container_path, mode in extra_mounts:
|
|
103
|
+
cmd.extend(["-v", f"{host_path}:{container_path}:{mode}"])
|
|
104
|
+
|
|
105
|
+
# Write env vars to temp file instead of CLI args to avoid
|
|
106
|
+
# leaking secrets via `ps aux` (CR-022). Cleanup in finally.
|
|
107
|
+
env_file_path: str | None = None
|
|
108
|
+
try:
|
|
109
|
+
if env_vars:
|
|
110
|
+
fd, env_file_path = tempfile.mkstemp(prefix=".forge-env-", suffix=".env")
|
|
111
|
+
with os.fdopen(fd, "w") as f:
|
|
112
|
+
for k, v in env_vars.items():
|
|
113
|
+
f.write(f"{k}={v}\n")
|
|
114
|
+
os.chmod(env_file_path, 0o600)
|
|
115
|
+
cmd.extend(["--env-file", env_file_path])
|
|
116
|
+
|
|
117
|
+
cmd.append(image)
|
|
118
|
+
if claude_args:
|
|
119
|
+
cmd.extend(claude_args)
|
|
120
|
+
|
|
121
|
+
result = subprocess.run(cmd)
|
|
122
|
+
return result.returncode
|
|
123
|
+
finally:
|
|
124
|
+
if env_file_path:
|
|
125
|
+
try:
|
|
126
|
+
os.unlink(env_file_path)
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def exec_in_container(container_name: str, command: list[str]) -> int:
|
|
132
|
+
"""Execute interactive command in running container."""
|
|
133
|
+
cmd = ["docker", "exec", "-it", container_name, *command]
|
|
134
|
+
result = subprocess.run(cmd)
|
|
135
|
+
return result.returncode
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_mounts(mount_specs: tuple[str, ...]) -> list[tuple[str, str, str]]:
|
|
139
|
+
"""Parse --mount flag specifications into (host, container, mode) tuples.
|
|
140
|
+
|
|
141
|
+
Format: "host_path:container_path[:ro|rw]"
|
|
142
|
+
Default mode is "rw" if not specified.
|
|
143
|
+
"""
|
|
144
|
+
mounts = []
|
|
145
|
+
for spec in mount_specs:
|
|
146
|
+
parts = spec.split(":")
|
|
147
|
+
|
|
148
|
+
if len(parts) < 2:
|
|
149
|
+
raise ValueError(f"Invalid mount specification: {spec}. Expected 'host:container[:ro|rw]'")
|
|
150
|
+
|
|
151
|
+
host_path = parts[0]
|
|
152
|
+
container_path = parts[1]
|
|
153
|
+
mode = parts[2] if len(parts) > 2 else "rw"
|
|
154
|
+
|
|
155
|
+
if mode not in ("ro", "rw"):
|
|
156
|
+
raise ValueError(f"Invalid mount mode: {mode}. Must be 'ro' or 'rw'")
|
|
157
|
+
|
|
158
|
+
host_path = os.path.expanduser(host_path)
|
|
159
|
+
mounts.append((host_path, container_path, mode))
|
|
160
|
+
|
|
161
|
+
return mounts
|
forge/sidecar/docker.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Docker utility functions for sidecar execution.
|
|
2
|
+
|
|
3
|
+
Low-level Docker operations used by the container lifecycle module.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _docker_name_filter(container_name: str) -> str:
|
|
13
|
+
"""Build an exact-match docker ps name filter, escaping regex metacharacters."""
|
|
14
|
+
return f"name=^{re.escape(container_name)}$"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_container_running(container_name: str) -> bool:
|
|
18
|
+
"""Check if a Docker container is running by name.
|
|
19
|
+
|
|
20
|
+
Uses docker ps filtering with exact name match to avoid partial matches.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
container_name: The container name to check.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True if container exists and is running, False otherwise.
|
|
27
|
+
"""
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
["docker", "ps", "-q", "-f", _docker_name_filter(container_name)],
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
)
|
|
33
|
+
return bool(result.stdout.strip())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_docker_available() -> bool:
|
|
37
|
+
"""Check if Docker is available and running.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
True if docker daemon is accessible, False otherwise.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
["docker", "info"],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
timeout=10,
|
|
47
|
+
)
|
|
48
|
+
return result.returncode == 0
|
|
49
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def stop_container(container_name: str) -> bool:
|
|
54
|
+
"""Stop a running container by name.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
container_name: The container name to stop.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if container was stopped, False if container was not running.
|
|
61
|
+
"""
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
["docker", "stop", container_name],
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
return result.returncode == 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def remove_container(container_name: str, force: bool = False) -> bool:
|
|
71
|
+
"""Remove a container by name.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
container_name: The container name to remove.
|
|
75
|
+
force: If True, force remove even if running.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if container was removed, False otherwise.
|
|
79
|
+
"""
|
|
80
|
+
cmd = ["docker", "rm"]
|
|
81
|
+
if force:
|
|
82
|
+
cmd.append("-f")
|
|
83
|
+
cmd.append(container_name)
|
|
84
|
+
|
|
85
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
86
|
+
return result.returncode == 0
|
forge/sidecar/secrets.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Secrets propagation for sidecar sessions.
|
|
2
|
+
|
|
3
|
+
Forward required secrets (API keys, connection values) from the host
|
|
4
|
+
environment into Docker containers. Secrets are template-dependent.
|
|
5
|
+
|
|
6
|
+
Resolution order: environment variable -> credential file (~/.forge/credentials.yaml).
|
|
7
|
+
|
|
8
|
+
Implementation lives in ``forge.core.auth.template_secrets``; this module
|
|
9
|
+
re-exports the public API for backward compatibility.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from forge.core.auth.template_secrets import (
|
|
15
|
+
TEMPLATE_SECRETS,
|
|
16
|
+
get_secrets_for_template,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = ["TEMPLATE_SECRETS", "get_secrets_for_template"]
|