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.
Files changed (46) hide show
  1. up/__init__.py +1 -1
  2. up/ai_cli.py +229 -0
  3. up/cli.py +54 -9
  4. up/commands/agent.py +521 -0
  5. up/commands/bisect.py +343 -0
  6. up/commands/branch.py +350 -0
  7. up/commands/init.py +195 -6
  8. up/commands/learn.py +1392 -32
  9. up/commands/memory.py +545 -0
  10. up/commands/provenance.py +267 -0
  11. up/commands/review.py +239 -0
  12. up/commands/start.py +752 -42
  13. up/commands/status.py +173 -18
  14. up/commands/sync.py +317 -0
  15. up/commands/vibe.py +304 -0
  16. up/context.py +64 -10
  17. up/core/__init__.py +69 -0
  18. up/core/checkpoint.py +479 -0
  19. up/core/provenance.py +364 -0
  20. up/core/state.py +678 -0
  21. up/events.py +512 -0
  22. up/git/__init__.py +37 -0
  23. up/git/utils.py +270 -0
  24. up/git/worktree.py +331 -0
  25. up/learn/__init__.py +155 -0
  26. up/learn/analyzer.py +227 -0
  27. up/learn/plan.py +374 -0
  28. up/learn/research.py +511 -0
  29. up/learn/utils.py +117 -0
  30. up/memory.py +1096 -0
  31. up/parallel.py +551 -0
  32. up/templates/config/__init__.py +1 -1
  33. up/templates/docs/SKILL.md +28 -0
  34. up/templates/docs/__init__.py +341 -0
  35. up/templates/docs/standards/HEADERS.md +24 -0
  36. up/templates/docs/standards/STRUCTURE.md +18 -0
  37. up/templates/docs/standards/TEMPLATES.md +19 -0
  38. up/templates/loop/__init__.py +92 -32
  39. up/ui/__init__.py +14 -0
  40. up/ui/loop_display.py +650 -0
  41. up/ui/theme.py +137 -0
  42. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/METADATA +160 -15
  43. up_cli-0.5.0.dist-info/RECORD +55 -0
  44. up_cli-0.2.0.dist-info/RECORD +0 -23
  45. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
  46. {up_cli-0.2.0.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}[/]")