up-cli 0.2.0__py3-none-any.whl → 0.5.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.
- up/__init__.py +1 -1
- up/ai_cli.py +229 -0
- up/cli.py +54 -9
- up/commands/agent.py +521 -0
- up/commands/bisect.py +343 -0
- up/commands/branch.py +350 -0
- up/commands/init.py +195 -6
- up/commands/learn.py +1392 -32
- up/commands/memory.py +545 -0
- up/commands/provenance.py +267 -0
- up/commands/review.py +239 -0
- up/commands/start.py +752 -42
- up/commands/status.py +173 -18
- up/commands/sync.py +317 -0
- up/commands/vibe.py +304 -0
- up/context.py +64 -10
- up/core/__init__.py +69 -0
- up/core/checkpoint.py +479 -0
- up/core/provenance.py +364 -0
- up/core/state.py +678 -0
- up/events.py +512 -0
- up/git/__init__.py +37 -0
- up/git/utils.py +270 -0
- up/git/worktree.py +331 -0
- up/learn/__init__.py +155 -0
- up/learn/analyzer.py +227 -0
- up/learn/plan.py +374 -0
- up/learn/research.py +511 -0
- up/learn/utils.py +117 -0
- up/memory.py +1096 -0
- up/parallel.py +551 -0
- up/templates/config/__init__.py +1 -1
- up/templates/docs/SKILL.md +28 -0
- up/templates/docs/__init__.py +341 -0
- up/templates/docs/standards/HEADERS.md +24 -0
- up/templates/docs/standards/STRUCTURE.md +18 -0
- up/templates/docs/standards/TEMPLATES.md +19 -0
- up/templates/loop/__init__.py +92 -32
- up/ui/__init__.py +14 -0
- up/ui/loop_display.py +650 -0
- up/ui/theme.py +137 -0
- {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/METADATA +160 -15
- up_cli-0.5.0.dist-info/RECORD +55 -0
- up_cli-0.2.0.dist-info/RECORD +0 -23
- {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
- {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/entry_points.txt +0 -0
up/git/utils.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Shared Git utilities for up-cli.
|
|
2
|
+
|
|
3
|
+
This module provides common Git operations used across the codebase,
|
|
4
|
+
eliminating duplication between worktree.py and agent.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, List, Tuple
|
|
10
|
+
|
|
11
|
+
# Standard branch prefix for all agent/worktree operations
|
|
12
|
+
BRANCH_PREFIX = "agent"
|
|
13
|
+
|
|
14
|
+
# Default timeout for git operations (seconds)
|
|
15
|
+
DEFAULT_GIT_TIMEOUT = 60
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# =============================================================================
|
|
19
|
+
# Exceptions
|
|
20
|
+
# =============================================================================
|
|
21
|
+
|
|
22
|
+
class GitError(Exception):
|
|
23
|
+
"""Base exception for Git operations."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GitNotInstalledError(GitError):
|
|
28
|
+
"""Git is not installed or not in PATH."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GitTimeoutError(GitError):
|
|
33
|
+
"""Git command timed out."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, timeout: int):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GitCommandError(GitError):
|
|
41
|
+
"""Git command failed with non-zero exit code."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, returncode: int, stderr: str = ""):
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.returncode = returncode
|
|
46
|
+
self.stderr = stderr
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# =============================================================================
|
|
50
|
+
# Core Functions
|
|
51
|
+
# =============================================================================
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_git_repo(path: Optional[Path] = None) -> bool:
|
|
55
|
+
"""Check if path is inside a Git repository.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
path: Directory to check (defaults to cwd)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if path is in a Git repository
|
|
62
|
+
|
|
63
|
+
Note:
|
|
64
|
+
Returns False if git is not installed (does not raise).
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
result = run_git("rev-parse", "--git-dir", cwd=path, timeout=10)
|
|
68
|
+
return result.returncode == 0
|
|
69
|
+
except (GitNotInstalledError, GitTimeoutError, GitError):
|
|
70
|
+
return False
|
|
71
|
+
except Exception:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_current_branch(path: Optional[Path] = None) -> str:
|
|
76
|
+
"""Get current Git branch name.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
path: Repository path (defaults to cwd)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Current branch name
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
GitNotInstalledError: If git is not installed
|
|
86
|
+
GitTimeoutError: If command times out
|
|
87
|
+
"""
|
|
88
|
+
result = run_git("rev-parse", "--abbrev-ref", "HEAD", cwd=path)
|
|
89
|
+
return result.stdout.strip()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def count_commits_since(path: Path, base: str = "main") -> int:
|
|
93
|
+
"""Count commits since branching from base.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
path: Repository path
|
|
97
|
+
base: Base branch to compare against
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Number of commits since base (0 if error)
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
result = run_git("rev-list", "--count", f"{base}..HEAD", cwd=path)
|
|
104
|
+
return int(result.stdout.strip())
|
|
105
|
+
except (GitError, ValueError):
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_repo_root(path: Optional[Path] = None) -> Optional[Path]:
|
|
110
|
+
"""Get the root directory of the Git repository.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
path: Starting path (defaults to cwd)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Repository root path or None if not in a repo
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
result = run_git("rev-parse", "--show-toplevel", cwd=path)
|
|
120
|
+
if result.returncode == 0:
|
|
121
|
+
return Path(result.stdout.strip())
|
|
122
|
+
return None
|
|
123
|
+
except GitError:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def make_branch_name(name: str) -> str:
|
|
128
|
+
"""Create a standardized branch name.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
name: Agent or task name
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Branch name with standard prefix
|
|
135
|
+
"""
|
|
136
|
+
return f"{BRANCH_PREFIX}/{name}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def run_git(
|
|
140
|
+
*args,
|
|
141
|
+
cwd: Optional[Path] = None,
|
|
142
|
+
check: bool = False,
|
|
143
|
+
timeout: int = DEFAULT_GIT_TIMEOUT
|
|
144
|
+
) -> subprocess.CompletedProcess:
|
|
145
|
+
"""Run a git command with standard options.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
*args: Git command arguments
|
|
149
|
+
cwd: Working directory
|
|
150
|
+
check: Raise exception on failure
|
|
151
|
+
timeout: Command timeout in seconds
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
CompletedProcess result
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
GitNotInstalledError: If git is not installed
|
|
158
|
+
GitTimeoutError: If command times out
|
|
159
|
+
GitCommandError: If check=True and command fails
|
|
160
|
+
"""
|
|
161
|
+
cmd = ["git"] + list(args)
|
|
162
|
+
cmd_str = " ".join(cmd)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
cmd,
|
|
167
|
+
cwd=cwd or Path.cwd(),
|
|
168
|
+
capture_output=True,
|
|
169
|
+
text=True,
|
|
170
|
+
timeout=timeout
|
|
171
|
+
)
|
|
172
|
+
if check and result.returncode != 0:
|
|
173
|
+
raise GitCommandError(
|
|
174
|
+
f"Git command failed: {cmd_str}\n{result.stderr}",
|
|
175
|
+
returncode=result.returncode,
|
|
176
|
+
stderr=result.stderr
|
|
177
|
+
)
|
|
178
|
+
return result
|
|
179
|
+
except FileNotFoundError:
|
|
180
|
+
raise GitNotInstalledError(
|
|
181
|
+
"Git is not installed or not in PATH. "
|
|
182
|
+
"Please install git: https://git-scm.com/downloads"
|
|
183
|
+
)
|
|
184
|
+
except subprocess.TimeoutExpired:
|
|
185
|
+
raise GitTimeoutError(
|
|
186
|
+
f"Git command timed out after {timeout}s: {cmd_str}",
|
|
187
|
+
timeout=timeout
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# Legacy branch prefix for migration
|
|
192
|
+
LEGACY_BRANCH_PREFIX = "worktree"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def migrate_legacy_branch(name: str, cwd: Optional[Path] = None) -> bool:
|
|
196
|
+
"""Migrate a legacy worktree/ branch to agent/ prefix.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
name: Branch name without prefix
|
|
200
|
+
cwd: Repository path
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
True if migration successful or not needed
|
|
204
|
+
"""
|
|
205
|
+
old_branch = f"{LEGACY_BRANCH_PREFIX}/{name}"
|
|
206
|
+
new_branch = make_branch_name(name)
|
|
207
|
+
|
|
208
|
+
# Check if old branch exists
|
|
209
|
+
result = run_git("branch", "--list", old_branch, cwd=cwd)
|
|
210
|
+
if not result.stdout.strip():
|
|
211
|
+
return True # No migration needed
|
|
212
|
+
|
|
213
|
+
# Check if new branch already exists
|
|
214
|
+
result = run_git("branch", "--list", new_branch, cwd=cwd)
|
|
215
|
+
if result.stdout.strip():
|
|
216
|
+
return True # Already migrated
|
|
217
|
+
|
|
218
|
+
# Rename branch
|
|
219
|
+
result = run_git("branch", "-m", old_branch, new_branch, cwd=cwd)
|
|
220
|
+
return result.returncode == 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def preview_merge(
|
|
224
|
+
source_branch: str,
|
|
225
|
+
target_branch: str = "main",
|
|
226
|
+
cwd: Optional[Path] = None
|
|
227
|
+
) -> Tuple[bool, List[str]]:
|
|
228
|
+
"""Preview merge to check for conflicts before actual merge.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
source_branch: Branch to merge from
|
|
232
|
+
target_branch: Branch to merge into
|
|
233
|
+
cwd: Repository path
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Tuple of (can_merge, conflicting_files)
|
|
237
|
+
"""
|
|
238
|
+
workspace = cwd or Path.cwd()
|
|
239
|
+
|
|
240
|
+
# Save current branch
|
|
241
|
+
original_branch = get_current_branch(workspace)
|
|
242
|
+
|
|
243
|
+
# Checkout target branch
|
|
244
|
+
result = run_git("checkout", target_branch, cwd=workspace)
|
|
245
|
+
if result.returncode != 0:
|
|
246
|
+
return False, [f"Cannot checkout {target_branch}"]
|
|
247
|
+
|
|
248
|
+
# Try merge with --no-commit --no-ff
|
|
249
|
+
result = run_git(
|
|
250
|
+
"merge", "--no-commit", "--no-ff", source_branch,
|
|
251
|
+
cwd=workspace
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
conflicts = []
|
|
255
|
+
can_merge = result.returncode == 0
|
|
256
|
+
|
|
257
|
+
if not can_merge:
|
|
258
|
+
# Get list of conflicting files
|
|
259
|
+
status_result = run_git("status", "--porcelain", cwd=workspace)
|
|
260
|
+
for line in status_result.stdout.strip().split("\n"):
|
|
261
|
+
if line.startswith("UU ") or line.startswith("AA "):
|
|
262
|
+
conflicts.append(line[3:])
|
|
263
|
+
|
|
264
|
+
# Always abort the merge attempt
|
|
265
|
+
run_git("merge", "--abort", cwd=workspace)
|
|
266
|
+
|
|
267
|
+
# Return to original branch
|
|
268
|
+
run_git("checkout", original_branch, cwd=workspace)
|
|
269
|
+
|
|
270
|
+
return can_merge, conflicts
|
up/git/worktree.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Git worktree management for parallel task execution.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for creating, managing, and merging
|
|
4
|
+
Git worktrees - enabling parallel AI task execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass, field, asdict
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from up.git.utils import (
|
|
16
|
+
is_git_repo,
|
|
17
|
+
get_current_branch,
|
|
18
|
+
count_commits_since,
|
|
19
|
+
make_branch_name,
|
|
20
|
+
run_git,
|
|
21
|
+
BRANCH_PREFIX,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class WorktreeState:
|
|
27
|
+
"""State of a task worktree."""
|
|
28
|
+
task_id: str
|
|
29
|
+
task_title: str
|
|
30
|
+
branch: str
|
|
31
|
+
path: str
|
|
32
|
+
status: str = "created" # created, executing, verifying, passed, failed, merged
|
|
33
|
+
phase: str = "INIT"
|
|
34
|
+
started: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
35
|
+
checkpoints: list = field(default_factory=list)
|
|
36
|
+
ai_invocations: list = field(default_factory=list)
|
|
37
|
+
verification: dict = field(default_factory=lambda: {
|
|
38
|
+
"tests_passed": None,
|
|
39
|
+
"lint_passed": None,
|
|
40
|
+
"type_check_passed": None
|
|
41
|
+
})
|
|
42
|
+
error: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
def save(self, worktree_path: Path):
|
|
45
|
+
"""Save state to worktree."""
|
|
46
|
+
state_file = worktree_path / ".agent_state.json"
|
|
47
|
+
state_file.write_text(json.dumps(asdict(self), indent=2))
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def load(cls, worktree_path: Path) -> "WorktreeState":
|
|
51
|
+
"""Load state from worktree."""
|
|
52
|
+
state_file = worktree_path / ".agent_state.json"
|
|
53
|
+
if state_file.exists():
|
|
54
|
+
data = json.loads(state_file.read_text())
|
|
55
|
+
return cls(**data)
|
|
56
|
+
raise FileNotFoundError(f"No state file in {worktree_path}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_worktree(
|
|
60
|
+
task_id: str,
|
|
61
|
+
task_title: str = "",
|
|
62
|
+
base_branch: str = "main"
|
|
63
|
+
) -> tuple[Path, WorktreeState]:
|
|
64
|
+
"""Create an isolated worktree for a task.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
task_id: Unique task identifier (e.g., "US-004")
|
|
68
|
+
task_title: Human-readable task title
|
|
69
|
+
base_branch: Branch to base the worktree on
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Tuple of (worktree_path, state)
|
|
73
|
+
"""
|
|
74
|
+
branch = make_branch_name(task_id)
|
|
75
|
+
worktree_dir = Path(".worktrees")
|
|
76
|
+
worktree_path = worktree_dir / task_id
|
|
77
|
+
|
|
78
|
+
# Ensure .worktrees directory exists
|
|
79
|
+
worktree_dir.mkdir(exist_ok=True)
|
|
80
|
+
|
|
81
|
+
# Check if worktree already exists
|
|
82
|
+
if worktree_path.exists():
|
|
83
|
+
try:
|
|
84
|
+
state = WorktreeState.load(worktree_path)
|
|
85
|
+
return worktree_path, state
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
# Corrupt state, remove and recreate
|
|
88
|
+
remove_worktree(task_id)
|
|
89
|
+
|
|
90
|
+
# Create branch and worktree
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
["git", "worktree", "add", "-b", branch, str(worktree_path), base_branch],
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if result.returncode != 0:
|
|
98
|
+
# Branch might already exist, try without -b
|
|
99
|
+
result = subprocess.run(
|
|
100
|
+
["git", "worktree", "add", str(worktree_path), branch],
|
|
101
|
+
capture_output=True,
|
|
102
|
+
text=True
|
|
103
|
+
)
|
|
104
|
+
if result.returncode != 0:
|
|
105
|
+
raise RuntimeError(f"Failed to create worktree: {result.stderr}")
|
|
106
|
+
|
|
107
|
+
# Copy environment files
|
|
108
|
+
env_files = [".env", ".env.local", ".env.development"]
|
|
109
|
+
for env_file in env_files:
|
|
110
|
+
if Path(env_file).exists():
|
|
111
|
+
shutil.copy(env_file, worktree_path / env_file)
|
|
112
|
+
|
|
113
|
+
# Initialize state
|
|
114
|
+
state = WorktreeState(
|
|
115
|
+
task_id=task_id,
|
|
116
|
+
task_title=task_title or task_id,
|
|
117
|
+
branch=branch,
|
|
118
|
+
path=str(worktree_path),
|
|
119
|
+
status="created"
|
|
120
|
+
)
|
|
121
|
+
state.save(worktree_path)
|
|
122
|
+
|
|
123
|
+
return worktree_path, state
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def remove_worktree(task_id: str, force: bool = False):
|
|
127
|
+
"""Remove a worktree and its branch.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
task_id: Task identifier
|
|
131
|
+
force: Force removal even if changes exist
|
|
132
|
+
"""
|
|
133
|
+
worktree_path = Path(f".worktrees/{task_id}")
|
|
134
|
+
branch = make_branch_name(task_id)
|
|
135
|
+
|
|
136
|
+
# Remove worktree
|
|
137
|
+
if worktree_path.exists():
|
|
138
|
+
cmd = ["git", "worktree", "remove", str(worktree_path)]
|
|
139
|
+
if force:
|
|
140
|
+
cmd.append("--force")
|
|
141
|
+
subprocess.run(cmd, capture_output=True)
|
|
142
|
+
|
|
143
|
+
# Delete branch
|
|
144
|
+
subprocess.run(
|
|
145
|
+
["git", "branch", "-D" if force else "-d", branch],
|
|
146
|
+
capture_output=True
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def list_worktrees() -> list[dict]:
|
|
151
|
+
"""List all active worktrees with their state.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List of worktree info dicts
|
|
155
|
+
"""
|
|
156
|
+
result = subprocess.run(
|
|
157
|
+
["git", "worktree", "list", "--porcelain"],
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
worktrees = []
|
|
163
|
+
current = {}
|
|
164
|
+
|
|
165
|
+
for line in result.stdout.strip().split("\n"):
|
|
166
|
+
if not line:
|
|
167
|
+
if current and ".worktrees" in current.get("worktree", ""):
|
|
168
|
+
# Load state if available
|
|
169
|
+
wt_path = Path(current["worktree"])
|
|
170
|
+
try:
|
|
171
|
+
state = WorktreeState.load(wt_path)
|
|
172
|
+
current["state"] = asdict(state)
|
|
173
|
+
except FileNotFoundError:
|
|
174
|
+
current["state"] = None
|
|
175
|
+
worktrees.append(current)
|
|
176
|
+
current = {}
|
|
177
|
+
elif line.startswith("worktree "):
|
|
178
|
+
current["worktree"] = line[9:]
|
|
179
|
+
elif line.startswith("HEAD "):
|
|
180
|
+
current["head"] = line[5:]
|
|
181
|
+
elif line.startswith("branch "):
|
|
182
|
+
current["branch"] = line[7:]
|
|
183
|
+
|
|
184
|
+
# Don't forget last entry
|
|
185
|
+
if current and ".worktrees" in current.get("worktree", ""):
|
|
186
|
+
wt_path = Path(current["worktree"])
|
|
187
|
+
try:
|
|
188
|
+
state = WorktreeState.load(wt_path)
|
|
189
|
+
current["state"] = asdict(state)
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
current["state"] = None
|
|
192
|
+
worktrees.append(current)
|
|
193
|
+
|
|
194
|
+
return worktrees
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def merge_worktree(
|
|
198
|
+
task_id: str,
|
|
199
|
+
target_branch: str = "main",
|
|
200
|
+
squash: bool = True,
|
|
201
|
+
message: str = None
|
|
202
|
+
) -> bool:
|
|
203
|
+
"""Merge worktree changes into target branch.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
task_id: Task identifier
|
|
207
|
+
target_branch: Branch to merge into
|
|
208
|
+
squash: Whether to squash commits
|
|
209
|
+
message: Custom commit message
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if merge successful
|
|
213
|
+
"""
|
|
214
|
+
branch = make_branch_name(task_id)
|
|
215
|
+
worktree_path = Path(f".worktrees/{task_id}")
|
|
216
|
+
|
|
217
|
+
# Load state for commit message
|
|
218
|
+
try:
|
|
219
|
+
state = WorktreeState.load(worktree_path)
|
|
220
|
+
default_message = f"feat({task_id}): {state.task_title}"
|
|
221
|
+
except FileNotFoundError:
|
|
222
|
+
default_message = f"feat({task_id}): Implement task"
|
|
223
|
+
|
|
224
|
+
commit_message = message or default_message
|
|
225
|
+
|
|
226
|
+
# Checkout target branch
|
|
227
|
+
result = subprocess.run(
|
|
228
|
+
["git", "checkout", target_branch],
|
|
229
|
+
capture_output=True,
|
|
230
|
+
text=True
|
|
231
|
+
)
|
|
232
|
+
if result.returncode != 0:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
# Merge
|
|
236
|
+
if squash:
|
|
237
|
+
# Squash merge - combines all commits into staging
|
|
238
|
+
result = subprocess.run(
|
|
239
|
+
["git", "merge", "--squash", branch],
|
|
240
|
+
capture_output=True,
|
|
241
|
+
text=True
|
|
242
|
+
)
|
|
243
|
+
if result.returncode != 0:
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
# Commit the squashed changes
|
|
247
|
+
result = subprocess.run(
|
|
248
|
+
["git", "commit", "-m", commit_message],
|
|
249
|
+
capture_output=True,
|
|
250
|
+
text=True
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
# Regular merge
|
|
254
|
+
result = subprocess.run(
|
|
255
|
+
["git", "merge", branch, "-m", commit_message],
|
|
256
|
+
capture_output=True,
|
|
257
|
+
text=True
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if result.returncode != 0:
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
# Cleanup worktree
|
|
264
|
+
remove_worktree(task_id)
|
|
265
|
+
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# Standard checkpoint tag prefix (matches checkpoint.py)
|
|
270
|
+
CHECKPOINT_TAG_PREFIX = "up-checkpoint"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def create_checkpoint(worktree_path: Path, name: str = None) -> str:
|
|
274
|
+
"""Create a checkpoint (commit + tag) in a worktree.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
worktree_path: Path to worktree
|
|
278
|
+
name: Optional checkpoint name
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Checkpoint identifier
|
|
282
|
+
"""
|
|
283
|
+
checkpoint_name = name or f"cp-{datetime.now().strftime('%H%M%S')}"
|
|
284
|
+
|
|
285
|
+
# Stage all changes
|
|
286
|
+
subprocess.run(
|
|
287
|
+
["git", "add", "-A"],
|
|
288
|
+
cwd=worktree_path,
|
|
289
|
+
capture_output=True
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Check if there are changes to commit
|
|
293
|
+
result = subprocess.run(
|
|
294
|
+
["git", "status", "--porcelain"],
|
|
295
|
+
cwd=worktree_path,
|
|
296
|
+
capture_output=True,
|
|
297
|
+
text=True
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if result.stdout.strip():
|
|
301
|
+
# Commit changes
|
|
302
|
+
subprocess.run(
|
|
303
|
+
["git", "commit", "-m", f"checkpoint: {checkpoint_name}"],
|
|
304
|
+
cwd=worktree_path,
|
|
305
|
+
capture_output=True
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Create lightweight tag with standard prefix
|
|
309
|
+
subprocess.run(
|
|
310
|
+
["git", "tag", f"{CHECKPOINT_TAG_PREFIX}/{checkpoint_name}"],
|
|
311
|
+
cwd=worktree_path,
|
|
312
|
+
capture_output=True
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return checkpoint_name
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def reset_to_checkpoint(worktree_path: Path, checkpoint: str = None):
|
|
319
|
+
"""Reset worktree to a checkpoint.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
worktree_path: Path to worktree
|
|
323
|
+
checkpoint: Checkpoint name (defaults to HEAD)
|
|
324
|
+
"""
|
|
325
|
+
target = f"{CHECKPOINT_TAG_PREFIX}/{checkpoint}" if checkpoint else "HEAD"
|
|
326
|
+
|
|
327
|
+
subprocess.run(
|
|
328
|
+
["git", "reset", "--hard", target],
|
|
329
|
+
cwd=worktree_path,
|
|
330
|
+
capture_output=True
|
|
331
|
+
)
|