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

2

3 from __future__ import annotations

4

5 import argparse

6 import os

7 import subprocess

8 import sys

9 from pathlib import Path

10

11 from little_loops.cli.output import configure_output, use_color_enabled

12 from 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)

31 from little_loops.config import BRConfig

32 from little_loops.logger import Logger

33 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context

34

35

36 def 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="""

49 Examples:

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()