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

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 from pathlib import Path

9

10 from little_loops.cli.output import configure_output

11 from little_loops.cli_args import (

12 add_dry_run_arg,

13 add_handoff_threshold_arg,

14 add_idle_timeout_arg,

15 add_max_issues_arg,

16 add_only_arg,

17 add_quiet_arg,

18 add_resume_arg,

19 add_skip_arg,

20 add_timeout_arg,

21 add_type_arg,

22 parse_issue_ids,

23 parse_issue_types,

24)

25 from little_loops.config import BRConfig

26 from little_loops.logger import Logger

27

28

29 def main_parallel() -> int:

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

31

32 Process issues concurrently using isolated git worktrees.

33

34 Returns:

35 Exit code (0 = success)

36 """

37 parser = argparse.ArgumentParser(

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

39 formatter_class=argparse.RawDescriptionHelpFormatter,

40 epilog="""

41 Examples:

42 %(prog)s # Process with default workers

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

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

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

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

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

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

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

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

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

52""",

53 )

54

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

56 parser.add_argument(

57 "--workers",

58 "-w",

59 type=int,

60 default=None,

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

62 )

63 parser.add_argument(

64 "--priority",

65 "-p",

66 type=str,

67 default=None,

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

69 )

70 parser.add_argument(

71 "--worktree-base",

72 type=Path,

73 default=None,

74 help="Base directory for git worktrees",

75 )

76 parser.add_argument(

77 "--cleanup",

78 "-c",

79 action="store_true",

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

81 )

82 parser.add_argument(

83 "--merge-pending",

84 action="store_true",

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

86 )

87 parser.add_argument(

88 "--clean-start",

89 action="store_true",

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

91 )

92 parser.add_argument(

93 "--ignore-pending",

94 action="store_true",

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

96 )

97 parser.add_argument(

98 "--stream-output",

99 action="store_true",

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

101 )

102 parser.add_argument(

103 "--show-model",

104 action="store_true",

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

106 )

107 parser.add_argument(

108 "--overlap-detection",

109 action="store_true",

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

111 )

112 parser.add_argument(

113 "--warn-only",

114 action="store_true",

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

116 )

117

118 # Add common arguments from shared module

119 add_dry_run_arg(parser)

120 add_resume_arg(parser)

121 add_timeout_arg(parser)

122 add_idle_timeout_arg(parser)

123 add_handoff_threshold_arg(parser)

124 add_quiet_arg(parser)

125 add_only_arg(parser)

126 add_skip_arg(parser)

127 add_type_arg(parser)

128

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

130 add_max_issues_arg(parser)

131 parser.add_argument(

132 "--config",

133 type=Path,

134 default=None,

135 help="Path to project root",

136 )

137

138 args = parser.parse_args()

139

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

141 config = BRConfig(project_root)

142 configure_output(config.cli)

143

144 logger = Logger(verbose=not args.quiet)

145

146 # Handle cleanup mode

147 if args.cleanup:

148 from little_loops.parallel import WorkerPool

149

150 parallel_config = config.create_parallel_config()

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

152 pool.cleanup_all_worktrees()

153 logger.success("Cleanup complete")

154 return 0

155

156 # Build priority filter

157 priority_filter = (

158 [p.strip().upper() for p in args.priority.split(",")] if args.priority else None

159 )

160

161 if args.handoff_threshold is not None:

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

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

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

165

166 # Parse issue ID filters

167 only_ids = parse_issue_ids(args.only)

168 skip_ids = parse_issue_ids(args.skip)

169 type_prefixes = parse_issue_types(args.type)

170

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

172 _branch_result = subprocess.run(

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

174 capture_output=True,

175 text=True,

176 cwd=project_root,

177 )

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

179

180 # Create parallel config with CLI overrides

181 parallel_config = config.create_parallel_config(

182 max_workers=args.workers,

183 priority_filter=priority_filter,

184 max_issues=args.max_issues,

185 dry_run=args.dry_run,

186 timeout_seconds=args.timeout,

187 idle_timeout_per_issue=args.idle_timeout,

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

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

190 only_ids=only_ids,

191 skip_ids=skip_ids,

192 type_prefixes=type_prefixes,

193 merge_pending=args.merge_pending,

194 clean_start=args.clean_start,

195 ignore_pending=args.ignore_pending,

196 overlap_detection=args.overlap_detection,

197 serialize_overlapping=not args.warn_only,

198 base_branch=_base_branch,

199 )

200

201 # Delete state file if not resuming

202 if not args.resume:

203 state_file = config.get_parallel_state_file()

204 if state_file.exists():

205 state_file.unlink()

206

207 # Create and run orchestrator

208 from little_loops.parallel import ParallelOrchestrator

209

210 orchestrator = ParallelOrchestrator(

211 parallel_config=parallel_config,

212 br_config=config,

213 repo_path=project_root,

214 verbose=not args.quiet,

215 )

216

217 return orchestrator.run()