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.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +4 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +18 -4
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +14 -11
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +7 -0
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -486
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -51
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +84 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1175 -117
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +6 -6
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +12 -120
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +223 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +17 -13
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +60 -11
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +217 -30
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +38 -4
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +56 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +12 -1
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +31 -3
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +4 -4
- data/lib/openlanes/live/store.rb +18 -1
- data/lib/openlanes/live/subscriber.rb +22 -2
- data/lib/openlanes/live.rb +66 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +64 -5
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +39 -24
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +226 -8
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +167 -6
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +115 -16
- data/lib/openlanes/web/context.rb +15 -9
- data/lib/openlanes/web/demo.rb +29 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +86 -7
- data/lib/openlanes/web/launcher.rb +69 -7
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +29 -10
- data/lib/openlanes/web.rb +6 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
- data/web/dist/index.html +2 -2
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -889
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/index-BYJCWlk0.js +0 -198
- data/web/dist/assets/index-CjInBi5X.css +0 -1
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /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
|
-
|
|
39
|
-
|
|
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 (
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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)
|
|
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
|