openlanes 3.0.1 → 3.1.0

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 (319) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +4 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +7 -0
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +84 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1175 -117
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +6 -6
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +12 -120
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +223 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +17 -13
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +60 -11
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "open3"
4
5
  require "tempfile"
5
6
  require "tty/command"
6
7
 
@@ -35,13 +36,23 @@ module OpenLanes
35
36
  # @!attribute [r] updated
36
37
  # @return [Boolean] whether the push went to a pull request the plan
37
38
  # already had open, rather than opening a new one
38
- Publication = Data.define(:ordinal, :branch, :title, :url, :refusal, :updated) do
39
- def initialize(updated: false, **rest) = super
39
+ # @!attribute [r] label_error
40
+ # @return [String, nil] why `ready-for-review` could not be applied; the
41
+ # pull request stays open regardless (open question 4)
42
+ Publication = Data.define(:ordinal, :branch, :title, :url, :refusal, :updated, :label_error) do
43
+ def initialize(updated: false, label_error: nil, **rest) = super
40
44
 
41
45
  # @return [Boolean]
42
46
  def published? = !url.nil?
47
+
48
+ # @return [Boolean] whether it was refused for want of `## How to test`,
49
+ # which an agent can write, unlike a rejected push
50
+ def untestable? = refusal == UNTESTABLE
43
51
  end
44
52
 
53
+ # Why a pull request nobody could test is not opened.
54
+ UNTESTABLE = "publish refused — plan.md has no `#{Lifecycle::Completion::HOW_TO_TEST}` section".freeze
55
+
45
56
  # A..Z, which is more parts than any feature should be split into.
46
57
  LETTERS = ("A".."Z").to_a.freeze
47
58
 
@@ -89,6 +100,9 @@ module OpenLanes
89
100
  return refuse(subject, checkout, title, "nothing to publish — the worktree is unchanged")
90
101
  end
91
102
 
103
+ untestable = open.nil? && missing_how_to_test(subject)
104
+ return refuse(subject, checkout, title, untestable) if untestable
105
+
92
106
  return Publication.new(**base_fields(subject, checkout, title), url: nil, refusal: nil) if @dry_run
93
107
 
94
108
  mismatch = open && head_mismatch(open, checkout)
@@ -98,12 +112,82 @@ module OpenLanes
98
112
  commit(checkout, title)
99
113
  push(checkout)
100
114
  url = open ? open.url : create_pull_request(checkout, subject, title)
115
+ label_error = url.nil? ? nil : label_for_review(url, resubmitted: !open.nil?)
101
116
 
102
- Publication.new(**base_fields(subject, checkout, title), url:, refusal: nil, updated: !open.nil?)
117
+ Publication.new(**base_fields(subject, checkout, title), url:, refusal: nil, updated: !open.nil?, label_error:)
103
118
  rescue TTY::Command::ExitError => e
104
119
  refuse(subject, checkout, title, git_complaint(e))
105
120
  end
106
121
 
122
+ # Why a push from this checkout would not reach the pull request, as
123
+ # {#publish} asks before pushing. `pull-requests.md` does not record a
124
+ # head branch, and an adopted or hand-opened pull request can come from
125
+ # any branch, while the checkout's branch is derived from the plan.
126
+ # Verification asks it too, before chewie runs and before its commit:
127
+ # otherwise it verifies one branch's code, pushes screenshots to it,
128
+ # and labels a pull request built from another.
129
+ #
130
+ # @param pull [OpenLanes::Plans::PullRequest] an open pull request
131
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
132
+ # @param refused [String] how the sentence starts
133
+ # @return [String, nil] nil when the pull request is built from the checkout's branch
134
+ def branch_mismatch(pull, checkout, refused: "refused") = head_mismatch(pull, checkout, refused:)
135
+
136
+ # Paths named in a {#commit_plan} refusal before the rest are counted.
137
+ STRAY_SHOWN = 10
138
+
139
+ # Commit the plan's folder, and nothing else, to the checkout's branch and
140
+ # push it. Verification uses it to land chewie's screenshots on the pull
141
+ # request before the engine links them by SHA.
142
+ #
143
+ # It refuses a checkout with changes outside the plan folder: chewie
144
+ # writes `verification/**` and nothing more, so a stray code edit is
145
+ # something it should not have made, and committing it here would slip
146
+ # unreviewed code into a pull request that already passed review. Nor is
147
+ # it a resubmit, so the review labels are left exactly as they are.
148
+ #
149
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
150
+ # @param plan [OpenLanes::Plans::Subject] the plan in the main checkout
151
+ # @param message [String] the commit subject
152
+ # @return [String, nil] the new commit's SHA, nil on a dry run
153
+ # @raise [OpenLanes::Error] on a stray change, a pull request built from
154
+ # another branch, or nothing to commit
155
+ def commit_plan(checkout:, plan:, message:)
156
+ folder = plan_parent(plan)
157
+ raise Error, "the plans live outside the repository, so the branch has no place for them" if folder.nil?
158
+
159
+ stray = changed_paths(checkout).reject { |path, _| inside_plan?(path, folder, plan) }
160
+ raise Error, stray_complaint(plan, stray) if stray.any?
161
+ return nil if @dry_run
162
+
163
+ open = plan.pull_requests.select(&:open?).last
164
+ mismatch = open && branch_mismatch(open, checkout, refused: "refused to commit #{plan.feature.ordinal}")
165
+ raise Error, mismatch if mismatch
166
+
167
+ carry_plan(checkout, plan)
168
+ run(checkout, "git", "add", "-A", "--", folder)
169
+ raise Error, "nothing to commit for #{plan.feature.ordinal}" if run!(checkout, "git", "diff", "--cached", "--quiet").success?
170
+
171
+ run(checkout, "git", "commit", "-m", message)
172
+ sha = run(checkout, "git", "rev-parse", "HEAD").out.strip
173
+ pushed!(checkout, sha)
174
+ sha
175
+ rescue TTY::Command::ExitError => e
176
+ raise Error, git_complaint(e)
177
+ end
178
+
179
+ # A commit that stayed on this machine is said as one: "not committed"
180
+ # would send someone looking for a change that is right there in the
181
+ # checkout, waiting for the next push to carry it.
182
+ #
183
+ # @return [void]
184
+ # @raise [OpenLanes::Error] when the push fails
185
+ def pushed!(checkout, sha)
186
+ push(checkout)
187
+ rescue TTY::Command::ExitError => e
188
+ raise Error, "committed #{sha[0, 7]} locally, but the push failed: #{git_complaint(e)}"
189
+ end
190
+
107
191
  # git and gh put the useful sentence in stderr, several lines below the
108
192
  # command they echo. Reporting only the first line reports "it failed".
109
193
  #
@@ -144,7 +228,7 @@ module OpenLanes
144
228
  # The main checkout stays the only writer. The engine reads, signs and
145
229
  # renames the plan there and nowhere else; the worktree gets a snapshot,
146
230
  # refreshed on every publish. Every copy of the same plan number is
147
- # removed first, so a folder renamed since the last publish (⭐️ to 🟢,
231
+ # removed first, so a folder renamed since the last publish (⭐ to 🟢,
148
232
  # say) does not leave its old name behind in the branch, and a file
149
233
  # deleted in the main checkout is deleted in the branch too.
150
234
  #
@@ -172,6 +256,64 @@ module OpenLanes
172
256
 
173
257
  private
174
258
 
259
+ # @param plan [OpenLanes::Plans::Subject]
260
+ # @return [String, nil] the plans directory relative to the repository,
261
+ # nil when the plans live outside it
262
+ def plan_parent(plan)
263
+ root = real(@root)
264
+ source = real(plan.feature.path)
265
+ source.start_with?("#{root}/") ? File.dirname(source.delete_prefix("#{root}/")) : nil
266
+ end
267
+
268
+ # Every path git sees changed, untracked files included. `-z` keeps an
269
+ # emoji folder name unquoted; a rename's second entry is its old path.
270
+ #
271
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
272
+ # @return [Hash{String => Boolean}] each path, and whether git does not
273
+ # track it
274
+ def changed_paths(checkout)
275
+ entries = run(checkout, "git", "status", "--porcelain", "-z", "--untracked-files=all").out.split("\0")
276
+ paths = {}
277
+ until entries.empty?
278
+ entry = entries.shift
279
+ paths[entry[3..]] = entry.start_with?("??")
280
+ paths[entries.shift] = false if entry.start_with?("R", "C")
281
+ end
282
+ paths.reject { |path, _| path.nil? }
283
+ end
284
+
285
+ # Running the app in the checkout leaves files behind that a project
286
+ # may never have ignored (logs, `tmp/`, a SQLite file, `coverage/`),
287
+ # and each one refuses every verification commit until it is. So the
288
+ # refusal names them, apart from edits to tracked files, and says what
289
+ # to do about them; "changes outside its plan folder" alone reads as a
290
+ # stray code edit and sends someone looking for one.
291
+ #
292
+ # @param plan [OpenLanes::Plans::Subject]
293
+ # @param stray [Hash{String => Boolean}] see {#changed_paths}
294
+ # @return [String]
295
+ def stray_complaint(plan, stray)
296
+ untracked, edited = stray.partition { |_, new| new }.map { |pairs| pairs.map(&:first) }
297
+ parts = []
298
+ parts << "edited #{shown(edited)}" if edited.any?
299
+ parts << "untracked #{shown(untracked)} (left by the app or its tests? add them to .gitignore)" if untracked.any?
300
+ "refused to commit #{plan.feature.ordinal}: changes outside its plan folder: #{parts.join("; ")}"
301
+ end
302
+
303
+ # @param paths [Array<String>]
304
+ # @return [String]
305
+ def shown(paths)
306
+ more = paths.size - STRAY_SHOWN
307
+ "#{paths.first(STRAY_SHOWN).join(", ")}#{" and #{more} more" if more.positive?}"
308
+ end
309
+
310
+ # @return [Boolean] whether `path` lies in any copy of this plan's folder
311
+ def inside_plan?(path, folder, plan)
312
+ return false unless path.start_with?("#{folder}/")
313
+
314
+ Plans::Ordinal.from_dirname(path.delete_prefix("#{folder}/").split("/").first) == plan.feature.ordinal
315
+ end
316
+
175
317
  # @param path [String]
176
318
  # @return [String] symlinks resolved, so /var and /private/var compare equal
177
319
  def real(path)
@@ -202,14 +344,14 @@ module OpenLanes
202
344
  # @param open [OpenLanes::Plans::PullRequest]
203
345
  # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
204
346
  # @return [String, nil] nil when the pull request is built from the checkout's branch
205
- def head_mismatch(open, checkout)
347
+ def head_mismatch(open, checkout, refused: "publish refused")
206
348
  head = @github.pull_request(open.number || open.url).fetch(:branch)
207
349
  return if head == checkout.branch
208
350
 
209
- "publish refused — ##{open.number} is built from #{head.empty? ? "an unknown branch" : head}, " \
351
+ "#{refused} — ##{open.number} is built from #{head.empty? ? "an unknown branch" : head}, " \
210
352
  "not #{checkout.branch}, so a push would not reach it"
211
353
  rescue Error => e
212
- "publish refused — could not tell which branch ##{open.number} is built from: #{e.message}"
354
+ "#{refused} — could not tell which branch ##{open.number} is built from: #{e.message}"
213
355
  end
214
356
 
215
357
  # @return [Hash]
@@ -232,9 +374,42 @@ module OpenLanes
232
374
  run(checkout, "git", "commit", "-m", title.sub(/\A\[[^\]]+\](?:\([A-Z]\))?\s*/, ""))
233
375
  end
234
376
 
377
+ # The branch is the checkout's own, named by {Worktree#branch_for} and
378
+ # written only by this harness, so after the harness has rebased it
379
+ # (see {Refresh}) it may replace what `origin` holds. The lease alone
380
+ # is not enough: it trusts the tracking ref, and worktrees share
381
+ # `refs/remotes` with the main checkout, so a fetch there (an editor's
382
+ # auto-fetch, `gh pr checkout`) moves it onto somebody else's push and
383
+ # the lease would then let us overwrite it. `--force-if-includes` adds
384
+ # that the tracking ref's tip must be in this branch's own reflog,
385
+ # i.e. a commit the harness had here. It is a no-op beside an explicit
386
+ # `:<expect>`, so the lease names only the ref. A branch with no
387
+ # tracking ref yet gets a plain push, which can only fast-forward.
388
+ #
235
389
  # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
236
390
  # @return [void]
237
- def push(checkout) = run(checkout, "git", "push", "-u", "origin", checkout.branch)
391
+ def push(checkout)
392
+ # Declared first, so an agent still running in another worktree is
393
+ # not blamed for the tracking ref this push moves under it.
394
+ Execution::Boundary.sanction(checkout.path, "origin", checkout.branch)
395
+ run(checkout, "git", "push", *lease(checkout), "-u", "origin", checkout.branch)
396
+ end
397
+
398
+ # @param checkout [OpenLanes::Vcs::Worktree::Checkout]
399
+ # @return [Array<String>] the lease flags, or nothing
400
+ def lease(checkout)
401
+ # Read directly rather than through {#run}: it is a local lookup that
402
+ # changes nothing, and the command seam is for what leaves the machine.
403
+ seen, = Open3.capture2("git",
404
+ "-C",
405
+ checkout.path,
406
+ "rev-parse",
407
+ "--verify",
408
+ "--quiet",
409
+ "refs/remotes/origin/#{checkout.branch}",
410
+ err: File::NULL)
411
+ seen.strip.empty? ? [] : ["--force-with-lease=refs/heads/#{checkout.branch}", "--force-if-includes"]
412
+ end
238
413
 
239
414
  # @return [String, nil] the pull request URL
240
415
  def create_pull_request(checkout, subject, title)
@@ -263,9 +438,49 @@ module OpenLanes
263
438
 
264
439
  Implements `#{subject.feature.dirname}`. The specification and the plan
265
440
  for this work live in that folder.
441
+
442
+ #{Lifecycle::Completion::HOW_TO_TEST}
443
+
444
+ #{how_to_test(subject)}
266
445
  MARKDOWN
267
446
  end
268
447
 
448
+ # A pull request a person cannot test is not ready for one to review.
449
+ #
450
+ # @return [String, nil] the refusal, or nil when plan.md has the section
451
+ def missing_how_to_test(subject)
452
+ UNTESTABLE if how_to_test(subject).empty?
453
+ end
454
+
455
+ # A plan.md written before the rename says `## How to verify`; the
456
+ # harness no longer appends a duplicate to it, so its steps are read
457
+ # here under either heading rather than refused.
458
+ #
459
+ # @return [String] the section's body, without its heading
460
+ def how_to_test(subject)
461
+ headings = [Lifecycle::Completion::HOW_TO_TEST, Lifecycle::Completion::FORMER_HOW_TO_TEST]
462
+ lines = subject.read("plan.md").to_s.lines
463
+ start = lines.index { |l| headings.any? { l.strip.casecmp?(it) } } or return ""
464
+ lines[(start + 1)..].take_while { |l| !l.start_with?("## ", "# ") }.join.strip
465
+ end
466
+
467
+ # Put `ready-for-review` on the pull request. A resubmission also takes
468
+ # off whatever verdict the last review left: a `review-rejected` still
469
+ # there after the fixes are pushed reads to the watch as a fresh
470
+ # rejection, and the 🟢 plan would be sent straight back to 🔴.
471
+ #
472
+ # @param url [String]
473
+ # @param resubmitted [Boolean] whether the pull request was already open
474
+ # @return [String, nil] the error, when labelling failed
475
+ def label_for_review(url, resubmitted: false)
476
+ number = GitHub.number_of(url)
477
+ remove = resubmitted ? Plans::ReviewLabel.known : []
478
+ @github.swap_label(number:, repo: GitHub.repo_of(url), add: Plans::ReviewLabel::INITIAL, remove:)
479
+ nil
480
+ rescue Error => e
481
+ e.message
482
+ end
483
+
269
484
  # The specification's own Goal section, verbatim. A pull request body that
270
485
  # paraphrases the spec is a second copy that drifts; quoting it is not.
271
486
  #
@@ -278,6 +493,9 @@ module OpenLanes
278
493
 
279
494
  # @return [TTY::Command::Result]
280
495
  def run(checkout, *argv) = @command.run(*argv, chdir: checkout.path)
496
+
497
+ # @return [TTY::Command::Result] whatever the exit status
498
+ def run!(checkout, *argv) = @command.run!(*argv, chdir: checkout.path)
281
499
  end
282
500
  end
283
501
  end
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module OpenLanes
6
+ module Vcs
7
+ # Brings one plan's worktree up to date with the default branch on
8
+ # `origin`, before an agent runs in it.
9
+ #
10
+ # A worktree reused round after round never moved: plan 008.01's branch
11
+ # had no commits of its own and sat 211 commits behind origin/main, so
12
+ # luke-backend went looking for code that had landed weeks earlier and
13
+ # could not find it. The agent could not catch up on its own either,
14
+ # because the boundary fails every run in which HEAD moved. So the
15
+ # harness moves HEAD, here, before {Execution::Boundary} takes its
16
+ # snapshot, and the agent's HEAD check still holds.
17
+ #
18
+ # Three cases, by what the branch carries:
19
+ #
20
+ # - nothing of its own: fast-forward. No agent is ever involved.
21
+ # - commits of its own, a clean tree: `git rebase`. A conflict leaves the
22
+ # rebase stopped for an agent to resolve; the harness continues it.
23
+ # - uncommitted changes and commits of its own: left alone. Rebasing
24
+ # needs a clean tree, and `--autostash` can strand an agent's
25
+ # uncommitted work in the stash when it fails to re-apply, which is
26
+ # exactly the silent loss worktrees exist to prevent.
27
+ #
28
+ # Nothing here commits anything of its own; `rebase --continue` replays
29
+ # commits that already exist, with the resolution an agent staged.
30
+ class Refresh
31
+ # The remote every plan branch is published to.
32
+ REMOTE = "origin"
33
+
34
+ # The default branch when `origin/HEAD` names none, as in a clone made
35
+ # with `git init` and `remote add` rather than `git clone`.
36
+ FALLBACK = "main"
37
+
38
+ # How git says a staged file still holds a conflict marker. `diff
39
+ # --check` also reports whitespace, which is no reason to stop a rebase.
40
+ MARKER = "leftover conflict marker"
41
+
42
+ # Seconds a fetch may take before it is abandoned. It runs on the
43
+ # dispatcher's tick thread, so a fetch stuck on a credential prompt or
44
+ # a stalled socket would stop every lane on the machine.
45
+ FETCH_TIMEOUT = 60
46
+
47
+ # git and ssh must fail rather than ask: nobody is there to answer.
48
+ # BatchMode is appended to the user's own ssh command, not put in its
49
+ # place, so a configured key or jump host still applies.
50
+ #
51
+ # @return [Hash{String => String}]
52
+ def self.fetch_env
53
+ { "GIT_TERMINAL_PROMPT" => "0", "GIT_SSH_COMMAND" => "#{ENV.fetch("GIT_SSH_COMMAND", "ssh")} -o BatchMode=yes" }
54
+ end
55
+
56
+ # What one refresh did.
57
+ #
58
+ # @!attribute [r] kind
59
+ # @return [Symbol] :current, :fast_forwarded, :rebased, :conflicted,
60
+ # :skipped, :pending or :aborted. Only :conflicted leaves a rebase
61
+ # stopped; a :skipped from {#continue} still has one, for the
62
+ # caller to abort
63
+ # @!attribute [r] base
64
+ # @return [String, nil] e.g. "origin/main"
65
+ # @!attribute [r] target
66
+ # @return [String, nil] the commit `base` named, so a retry can tell
67
+ # whether the default branch has moved since
68
+ # @!attribute [r] commits
69
+ # @return [Integer] how many commits the branch was behind
70
+ # @!attribute [r] files
71
+ # @return [Array<String>] the conflicted paths, when it stopped
72
+ # @!attribute [r] reason
73
+ # @return [String, nil] why nothing happened, for :skipped and :pending
74
+ Outcome = Data.define(:kind, :base, :target, :commits, :files, :reason) do
75
+ def initialize(base: nil, target: nil, commits: 0, files: [], reason: nil, **rest) = super
76
+
77
+ # @return [Boolean] the rebase is stopped, waiting on a resolution
78
+ def conflicted? = kind == :conflicted
79
+
80
+ # @return [Boolean] the branch now sits on the latest default branch
81
+ def done? = %i[current fast_forwarded rebased].include?(kind)
82
+
83
+ # One line for the progress log.
84
+ #
85
+ # @return [String]
86
+ def summary
87
+ case kind
88
+ when :current then "worktree: up to date with #{base}"
89
+ when :fast_forwarded then "worktree: fast-forwarded #{commits} commit#{"s" unless commits == 1} to #{base}"
90
+ when :rebased then "worktree: rebased onto #{base} (#{commits} new upstream commit#{"s" unless commits == 1})"
91
+ when :conflicted then "worktree: rebase onto #{base} stopped on conflicts in #{files.join(", ")}"
92
+ when :aborted then "worktree: rebase onto #{base} aborted, branch restored: #{reason}"
93
+ when :pending then "worktree: rebase onto #{base} pending: #{reason}"
94
+ else "worktree: not refreshed: #{reason}"
95
+ end
96
+ end
97
+ end
98
+
99
+ class << self
100
+ # The default branch, as `origin/HEAD` names it.
101
+ #
102
+ # @param path [String] any checkout of the repository
103
+ # @return [String] e.g. "main"
104
+ def default_branch(path)
105
+ out, status = Open3.capture2("git",
106
+ "-C",
107
+ path,
108
+ "symbolic-ref",
109
+ "--quiet",
110
+ "refs/remotes/#{REMOTE}/HEAD",
111
+ err: File::NULL)
112
+ name = out.strip.delete_prefix("refs/remotes/#{REMOTE}/")
113
+ status.success? && !name.empty? ? name : FALLBACK
114
+ end
115
+
116
+ # Fetch the default branch, and say where a new branch should start.
117
+ #
118
+ # A fetch only moves `refs/remotes`, logged as a fetch rather than a
119
+ # push, so {Execution::Boundary} does not blame an agent running in
120
+ # another worktree for it.
121
+ #
122
+ # @param path [String]
123
+ # @return [String, nil] "origin/<default>" once it exists, nil when
124
+ # the repository has no such remote branch
125
+ def fetch(path)
126
+ return nil unless system("git", "-C", path, "remote", "get-url", REMOTE, out: File::NULL, err: File::NULL)
127
+
128
+ base = default_branch(path)
129
+ fetched = fetch_within("git", "-C", path, "fetch", "--quiet", REMOTE, base)
130
+ UI.log("worktree: could not fetch #{REMOTE} #{base}; working from the last fetch") unless fetched
131
+ ref = "#{REMOTE}/#{base}"
132
+ if system("git",
133
+ "-C",
134
+ path,
135
+ "rev-parse",
136
+ "--verify",
137
+ "--quiet",
138
+ "refs/remotes/#{ref}",
139
+ out: File::NULL,
140
+ err: File::NULL)
141
+ ref
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ # Run a fetch, killing its whole process group (git, and the ssh or
148
+ # credential helper it started) once it has run past {FETCH_TIMEOUT}.
149
+ #
150
+ # @return [Boolean] whether it finished, and succeeded, in time
151
+ def fetch_within(*argv, timeout: FETCH_TIMEOUT, spawner: Spawner::System)
152
+ pid = spawner.spawn(fetch_env, *argv, pgroup: true, in: File::NULL, out: File::NULL, err: File::NULL)
153
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
154
+ loop do
155
+ _, status = Process.wait2(pid, Process::WNOHANG)
156
+ return status.success? if status
157
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
158
+
159
+ sleep 0.05
160
+ end
161
+ UI.log("worktree: git fetch timed out after #{timeout}s")
162
+ begin
163
+ Process.kill("KILL", -pid)
164
+ rescue Errno::ESRCH, Errno::EPERM
165
+ # Already gone, or not ours to end.
166
+ end
167
+ Process.wait(pid)
168
+ false
169
+ end
170
+ end
171
+
172
+ # @param path [String] the plan's worktree
173
+ def initialize(path:) = @path = path
174
+
175
+ # @return [String]
176
+ attr_reader :path
177
+
178
+ # Fetch, then fast-forward or rebase onto the default branch.
179
+ #
180
+ # @param hold [String, nil] a default-branch commit the rebase has
181
+ # already failed on as often as it may: while `origin/<default>` still
182
+ # names it, a branch that would need a rebase is left where it is
183
+ # @return [OpenLanes::Vcs::Refresh::Outcome]
184
+ def call(hold: nil)
185
+ return resume if rebasing?
186
+
187
+ base = self.class.fetch(path) or return Outcome.new(kind: :skipped, reason: "no #{REMOTE} default branch")
188
+ target = rev(base)
189
+ behind = count("HEAD..#{base}")
190
+ return Outcome.new(kind: :current, base:, target:) if behind.zero?
191
+ return fast_forward(base, target, behind) if count("#{base}..HEAD").zero?
192
+
193
+ outcome = Outcome.new(kind: :skipped, base:, target:, commits: behind)
194
+ return outcome.with(reason: "uncommitted changes in the worktree") if dirty?
195
+ return outcome.with(kind: :pending, reason: "retries exhausted until #{base} moves") if hold == target
196
+
197
+ rebase(base, target, behind)
198
+ end
199
+
200
+ # Carry a stopped rebase on, once an agent has resolved and staged.
201
+ #
202
+ # Refused while any path is unmerged or a staged file still holds a
203
+ # marker: continuing then would commit the marker into the branch.
204
+ #
205
+ # @return [OpenLanes::Vcs::Refresh::Outcome] :rebased, or :conflicted
206
+ # on the files that still need a resolution (the same commit's, or
207
+ # the next one's)
208
+ def continue
209
+ base = onto
210
+ left = unresolved
211
+ return Outcome.new(kind: :conflicted, base:, target: rev(base), files: left) unless left.empty?
212
+
213
+ # A resolution identical to what is already on HEAD leaves the commit
214
+ # empty. Newer git drops it on continue, older git refuses and is
215
+ # skipped past. Either way it drops nothing the branch still needs
216
+ # (the index is HEAD), but the commit is gone, so it is named.
217
+ empty = git({}, "diff", "--cached", "--quiet")
218
+ stopped_at = capture("log", "-1", "--format=%h %s", "REBASE_HEAD") if empty
219
+ ok = git({ "GIT_EDITOR" => "true" }, "rebase", "--continue")
220
+ ok ||= empty && rebasing? && unmerged.empty? && git({}, "rebase", "--skip")
221
+ dropped(stopped_at, base)
222
+ stopped = rebasing? ? unmerged : []
223
+ return Outcome.new(kind: :conflicted, base:, target: rev(base), files: stopped) unless stopped.empty?
224
+ return Outcome.new(kind: :rebased, base:, target: rev(base)) if ok && !rebasing?
225
+
226
+ # Stopped with nothing to resolve (a hook refused, say): no agent can
227
+ # help, so the caller aborts.
228
+ Outcome.new(kind: :skipped, base:, reason: "git rebase --continue failed with nothing unmerged")
229
+ end
230
+
231
+ # Abandon a stopped rebase. git puts the branch back exactly where it
232
+ # was before the rebase began.
233
+ #
234
+ # @param why [String]
235
+ # @return [OpenLanes::Vcs::Refresh::Outcome]
236
+ def abort!(why)
237
+ base = onto
238
+ git({}, "rebase", "--abort") if rebasing?
239
+ Outcome.new(kind: :aborted, base:, target: rev(base), reason: why)
240
+ end
241
+
242
+ # @return [Boolean] whether a rebase is stopped in this worktree
243
+ def rebasing? = %w[rebase-merge rebase-apply].any? { File.directory?(git_path(it)) }
244
+
245
+ # @return [Array<String>] paths git still lists as unmerged
246
+ def unmerged = lines("diff", "--name-only", "--diff-filter=U")
247
+
248
+ # Unmerged paths, and staged files that still hold a conflict marker.
249
+ #
250
+ # @return [Array<String>]
251
+ def unresolved
252
+ marked = lines("diff", "--cached", "--check").filter_map { |line| line.split(":", 2).first if line.include?(MARKER) }
253
+ (unmerged + marked).uniq
254
+ end
255
+
256
+ private
257
+
258
+ # A rebase found stopped before anyone asked: a harness that died
259
+ # mid-resolution, or a round that ended before it continued. It is
260
+ # picked up where it stood rather than started again.
261
+ #
262
+ # @return [OpenLanes::Vcs::Refresh::Outcome]
263
+ def resume
264
+ left = unresolved
265
+ return Outcome.new(kind: :conflicted, base: onto, target: rev(onto), files: left) unless left.empty?
266
+
267
+ continue
268
+ end
269
+
270
+ # @return [OpenLanes::Vcs::Refresh::Outcome]
271
+ def fast_forward(base, target, behind)
272
+ # Safe with uncommitted changes: git refuses, touching nothing, when
273
+ # one of them is in a file the merge would change.
274
+ return Outcome.new(kind: :fast_forwarded, base:, target:, commits: behind) if git({}, "merge", "--ff-only", "--quiet", base)
275
+
276
+ Outcome.new(kind: :skipped,
277
+ base:,
278
+ target:,
279
+ commits: behind,
280
+ reason: "fast-forward refused; uncommitted changes touch files #{base} changed")
281
+ end
282
+
283
+ # @return [OpenLanes::Vcs::Refresh::Outcome]
284
+ def rebase(base, target, behind)
285
+ return Outcome.new(kind: :rebased, base:, target:, commits: behind) if git({ "GIT_EDITOR" => "true" }, "rebase", "--quiet", base)
286
+ return Outcome.new(kind: :conflicted, base:, target:, commits: behind, files: unresolved) if rebasing?
287
+
288
+ Outcome.new(kind: :skipped, base:, target:, commits: behind, reason: "git rebase refused to start")
289
+ end
290
+
291
+ # The branch the stopped rebase is replaying onto, as a name.
292
+ #
293
+ # @return [String]
294
+ def onto
295
+ sha = File.read(git_path("rebase-merge/onto")).strip if File.file?(git_path("rebase-merge/onto"))
296
+ base = "#{REMOTE}/#{self.class.default_branch(path)}"
297
+ sha.nil? || rev(base) == sha ? base : sha[0, 7]
298
+ end
299
+
300
+ # Log a commit the rebase replayed as empty, once it is past it.
301
+ #
302
+ # @param stopped_at [String, nil] "<sha> <subject>" of the commit
303
+ # @param base [String]
304
+ # @return [void]
305
+ def dropped(stopped_at, base)
306
+ return if stopped_at.nil?
307
+ return if rebasing? && capture("log", "-1", "--format=%h %s", "REBASE_HEAD") == stopped_at
308
+
309
+ UI.log("worktree: rebase dropped #{stopped_at}: its changes are already on #{base}")
310
+ end
311
+
312
+ # @return [Boolean]
313
+ def dirty? = !lines("status", "--porcelain").empty?
314
+
315
+ # @return [Integer]
316
+ def count(range) = capture("rev-list", "--count", range).to_i
317
+
318
+ # @return [String, nil]
319
+ def rev(ref) = capture("rev-parse", "--verify", "--quiet", ref).then { it.empty? ? nil : it }
320
+
321
+ # @return [String] a path inside this worktree's own git directory
322
+ def git_path(name) = File.expand_path(capture("rev-parse", "--git-path", name), path)
323
+
324
+ # @return [Array<String>]
325
+ def lines(*) = capture(*).lines(chomp: true).reject(&:empty?)
326
+
327
+ # @return [String]
328
+ def capture(*)
329
+ out, = Open3.capture2("git", "-C", path, *, err: File::NULL)
330
+ out.strip
331
+ end
332
+
333
+ # @return [Boolean]
334
+ def git(env, *) = system(env, "git", "-C", path, *, out: File::NULL, err: File::NULL)
335
+ end
336
+ end
337
+ end