up-cli 0.1.1__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 +75 -4
- up/commands/agent.py +521 -0
- up/commands/bisect.py +343 -0
- up/commands/branch.py +350 -0
- up/commands/dashboard.py +248 -0
- up/commands/init.py +195 -6
- up/commands/learn.py +1741 -0
- up/commands/memory.py +545 -0
- up/commands/new.py +108 -10
- up/commands/provenance.py +267 -0
- up/commands/review.py +239 -0
- up/commands/start.py +1124 -0
- up/commands/status.py +360 -0
- up/commands/summarize.py +122 -0
- up/commands/sync.py +317 -0
- up/commands/vibe.py +304 -0
- up/context.py +421 -0
- 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/summarizer.py +407 -0
- up/templates/__init__.py +70 -2
- up/templates/config/__init__.py +502 -20
- 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/learn/__init__.py +567 -14
- up/templates/loop/__init__.py +546 -27
- up/templates/mcp/__init__.py +474 -0
- up/templates/projects/__init__.py +786 -0
- up/ui/__init__.py +14 -0
- up/ui/loop_display.py +650 -0
- up/ui/theme.py +137 -0
- up_cli-0.5.0.dist-info/METADATA +519 -0
- up_cli-0.5.0.dist-info/RECORD +55 -0
- up_cli-0.1.1.dist-info/METADATA +0 -186
- up_cli-0.1.1.dist-info/RECORD +0 -14
- {up_cli-0.1.1.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
- {up_cli-0.1.1.dist-info → up_cli-0.5.0.dist-info}/entry_points.txt +0 -0
up/commands/bisect.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""up bisect - Automated bug hunting with git bisect.
|
|
2
|
+
|
|
3
|
+
Finds the commit that introduced a bug using binary search.
|
|
4
|
+
Runs in O(log n) steps regardless of history length.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from up.git.utils import is_git_repo, run_git
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_last_tag(path: Path) -> Optional[str]:
|
|
23
|
+
"""Get most recent tag."""
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["git", "describe", "--tags", "--abbrev=0"],
|
|
26
|
+
cwd=path,
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True
|
|
29
|
+
)
|
|
30
|
+
if result.returncode == 0:
|
|
31
|
+
return result.stdout.strip()
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_commit_info(path: Path, commit: str) -> dict:
|
|
36
|
+
"""Get information about a commit."""
|
|
37
|
+
format_str = "%H%n%an%n%ae%n%ad%n%s"
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
["git", "log", "-1", f"--format={format_str}", commit],
|
|
40
|
+
cwd=path,
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if result.returncode != 0:
|
|
46
|
+
return {}
|
|
47
|
+
|
|
48
|
+
lines = result.stdout.strip().split("\n")
|
|
49
|
+
if len(lines) < 5:
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"sha": lines[0],
|
|
54
|
+
"author": lines[1],
|
|
55
|
+
"email": lines[2],
|
|
56
|
+
"date": lines[3],
|
|
57
|
+
"message": lines[4],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_diff(path: Path, commit: str) -> str:
|
|
62
|
+
"""Get diff for a commit."""
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["git", "show", "--stat", commit],
|
|
65
|
+
cwd=path,
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True
|
|
68
|
+
)
|
|
69
|
+
return result.stdout if result.returncode == 0 else ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@click.command("bisect")
|
|
73
|
+
@click.option("--test", "-t", "test_cmd", help="Test command to run (exit 0 = good, 1 = bad)")
|
|
74
|
+
@click.option("--good", "-g", help="Known good commit (default: last tag or HEAD~50)")
|
|
75
|
+
@click.option("--bad", "-b", default="HEAD", help="Known bad commit (default: HEAD)")
|
|
76
|
+
@click.option("--script", "-s", type=click.Path(exists=True), help="Path to test script")
|
|
77
|
+
@click.option("--start", is_flag=True, help="Start interactive bisect session")
|
|
78
|
+
@click.option("--reset", is_flag=True, help="Reset/abort current bisect")
|
|
79
|
+
def bisect_cmd(test_cmd: str, good: str, bad: str, script: str, start: bool, reset: bool):
|
|
80
|
+
"""Find the commit that introduced a bug.
|
|
81
|
+
|
|
82
|
+
Uses binary search through Git history to find the exact commit
|
|
83
|
+
that introduced a bug. Runs in O(log n) steps.
|
|
84
|
+
|
|
85
|
+
\b
|
|
86
|
+
Examples:
|
|
87
|
+
up bisect --test "pytest tests/auth.py"
|
|
88
|
+
up bisect --script ./test_regression.sh
|
|
89
|
+
up bisect --good v1.0.0 --bad HEAD --test "npm test"
|
|
90
|
+
up bisect --start # Interactive mode
|
|
91
|
+
up bisect --reset # Abort bisect
|
|
92
|
+
|
|
93
|
+
\b
|
|
94
|
+
Test Script Requirements:
|
|
95
|
+
- Exit 0: Bug NOT present (good commit)
|
|
96
|
+
- Exit 1: Bug IS present (bad commit)
|
|
97
|
+
- Exit 125: Skip this commit (can't test)
|
|
98
|
+
"""
|
|
99
|
+
cwd = Path.cwd()
|
|
100
|
+
|
|
101
|
+
if not is_git_repo(cwd):
|
|
102
|
+
console.print("[red]Error:[/] Not a git repository")
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
# Reset mode
|
|
106
|
+
if reset:
|
|
107
|
+
result = subprocess.run(
|
|
108
|
+
["git", "bisect", "reset"],
|
|
109
|
+
cwd=cwd,
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True
|
|
112
|
+
)
|
|
113
|
+
if result.returncode == 0:
|
|
114
|
+
console.print("[green]✓[/] Bisect session reset")
|
|
115
|
+
else:
|
|
116
|
+
console.print("[yellow]No bisect session active[/]")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
# Interactive start mode
|
|
120
|
+
if start:
|
|
121
|
+
_start_interactive_bisect(cwd, good, bad)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Need test command or script
|
|
125
|
+
if not test_cmd and not script:
|
|
126
|
+
console.print("[yellow]Provide a test command or script[/]")
|
|
127
|
+
console.print("\nUsage:")
|
|
128
|
+
console.print(' up bisect --test "pytest tests/auth.py"')
|
|
129
|
+
console.print(" up bisect --script ./test_regression.sh")
|
|
130
|
+
console.print(" up bisect --start # Interactive mode")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Determine good commit
|
|
134
|
+
if not good:
|
|
135
|
+
# Try last tag
|
|
136
|
+
good = _get_last_tag(cwd)
|
|
137
|
+
if not good:
|
|
138
|
+
# Fall back to N commits ago
|
|
139
|
+
good = "HEAD~50"
|
|
140
|
+
console.print(f"[dim]Using good commit: {good}[/]")
|
|
141
|
+
|
|
142
|
+
# Build test script
|
|
143
|
+
if script:
|
|
144
|
+
test_script = Path(script).read_text()
|
|
145
|
+
else:
|
|
146
|
+
test_script = f"""#!/bin/bash
|
|
147
|
+
# Auto-generated bisect test script
|
|
148
|
+
|
|
149
|
+
# Run the test command
|
|
150
|
+
{test_cmd}
|
|
151
|
+
exit $?
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
# Write test script
|
|
155
|
+
script_path = cwd / ".bisect_test.sh"
|
|
156
|
+
script_path.write_text(test_script)
|
|
157
|
+
script_path.chmod(0o755)
|
|
158
|
+
|
|
159
|
+
console.print(Panel.fit(
|
|
160
|
+
f"[bold]Git Bisect - Bug Hunter[/]\n\n"
|
|
161
|
+
f"Good: {good}\n"
|
|
162
|
+
f"Bad: {bad}\n"
|
|
163
|
+
f"Test: {test_cmd or script}",
|
|
164
|
+
border_style="blue"
|
|
165
|
+
))
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
# Start bisect
|
|
169
|
+
console.print("\n[dim]Starting bisect...[/]")
|
|
170
|
+
|
|
171
|
+
subprocess.run(["git", "bisect", "start"], cwd=cwd, capture_output=True)
|
|
172
|
+
subprocess.run(["git", "bisect", "bad", bad], cwd=cwd, capture_output=True)
|
|
173
|
+
|
|
174
|
+
result = subprocess.run(
|
|
175
|
+
["git", "bisect", "good", good],
|
|
176
|
+
cwd=cwd,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
text=True
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if result.returncode != 0:
|
|
182
|
+
console.print(f"[red]Error:[/] Failed to set good commit")
|
|
183
|
+
console.print(f"[dim]{result.stderr}[/]")
|
|
184
|
+
subprocess.run(["git", "bisect", "reset"], cwd=cwd, capture_output=True)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
# Run automated bisect
|
|
188
|
+
console.print("[dim]Running automated bisect (this may take a while)...[/]")
|
|
189
|
+
console.print()
|
|
190
|
+
|
|
191
|
+
result = subprocess.run(
|
|
192
|
+
["git", "bisect", "run", str(script_path)],
|
|
193
|
+
cwd=cwd,
|
|
194
|
+
capture_output=True,
|
|
195
|
+
text=True
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Parse result to find culprit
|
|
199
|
+
output = result.stdout + result.stderr
|
|
200
|
+
|
|
201
|
+
# Look for the culprit commit
|
|
202
|
+
culprit = None
|
|
203
|
+
for line in output.split("\n"):
|
|
204
|
+
if "is the first bad commit" in line:
|
|
205
|
+
# Extract commit SHA
|
|
206
|
+
parts = line.split()
|
|
207
|
+
if parts:
|
|
208
|
+
culprit = parts[0]
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
if culprit:
|
|
212
|
+
_display_culprit(cwd, culprit)
|
|
213
|
+
else:
|
|
214
|
+
console.print("[yellow]Could not determine culprit commit[/]")
|
|
215
|
+
console.print("[dim]Bisect output:[/]")
|
|
216
|
+
console.print(output[-500:] if len(output) > 500 else output)
|
|
217
|
+
|
|
218
|
+
# Reset bisect
|
|
219
|
+
subprocess.run(["git", "bisect", "reset"], cwd=cwd, capture_output=True)
|
|
220
|
+
|
|
221
|
+
finally:
|
|
222
|
+
# Cleanup
|
|
223
|
+
if script_path.exists():
|
|
224
|
+
script_path.unlink()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _start_interactive_bisect(cwd: Path, good: str, bad: str):
|
|
228
|
+
"""Start an interactive bisect session."""
|
|
229
|
+
|
|
230
|
+
if not good:
|
|
231
|
+
good = _get_last_tag(cwd) or "HEAD~20"
|
|
232
|
+
|
|
233
|
+
console.print(Panel.fit(
|
|
234
|
+
"[bold]Interactive Bisect Session[/]\n\n"
|
|
235
|
+
"You'll be guided through testing commits.\n"
|
|
236
|
+
"For each commit, run your test and mark as good/bad.",
|
|
237
|
+
border_style="blue"
|
|
238
|
+
))
|
|
239
|
+
|
|
240
|
+
# Start bisect
|
|
241
|
+
subprocess.run(["git", "bisect", "start"], cwd=cwd, capture_output=True)
|
|
242
|
+
subprocess.run(["git", "bisect", "bad", bad], cwd=cwd, capture_output=True)
|
|
243
|
+
result = subprocess.run(
|
|
244
|
+
["git", "bisect", "good", good],
|
|
245
|
+
cwd=cwd,
|
|
246
|
+
capture_output=True,
|
|
247
|
+
text=True
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
console.print(f"\n[dim]Bisect started: good={good}, bad={bad}[/]")
|
|
251
|
+
console.print("\n[bold]Commands:[/]")
|
|
252
|
+
console.print(" [cyan]git bisect good[/] - Mark current commit as good")
|
|
253
|
+
console.print(" [cyan]git bisect bad[/] - Mark current commit as bad")
|
|
254
|
+
console.print(" [cyan]git bisect skip[/] - Skip current commit")
|
|
255
|
+
console.print(" [cyan]up bisect --reset[/] - Abort bisect")
|
|
256
|
+
console.print("\n[dim]Run your test, then mark the commit.[/]")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _display_culprit(cwd: Path, commit: str):
|
|
260
|
+
"""Display information about the culprit commit."""
|
|
261
|
+
info = _get_commit_info(cwd, commit)
|
|
262
|
+
|
|
263
|
+
console.print(Panel.fit(
|
|
264
|
+
f"[bold red]🐛 Bug-Introducing Commit Found![/]",
|
|
265
|
+
border_style="red"
|
|
266
|
+
))
|
|
267
|
+
console.print()
|
|
268
|
+
|
|
269
|
+
table = Table(show_header=False, box=None)
|
|
270
|
+
table.add_column("Key", style="dim")
|
|
271
|
+
table.add_column("Value")
|
|
272
|
+
|
|
273
|
+
table.add_row("Commit", f"[cyan]{info.get('sha', commit)[:12]}[/]")
|
|
274
|
+
table.add_row("Author", info.get('author', 'Unknown'))
|
|
275
|
+
table.add_row("Date", info.get('date', 'Unknown'))
|
|
276
|
+
table.add_row("Message", info.get('message', 'No message'))
|
|
277
|
+
|
|
278
|
+
console.print(table)
|
|
279
|
+
|
|
280
|
+
# Show diff stats
|
|
281
|
+
diff = _get_diff(cwd, commit)
|
|
282
|
+
if diff:
|
|
283
|
+
console.print("\n[bold]Changes in this commit:[/]")
|
|
284
|
+
# Only show the stat part
|
|
285
|
+
lines = diff.split("\n")
|
|
286
|
+
for line in lines:
|
|
287
|
+
if line.startswith(" ") and ("|" in line or "changed" in line):
|
|
288
|
+
console.print(f" {line}")
|
|
289
|
+
|
|
290
|
+
console.print("\n[bold]Next steps:[/]")
|
|
291
|
+
console.print(f" 1. View full diff: [cyan]git show {commit[:8]}[/]")
|
|
292
|
+
console.print(f" 2. Check this commit: [cyan]git checkout {commit[:8]}[/]")
|
|
293
|
+
console.print(f" 3. Revert if needed: [cyan]git revert {commit[:8]}[/]")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# Also add a 'history' command for viewing commit history with context
|
|
297
|
+
|
|
298
|
+
@click.command("history")
|
|
299
|
+
@click.option("--limit", "-n", default=20, help="Number of commits to show")
|
|
300
|
+
@click.option("--since", help="Show commits since date/ref")
|
|
301
|
+
@click.option("--author", help="Filter by author")
|
|
302
|
+
@click.option("--grep", "grep_pattern", help="Filter by message pattern")
|
|
303
|
+
def history_cmd(limit: int, since: str, author: str, grep_pattern: str):
|
|
304
|
+
"""Show commit history with context.
|
|
305
|
+
|
|
306
|
+
Displays recent commits with author, date, and message.
|
|
307
|
+
Useful for finding commits to bisect from.
|
|
308
|
+
"""
|
|
309
|
+
cwd = Path.cwd()
|
|
310
|
+
|
|
311
|
+
if not is_git_repo(cwd):
|
|
312
|
+
console.print("[red]Error:[/] Not a git repository")
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
# Build git log command
|
|
316
|
+
cmd = ["git", "log", f"-{limit}", "--pretty=format:%h|%an|%ar|%s"]
|
|
317
|
+
|
|
318
|
+
if since:
|
|
319
|
+
cmd.append(f"--since={since}")
|
|
320
|
+
if author:
|
|
321
|
+
cmd.append(f"--author={author}")
|
|
322
|
+
if grep_pattern:
|
|
323
|
+
cmd.append(f"--grep={grep_pattern}")
|
|
324
|
+
|
|
325
|
+
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
|
326
|
+
|
|
327
|
+
if result.returncode != 0:
|
|
328
|
+
console.print("[red]Error:[/] Failed to get history")
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
table = Table(title=f"Recent Commits (last {limit})")
|
|
332
|
+
table.add_column("SHA", style="cyan")
|
|
333
|
+
table.add_column("Author")
|
|
334
|
+
table.add_column("When")
|
|
335
|
+
table.add_column("Message")
|
|
336
|
+
|
|
337
|
+
for line in result.stdout.strip().split("\n"):
|
|
338
|
+
if "|" in line:
|
|
339
|
+
parts = line.split("|", 3)
|
|
340
|
+
if len(parts) >= 4:
|
|
341
|
+
table.add_row(parts[0], parts[1], parts[2], parts[3][:50])
|
|
342
|
+
|
|
343
|
+
console.print(table)
|
up/commands/branch.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""up branch - Branch hierarchy management.
|
|
2
|
+
|
|
3
|
+
Implements a tiered branch model for AI-assisted development:
|
|
4
|
+
- main: Stable, production-ready code
|
|
5
|
+
- develop: Integration branch for features
|
|
6
|
+
- feature/*: Feature branches from develop
|
|
7
|
+
- agent/*: AI worktree branches
|
|
8
|
+
|
|
9
|
+
Changes flow upward: agent/* → feature/* → develop → main
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional, List
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
from rich.tree import Tree
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Branch hierarchy configuration
|
|
26
|
+
BRANCH_HIERARCHY = {
|
|
27
|
+
"main": {
|
|
28
|
+
"level": 0,
|
|
29
|
+
"description": "Production-ready code",
|
|
30
|
+
"allows_from": ["develop", "hotfix/*"],
|
|
31
|
+
"protected": True,
|
|
32
|
+
},
|
|
33
|
+
"develop": {
|
|
34
|
+
"level": 1,
|
|
35
|
+
"description": "Integration branch",
|
|
36
|
+
"allows_from": ["feature/*", "agent/*"],
|
|
37
|
+
"protected": False,
|
|
38
|
+
},
|
|
39
|
+
"feature/*": {
|
|
40
|
+
"level": 2,
|
|
41
|
+
"description": "Feature branches",
|
|
42
|
+
"allows_from": ["agent/*"],
|
|
43
|
+
"protected": False,
|
|
44
|
+
},
|
|
45
|
+
"agent/*": {
|
|
46
|
+
"level": 3,
|
|
47
|
+
"description": "AI worktree branches",
|
|
48
|
+
"allows_from": [],
|
|
49
|
+
"protected": False,
|
|
50
|
+
},
|
|
51
|
+
"hotfix/*": {
|
|
52
|
+
"level": 1,
|
|
53
|
+
"description": "Emergency fixes",
|
|
54
|
+
"allows_from": [],
|
|
55
|
+
"protected": False,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_git_repo(path: Path) -> bool:
|
|
61
|
+
"""Check if path is a git repository."""
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
["git", "rev-parse", "--git-dir"],
|
|
64
|
+
cwd=path,
|
|
65
|
+
capture_output=True
|
|
66
|
+
)
|
|
67
|
+
return result.returncode == 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _get_current_branch(path: Path) -> str:
|
|
71
|
+
"""Get current branch name."""
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
74
|
+
cwd=path,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True
|
|
77
|
+
)
|
|
78
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_all_branches(path: Path) -> List[str]:
|
|
82
|
+
"""Get all branch names."""
|
|
83
|
+
result = subprocess.run(
|
|
84
|
+
["git", "branch", "-a", "--format=%(refname:short)"],
|
|
85
|
+
cwd=path,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True
|
|
88
|
+
)
|
|
89
|
+
if result.returncode == 0:
|
|
90
|
+
return [b.strip() for b in result.stdout.strip().split("\n") if b.strip()]
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _get_branch_pattern(branch: str) -> str:
|
|
95
|
+
"""Get the pattern that matches a branch name."""
|
|
96
|
+
if branch == "main" or branch == "master":
|
|
97
|
+
return "main"
|
|
98
|
+
if branch == "develop" or branch == "development":
|
|
99
|
+
return "develop"
|
|
100
|
+
if branch.startswith("feature/"):
|
|
101
|
+
return "feature/*"
|
|
102
|
+
if branch.startswith("agent/"):
|
|
103
|
+
return "agent/*"
|
|
104
|
+
if branch.startswith("hotfix/"):
|
|
105
|
+
return "hotfix/*"
|
|
106
|
+
return branch
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _can_merge(source: str, target: str) -> tuple[bool, str]:
|
|
110
|
+
"""Check if source can be merged into target.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
(can_merge, reason)
|
|
114
|
+
"""
|
|
115
|
+
source_pattern = _get_branch_pattern(source)
|
|
116
|
+
target_pattern = _get_branch_pattern(target)
|
|
117
|
+
|
|
118
|
+
target_config = BRANCH_HIERARCHY.get(target_pattern)
|
|
119
|
+
|
|
120
|
+
if not target_config:
|
|
121
|
+
# Unknown target branch - allow by default
|
|
122
|
+
return True, "Target branch not in hierarchy"
|
|
123
|
+
|
|
124
|
+
allowed = target_config.get("allows_from", [])
|
|
125
|
+
|
|
126
|
+
if source_pattern in allowed:
|
|
127
|
+
return True, f"Merge allowed: {source_pattern} → {target_pattern}"
|
|
128
|
+
|
|
129
|
+
# Check if source is at a lower level (wrong direction)
|
|
130
|
+
source_config = BRANCH_HIERARCHY.get(source_pattern)
|
|
131
|
+
if source_config:
|
|
132
|
+
source_level = source_config.get("level", 99)
|
|
133
|
+
target_level = target_config.get("level", 99)
|
|
134
|
+
|
|
135
|
+
if source_level < target_level:
|
|
136
|
+
return False, f"Wrong direction: {source_pattern} (level {source_level}) cannot merge into {target_pattern} (level {target_level})"
|
|
137
|
+
|
|
138
|
+
return False, f"Not allowed: {source_pattern} → {target_pattern}. Allowed sources: {', '.join(allowed)}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@click.group()
|
|
142
|
+
def branch():
|
|
143
|
+
"""Branch hierarchy management.
|
|
144
|
+
|
|
145
|
+
Enforce a tiered branch model for safe AI development.
|
|
146
|
+
Changes flow upward: agent/* → feature/* → develop → main
|
|
147
|
+
"""
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@branch.command("status")
|
|
152
|
+
def status_cmd():
|
|
153
|
+
"""Show current branch status and hierarchy."""
|
|
154
|
+
cwd = Path.cwd()
|
|
155
|
+
|
|
156
|
+
if not _is_git_repo(cwd):
|
|
157
|
+
console.print("[red]Error:[/] Not a git repository")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
current = _get_current_branch(cwd)
|
|
161
|
+
all_branches = _get_all_branches(cwd)
|
|
162
|
+
|
|
163
|
+
console.print(Panel.fit(
|
|
164
|
+
f"[bold]Branch Hierarchy[/]\nCurrent: [cyan]{current}[/]",
|
|
165
|
+
border_style="blue"
|
|
166
|
+
))
|
|
167
|
+
|
|
168
|
+
# Build hierarchy tree
|
|
169
|
+
tree = Tree("[bold]Branch Hierarchy[/]")
|
|
170
|
+
|
|
171
|
+
main_node = tree.add("🔒 [bold green]main[/] - Production")
|
|
172
|
+
develop_node = main_node.add("📦 [yellow]develop[/] - Integration")
|
|
173
|
+
|
|
174
|
+
# Group branches
|
|
175
|
+
feature_branches = [b for b in all_branches if b.startswith("feature/")]
|
|
176
|
+
agent_branches = [b for b in all_branches if b.startswith("agent/")]
|
|
177
|
+
hotfix_branches = [b for b in all_branches if b.startswith("hotfix/")]
|
|
178
|
+
|
|
179
|
+
if feature_branches:
|
|
180
|
+
features = develop_node.add(f"🔧 [cyan]feature/*[/] ({len(feature_branches)})")
|
|
181
|
+
for fb in feature_branches[:5]:
|
|
182
|
+
is_current = "← current" if fb == current else ""
|
|
183
|
+
features.add(f"[dim]{fb}[/] {is_current}")
|
|
184
|
+
if len(feature_branches) > 5:
|
|
185
|
+
features.add(f"[dim]... and {len(feature_branches) - 5} more[/]")
|
|
186
|
+
|
|
187
|
+
if agent_branches:
|
|
188
|
+
agents = develop_node.add(f"🤖 [magenta]agent/*[/] ({len(agent_branches)})")
|
|
189
|
+
for ab in agent_branches[:5]:
|
|
190
|
+
is_current = "← current" if ab == current else ""
|
|
191
|
+
agents.add(f"[dim]{ab}[/] {is_current}")
|
|
192
|
+
if len(agent_branches) > 5:
|
|
193
|
+
agents.add(f"[dim]... and {len(agent_branches) - 5} more[/]")
|
|
194
|
+
|
|
195
|
+
if hotfix_branches:
|
|
196
|
+
hotfixes = main_node.add(f"🚨 [red]hotfix/*[/] ({len(hotfix_branches)})")
|
|
197
|
+
for hb in hotfix_branches[:3]:
|
|
198
|
+
hotfixes.add(f"[dim]{hb}[/]")
|
|
199
|
+
|
|
200
|
+
console.print(tree)
|
|
201
|
+
|
|
202
|
+
# Show allowed merges for current branch
|
|
203
|
+
current_pattern = _get_branch_pattern(current)
|
|
204
|
+
console.print(f"\n[bold]Current Branch:[/] {current} ({current_pattern})")
|
|
205
|
+
|
|
206
|
+
# Where can we merge to?
|
|
207
|
+
merge_targets = []
|
|
208
|
+
for target, config in BRANCH_HIERARCHY.items():
|
|
209
|
+
if current_pattern in config.get("allows_from", []):
|
|
210
|
+
merge_targets.append(target)
|
|
211
|
+
|
|
212
|
+
if merge_targets:
|
|
213
|
+
console.print(f"[green]Can merge to:[/] {', '.join(merge_targets)}")
|
|
214
|
+
else:
|
|
215
|
+
console.print("[dim]No merge targets in hierarchy[/]")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@branch.command("check")
|
|
219
|
+
@click.argument("target", required=False)
|
|
220
|
+
@click.option("--source", "-s", help="Source branch (default: current)")
|
|
221
|
+
def check_cmd(target: str, source: str):
|
|
222
|
+
"""Check if merge is allowed by hierarchy.
|
|
223
|
+
|
|
224
|
+
\b
|
|
225
|
+
Examples:
|
|
226
|
+
up branch check develop # Check current → develop
|
|
227
|
+
up branch check main -s develop # Check develop → main
|
|
228
|
+
"""
|
|
229
|
+
cwd = Path.cwd()
|
|
230
|
+
|
|
231
|
+
if not _is_git_repo(cwd):
|
|
232
|
+
console.print("[red]Error:[/] Not a git repository")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
source_branch = source or _get_current_branch(cwd)
|
|
236
|
+
target_branch = target or "develop"
|
|
237
|
+
|
|
238
|
+
can_merge, reason = _can_merge(source_branch, target_branch)
|
|
239
|
+
|
|
240
|
+
if can_merge:
|
|
241
|
+
console.print(f"[green]✓[/] {reason}")
|
|
242
|
+
console.print(f"\n {source_branch} → {target_branch}")
|
|
243
|
+
else:
|
|
244
|
+
console.print(f"[red]✗[/] {reason}")
|
|
245
|
+
|
|
246
|
+
# Suggest correct flow
|
|
247
|
+
source_pattern = _get_branch_pattern(source_branch)
|
|
248
|
+
source_config = BRANCH_HIERARCHY.get(source_pattern)
|
|
249
|
+
|
|
250
|
+
if source_config:
|
|
251
|
+
# Find where source can go
|
|
252
|
+
for t, config in BRANCH_HIERARCHY.items():
|
|
253
|
+
if source_pattern in config.get("allows_from", []):
|
|
254
|
+
console.print(f"\n[dim]Suggestion: Merge to {t} first[/]")
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@branch.command("enforce")
|
|
259
|
+
@click.option("--enable", is_flag=True, help="Enable enforcement")
|
|
260
|
+
@click.option("--disable", is_flag=True, help="Disable enforcement")
|
|
261
|
+
def enforce_cmd(enable: bool, disable: bool):
|
|
262
|
+
"""Enable/disable branch hierarchy enforcement.
|
|
263
|
+
|
|
264
|
+
When enabled, 'up agent merge' will check hierarchy before merging.
|
|
265
|
+
"""
|
|
266
|
+
cwd = Path.cwd()
|
|
267
|
+
|
|
268
|
+
if not _is_git_repo(cwd):
|
|
269
|
+
console.print("[red]Error:[/] Not a git repository")
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
# Store setting in .up/config.json
|
|
273
|
+
config_dir = cwd / ".up"
|
|
274
|
+
config_file = config_dir / "config.json"
|
|
275
|
+
|
|
276
|
+
import json
|
|
277
|
+
|
|
278
|
+
config = {}
|
|
279
|
+
if config_file.exists():
|
|
280
|
+
try:
|
|
281
|
+
config = json.loads(config_file.read_text())
|
|
282
|
+
except json.JSONDecodeError:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
if enable:
|
|
286
|
+
config["branch_hierarchy_enforcement"] = True
|
|
287
|
+
console.print("[green]✓[/] Branch hierarchy enforcement enabled")
|
|
288
|
+
elif disable:
|
|
289
|
+
config["branch_hierarchy_enforcement"] = False
|
|
290
|
+
console.print("[yellow]○[/] Branch hierarchy enforcement disabled")
|
|
291
|
+
else:
|
|
292
|
+
current = config.get("branch_hierarchy_enforcement", False)
|
|
293
|
+
status = "[green]enabled[/]" if current else "[dim]disabled[/]"
|
|
294
|
+
console.print(f"Branch hierarchy enforcement: {status}")
|
|
295
|
+
console.print("\nUse [cyan]--enable[/] or [cyan]--disable[/] to change")
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
299
|
+
config_file.write_text(json.dumps(config, indent=2))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@branch.command("create")
|
|
303
|
+
@click.argument("name")
|
|
304
|
+
@click.option("--type", "branch_type", type=click.Choice(["feature", "agent", "hotfix"]),
|
|
305
|
+
default="feature", help="Branch type")
|
|
306
|
+
@click.option("--from", "from_branch", default="develop", help="Base branch")
|
|
307
|
+
def create_cmd(name: str, branch_type: str, from_branch: str):
|
|
308
|
+
"""Create a new branch following hierarchy.
|
|
309
|
+
|
|
310
|
+
\b
|
|
311
|
+
Examples:
|
|
312
|
+
up branch create auth # feature/auth from develop
|
|
313
|
+
up branch create US-007 --type agent # agent/US-007 from develop
|
|
314
|
+
up branch create fix-login --type hotfix --from main
|
|
315
|
+
"""
|
|
316
|
+
cwd = Path.cwd()
|
|
317
|
+
|
|
318
|
+
if not _is_git_repo(cwd):
|
|
319
|
+
console.print("[red]Error:[/] Not a git repository")
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
# Build branch name
|
|
323
|
+
if branch_type == "feature":
|
|
324
|
+
full_name = f"feature/{name}"
|
|
325
|
+
elif branch_type == "agent":
|
|
326
|
+
full_name = f"agent/{name}"
|
|
327
|
+
elif branch_type == "hotfix":
|
|
328
|
+
full_name = f"hotfix/{name}"
|
|
329
|
+
from_branch = "main" # Hotfixes always from main
|
|
330
|
+
else:
|
|
331
|
+
full_name = name
|
|
332
|
+
|
|
333
|
+
# Create branch
|
|
334
|
+
result = subprocess.run(
|
|
335
|
+
["git", "checkout", "-b", full_name, from_branch],
|
|
336
|
+
cwd=cwd,
|
|
337
|
+
capture_output=True,
|
|
338
|
+
text=True
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
if result.returncode == 0:
|
|
342
|
+
console.print(f"[green]✓[/] Created branch: [cyan]{full_name}[/]")
|
|
343
|
+
console.print(f" Based on: {from_branch}")
|
|
344
|
+
|
|
345
|
+
# Show merge path
|
|
346
|
+
target = "develop" if branch_type in ["feature", "agent"] else "main"
|
|
347
|
+
console.print(f"\n[dim]Merge path: {full_name} → {target}[/]")
|
|
348
|
+
else:
|
|
349
|
+
console.print(f"[red]Error:[/] Failed to create branch")
|
|
350
|
+
console.print(f"[dim]{result.stderr}[/]")
|