fun_ci 1.2.0 → 2.0.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 (228) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +241 -0
  3. data/README.md +180 -59
  4. data/lib/fun_ci/agent/age.rb +15 -0
  5. data/lib/fun_ci/agent/command_support.rb +27 -0
  6. data/lib/fun_ci/agent/commands.rb +27 -0
  7. data/lib/fun_ci/agent/context.rb +10 -0
  8. data/lib/fun_ci/agent/digest.rb +49 -0
  9. data/lib/fun_ci/agent/events.rb +47 -0
  10. data/lib/fun_ci/agent/events_command.rb +61 -0
  11. data/lib/fun_ci/agent/evidence_text.rb +46 -0
  12. data/lib/fun_ci/agent/exit_code.rb +11 -0
  13. data/lib/fun_ci/agent/git.rb +31 -0
  14. data/lib/fun_ci/agent/live_pipeline.rb +35 -0
  15. data/lib/fun_ci/agent/options.rb +75 -0
  16. data/lib/fun_ci/agent/output.rb +81 -0
  17. data/lib/fun_ci/agent/report_stage.rb +73 -0
  18. data/lib/fun_ci/agent/reports.rb +47 -0
  19. data/lib/fun_ci/agent/run_report.rb +30 -0
  20. data/lib/fun_ci/agent/runs_command.rb +34 -0
  21. data/lib/fun_ci/agent/runs_text.rb +27 -0
  22. data/lib/fun_ci/agent/snapshots.rb +39 -0
  23. data/lib/fun_ci/agent/stage_summary.rb +30 -0
  24. data/lib/fun_ci/agent/status_command.rb +36 -0
  25. data/lib/fun_ci/agent/status_json.rb +27 -0
  26. data/lib/fun_ci/agent/status_text.rb +37 -0
  27. data/lib/fun_ci/agent/system_clock.rb +11 -0
  28. data/lib/fun_ci/agent/verdict.rb +32 -0
  29. data/lib/fun_ci/agent/wait_command.rb +71 -0
  30. data/lib/fun_ci/agent/waiting.rb +32 -0
  31. data/lib/fun_ci/agent/why_command.rb +50 -0
  32. data/lib/fun_ci/agent/why_json.rb +35 -0
  33. data/lib/fun_ci/agent/why_text.rb +43 -0
  34. data/lib/fun_ci/cli.rb +72 -94
  35. data/lib/fun_ci/cli_help.rb +65 -0
  36. data/lib/fun_ci/console/board_data.rb +66 -0
  37. data/lib/fun_ci/console/board_heartbeat.rb +40 -0
  38. data/lib/fun_ci/console/console_events.rb +20 -0
  39. data/lib/fun_ci/console/console_log.rb +26 -0
  40. data/lib/fun_ci/console/console_loop.rb +41 -0
  41. data/lib/fun_ci/console/console_session.rb +82 -0
  42. data/lib/fun_ci/console/console_state.rb +42 -0
  43. data/lib/fun_ci/console/key_handler.rb +73 -0
  44. data/lib/fun_ci/console/launcher.rb +43 -0
  45. data/lib/fun_ci/console/milestone_events.rb +38 -0
  46. data/lib/fun_ci/console/milestones.rb +25 -0
  47. data/lib/fun_ci/console/renderer_lookup.rb +51 -0
  48. data/lib/fun_ci/console/renderer_process.rb +60 -0
  49. data/lib/fun_ci/console/run_message.rb +33 -0
  50. data/lib/fun_ci/console/stage_change_detector.rb +32 -0
  51. data/lib/fun_ci/console/stage_events.rb +31 -0
  52. data/lib/fun_ci/console/stop_report.rb +39 -0
  53. data/lib/fun_ci/console/streak_counter.rb +19 -0
  54. data/lib/fun_ci/console/view.rb +47 -0
  55. data/lib/fun_ci/evidence/about.rb +12 -0
  56. data/lib/fun_ci/evidence/about_stage.rb +29 -0
  57. data/lib/fun_ci/evidence/caps.rb +77 -0
  58. data/lib/fun_ci/evidence/catalog.rb +83 -0
  59. data/lib/fun_ci/evidence/collector.rb +95 -0
  60. data/lib/fun_ci/evidence/command_output.rb +45 -0
  61. data/lib/fun_ci/evidence/command_reading.rb +38 -0
  62. data/lib/fun_ci/evidence/command_runner.rb +75 -0
  63. data/lib/fun_ci/evidence/context.rb +21 -0
  64. data/lib/fun_ci/evidence/context_document.rb +29 -0
  65. data/lib/fun_ci/evidence/deadline.rb +15 -0
  66. data/lib/fun_ci/evidence/detection.rb +82 -0
  67. data/lib/fun_ci/evidence/document.rb +59 -0
  68. data/lib/fun_ci/evidence/extract_command.rb +83 -0
  69. data/lib/fun_ci/evidence/extract_options.rb +45 -0
  70. data/lib/fun_ci/evidence/extraction.rb +54 -0
  71. data/lib/fun_ci/evidence/extractors/command.rb +52 -0
  72. data/lib/fun_ci/evidence/extractors/grep.rb +49 -0
  73. data/lib/fun_ci/evidence/extractors/json_log.rb +45 -0
  74. data/lib/fun_ci/evidence/extractors/junit_files.rb +32 -0
  75. data/lib/fun_ci/evidence/extractors/last_lines.rb +25 -0
  76. data/lib/fun_ci/evidence/extractors/log_file.rb +62 -0
  77. data/lib/fun_ci/evidence/extractors/process_tree.rb +51 -0
  78. data/lib/fun_ci/evidence/extractors/section.rb +49 -0
  79. data/lib/fun_ci/evidence/findings.rb +14 -0
  80. data/lib/fun_ci/evidence/line_ranges.rb +21 -0
  81. data/lib/fun_ci/evidence/line_scan.rb +22 -0
  82. data/lib/fun_ci/evidence/log_record.rb +58 -0
  83. data/lib/fun_ci/evidence/masking.rb +55 -0
  84. data/lib/fun_ci/evidence/option_check.rb +53 -0
  85. data/lib/fun_ci/evidence/outcome.rb +15 -0
  86. data/lib/fun_ci/evidence/patterns.rb +22 -0
  87. data/lib/fun_ci/evidence/presets/README.md +7 -0
  88. data/lib/fun_ci/evidence/presets/bun-test.yml +6 -0
  89. data/lib/fun_ci/evidence/presets/cargo-test.yml +6 -0
  90. data/lib/fun_ci/evidence/presets/dart-test.yml +6 -0
  91. data/lib/fun_ci/evidence/presets/deno-test.yml +6 -0
  92. data/lib/fun_ci/evidence/presets/dotnet-test.yml +6 -0
  93. data/lib/fun_ci/evidence/presets/ecs.yml +3 -0
  94. data/lib/fun_ci/evidence/presets/eslint.yml +6 -0
  95. data/lib/fun_ci/evidence/presets/exunit.yml +6 -0
  96. data/lib/fun_ci/evidence/presets/gcc.yml +6 -0
  97. data/lib/fun_ci/evidence/presets/go-build.yml +5 -0
  98. data/lib/fun_ci/evidence/presets/go-test.yml +6 -0
  99. data/lib/fun_ci/evidence/presets/gradle.yml +6 -0
  100. data/lib/fun_ci/evidence/presets/gtest.yml +6 -0
  101. data/lib/fun_ci/evidence/presets/jest.yml +6 -0
  102. data/lib/fun_ci/evidence/presets/logstash.yml +3 -0
  103. data/lib/fun_ci/evidence/presets/maven.yml +6 -0
  104. data/lib/fun_ci/evidence/presets/minitest.yml +6 -0
  105. data/lib/fun_ci/evidence/presets/mocha.yml +6 -0
  106. data/lib/fun_ci/evidence/presets/mypy.yml +6 -0
  107. data/lib/fun_ci/evidence/presets/node-test.yml +6 -0
  108. data/lib/fun_ci/evidence/presets/phpunit.yml +6 -0
  109. data/lib/fun_ci/evidence/presets/pino.yml +5 -0
  110. data/lib/fun_ci/evidence/presets/prove.yml +6 -0
  111. data/lib/fun_ci/evidence/presets/pytest.yml +6 -0
  112. data/lib/fun_ci/evidence/presets/rspec.yml +6 -0
  113. data/lib/fun_ci/evidence/presets/rubocop.yml +6 -0
  114. data/lib/fun_ci/evidence/presets/ruff.yml +6 -0
  115. data/lib/fun_ci/evidence/presets/rustc.yml +6 -0
  116. data/lib/fun_ci/evidence/presets/shellcheck.yml +5 -0
  117. data/lib/fun_ci/evidence/presets/structlog.yml +4 -0
  118. data/lib/fun_ci/evidence/presets/swift-test.yml +5 -0
  119. data/lib/fun_ci/evidence/presets/tsc.yml +6 -0
  120. data/lib/fun_ci/evidence/presets/unittest.yml +6 -0
  121. data/lib/fun_ci/evidence/presets/vitest.yml +6 -0
  122. data/lib/fun_ci/evidence/presets.rb +30 -0
  123. data/lib/fun_ci/evidence/problem.rb +9 -0
  124. data/lib/fun_ci/evidence/process_table.rb +40 -0
  125. data/lib/fun_ci/evidence/saved_run.rb +22 -0
  126. data/lib/fun_ci/evidence/section_walk.rb +49 -0
  127. data/lib/fun_ci/evidence/settings.rb +64 -0
  128. data/lib/fun_ci/evidence/settings_check.rb +78 -0
  129. data/lib/fun_ci/evidence/source.rb +34 -0
  130. data/lib/fun_ci/evidence/sources.rb +21 -0
  131. data/lib/fun_ci/evidence/stage_context.rb +30 -0
  132. data/lib/fun_ci/evidence/stamp.rb +10 -0
  133. data/lib/fun_ci/evidence/start.rb +36 -0
  134. data/lib/fun_ci/evidence/worktree.rb +62 -0
  135. data/lib/fun_ci/persistence/active_runs.rb +69 -0
  136. data/lib/fun_ci/persistence/database.rb +59 -30
  137. data/lib/fun_ci/persistence/null_recorder.rb +25 -0
  138. data/lib/fun_ci/persistence/output_tail.rb +24 -0
  139. data/lib/fun_ci/persistence/pipeline_recorder.rb +78 -32
  140. data/lib/fun_ci/persistence/pipeline_run.rb +26 -17
  141. data/lib/fun_ci/persistence/project_runs.rb +52 -0
  142. data/lib/fun_ci/persistence/raw_outputs.rb +46 -0
  143. data/lib/fun_ci/persistence/retention.rb +25 -0
  144. data/lib/fun_ci/persistence/run_status.rb +31 -0
  145. data/lib/fun_ci/persistence/stage_job.rb +58 -13
  146. data/lib/fun_ci/persistence/state_dir.rb +26 -0
  147. data/lib/fun_ci/persistence/write_trouble.rb +46 -0
  148. data/lib/fun_ci/pipeline/background_fork.rb +42 -0
  149. data/lib/fun_ci/pipeline/command_executor.rb +58 -0
  150. data/lib/fun_ci/pipeline/gate.rb +35 -0
  151. data/lib/fun_ci/pipeline/git_environment.rb +16 -0
  152. data/lib/fun_ci/pipeline/head_part.rb +40 -0
  153. data/lib/fun_ci/pipeline/in_place.rb +17 -0
  154. data/lib/fun_ci/pipeline/output_window.rb +73 -0
  155. data/lib/fun_ci/pipeline/pipeline_forker.rb +18 -16
  156. data/lib/fun_ci/pipeline/process_runner.rb +90 -17
  157. data/lib/fun_ci/pipeline/run_canceller.rb +75 -0
  158. data/lib/fun_ci/pipeline/slot.rb +30 -0
  159. data/lib/fun_ci/pipeline/slot_lock.rb +13 -0
  160. data/lib/fun_ci/pipeline/slot_run.rb +86 -0
  161. data/lib/fun_ci/pipeline/stage_dir.rb +50 -0
  162. data/lib/fun_ci/pipeline/stage_end.rb +70 -0
  163. data/lib/fun_ci/pipeline/stage_execution.rb +67 -0
  164. data/lib/fun_ci/pipeline/stage_runner.rb +42 -64
  165. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +16 -33
  166. data/lib/fun_ci/pipeline/tail_segments.rb +60 -0
  167. data/lib/fun_ci/pipeline/test_report.rb +48 -0
  168. data/lib/fun_ci/pipeline/trigger.rb +55 -115
  169. data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
  170. data/lib/fun_ci/pipeline/trigger_params.rb +48 -0
  171. data/lib/fun_ci/pipeline/workspaces.rb +22 -0
  172. data/lib/fun_ci/pipeline/worktree_pool.rb +66 -0
  173. data/lib/fun_ci/pipeline/worktree_prune.rb +49 -0
  174. data/lib/fun_ci/pipeline/worktrees.rb +45 -0
  175. data/lib/fun_ci/setup/agent_instructions.rb +31 -0
  176. data/lib/fun_ci/setup/commands.rb +49 -0
  177. data/lib/fun_ci/setup/git_hooks.rb +20 -0
  178. data/lib/fun_ci/setup/hook_script.rb +47 -0
  179. data/lib/fun_ci/setup/hook_writer.rb +15 -33
  180. data/lib/fun_ci/setup/installer.rb +39 -23
  181. data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
  182. data/lib/fun_ci/setup/project_config.rb +43 -17
  183. data/lib/fun_ci/setup/project_detector.rb +39 -7
  184. data/lib/fun_ci/setup/settings.rb +36 -0
  185. data/lib/fun_ci/setup/setup_checker.rb +18 -12
  186. data/lib/fun_ci/setup/stage_templates/javascript.rb +18 -0
  187. data/lib/fun_ci/setup/stage_templates/managed.rb +24 -0
  188. data/lib/fun_ci/setup/stage_templates/native.rb +25 -0
  189. data/lib/fun_ci/setup/stage_templates/scripting.rb +27 -0
  190. data/lib/fun_ci/setup/stage_templates/shell.rb +16 -0
  191. data/lib/fun_ci/setup/stage_templates.rb +25 -0
  192. data/lib/fun_ci/setup/template_writer.rb +11 -36
  193. data/lib/fun_ci/version.rb +6 -0
  194. data/lib/fun_ci.rb +1 -3
  195. metadata +212 -43
  196. data/exe/fun-ci-trigger +0 -19
  197. data/exe/fun-ci-tui +0 -23
  198. data/lib/fun_ci/animations/celebrate.rb +0 -210
  199. data/lib/fun_ci/animations/explosion.rb +0 -138
  200. data/lib/fun_ci/animations/flash.rb +0 -205
  201. data/lib/fun_ci/animations/idle.rb +0 -127
  202. data/lib/fun_ci/animations/leprechauns.rb +0 -240
  203. data/lib/fun_ci/animations/running.rb +0 -129
  204. data/lib/fun_ci/animations/success.rb +0 -281
  205. data/lib/fun_ci/animations/yay.rb +0 -169
  206. data/lib/fun_ci/persistence/state_machine.rb +0 -50
  207. data/lib/fun_ci/pipeline/background_wrapper.rb +0 -27
  208. data/lib/fun_ci/tui/admin_tui.rb +0 -90
  209. data/lib/fun_ci/tui/animation.rb +0 -49
  210. data/lib/fun_ci/tui/animation_compositor.rb +0 -107
  211. data/lib/fun_ci/tui/animation_frames.rb +0 -112
  212. data/lib/fun_ci/tui/animation_library.rb +0 -46
  213. data/lib/fun_ci/tui/animation_renderer.rb +0 -144
  214. data/lib/fun_ci/tui/ansi.rb +0 -34
  215. data/lib/fun_ci/tui/board_data.rb +0 -53
  216. data/lib/fun_ci/tui/board_renderer.rb +0 -105
  217. data/lib/fun_ci/tui/duration_formatter.rb +0 -24
  218. data/lib/fun_ci/tui/header_animation_manager.rb +0 -71
  219. data/lib/fun_ci/tui/header_animation_player.rb +0 -45
  220. data/lib/fun_ci/tui/key_handler.rb +0 -86
  221. data/lib/fun_ci/tui/looping_animation_player.rb +0 -45
  222. data/lib/fun_ci/tui/relative_time.rb +0 -22
  223. data/lib/fun_ci/tui/row_formatter.rb +0 -108
  224. data/lib/fun_ci/tui/screen.rb +0 -103
  225. data/lib/fun_ci/tui/spinner.rb +0 -24
  226. data/lib/fun_ci/tui/stage_change_detector.rb +0 -58
  227. data/lib/fun_ci/tui/streak_counter.rb +0 -29
  228. data/lib/fun_ci/tui/terminal_input.rb +0 -69
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "trigger_params"
4
+ require_relative "pipeline_forker"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # `fun-ci trigger [--background] <commit-hash> <branch>`. --no-validate is
9
+ # the 1.x name for --background, kept for one release.
10
+ class TriggerCommand
11
+ BACKGROUND = %w[--background --no-validate].freeze
12
+ DEPRECATED = "fun-ci: --no-validate is now --background; the old name goes in 2.1."
13
+
14
+ def initialize(io:, recorder:, pipeline_forker:, project: Dir.pwd)
15
+ @io = io
16
+ @recorder = recorder
17
+ @pipeline_forker = pipeline_forker || PipelineForker.method(:fork_pipeline)
18
+ @project = project
19
+ end
20
+
21
+ # Closes the database connection it was given on every path.
22
+ def run(args)
23
+ sha, branch = args.reject { |a| a.start_with?("--") }
24
+ return usage unless branch
25
+
26
+ @io.stderr.puts DEPRECATED if args.include?("--no-validate")
27
+ commit = Commit.new(sha: sha, branch: branch)
28
+ args.intersect?(BACKGROUND) ? fork_pipeline(commit) : run_pipeline(commit)
29
+ ensure
30
+ @recorder.close
31
+ end
32
+
33
+ private
34
+
35
+ # After forking the slow suite the trigger holds a recorder of its own.
36
+ def run_pipeline(commit)
37
+ trigger = Trigger.new(project: @project, commit: commit, io: @io, seams: Seams.new(recorder: @recorder))
38
+ trigger.run
39
+ ensure
40
+ trigger&.close
41
+ end
42
+
43
+ def usage
44
+ @io.stderr.puts "fun-ci: commit hash and branch name are required."
45
+ @io.stderr.puts "Usage: fun-ci trigger <commit-hash> <branch>"
46
+ 1
47
+ end
48
+
49
+ # The forker answers whether it started a run; one that did says how an
50
+ # agent gets its verdict (acceptance-tests.md, AT-9.14).
51
+ def fork_pipeline(commit)
52
+ db_path = @recorder.db_path
53
+ @recorder.close
54
+ started = @pipeline_forker.call(commit_hash: commit.sha, branch: commit.branch, db_path: db_path)
55
+ say_how_to_wait(commit.sha[0, 7]) if started
56
+ 0
57
+ end
58
+
59
+ def say_how_to_wait(sha) = @io.stdout.puts("fun-ci: testing #{sha}. Verdict: fun-ci wait #{sha} --need all")
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../persistence/pipeline_recorder"
5
+ require_relative "command_executor"
6
+ require_relative "stage_dir"
7
+ require_relative "git_environment"
8
+ require_relative "../evidence/command_runner"
9
+ require_relative "../evidence/process_table"
10
+
11
+ module FunCi
12
+ module Pipeline
13
+ DEFAULT_BUDGETS = { "lint" => 30, "build" => 30, "fast" => 10, "slow" => 300 }.freeze
14
+
15
+ Commit = Data.define(:sha, :branch)
16
+
17
+ Io = Data.define(:stdout, :stderr) do
18
+ def initialize(stdout: $stdout, stderr: $stderr) = super
19
+ end
20
+
21
+ # The collaborators a pipeline run can have replaced. Each one left out
22
+ # gets the real thing. `environment` is the stages' environment as a hash,
23
+ # which masking and injected command runners see; `clock` answers seconds
24
+ # for the evidence budget; `extractor_runner` makes what runs a project's
25
+ # own extractors (`run:` entries), given dir:, env: and scratch:;
26
+ # `process_table` answers what ps lists, for a stage over budget.
27
+ Seams = Data.define(:command_runner, :time_budgets, :commit_validator, :recorder, :background_launcher, :workspace,
28
+ :stage_dir, :environment, :clock, :extractor_runner, :process_table)
29
+
30
+ # Reopened rather than given as a block to Data.define, so tools that read
31
+ # the source (mutineer) see these as Seams' methods.
32
+ class Seams
33
+ def self.defaults
34
+ { command_runner: nil, time_budgets: {}, recorder: Persistence::NullRecorder.new, background_launcher: nil,
35
+ workspace: nil, commit_validator: method(:commit_exists?), stage_dir: StageDir.method(:create),
36
+ environment: ENV.to_h, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
37
+ extractor_runner: Evidence::CommandRunner.method(:new),
38
+ process_table: Evidence::ProcessTable.method(:now) }
39
+ end
40
+
41
+ def self.commit_exists?(sha) = Open3.capture2e(GitEnvironment::CLEAN, "git", "cat-file", "-t", sha).last.success?
42
+
43
+ def initialize(**given) = super(**self.class.defaults.merge(given))
44
+ def budgets = DEFAULT_BUDGETS.merge(time_budgets)
45
+ def executor(dir) = CommandExecutor.new(command_runner, dir, environment)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../setup/project_config"
4
+ require_relative "worktree_pool"
5
+ require_relative "in_place"
6
+
7
+ module FunCi
8
+ module Pipeline
9
+ # Where a pipeline for a commit runs: a slot from the project's worktree
10
+ # pool, sized by .fun-ci/config, or, for a root commit's null SHA, which
11
+ # no worktree can check out, the project directory itself.
12
+ module Workspaces
13
+ NULL_SHA = ("0" * 40).freeze
14
+
15
+ def self.for(project, sha)
16
+ return InPlace.new(project) if sha == NULL_SHA
17
+
18
+ WorktreePool.new(Worktrees.new(project), size: Setup::ProjectConfig.new(project).worktree_slots)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "slot"
5
+ require_relative "worktrees"
6
+
7
+ module FunCi
8
+ module Pipeline
9
+ # Hands each pipeline a worktree slot of its own under
10
+ # <git-common-dir>/fun-ci/worktrees, checked out at its commit, waiting
11
+ # while every slot is taken.
12
+ class WorktreePool
13
+ DEFAULT_SIZE = 2
14
+
15
+ attr_reader :size
16
+
17
+ def initialize(worktrees, size: DEFAULT_SIZE, waiter: -> { sleep 0.25 })
18
+ @worktrees = worktrees
19
+ @size = size
20
+ @waiter = waiter
21
+ end
22
+
23
+ def acquire(sha)
24
+ slot = wait_for_free_slot
25
+ @worktrees.check_out(slot.path, sha)
26
+ slot
27
+ rescue StandardError
28
+ slot&.release
29
+ raise
30
+ end
31
+
32
+ private
33
+
34
+ def wait_for_free_slot
35
+ loop do
36
+ slot = free_slot
37
+ return slot if slot
38
+
39
+ @waiter.call
40
+ end
41
+ end
42
+
43
+ def free_slot
44
+ root = @worktrees.root
45
+ FileUtils.mkdir_p(root)
46
+ @size.times.lazy.filter_map { |index| try_lock(File.join(root, "slot-#{index}")) }.first
47
+ end
48
+
49
+ # The lock stays open for as long as the slot is held, so no block.
50
+ def try_lock(path)
51
+ lock = File.new("#{path}.lock", File::RDWR | File::CREAT, 0o644)
52
+ return Slot.new(path, stamped(lock), lock.path) if lock.flock(File::LOCK_EX | File::LOCK_NB)
53
+
54
+ lock.close
55
+ nil
56
+ end
57
+
58
+ def stamped(lock)
59
+ lock.truncate(0)
60
+ lock.write(Process.pid.to_s)
61
+ lock.flush
62
+ lock
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "worktrees"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # Removes every slot of a project's worktree pool, and git's entries for
9
+ # them, when no run holds one. It takes each slot's lock first, so a run
10
+ # starting meanwhile waits until the slot is gone and then checks it out
11
+ # afresh.
12
+ class WorktreePrune
13
+ class Busy < StandardError; end
14
+
15
+ def initialize(worktrees)
16
+ @worktrees = worktrees
17
+ end
18
+
19
+ # How many slots it removed. Raises Busy, removing nothing, while a run
20
+ # holds a slot.
21
+ def run
22
+ locks = take_locks
23
+ locks.each { |lock| FileUtils.rm_rf(lock.path.delete_suffix(".lock")) }
24
+ @worktrees.prune
25
+ locks.size
26
+ ensure
27
+ locks&.each(&:close)
28
+ end
29
+
30
+ private
31
+
32
+ # Every slot's lock, or none: on the first slot a run holds, it lets go
33
+ # of the locks it took before raising Busy.
34
+ def take_locks
35
+ slot_paths.each_with_object([]) do |path, locks|
36
+ locks << File.new("#{path}.lock", File::RDWR | File::CREAT, 0o644)
37
+ next if locks.last.flock(File::LOCK_EX | File::LOCK_NB)
38
+
39
+ locks.each(&:close)
40
+ raise Busy, "a pipeline is running in #{path}"
41
+ end
42
+ end
43
+
44
+ def slot_paths
45
+ Dir.glob(File.join(@worktrees.root, "slot-*")).map { |path| path.delete_suffix(".lock") }.uniq
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "git_environment"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # The git commands a worktree slot needs, run against the project's repository.
9
+ class Worktrees
10
+ class GitError < StandardError; end
11
+
12
+ def initialize(project_root)
13
+ @project_root = project_root
14
+ end
15
+
16
+ def root
17
+ File.join(File.expand_path(git(@project_root, "rev-parse", "--git-common-dir").strip, @project_root),
18
+ "fun-ci", "worktrees")
19
+ end
20
+
21
+ # Leaves +path+ as a clean detached checkout of +sha+. Ignored files stay,
22
+ # so caches such as vendor/bundle survive between runs in the same slot.
23
+ def check_out(path, sha)
24
+ return add(path, sha) unless File.exist?(File.join(path, ".git"))
25
+
26
+ git(path, "checkout", "--detach", "--force", sha)
27
+ git(path, "clean", "-fd")
28
+ end
29
+
30
+ # Has git forget the worktrees whose directories are gone.
31
+ def prune = git(@project_root, "worktree", "prune")
32
+
33
+ private
34
+
35
+ def add(path, sha) = git(@project_root, "worktree", "add", "--detach", "--force", path, sha)
36
+
37
+ def git(dir, *args)
38
+ output, status = Open3.capture2e(GitEnvironment::CLEAN, "git", *args, chdir: dir)
39
+ raise GitError, "git #{args.join(" ")}: #{output}" unless status.success?
40
+
41
+ output
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ # What `fun-ci init` tells a coding agent working in the project, in the
6
+ # instructions file agents read (acceptance-tests.md, AT-9.15): AGENTS.md,
7
+ # or CLAUDE.md when that is the only one. The marker keeps it to once.
8
+ module AgentInstructions
9
+ MARKER = "<!-- fun-ci: agent instructions -->"
10
+ SECTION = <<~MD.freeze
11
+ ## fun-ci
12
+
13
+ #{MARKER}
14
+ This project runs fun-ci on every commit. After each commit, run the
15
+ `fun-ci wait` command it prints, in the background, and act on its exit
16
+ code: 0 passed, 1 failed, 2 over budget, 3 undecided, 4 superseded.
17
+ `fun-ci wait --need all` must exit 0 before the work is called done.
18
+ `fun-ci runs` lists recent runs, and `fun-ci status` says where one stands.
19
+ MD
20
+
21
+ def self.file_for(names) = names.include?("CLAUDE.md") && !names.include?("AGENTS.md") ? "CLAUDE.md" : "AGENTS.md"
22
+
23
+ # The text with the section added, or nil when it has it already.
24
+ def self.merged(text)
25
+ return nil if text.include?(MARKER)
26
+
27
+ text.strip.empty? ? SECTION : "#{text.rstrip}\n\n#{SECTION}"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "installer"
4
+ require_relative "hook_writer"
5
+ require_relative "git_hooks"
6
+ require_relative "setup_checker"
7
+ require_relative "legacy_hooks"
8
+
9
+ module FunCi
10
+ module Setup
11
+ # `fun-ci init`, `install-hooks` and `check`, in the project directory.
12
+ # `hooks_dir` answers where git runs a project's hooks from.
13
+ class Commands
14
+ def initialize(project_root, stdout, hooks_dir: GitHooks.method(:dir))
15
+ @project_root = project_root
16
+ @stdout = stdout
17
+ @hooks_dir = hooks_dir
18
+ end
19
+
20
+ def init(args)
21
+ code = Installer.run(project_root: @project_root, stdout: @stdout)
22
+ return code if !code.zero? || !args.include?("--everything")
23
+
24
+ code = install_hooks([])
25
+ return code unless code.zero?
26
+
27
+ check([])
28
+ end
29
+
30
+ def install_hooks(args)
31
+ LegacyHooks.new(@project_root).remove(@stdout)
32
+ install(args.any? ? [args.first] : HookScript.types)
33
+ end
34
+
35
+ def check(_args) = SetupChecker.run(project_root: @project_root, stdout: @stdout)
36
+
37
+ private
38
+
39
+ def install(types)
40
+ hooks_dir = @hooks_dir.call(@project_root)
41
+ types.each do |type|
42
+ code = HookWriter.run(hooks_dir: hooks_dir, hook_type: type, stdout: @stdout)
43
+ return code unless code.zero?
44
+ end
45
+ 0
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../pipeline/git_environment"
5
+
6
+ module FunCi
7
+ module Setup
8
+ # Where git runs a project's hooks from: its git directory's hooks, or
9
+ # where core.hooksPath points, and from a linked worktree the
10
+ # repository's own. Git is asked, as .git may be a file or be ignored.
11
+ module GitHooks
12
+ # The directory, or nil outside a git repository.
13
+ def self.dir(project_root)
14
+ out, status = Open3.capture2(Pipeline::GitEnvironment::CLEAN, "git", "rev-parse", "--git-path", "hooks",
15
+ chdir: project_root, err: File::NULL)
16
+ status.success? ? File.expand_path(out.strip, project_root) : nil
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ # The script fun-ci writes into each git hook it manages.
6
+ module HookScript
7
+ MARKER = "# fun-ci-managed-hook"
8
+ NULL_SHA = "0" * 40
9
+
10
+ GUARD = <<~SH.freeze
11
+ #!/bin/sh
12
+ #{MARKER}
13
+ if ! command -v fun-ci >/dev/null 2>&1; then
14
+ echo "fun-ci is not installed or not on PATH, so this runs without CI. Install it with: gem install fun_ci" >&2
15
+ exit 0
16
+ fi
17
+ SH
18
+
19
+ # After a commit: the commit just made, in the background.
20
+ POST_COMMIT = <<~SH.freeze
21
+ COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "#{NULL_SHA}")
22
+ BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
23
+ fun-ci trigger --background "$COMMIT" "$BRANCH"
24
+ SH
25
+
26
+ # Before a push: the fast verdict of each commit git says it pushes
27
+ # (acceptance-tests.md, AT-9.13). A deleted ref pushes the null SHA, and
28
+ # exit 5 is a project not set up for fun-ci, which pushes without CI.
29
+ PRE_PUSH = <<~SH.freeze
30
+ status=0
31
+ while read -r local_ref local_sha remote_ref remote_sha; do
32
+ [ "$local_sha" = "#{NULL_SHA}" ] && continue
33
+ fun-ci wait "$local_sha" --need fast
34
+ code=$?
35
+ [ "$code" -eq 0 ] || [ "$code" -eq 5 ] || status=1
36
+ done
37
+ exit $status
38
+ SH
39
+
40
+ BODIES = { "post-commit" => POST_COMMIT, "pre-push" => PRE_PUSH }.freeze
41
+
42
+ def self.types = BODIES.keys
43
+ def self.for(hook_type) = GUARD + BODIES.fetch(hook_type)
44
+ def self.managed?(script) = script.include?(MARKER)
45
+ end
46
+ end
47
+ end
@@ -1,40 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "hook_script"
4
5
 
5
6
  module FunCi
6
7
  module Setup
8
+ # Writes a hook into `hooks_dir`, where git runs the project's hooks
9
+ # from (GitHooks.dir); nil outside a git repository.
7
10
  class HookWriter
8
- ALLOWED_HOOKS = %w[pre-commit pre-push].freeze
9
- MARKER = "# fun-ci-managed-hook"
10
-
11
- HOOK_COMMANDS = {
12
- "pre-commit" => "fun-ci trigger --no-validate",
13
- "pre-push" => "fun-ci trigger"
14
- }.freeze
15
-
16
- HOOK_TEMPLATE = <<~SH
17
- #!/bin/sh
18
- #{MARKER}
19
- COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "0000000000000000000000000000000000000000")
20
- BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
21
- %<command>s "$COMMIT" "$BRANCH"
22
- SH
23
-
24
- def self.run(project_root:, hook_type:, stdout: $stdout)
25
- new(project_root: project_root, hook_type: hook_type, stdout: stdout).run
11
+ def self.run(hooks_dir:, hook_type:, stdout: $stdout)
12
+ new(hooks_dir: hooks_dir, hook_type: hook_type, stdout: stdout).run
26
13
  end
27
14
 
28
- def initialize(project_root:, hook_type:, stdout:)
29
- @project_root = project_root
15
+ def initialize(hooks_dir:, hook_type:, stdout:)
16
+ @hooks_dir = hooks_dir
30
17
  @hook_type = hook_type
31
18
  @stdout = stdout
32
19
  end
33
20
 
34
21
  def run
35
- return reject("Not a git repository — no .git/ found.") unless git_repo?
36
- return reject("Unknown hook type: #{@hook_type}") unless ALLOWED_HOOKS.include?(@hook_type)
37
- return skip("Hook #{@hook_type} already exists from another tool — skipping.") if foreign_hook?
22
+ return reject("Not a git repository: no .git/ found.") unless git_repo?
23
+ return reject("Unknown hook type: #{@hook_type}") unless HookScript.types.include?(@hook_type)
24
+ return skip("Hook #{@hook_type} already exists from another tool, so it was left alone.") if foreign_hook?
38
25
 
39
26
  write_hook
40
27
  @stdout.puts "Installed #{@hook_type} hook."
@@ -43,21 +30,16 @@ module FunCi
43
30
 
44
31
  private
45
32
 
46
- def git_repo?
47
- Dir.exist?(File.join(@project_root, ".git"))
48
- end
49
-
50
- def hook_path
51
- File.join(@project_root, ".git", "hooks", @hook_type)
52
- end
33
+ def git_repo? = !@hooks_dir.nil?
34
+ def hook_path = File.join(@hooks_dir, @hook_type)
53
35
 
54
36
  def foreign_hook?
55
- File.exist?(hook_path) && !File.read(hook_path).include?(MARKER)
37
+ File.exist?(hook_path) && !HookScript.managed?(File.read(hook_path))
56
38
  end
57
39
 
58
40
  def write_hook
59
- FileUtils.mkdir_p(File.join(@project_root, ".git", "hooks"))
60
- File.write(hook_path, format(HOOK_TEMPLATE, command: HOOK_COMMANDS[@hook_type]))
41
+ FileUtils.mkdir_p(@hooks_dir)
42
+ File.write(hook_path, HookScript.for(@hook_type))
61
43
  File.chmod(0o755, hook_path)
62
44
  end
63
45
 
@@ -3,51 +3,67 @@
3
3
  require_relative "project_detector"
4
4
  require_relative "template_writer"
5
5
  require_relative "maven_linter_detector"
6
+ require_relative "agent_instructions"
6
7
 
7
8
  module FunCi
8
9
  module Setup
9
10
  class Installer
10
- def self.run(project_root:, stdout: $stdout, pom_reader: nil)
11
- new(project_root: project_root, stdout: stdout, pom_reader: pom_reader).run
11
+ def self.run(project_root:, stdout: $stdout)
12
+ new(project_root: project_root, stdout: stdout).run
12
13
  end
13
14
 
14
- def initialize(project_root:, stdout:, pom_reader: nil)
15
+ def initialize(project_root:, stdout:)
15
16
  @project_root = project_root
16
17
  @stdout = stdout
17
- @pom_reader = pom_reader || ->(path) { File.read(path) }
18
18
  end
19
19
 
20
+ # A project set up for fun-ci, new or not, also tells agents what to do.
20
21
  def run
21
- if Dir.exist?(File.join(@project_root, ".fun-ci"))
22
- @stdout.puts ".fun-ci/ already exists — skipping init."
23
- return 0
24
- end
22
+ code = set_up
23
+ tell_agents if code.zero?
24
+ code
25
+ end
25
26
 
26
- filenames = Dir.children(@project_root)
27
- detected = ProjectDetector.new(filenames).detect
27
+ private
28
28
 
29
- if detected == :unknown
30
- @stdout.puts "Could not detect project type. Create .fun-ci/ manually."
31
- return 1
32
- end
29
+ def set_up
30
+ return report(".fun-ci/ already exists, so init left it as it is.", 0) if initialised?
33
31
 
34
- @stdout.puts "Detected: #{detected.to_s.tr("_", " ")}"
32
+ detected = ProjectDetector.new(Dir.children(@project_root)).detect
33
+ return report("Could not detect project type. Create .fun-ci/ manually.", 1) if detected == :unknown
35
34
 
36
- lint_override = detect_maven_linter(detected)
37
- TemplateWriter.new(detected, @project_root, lint_override: lint_override).write
35
+ write_templates(detected)
36
+ end
37
+
38
+ def tell_agents
39
+ file = AgentInstructions.file_for(Dir.children(@project_root))
40
+ path = File.join(@project_root, file)
41
+ merged = AgentInstructions.merged(File.exist?(path) ? File.read(path) : "")
42
+ return unless merged
38
43
 
39
- @stdout.puts "Created .fun-ci/ with template scripts."
40
- 0
44
+ File.write(path, merged)
45
+ @stdout.puts "Told agents what to do after a commit, in #{file}."
41
46
  end
42
47
 
43
- private
48
+ def initialised?
49
+ Dir.exist?(File.join(@project_root, ".fun-ci"))
50
+ end
51
+
52
+ def write_templates(detected)
53
+ @stdout.puts "Detected: #{detected.to_s.tr("_", " ")}"
54
+ TemplateWriter.new(detected, @project_root, lint_override: detect_maven_linter(detected)).write
55
+ report("Created .fun-ci/ with template scripts.", 0)
56
+ end
57
+
58
+ def report(message, exit_code)
59
+ @stdout.puts message
60
+ exit_code
61
+ end
44
62
 
45
63
  def detect_maven_linter(detected)
46
64
  return nil unless detected == :jvm_maven
47
65
 
48
- pom_path = File.join(@project_root, "pom.xml")
49
- pom_content = @pom_reader.call(pom_path)
50
- command = MavenLinterDetector.new(pom_content).lint_command
66
+ command = MavenLinterDetector.new(File.read(File.join(@project_root, "pom.xml"))).lint_command
51
67
  command == MavenLinterDetector::DEFAULT_COMMAND ? nil : command
52
68
  end
53
69
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hook_script"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # fun-ci 1.x ran the background pipeline from a pre-commit hook, before
8
+ # the commit existed. The hook fun-ci wrote goes on upgrade, and is warned
9
+ # about until then; one someone wrote by hand that still calls fun-ci is
10
+ # warned about, never touched.
11
+ class LegacyHooks
12
+ WARNING = ".git/hooks/pre-commit calls fun-ci, which now runs after the commit: " \
13
+ "take fun-ci out of it and run `fun-ci install-hooks`"
14
+ NOT_UPGRADED = "fun-ci 1.x's pre-commit hook is still installed, so each run tests the commit before yours " \
15
+ "and yours gets none: run `fun-ci install-hooks`"
16
+
17
+ def initialize(project_root)
18
+ @hook = File.join(project_root, ".git", "hooks", "pre-commit")
19
+ end
20
+
21
+ def remove(stdout)
22
+ return unless File.exist?(@hook) && HookScript.managed?(File.read(@hook))
23
+
24
+ File.delete(@hook)
25
+ stdout.puts "Removed fun-ci's 1.x pre-commit hook; the background pipeline now runs from post-commit."
26
+ end
27
+
28
+ def warnings
29
+ return [] unless File.exist?(@hook)
30
+
31
+ script = File.read(@hook)
32
+ return [NOT_UPGRADED] if HookScript.managed?(script)
33
+
34
+ script.include?("fun-ci") ? [WARNING] : []
35
+ end
36
+ end
37
+ end
38
+ end