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

77 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -0500

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import os 

7import subprocess 

8from pathlib import Path 

9 

10from little_loops.cli.output import configure_output, use_color_enabled 

11from little_loops.cli_args import ( 

12 add_context_limit_arg, 

13 add_dry_run_arg, 

14 add_handoff_threshold_arg, 

15 add_idle_timeout_arg, 

16 add_label_arg, 

17 add_max_issues_arg, 

18 add_only_arg, 

19 add_quiet_arg, 

20 add_resume_arg, 

21 add_skip_arg, 

22 add_timeout_arg, 

23 add_type_arg, 

24 parse_issue_ids, 

25 parse_issue_types, 

26 parse_labels, 

27 parse_priorities, 

28) 

29from little_loops.config import BRConfig 

30from little_loops.logger import Logger 

31 

32 

33def main_parallel() -> int: 

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

35 

36 Process issues concurrently using isolated git worktrees. 

37 

38 Returns: 

39 Exit code (0 = success) 

40 """ 

41 parser = argparse.ArgumentParser( 

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

43 formatter_class=argparse.RawDescriptionHelpFormatter, 

44 epilog=""" 

45Examples: 

46 %(prog)s # Process with default workers 

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

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

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

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

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

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

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

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

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

56""", 

57 ) 

58 

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

60 parser.add_argument( 

61 "--workers", 

62 "-w", 

63 type=int, 

64 default=None, 

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

66 ) 

67 parser.add_argument( 

68 "--priority", 

69 "-p", 

70 type=str, 

71 default=None, 

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

73 ) 

74 parser.add_argument( 

75 "--worktree-base", 

76 type=Path, 

77 default=None, 

78 help="Base directory for git worktrees", 

79 ) 

80 parser.add_argument( 

81 "--cleanup", 

82 "-c", 

83 action="store_true", 

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

85 ) 

86 parser.add_argument( 

87 "--merge-pending", 

88 action="store_true", 

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

90 ) 

91 parser.add_argument( 

92 "--clean-start", 

93 action="store_true", 

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

95 ) 

96 parser.add_argument( 

97 "--ignore-pending", 

98 action="store_true", 

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

100 ) 

101 parser.add_argument( 

102 "--stream-output", 

103 action="store_true", 

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

105 ) 

106 parser.add_argument( 

107 "--show-model", 

108 action="store_true", 

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

110 ) 

111 parser.add_argument( 

112 "--overlap-detection", 

113 action="store_true", 

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

115 ) 

116 parser.add_argument( 

117 "--warn-only", 

118 action="store_true", 

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

120 ) 

121 

122 parser.add_argument( 

123 "--verbose", 

124 "-v", 

125 action="store_true", 

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

127 ) 

128 

129 # Add common arguments from shared module 

130 add_dry_run_arg(parser) 

131 add_resume_arg(parser) 

132 add_timeout_arg(parser) 

133 add_idle_timeout_arg(parser) 

134 add_handoff_threshold_arg(parser) 

135 add_context_limit_arg(parser) 

136 add_quiet_arg(parser) 

137 add_only_arg(parser) 

138 add_skip_arg(parser) 

139 add_type_arg(parser) 

140 add_label_arg(parser) 

141 

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

143 add_max_issues_arg(parser) 

144 parser.add_argument( 

145 "--config", 

146 "-C", 

147 type=Path, 

148 default=None, 

149 help="Path to project root", 

150 ) 

151 

152 args = parser.parse_args() 

153 

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

155 config = BRConfig(project_root) 

156 configure_output(config.cli) 

157 

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

159 

160 # Handle cleanup mode 

161 if args.cleanup: 

162 from little_loops.parallel import WorkerPool 

163 

164 parallel_config = config.create_parallel_config() 

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

166 pool.cleanup_all_worktrees() 

167 logger.success("Cleanup complete") 

168 return 0 

169 

170 # Build priority filter (validates against VALID_PRIORITIES) 

171 priority_filter = parse_priorities(args.priority) 

172 

173 if args.handoff_threshold is not None: 

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

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

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

177 

178 if args.context_limit is not None: 

179 if args.context_limit < 50000: 

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

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

182 

183 # Parse issue ID filters 

184 only_ids = parse_issue_ids(args.only) 

185 skip_ids = parse_issue_ids(args.skip) 

186 type_prefixes = parse_issue_types(args.type) 

187 label_filter = parse_labels(args.label) 

188 

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

190 _branch_result = subprocess.run( 

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

192 capture_output=True, 

193 text=True, 

194 cwd=project_root, 

195 ) 

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

197 

198 # Create parallel config with CLI overrides 

199 parallel_config = config.create_parallel_config( 

200 max_workers=args.workers, 

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

202 label_filter=label_filter, 

203 max_issues=args.max_issues, 

204 dry_run=args.dry_run, 

205 timeout_seconds=args.timeout, 

206 idle_timeout_per_issue=args.idle_timeout, 

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

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

209 only_ids=only_ids, 

210 skip_ids=skip_ids, 

211 type_prefixes=type_prefixes, 

212 merge_pending=args.merge_pending, 

213 clean_start=args.clean_start, 

214 ignore_pending=args.ignore_pending, 

215 overlap_detection=args.overlap_detection, 

216 serialize_overlapping=not args.warn_only, 

217 base_branch=_base_branch, 

218 ) 

219 

220 # Delete state file if not resuming 

221 if not args.resume: 

222 state_file = config.get_parallel_state_file() 

223 if state_file.exists(): 

224 state_file.unlink() 

225 

226 # Create and run orchestrator 

227 from little_loops.events import EventBus 

228 from little_loops.parallel import ParallelOrchestrator 

229 

230 event_bus = EventBus() 

231 from little_loops.extension import wire_extensions 

232 from little_loops.transport import wire_transports 

233 

234 wire_extensions(event_bus, config.extensions) 

235 wire_transports(event_bus, config.events) 

236 orchestrator = ParallelOrchestrator( 

237 parallel_config=parallel_config, 

238 br_config=config, 

239 repo_path=project_root, 

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

241 event_bus=event_bus, 

242 ) 

243 

244 return orchestrator.run()