Coverage for little_loops / cli / parallel.py: 11%

106 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -0500

1"""ll-parallel: Process issues concurrently using isolated git worktrees.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import os 

7import subprocess 

8import sys 

9from pathlib import Path 

10 

11from little_loops.cli.output import configure_output, use_color_enabled 

12from little_loops.cli_args import ( 

13 add_context_limit_arg, 

14 add_dry_run_arg, 

15 add_handoff_threshold_arg, 

16 add_idle_timeout_arg, 

17 add_label_arg, 

18 add_max_issues_arg, 

19 add_only_arg, 

20 add_quiet_arg, 

21 add_resume_arg, 

22 add_skip_arg, 

23 add_skip_learning_gate_arg, 

24 add_timeout_arg, 

25 add_type_arg, 

26 parse_issue_ids, 

27 parse_issue_types, 

28 parse_labels, 

29 parse_priorities, 

30) 

31from little_loops.config import BRConfig 

32from little_loops.logger import Logger 

33from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

34 

35 

36def main_parallel() -> int: 

37 """Entry point for ll-parallel command. 

38 

39 Process issues concurrently using isolated git worktrees. 

40 

41 Returns: 

42 Exit code (0 = success) 

43 """ 

44 with cli_event_context(DEFAULT_DB_PATH, "ll-parallel", sys.argv[1:]): 

45 parser = argparse.ArgumentParser( 

46 description="Process issues concurrently using isolated git worktrees", 

47 formatter_class=argparse.RawDescriptionHelpFormatter, 

48 epilog=""" 

49Examples: 

50 %(prog)s # Process with default workers 

51 %(prog)s --workers 3 # Use 3 parallel workers 

52 %(prog)s --dry-run # Preview what would be processed 

53 %(prog)s --priority P1,P2 # Only process P1 and P2 issues 

54 %(prog)s --cleanup # Clean up worktrees and exit 

55 %(prog)s --cleanup-orphans # Clean orphaned worktrees (liveness-aware, skips live-process worktrees) 

56 %(prog)s --cleanup-orphans --dry-run # Preview orphan cleanup 

57 %(prog)s --prune-merged-branches # Delete merged feature/* branches 

58 %(prog)s --prune-merged-branches --dry-run # Preview what would be pruned 

59 %(prog)s --stream-output # Stream Claude CLI output in real-time 

60 %(prog)s --only BUG-001,BUG-002 # Process only specific issues 

61 %(prog)s --skip BUG-003 # Skip specific issues 

62 %(prog)s --type BUG # Process only bugs 

63 %(prog)s --type BUG,ENH # Process bugs and enhancements 

64""", 

65 ) 

66 

67 # Parallel-specific arguments (--workers, not --max-workers) 

68 parser.add_argument( 

69 "--workers", 

70 "-w", 

71 type=int, 

72 default=None, 

73 help="Number of parallel workers (default: from config or 2)", 

74 ) 

75 parser.add_argument( 

76 "--priority", 

77 "-p", 

78 type=str, 

79 default=None, 

80 help="Comma-separated priorities to process (default: all)", 

81 ) 

82 parser.add_argument( 

83 "--worktree-base", 

84 type=Path, 

85 default=None, 

86 help="Base directory for git worktrees", 

87 ) 

88 parser.add_argument( 

89 "--cleanup", 

90 "-c", 

91 action="store_true", 

92 help="Clean up all worktrees and exit", 

93 ) 

94 parser.add_argument( 

95 "--cleanup-orphans", 

96 action="store_true", 

97 help=( 

98 "Remove orphaned worktrees from interrupted runs (liveness-aware: skips " 

99 "worktrees owned by live processes). Use with --dry-run to preview." 

100 ), 

101 ) 

102 parser.add_argument( 

103 "--prune-merged-branches", 

104 action="store_true", 

105 help=( 

106 "Delete local feature/* branches already merged into the base branch. " 

107 "Use --dry-run to preview. " 

108 "Squash/rebase-merged branches require gh CLI for detection." 

109 ), 

110 ) 

111 parser.add_argument( 

112 "--merge-pending", 

113 action="store_true", 

114 help="Attempt to merge pending work from previous interrupted runs", 

115 ) 

116 parser.add_argument( 

117 "--clean-start", 

118 action="store_true", 

119 help="Remove all worktrees and start fresh (skip pending work check)", 

120 ) 

121 parser.add_argument( 

122 "--ignore-pending", 

123 action="store_true", 

124 help="Report pending work but continue without merging", 

125 ) 

126 parser.add_argument( 

127 "--stream-output", 

128 action="store_true", 

129 help="Stream Claude CLI subprocess output to console", 

130 ) 

131 parser.add_argument( 

132 "--show-model", 

133 action="store_true", 

134 help="Make API call to verify and display model on worktree setup", 

135 ) 

136 parser.add_argument( 

137 "--feature-branches", 

138 action=argparse.BooleanOptionalAction, 

139 default=None, 

140 help="Enable/disable feature-branch mode for this run (overrides config)", 

141 ) 

142 parser.add_argument( 

143 "--overlap-detection", 

144 action="store_true", 

145 help="Enable pre-flight overlap detection to reduce merge conflicts (ENH-143)", 

146 ) 

147 parser.add_argument( 

148 "--warn-only", 

149 action="store_true", 

150 help="With --overlap-detection, warn about overlaps instead of serializing", 

151 ) 

152 

153 parser.add_argument( 

154 "--verbose", 

155 "-v", 

156 action="store_true", 

157 help="Enable verbose output (default when --quiet is not set)", 

158 ) 

159 

160 # Add common arguments from shared module 

161 add_dry_run_arg(parser) 

162 add_resume_arg(parser) 

163 add_timeout_arg(parser) 

164 add_idle_timeout_arg(parser) 

165 add_handoff_threshold_arg(parser) 

166 add_context_limit_arg(parser) 

167 add_quiet_arg(parser) 

168 add_only_arg(parser) 

169 add_skip_arg(parser) 

170 add_type_arg(parser) 

171 add_label_arg(parser) 

172 add_skip_learning_gate_arg(parser) 

173 

174 # Add max-issues and config individually (different help text needed) 

175 add_max_issues_arg(parser) 

176 parser.add_argument( 

177 "--config", 

178 "-C", 

179 type=Path, 

180 default=None, 

181 help="Path to project root", 

182 ) 

183 

184 args = parser.parse_args() 

185 

186 project_root = args.config or Path.cwd() 

187 config = BRConfig(project_root) 

188 configure_output(config.cli) 

189 

190 logger = Logger(verbose=args.verbose or not args.quiet, use_color=use_color_enabled()) 

191 

192 # Handle cleanup mode 

193 if args.cleanup: 

194 from little_loops.parallel import WorkerPool 

195 

196 parallel_config = config.create_parallel_config() 

197 pool = WorkerPool(parallel_config, config, logger, project_root) 

198 pool.cleanup_all_worktrees() 

199 logger.success("Cleanup complete") 

200 return 0 

201 

202 # Handle cleanup-orphans mode (liveness-aware orphan cleanup) 

203 if args.cleanup_orphans: 

204 from little_loops.parallel import ParallelOrchestrator 

205 

206 parallel_config = config.create_parallel_config(dry_run=args.dry_run) 

207 orchestrator = ParallelOrchestrator( 

208 parallel_config=parallel_config, 

209 br_config=config, 

210 repo_path=project_root, 

211 verbose=args.verbose or not args.quiet, 

212 ) 

213 if args.dry_run: 

214 logger.info("[DRY RUN] No changes will be made.") 

215 orchestrator._cleanup_orphaned_worktrees(dry_run=args.dry_run) 

216 logger.success("Orphan cleanup complete") 

217 return 0 

218 

219 # Build priority filter (validates against VALID_PRIORITIES) 

220 priority_filter = parse_priorities(args.priority) 

221 

222 if args.handoff_threshold is not None: 

223 if not (1 <= args.handoff_threshold <= 100): 

224 parser.error("--handoff-threshold must be between 1 and 100") 

225 os.environ["LL_HANDOFF_THRESHOLD"] = str(args.handoff_threshold) 

226 

227 if args.context_limit is not None: 

228 if args.context_limit < 50000: 

229 parser.error("--context-limit must be at least 50000") 

230 os.environ["LL_CONTEXT_LIMIT"] = str(args.context_limit) 

231 

232 # Parse issue ID filters 

233 only_ids = parse_issue_ids(args.only) 

234 skip_ids = parse_issue_ids(args.skip) 

235 type_prefixes = parse_issue_types(args.type) 

236 label_filter = parse_labels(args.label) 

237 

238 # Detect current branch for rebase/merge operations (BUG-439) 

239 _branch_result = subprocess.run( 

240 ["git", "rev-parse", "--abbrev-ref", "HEAD"], 

241 capture_output=True, 

242 text=True, 

243 cwd=project_root, 

244 ) 

245 _base_branch = _branch_result.stdout.strip() if _branch_result.returncode == 0 else "main" 

246 

247 # Create parallel config with CLI overrides 

248 parallel_config = config.create_parallel_config( 

249 max_workers=args.workers, 

250 priority_filter=sorted(priority_filter) if priority_filter is not None else None, 

251 label_filter=label_filter, 

252 max_issues=args.max_issues, 

253 dry_run=args.dry_run, 

254 timeout_seconds=args.timeout, 

255 idle_timeout_per_issue=args.idle_timeout, 

256 stream_output=args.stream_output if args.stream_output else None, 

257 show_model=args.show_model if args.show_model else None, 

258 only_ids=only_ids, 

259 skip_ids=skip_ids, 

260 type_prefixes=type_prefixes, 

261 merge_pending=args.merge_pending, 

262 clean_start=args.clean_start, 

263 ignore_pending=args.ignore_pending, 

264 overlap_detection=args.overlap_detection, 

265 serialize_overlapping=not args.warn_only, 

266 base_branch=_base_branch, 

267 use_feature_branches=args.feature_branches, 

268 skip_learning_gate=args.skip_learning_gate, 

269 ) 

270 

271 # Handle prune mode 

272 if args.prune_merged_branches: 

273 from little_loops.parallel import WorkerPool 

274 

275 pool = WorkerPool(parallel_config, config, logger, project_root) 

276 if args.dry_run: 

277 logger.info("[DRY RUN] No branches will be deleted.") 

278 pruned, skipped = pool.prune_merged_feature_branches( 

279 base_branch=parallel_config.base_branch, 

280 dry_run=args.dry_run, 

281 ) 

282 if pruned: 

283 verb = "would delete" if args.dry_run else "deleted" 

284 logger.success( 

285 f"Pruned {len(pruned)} merged feature branch(es) ({verb}): {', '.join(pruned)}" 

286 ) 

287 else: 

288 logger.info("No merged feature branches found to prune.") 

289 if skipped: 

290 logger.warning(f"Could not delete {len(skipped)} branch(es): {', '.join(skipped)}") 

291 return 0 

292 

293 # Delete state file if not resuming 

294 if not args.resume: 

295 state_file = config.get_parallel_state_file() 

296 if state_file.exists(): 

297 state_file.unlink() 

298 

299 # Create and run orchestrator 

300 from little_loops.events import EventBus 

301 from little_loops.parallel import ParallelOrchestrator 

302 

303 event_bus = EventBus() 

304 from little_loops.extension import wire_extensions 

305 from little_loops.transport import wire_transports 

306 

307 wire_extensions(event_bus, config.extensions) 

308 wire_transports(event_bus, config.events) 

309 orchestrator = ParallelOrchestrator( 

310 parallel_config=parallel_config, 

311 br_config=config, 

312 repo_path=project_root, 

313 verbose=args.verbose or not args.quiet, 

314 event_bus=event_bus, 

315 ) 

316 

317 return orchestrator.run()