gobby 0.2.5__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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
gobby/worktrees/git.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""Git worktree operations manager."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import subprocess # nosec B404 - subprocess needed for git worktree operations
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class WorktreeInfo:
|
|
16
|
+
"""Information about a git worktree."""
|
|
17
|
+
|
|
18
|
+
path: str
|
|
19
|
+
branch: str | None
|
|
20
|
+
commit: str
|
|
21
|
+
is_bare: bool = False
|
|
22
|
+
is_detached: bool = False
|
|
23
|
+
locked: bool = False
|
|
24
|
+
prunable: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class WorktreeStatus:
|
|
29
|
+
"""Status of a worktree including changes and sync state."""
|
|
30
|
+
|
|
31
|
+
has_uncommitted_changes: bool
|
|
32
|
+
has_staged_changes: bool
|
|
33
|
+
has_untracked_files: bool
|
|
34
|
+
ahead: int # Commits ahead of upstream
|
|
35
|
+
behind: int # Commits behind upstream
|
|
36
|
+
branch: str | None
|
|
37
|
+
commit: str | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class GitOperationResult:
|
|
42
|
+
"""Result of a git operation."""
|
|
43
|
+
|
|
44
|
+
success: bool
|
|
45
|
+
message: str
|
|
46
|
+
output: str | None = None
|
|
47
|
+
error: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WorktreeGitManager:
|
|
51
|
+
"""
|
|
52
|
+
Manager for git worktree operations.
|
|
53
|
+
|
|
54
|
+
Provides methods to create, delete, and manage git worktrees.
|
|
55
|
+
All operations are performed relative to a base repository path.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, repo_path: str | Path):
|
|
59
|
+
"""
|
|
60
|
+
Initialize with base repository path.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
repo_path: Path to the main git repository
|
|
64
|
+
"""
|
|
65
|
+
self.repo_path = Path(repo_path)
|
|
66
|
+
if not self.repo_path.exists():
|
|
67
|
+
raise ValueError(f"Repository path does not exist: {repo_path}")
|
|
68
|
+
|
|
69
|
+
def _run_git(
|
|
70
|
+
self,
|
|
71
|
+
args: list[str],
|
|
72
|
+
cwd: str | Path | None = None,
|
|
73
|
+
timeout: int = 30,
|
|
74
|
+
check: bool = False,
|
|
75
|
+
) -> subprocess.CompletedProcess[str]:
|
|
76
|
+
"""
|
|
77
|
+
Run a git command.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
args: Git command arguments (without 'git' prefix)
|
|
81
|
+
cwd: Working directory (defaults to repo_path)
|
|
82
|
+
timeout: Command timeout in seconds
|
|
83
|
+
check: Raise exception on non-zero exit
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
CompletedProcess with stdout/stderr
|
|
87
|
+
"""
|
|
88
|
+
if cwd is None:
|
|
89
|
+
cwd = self.repo_path
|
|
90
|
+
|
|
91
|
+
cmd = ["git"] + args
|
|
92
|
+
logger.debug(f"Running: {' '.join(cmd)} in {cwd}")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
result = subprocess.run( # nosec B603 B607 - cmd built from hardcoded git arguments
|
|
96
|
+
cmd,
|
|
97
|
+
cwd=cwd,
|
|
98
|
+
capture_output=True,
|
|
99
|
+
text=True,
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
check=check,
|
|
102
|
+
)
|
|
103
|
+
return result
|
|
104
|
+
except subprocess.TimeoutExpired:
|
|
105
|
+
logger.error(f"Git command timed out: {' '.join(cmd)}")
|
|
106
|
+
raise
|
|
107
|
+
except subprocess.CalledProcessError as e:
|
|
108
|
+
logger.error(f"Git command failed: {' '.join(cmd)}, stderr: {e.stderr}")
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
def create_worktree(
|
|
112
|
+
self,
|
|
113
|
+
worktree_path: str | Path,
|
|
114
|
+
branch_name: str,
|
|
115
|
+
base_branch: str = "main",
|
|
116
|
+
create_branch: bool = True,
|
|
117
|
+
) -> GitOperationResult:
|
|
118
|
+
"""
|
|
119
|
+
Create a new git worktree.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
worktree_path: Path where worktree will be created
|
|
123
|
+
branch_name: Name of the branch for the worktree
|
|
124
|
+
base_branch: Branch to base the new branch on (if create_branch=True)
|
|
125
|
+
create_branch: Whether to create a new branch or use existing
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
GitOperationResult with success status and message
|
|
129
|
+
"""
|
|
130
|
+
worktree_path = Path(worktree_path)
|
|
131
|
+
|
|
132
|
+
# Check if path already exists
|
|
133
|
+
if worktree_path.exists():
|
|
134
|
+
return GitOperationResult(
|
|
135
|
+
success=False,
|
|
136
|
+
message=f"Path already exists: {worktree_path}",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Ensure parent directory exists
|
|
140
|
+
worktree_path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
if create_branch:
|
|
144
|
+
# Create worktree with new branch based on base_branch
|
|
145
|
+
# First, fetch to ensure we have latest refs
|
|
146
|
+
fetch_result = self._run_git(["fetch", "origin", base_branch], timeout=60)
|
|
147
|
+
if fetch_result.returncode != 0:
|
|
148
|
+
return GitOperationResult(
|
|
149
|
+
success=False,
|
|
150
|
+
message=f"Failed to fetch origin/{base_branch}: {fetch_result.stderr}",
|
|
151
|
+
error=fetch_result.stderr,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Create worktree with new branch
|
|
155
|
+
result = self._run_git(
|
|
156
|
+
[
|
|
157
|
+
"worktree",
|
|
158
|
+
"add",
|
|
159
|
+
"-b",
|
|
160
|
+
branch_name,
|
|
161
|
+
str(worktree_path),
|
|
162
|
+
f"origin/{base_branch}",
|
|
163
|
+
],
|
|
164
|
+
timeout=60,
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
# Use existing branch
|
|
168
|
+
result = self._run_git(
|
|
169
|
+
["worktree", "add", str(worktree_path), branch_name],
|
|
170
|
+
timeout=60,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if result.returncode == 0:
|
|
174
|
+
return GitOperationResult(
|
|
175
|
+
success=True,
|
|
176
|
+
message=f"Created worktree at {worktree_path}",
|
|
177
|
+
output=result.stdout,
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
return GitOperationResult(
|
|
181
|
+
success=False,
|
|
182
|
+
message=f"Failed to create worktree: {result.stderr}",
|
|
183
|
+
error=result.stderr,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
except subprocess.TimeoutExpired:
|
|
187
|
+
return GitOperationResult(
|
|
188
|
+
success=False,
|
|
189
|
+
message="Git command timed out",
|
|
190
|
+
)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
return GitOperationResult(
|
|
193
|
+
success=False,
|
|
194
|
+
message=f"Error creating worktree: {e}",
|
|
195
|
+
error=str(e),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def delete_worktree(
|
|
199
|
+
self,
|
|
200
|
+
worktree_path: str | Path,
|
|
201
|
+
force: bool = False,
|
|
202
|
+
delete_branch: bool = False,
|
|
203
|
+
branch_name: str | None = None,
|
|
204
|
+
) -> GitOperationResult:
|
|
205
|
+
"""
|
|
206
|
+
Delete a git worktree.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
worktree_path: Path to the worktree to delete
|
|
210
|
+
force: Force removal even if dirty
|
|
211
|
+
delete_branch: Also delete the associated branch
|
|
212
|
+
branch_name: Optional explicit branch name (if not provided, attempts to discover)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
GitOperationResult with success status and message
|
|
216
|
+
"""
|
|
217
|
+
worktree_path = Path(worktree_path)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
# Get branch name before removal (for optional branch deletion)
|
|
221
|
+
if delete_branch and not branch_name:
|
|
222
|
+
try:
|
|
223
|
+
status = self.get_worktree_status(worktree_path)
|
|
224
|
+
if status:
|
|
225
|
+
branch_name = status.branch
|
|
226
|
+
except Exception:
|
|
227
|
+
# nosec B110 - ignore errors getting status, we just won't have the branch name
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
# Remove worktree
|
|
231
|
+
args = ["worktree", "remove"]
|
|
232
|
+
if force:
|
|
233
|
+
args.append("--force")
|
|
234
|
+
args.append(str(worktree_path))
|
|
235
|
+
|
|
236
|
+
result = self._run_git(args, timeout=30)
|
|
237
|
+
|
|
238
|
+
if result.returncode != 0:
|
|
239
|
+
return GitOperationResult(
|
|
240
|
+
success=False,
|
|
241
|
+
message=f"Failed to remove worktree: {result.stderr}",
|
|
242
|
+
error=result.stderr,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Optionally delete the branch
|
|
246
|
+
if delete_branch and branch_name:
|
|
247
|
+
branch_result = self._run_git(
|
|
248
|
+
["branch", "-D" if force else "-d", branch_name],
|
|
249
|
+
timeout=10,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if branch_result.returncode != 0:
|
|
253
|
+
return GitOperationResult(
|
|
254
|
+
success=True, # Worktree removed, but branch deletion failed
|
|
255
|
+
message=f"Worktree removed, but failed to delete branch: {branch_result.stderr}",
|
|
256
|
+
error=branch_result.stderr,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return GitOperationResult(
|
|
260
|
+
success=True,
|
|
261
|
+
message=f"Deleted worktree at {worktree_path}"
|
|
262
|
+
+ (f" and branch {branch_name}" if delete_branch and branch_name else ""),
|
|
263
|
+
output=result.stdout,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
except subprocess.TimeoutExpired:
|
|
267
|
+
return GitOperationResult(
|
|
268
|
+
success=False,
|
|
269
|
+
message="Git command timed out",
|
|
270
|
+
)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
return GitOperationResult(
|
|
273
|
+
success=False,
|
|
274
|
+
message=f"Error deleting worktree: {e}",
|
|
275
|
+
error=str(e),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def sync_from_main(
|
|
279
|
+
self,
|
|
280
|
+
worktree_path: str | Path,
|
|
281
|
+
base_branch: str = "main",
|
|
282
|
+
strategy: Literal["rebase", "merge"] = "rebase",
|
|
283
|
+
) -> GitOperationResult:
|
|
284
|
+
"""
|
|
285
|
+
Sync worktree with base branch.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
worktree_path: Path to the worktree
|
|
289
|
+
base_branch: Branch to sync from
|
|
290
|
+
strategy: Sync strategy (rebase or merge)
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
GitOperationResult with success status and message
|
|
294
|
+
"""
|
|
295
|
+
worktree_path = Path(worktree_path)
|
|
296
|
+
|
|
297
|
+
if not worktree_path.exists():
|
|
298
|
+
return GitOperationResult(
|
|
299
|
+
success=False,
|
|
300
|
+
message=f"Worktree path does not exist: {worktree_path}",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
# Fetch latest from origin
|
|
305
|
+
fetch_result = self._run_git(
|
|
306
|
+
["fetch", "origin", base_branch],
|
|
307
|
+
cwd=worktree_path,
|
|
308
|
+
timeout=60,
|
|
309
|
+
)
|
|
310
|
+
if fetch_result.returncode != 0:
|
|
311
|
+
return GitOperationResult(
|
|
312
|
+
success=False,
|
|
313
|
+
message=f"Failed to fetch: {fetch_result.stderr}",
|
|
314
|
+
error=fetch_result.stderr,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Perform rebase or merge
|
|
318
|
+
if strategy == "rebase":
|
|
319
|
+
sync_result = self._run_git(
|
|
320
|
+
["rebase", f"origin/{base_branch}"],
|
|
321
|
+
cwd=worktree_path,
|
|
322
|
+
timeout=120,
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
sync_result = self._run_git(
|
|
326
|
+
["merge", f"origin/{base_branch}", "--no-edit"],
|
|
327
|
+
cwd=worktree_path,
|
|
328
|
+
timeout=120,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if sync_result.returncode != 0:
|
|
332
|
+
# Check if there are conflicts
|
|
333
|
+
if "CONFLICT" in sync_result.stdout or "CONFLICT" in sync_result.stderr:
|
|
334
|
+
return GitOperationResult(
|
|
335
|
+
success=False,
|
|
336
|
+
message=f"Sync failed due to conflicts. Run 'git {strategy} --abort' to cancel.",
|
|
337
|
+
error=sync_result.stderr or sync_result.stdout,
|
|
338
|
+
)
|
|
339
|
+
return GitOperationResult(
|
|
340
|
+
success=False,
|
|
341
|
+
message=f"Failed to {strategy}: {sync_result.stderr}",
|
|
342
|
+
error=sync_result.stderr,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return GitOperationResult(
|
|
346
|
+
success=True,
|
|
347
|
+
message=f"Successfully synced with origin/{base_branch} using {strategy}",
|
|
348
|
+
output=sync_result.stdout,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
except subprocess.TimeoutExpired:
|
|
352
|
+
return GitOperationResult(
|
|
353
|
+
success=False,
|
|
354
|
+
message="Git command timed out",
|
|
355
|
+
)
|
|
356
|
+
except Exception as e:
|
|
357
|
+
return GitOperationResult(
|
|
358
|
+
success=False,
|
|
359
|
+
message=f"Error syncing worktree: {e}",
|
|
360
|
+
error=str(e),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def get_worktree_status(
|
|
364
|
+
self,
|
|
365
|
+
worktree_path: str | Path,
|
|
366
|
+
) -> WorktreeStatus | None:
|
|
367
|
+
"""
|
|
368
|
+
Get status of a worktree.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
worktree_path: Path to the worktree
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
WorktreeStatus or None if path is not valid
|
|
375
|
+
"""
|
|
376
|
+
worktree_path = Path(worktree_path)
|
|
377
|
+
|
|
378
|
+
if not worktree_path.exists():
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
# Get current branch
|
|
383
|
+
branch_result = self._run_git(
|
|
384
|
+
["branch", "--show-current"],
|
|
385
|
+
cwd=worktree_path,
|
|
386
|
+
timeout=5,
|
|
387
|
+
)
|
|
388
|
+
branch = branch_result.stdout.strip() if branch_result.returncode == 0 else None
|
|
389
|
+
|
|
390
|
+
# Get current commit
|
|
391
|
+
commit_result = self._run_git(
|
|
392
|
+
["rev-parse", "--short", "HEAD"],
|
|
393
|
+
cwd=worktree_path,
|
|
394
|
+
timeout=5,
|
|
395
|
+
)
|
|
396
|
+
commit = commit_result.stdout.strip() if commit_result.returncode == 0 else None
|
|
397
|
+
|
|
398
|
+
# Get status (porcelain for parsing)
|
|
399
|
+
status_result = self._run_git(
|
|
400
|
+
["status", "--porcelain"],
|
|
401
|
+
cwd=worktree_path,
|
|
402
|
+
timeout=10,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
has_staged = False
|
|
406
|
+
has_uncommitted = False
|
|
407
|
+
has_untracked = False
|
|
408
|
+
|
|
409
|
+
if status_result.returncode == 0:
|
|
410
|
+
for line in status_result.stdout.split("\n"):
|
|
411
|
+
if not line:
|
|
412
|
+
continue
|
|
413
|
+
index_status = line[0] if len(line) > 0 else " "
|
|
414
|
+
worktree_status = line[1] if len(line) > 1 else " "
|
|
415
|
+
|
|
416
|
+
if index_status != " " and index_status != "?":
|
|
417
|
+
has_staged = True
|
|
418
|
+
if worktree_status != " " and worktree_status != "?":
|
|
419
|
+
has_uncommitted = True
|
|
420
|
+
if index_status == "?" or worktree_status == "?":
|
|
421
|
+
has_untracked = True
|
|
422
|
+
|
|
423
|
+
# Get ahead/behind count
|
|
424
|
+
ahead = 0
|
|
425
|
+
behind = 0
|
|
426
|
+
|
|
427
|
+
if branch:
|
|
428
|
+
# Try to get upstream info
|
|
429
|
+
upstream_result = self._run_git(
|
|
430
|
+
["rev-list", "--count", "--left-right", f"origin/{branch}...HEAD"],
|
|
431
|
+
cwd=worktree_path,
|
|
432
|
+
timeout=10,
|
|
433
|
+
)
|
|
434
|
+
if upstream_result.returncode == 0:
|
|
435
|
+
parts = upstream_result.stdout.strip().split("\t")
|
|
436
|
+
if len(parts) == 2:
|
|
437
|
+
behind = int(parts[0])
|
|
438
|
+
ahead = int(parts[1])
|
|
439
|
+
|
|
440
|
+
return WorktreeStatus(
|
|
441
|
+
has_uncommitted_changes=has_uncommitted,
|
|
442
|
+
has_staged_changes=has_staged,
|
|
443
|
+
has_untracked_files=has_untracked,
|
|
444
|
+
ahead=ahead,
|
|
445
|
+
behind=behind,
|
|
446
|
+
branch=branch,
|
|
447
|
+
commit=commit,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
except Exception as e:
|
|
451
|
+
logger.error(f"Error getting worktree status: {e}")
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
def list_worktrees(self) -> list[WorktreeInfo]:
|
|
455
|
+
"""
|
|
456
|
+
List all worktrees for this repository.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
List of WorktreeInfo objects
|
|
460
|
+
"""
|
|
461
|
+
try:
|
|
462
|
+
result = self._run_git(
|
|
463
|
+
["worktree", "list", "--porcelain"],
|
|
464
|
+
timeout=10,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if result.returncode != 0:
|
|
468
|
+
logger.error(f"Failed to list worktrees: {result.stderr}")
|
|
469
|
+
return []
|
|
470
|
+
|
|
471
|
+
worktrees = []
|
|
472
|
+
current: dict[str, str | bool] = {}
|
|
473
|
+
|
|
474
|
+
for line in result.stdout.split("\n"):
|
|
475
|
+
if not line:
|
|
476
|
+
if current:
|
|
477
|
+
worktrees.append(
|
|
478
|
+
WorktreeInfo(
|
|
479
|
+
path=str(current.get("worktree", "")),
|
|
480
|
+
branch=current.get("branch"), # type: ignore
|
|
481
|
+
commit=str(current.get("HEAD", "")),
|
|
482
|
+
is_bare=bool(current.get("bare")),
|
|
483
|
+
is_detached=bool(current.get("detached")),
|
|
484
|
+
locked=bool(current.get("locked")),
|
|
485
|
+
prunable=bool(current.get("prunable")),
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
current = {}
|
|
489
|
+
continue
|
|
490
|
+
|
|
491
|
+
if line.startswith("worktree "):
|
|
492
|
+
current["worktree"] = line[9:]
|
|
493
|
+
elif line.startswith("HEAD "):
|
|
494
|
+
current["HEAD"] = line[5:]
|
|
495
|
+
elif line.startswith("branch "):
|
|
496
|
+
# refs/heads/branch-name -> branch-name
|
|
497
|
+
branch_ref = line[7:]
|
|
498
|
+
if branch_ref.startswith("refs/heads/"):
|
|
499
|
+
current["branch"] = branch_ref[11:]
|
|
500
|
+
else:
|
|
501
|
+
current["branch"] = branch_ref
|
|
502
|
+
elif line == "bare":
|
|
503
|
+
current["bare"] = True
|
|
504
|
+
elif line == "detached":
|
|
505
|
+
current["detached"] = True
|
|
506
|
+
elif line.startswith("locked"):
|
|
507
|
+
current["locked"] = True
|
|
508
|
+
elif line.startswith("prunable"):
|
|
509
|
+
current["prunable"] = True
|
|
510
|
+
|
|
511
|
+
# Handle last entry
|
|
512
|
+
if current:
|
|
513
|
+
worktrees.append(
|
|
514
|
+
WorktreeInfo(
|
|
515
|
+
path=str(current.get("worktree", "")),
|
|
516
|
+
branch=current.get("branch"), # type: ignore
|
|
517
|
+
commit=str(current.get("HEAD", "")),
|
|
518
|
+
is_bare=bool(current.get("bare")),
|
|
519
|
+
is_detached=bool(current.get("detached")),
|
|
520
|
+
locked=bool(current.get("locked")),
|
|
521
|
+
prunable=bool(current.get("prunable")),
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
return worktrees
|
|
526
|
+
|
|
527
|
+
except Exception as e:
|
|
528
|
+
logger.error(f"Error listing worktrees: {e}")
|
|
529
|
+
return []
|
|
530
|
+
|
|
531
|
+
def prune_worktrees(self) -> GitOperationResult:
|
|
532
|
+
"""
|
|
533
|
+
Prune stale worktree entries.
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
GitOperationResult with success status
|
|
537
|
+
"""
|
|
538
|
+
try:
|
|
539
|
+
result = self._run_git(["worktree", "prune"], timeout=30)
|
|
540
|
+
|
|
541
|
+
if result.returncode == 0:
|
|
542
|
+
return GitOperationResult(
|
|
543
|
+
success=True,
|
|
544
|
+
message="Pruned stale worktree entries",
|
|
545
|
+
output=result.stdout,
|
|
546
|
+
)
|
|
547
|
+
else:
|
|
548
|
+
return GitOperationResult(
|
|
549
|
+
success=False,
|
|
550
|
+
message=f"Failed to prune: {result.stderr}",
|
|
551
|
+
error=result.stderr,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
except Exception as e:
|
|
555
|
+
return GitOperationResult(
|
|
556
|
+
success=False,
|
|
557
|
+
message=f"Error pruning worktrees: {e}",
|
|
558
|
+
error=str(e),
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def lock_worktree(
|
|
562
|
+
self,
|
|
563
|
+
worktree_path: str | Path,
|
|
564
|
+
reason: str | None = None,
|
|
565
|
+
) -> GitOperationResult:
|
|
566
|
+
"""
|
|
567
|
+
Lock a worktree to prevent accidental pruning.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
worktree_path: Path to the worktree
|
|
571
|
+
reason: Optional reason for locking
|
|
572
|
+
|
|
573
|
+
Returns:
|
|
574
|
+
GitOperationResult with success status
|
|
575
|
+
"""
|
|
576
|
+
args = ["worktree", "lock", str(worktree_path)]
|
|
577
|
+
if reason:
|
|
578
|
+
args.extend(["--reason", reason])
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
result = self._run_git(args, timeout=10)
|
|
582
|
+
|
|
583
|
+
if result.returncode == 0:
|
|
584
|
+
return GitOperationResult(
|
|
585
|
+
success=True,
|
|
586
|
+
message=f"Locked worktree at {worktree_path}",
|
|
587
|
+
)
|
|
588
|
+
else:
|
|
589
|
+
return GitOperationResult(
|
|
590
|
+
success=False,
|
|
591
|
+
message=f"Failed to lock: {result.stderr}",
|
|
592
|
+
error=result.stderr,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
except Exception as e:
|
|
596
|
+
return GitOperationResult(
|
|
597
|
+
success=False,
|
|
598
|
+
message=f"Error locking worktree: {e}",
|
|
599
|
+
error=str(e),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
def unlock_worktree(self, worktree_path: str | Path) -> GitOperationResult:
|
|
603
|
+
"""
|
|
604
|
+
Unlock a worktree.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
worktree_path: Path to the worktree
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
GitOperationResult with success status
|
|
611
|
+
"""
|
|
612
|
+
try:
|
|
613
|
+
result = self._run_git(
|
|
614
|
+
["worktree", "unlock", str(worktree_path)],
|
|
615
|
+
timeout=10,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
if result.returncode == 0:
|
|
619
|
+
return GitOperationResult(
|
|
620
|
+
success=True,
|
|
621
|
+
message=f"Unlocked worktree at {worktree_path}",
|
|
622
|
+
)
|
|
623
|
+
else:
|
|
624
|
+
return GitOperationResult(
|
|
625
|
+
success=False,
|
|
626
|
+
message=f"Failed to unlock: {result.stderr}",
|
|
627
|
+
error=result.stderr,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
except Exception as e:
|
|
631
|
+
return GitOperationResult(
|
|
632
|
+
success=False,
|
|
633
|
+
message=f"Error unlocking worktree: {e}",
|
|
634
|
+
error=str(e),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
def get_default_branch(self) -> str:
|
|
638
|
+
"""
|
|
639
|
+
Get the default branch for the repository.
|
|
640
|
+
|
|
641
|
+
Tries multiple methods to detect the default branch:
|
|
642
|
+
1. Check origin/HEAD symbolic ref (most reliable for cloned repos)
|
|
643
|
+
2. Check for common default branch names (main, master, develop)
|
|
644
|
+
3. Fall back to "main" if detection fails
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
Default branch name (e.g., "main", "master", "develop")
|
|
648
|
+
"""
|
|
649
|
+
# Method 1: Try to get the default branch from origin/HEAD
|
|
650
|
+
try:
|
|
651
|
+
result = self._run_git(
|
|
652
|
+
["symbolic-ref", "refs/remotes/origin/HEAD"],
|
|
653
|
+
timeout=5,
|
|
654
|
+
)
|
|
655
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
656
|
+
# Output is like "refs/remotes/origin/main"
|
|
657
|
+
ref = result.stdout.strip()
|
|
658
|
+
if ref.startswith("refs/remotes/origin/"):
|
|
659
|
+
branch = ref[len("refs/remotes/origin/") :]
|
|
660
|
+
logger.debug(f"Detected default branch from origin/HEAD: {branch}")
|
|
661
|
+
return branch
|
|
662
|
+
except Exception:
|
|
663
|
+
pass # nosec B110 - method 1 failed, try next method
|
|
664
|
+
|
|
665
|
+
# Method 2: Check which common default branches exist
|
|
666
|
+
for branch in ["main", "master", "develop"]:
|
|
667
|
+
try:
|
|
668
|
+
# Check if the branch exists locally or remotely
|
|
669
|
+
result = self._run_git(
|
|
670
|
+
["rev-parse", "--verify", f"refs/heads/{branch}"],
|
|
671
|
+
timeout=5,
|
|
672
|
+
)
|
|
673
|
+
if result.returncode == 0:
|
|
674
|
+
logger.debug(f"Detected default branch from local ref: {branch}")
|
|
675
|
+
return branch
|
|
676
|
+
|
|
677
|
+
# Check remote
|
|
678
|
+
result = self._run_git(
|
|
679
|
+
["rev-parse", "--verify", f"refs/remotes/origin/{branch}"],
|
|
680
|
+
timeout=5,
|
|
681
|
+
)
|
|
682
|
+
if result.returncode == 0:
|
|
683
|
+
logger.debug(f"Detected default branch from remote ref: {branch}")
|
|
684
|
+
return branch
|
|
685
|
+
except Exception:
|
|
686
|
+
continue # nosec B112 - try next branch if current one fails
|
|
687
|
+
|
|
688
|
+
# Method 3: Fall back to "main"
|
|
689
|
+
logger.debug("Could not detect default branch, falling back to 'main'")
|
|
690
|
+
return "main"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Merge conflict resolution utilities for worktrees."""
|
|
2
|
+
|
|
3
|
+
from gobby.worktrees.merge.conflict_parser import ConflictHunk, extract_conflict_hunks
|
|
4
|
+
from gobby.worktrees.merge.resolver import (
|
|
5
|
+
MergeResolver,
|
|
6
|
+
MergeResult,
|
|
7
|
+
ResolutionResult,
|
|
8
|
+
ResolutionStrategy,
|
|
9
|
+
ResolutionTier,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ConflictHunk",
|
|
14
|
+
"extract_conflict_hunks",
|
|
15
|
+
"MergeResolver",
|
|
16
|
+
"MergeResult",
|
|
17
|
+
"ResolutionResult",
|
|
18
|
+
"ResolutionStrategy",
|
|
19
|
+
"ResolutionTier",
|
|
20
|
+
]
|