fun_ci 2.2.0 → 2.3.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +15 -8
  4. data/lib/fun_ci/agent/cancel_command.rb +52 -0
  5. data/lib/fun_ci/agent/commands.rb +3 -1
  6. data/lib/fun_ci/agent/job_events.rb +26 -7
  7. data/lib/fun_ci/agent/job_json.rb +10 -1
  8. data/lib/fun_ci/agent/job_report.rb +10 -6
  9. data/lib/fun_ci/agent/job_why_text.rb +11 -3
  10. data/lib/fun_ci/agent/jobs_text.rb +8 -3
  11. data/lib/fun_ci/agent/live_pipeline.rb +7 -4
  12. data/lib/fun_ci/agent/snapshots.rb +5 -1
  13. data/lib/fun_ci/agent/starts_in.rb +14 -0
  14. data/lib/fun_ci/agent/status_json.rb +1 -1
  15. data/lib/fun_ci/agent/status_text.rb +15 -3
  16. data/lib/fun_ci/agent/trunk_reading.rb +2 -1
  17. data/lib/fun_ci/agent/wait_command.rb +2 -1
  18. data/lib/fun_ci/cli.rb +1 -1
  19. data/lib/fun_ci/cli_help.rb +2 -1
  20. data/lib/fun_ci/console/job_message.rb +12 -6
  21. data/lib/fun_ci/console/job_order.rb +5 -4
  22. data/lib/fun_ci/console/key_handler.rb +3 -1
  23. data/lib/fun_ci/evidence/settings.rb +1 -1
  24. data/lib/fun_ci/jobs/due.rb +4 -3
  25. data/lib/fun_ci/jobs/due_jobs.rb +26 -2
  26. data/lib/fun_ci/jobs/job_fork.rb +19 -12
  27. data/lib/fun_ci/jobs/job_run.rb +26 -7
  28. data/lib/fun_ci/jobs/schedule.rb +25 -0
  29. data/lib/fun_ci/jobs/site.rb +10 -3
  30. data/lib/fun_ci/jobs/standings.rb +4 -0
  31. data/lib/fun_ci/jobs/state.rb +3 -2
  32. data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
  33. data/lib/fun_ci/persistence/active_jobs.rb +13 -7
  34. data/lib/fun_ci/persistence/job_runs.rb +12 -0
  35. data/lib/fun_ci/persistence/pipeline_run.rb +2 -1
  36. data/lib/fun_ci/pipeline/budgets.rb +9 -0
  37. data/lib/fun_ci/pipeline/pipeline_forker.rb +11 -8
  38. data/lib/fun_ci/pipeline/priorities.rb +23 -0
  39. data/lib/fun_ci/pipeline/process_runner.rb +7 -5
  40. data/lib/fun_ci/pipeline/progress_reporter.rb +4 -0
  41. data/lib/fun_ci/pipeline/slot.rb +11 -0
  42. data/lib/fun_ci/pipeline/slot_run.rb +19 -4
  43. data/lib/fun_ci/pipeline/trigger.rb +17 -6
  44. data/lib/fun_ci/pipeline/trigger_command.rb +6 -2
  45. data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
  46. data/lib/fun_ci/pipeline/worktree_pool.rb +2 -2
  47. data/lib/fun_ci/pipeline/worktrees.rb +3 -1
  48. data/lib/fun_ci/setup/agent_instructions.rb +21 -5
  49. data/lib/fun_ci/setup/commands.rb +3 -1
  50. data/lib/fun_ci/setup/hook_script.rb +9 -4
  51. data/lib/fun_ci/setup/hook_writer.rb +13 -5
  52. data/lib/fun_ci/setup/installed_hooks.rb +45 -0
  53. data/lib/fun_ci/setup/installer.rb +3 -2
  54. data/lib/fun_ci/setup/lfs_hook.rb +50 -0
  55. data/lib/fun_ci/setup/project_config.rb +7 -1
  56. data/lib/fun_ci/setup/settings.rb +40 -11
  57. data/lib/fun_ci/setup/setup_checker.rb +14 -3
  58. data/lib/fun_ci/trunk/shown.rb +7 -4
  59. data/lib/fun_ci/version.rb +1 -1
  60. metadata +9 -1
@@ -34,22 +34,36 @@ module FunCi
34
34
 
35
35
  def recorder = @seams.recorder
36
36
  def progress = ProgressReporter.new(stdout: @io.stdout)
37
- def stage_runner = StageRunner.new(commit: @commit, stdout: @io.stdout, seams: @seams, dir: @slot.path)
37
+
38
+ def stage_runner(seams = @seams)
39
+ StageRunner.new(commit: @commit, stdout: @io.stdout, seams: seams, dir: @slot.path)
40
+ end
38
41
 
39
42
  # This process's part is over; the slow suite may still be running.
40
43
  def released(exit_code)
41
44
  @slot.release
42
- recorder.foreground_done
43
45
  exit_code
44
46
  end
45
47
 
46
48
  def phase_one_passed?(config)
47
- results = %w[lint build].to_h { |stage| [stage, Thread.new { stage_runner.passes?(config, stage) }] }
49
+ runner = stage_runner(phase_one_seams)
50
+ results = %w[lint build].to_h { |stage| [stage, Thread.new { runner.passes?(config, stage) }] }
48
51
  .transform_values(&:value)
49
52
  progress.phase_one_result(results)
50
53
  results.values.all?
51
54
  end
52
55
 
56
+ # A worktree just made has no caches, so its lint and build are no
57
+ # measure of the project's: they get the slow suite's budget, this once.
58
+ def phase_one_seams
59
+ return @seams unless @slot.cold?
60
+
61
+ cold = @seams.budgets["slow"]
62
+ progress.cold_slot(cold)
63
+ longer = %w[lint build].to_h { |stage| [stage, [@seams.budgets[stage], cold].max] }
64
+ @seams.with(time_budgets: @seams.time_budgets.merge(longer))
65
+ end
66
+
53
67
  def fast_passed?(config)
54
68
  passed = stage_runner.passes?(config, "fast")
55
69
  progress.fast_result(passed)
@@ -65,7 +79,8 @@ module FunCi
65
79
  # Called with the recorder and job to record the slow suite with, in
66
80
  # whichever process runs it; the slot is let go once it is recorded.
67
81
  def slow_suite(config, slot)
68
- execution = StageExecution.new(seams: @seams, dir: slot.path, commit: @commit)
82
+ execution = StageExecution.new(seams: @seams, dir: slot.path, commit: @commit,
83
+ launching: { priority: @seams.priorities.slow })
69
84
  command = config.stage_command("slow", @commit.sha)
70
85
  ->(recorder, job_id) { holding(slot) { execution.run("slow", command, recorder, job_id) } }
71
86
  end
@@ -29,9 +29,12 @@ module FunCi
29
29
  def run
30
30
  config = Setup::ProjectConfig.new(@project)
31
31
  return handle_config_errors(config) if config.validate.any?
32
- return unknown_commit unless known_commit?
33
32
 
34
- start_run
33
+ full = full_sha
34
+ return unknown_commit unless full
35
+
36
+ @commit = @commit.with(sha: full)
37
+ start_run(config)
35
38
  checking_the_trunk { run_in(workspace.acquire(@commit.sha)) }
36
39
  end
37
40
 
@@ -44,7 +47,8 @@ module FunCi
44
47
  private
45
48
 
46
49
  def recorder = @seams.recorder
47
- def known_commit? = @commit.sha == NULL_SHA || @seams.commit_validator.call(@commit.sha)
50
+ # Runs are kept under the full SHA, whatever revision named the commit.
51
+ def full_sha = @commit.sha == NULL_SHA ? NULL_SHA : @seams.commit_resolver.call(@commit.sha)
48
52
 
49
53
  def workspace = @seams.workspace || Workspaces.for(@project, @commit.sha)
50
54
  def branch = Persistence::Branch.new(project: @project, name: @commit.branch)
@@ -56,11 +60,16 @@ module FunCi
56
60
  committed.folder_exists? ? committed : Setup::ProjectConfig.new(@project)
57
61
  end
58
62
 
59
- # The check is recorded by the recorder the run holds once its stages are done.
63
+ # The check is recorded by the recorder the run holds once its stages are
64
+ # done, and before the run says its foreground is: until then, a reader
65
+ # takes a check not yet recorded to be still going.
60
66
  def checking_the_trunk
61
67
  trunk = TrunkRun.start(@seams.trunk || Trunk::Checker.for(@project), @commit.sha, recorder)
62
68
  @io.stdout.puts(trunk.notice) if trunk.notice
63
- yield.tap { trunk.finish(recorder) }
69
+ yield.tap do
70
+ trunk.finish(recorder)
71
+ recorder.foreground_done
72
+ end
64
73
  end
65
74
 
66
75
  def handle_config_errors(config)
@@ -77,7 +86,9 @@ module FunCi
77
86
  1
78
87
  end
79
88
 
80
- def start_run
89
+ # A mistake in the config is named, and its defaults used.
90
+ def start_run(config)
91
+ config.settings_errors.each { |e| @io.stdout.puts "fun-ci: #{e}" }
81
92
  recorder.report_trouble_to(@io.stdout)
82
93
  cancel_stale_pipelines
83
94
  recorder.create_run(commit_hash: @commit.sha, branch: @commit.branch, project_path: @project)
@@ -60,13 +60,17 @@ module FunCi
60
60
  0
61
61
  end
62
62
 
63
- # The first fetch's notice, and on stderr, why the jobs didn't start.
63
+ # The config's mistakes and the first fetch's notice, and on stderr, why
64
+ # the jobs didn't start; the run itself prints nowhere.
64
65
  def say(forked)
66
+ Setup::ProjectConfig.new(@project).settings_errors.each { |e| @io.stdout.puts "fun-ci: #{e}" }
65
67
  @io.stdout.puts(forked.notice) if forked.notice
66
68
  @io.stderr.puts(forked.jobs) if forked.jobs
67
69
  end
68
70
 
69
- def say_how_to_wait(sha) = @io.stdout.puts("fun-ci: testing #{sha}. Verdict: fun-ci wait #{sha} --need all")
71
+ def say_how_to_wait(sha)
72
+ @io.stdout.puts("fun-ci: testing #{sha}. Verdict: fun-ci wait #{sha} --need all --follow-branch")
73
+ end
70
74
  end
71
75
  end
72
76
  end
@@ -5,13 +5,13 @@ require_relative "../persistence/pipeline_recorder"
5
5
  require_relative "command_executor"
6
6
  require_relative "stage_dir"
7
7
  require_relative "git_environment"
8
+ require_relative "priorities"
9
+ require_relative "budgets"
8
10
  require_relative "../evidence/command_runner"
9
11
  require_relative "../evidence/process_table"
10
12
 
11
13
  module FunCi
12
14
  module Pipeline
13
- DEFAULT_BUDGETS = { "lint" => 30, "build" => 30, "fast" => 10, "slow" => 300 }.freeze
14
-
15
15
  Commit = Data.define(:sha, :branch)
16
16
 
17
17
  # On a detached HEAD git names no branch, and the hooks pass "": the run
@@ -33,22 +33,33 @@ module FunCi
33
33
  # own extractors (`run:` entries), given dir:, env: and scratch:;
34
34
  # `process_table` answers what ps lists, for a stage over budget; `trunk`
35
35
  # checks a commit against the trunk (#check(sha) answers a Trunk::Check,
36
- # or nil for no check), nil for the project's own.
37
- Seams = Data.define(:command_runner, :time_budgets, :commit_validator, :recorder, :background_launcher, :workspace,
38
- :stage_dir, :environment, :clock, :extractor_runner, :process_table, :trunk)
36
+ # or nil for no check), nil for the project's own; `priorities` what jobs
37
+ # and the slow suite start under.
38
+ Seams = Data.define(:command_runner, :time_budgets, :commit_resolver, :recorder, :background_launcher, :workspace,
39
+ :stage_dir, :environment, :clock, :extractor_runner, :process_table, :trunk, :priorities)
39
40
 
40
41
  # Reopened rather than given as a block to Data.define, so tools that read
41
42
  # the source (mutineer) see these as Seams' methods.
42
43
  class Seams
44
+ # Seconds, for the evidence budget. Kept out of `defaults`' hash: a block
45
+ # on a line the hash continues onto gives that line the block's count,
46
+ # and the mutation lane then runs the hash's later lines' mutants only
47
+ # against tests that call it (test/support/statement_coverage.rb).
48
+ MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
49
+
43
50
  def self.defaults
44
51
  { command_runner: nil, time_budgets: {}, recorder: Persistence::NullRecorder.new, background_launcher: nil,
45
- workspace: nil, commit_validator: method(:commit_exists?), stage_dir: StageDir.method(:create),
46
- environment: ENV.to_h, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
52
+ workspace: nil, commit_resolver: method(:full_sha), stage_dir: StageDir.method(:create),
53
+ environment: ENV.to_h, clock: MONOTONIC,
47
54
  extractor_runner: Evidence::CommandRunner.method(:new),
48
- process_table: Evidence::ProcessTable.method(:now), trunk: nil }
55
+ process_table: Evidence::ProcessTable.method(:now), trunk: nil, priorities: Priorities.for(RUBY_PLATFORM) }
49
56
  end
50
57
 
51
- def self.commit_exists?(sha) = Open3.capture2e(GitEnvironment::CLEAN, "git", "cat-file", "-t", sha).last.success?
58
+ # The full SHA of the commit `rev` names, or nil when the repository has none.
59
+ def self.full_sha(rev)
60
+ out, status = Open3.capture2(GitEnvironment::CLEAN, "git", "rev-parse", "--verify", "-q", "#{rev}^{commit}")
61
+ out.strip if status.success?
62
+ end
52
63
 
53
64
  def initialize(**given) = super(**self.class.defaults.merge(given))
54
65
  def budgets = DEFAULT_BUDGETS.merge(time_budgets)
@@ -20,10 +20,10 @@ module FunCi
20
20
  @waiter = waiter
21
21
  end
22
22
 
23
+ # A ColdSlot when the slot's worktree had to be made.
23
24
  def acquire(sha)
24
25
  slot = wait_for_free_slot
25
- @worktrees.check_out(slot.path, sha)
26
- slot
26
+ @worktrees.check_out(slot.path, sha) ? slot.as_cold : slot
27
27
  rescue StandardError
28
28
  slot&.release
29
29
  raise
@@ -20,11 +20,13 @@ module FunCi
20
20
 
21
21
  # Leaves +path+ as a clean detached checkout of +sha+. Ignored files stay,
22
22
  # so caches such as vendor/bundle survive between runs in the same slot.
23
+ # Answers whether it made the worktree, which then has no caches.
23
24
  def check_out(path, sha)
24
- return add(path, sha) unless File.exist?(File.join(path, ".git"))
25
+ return add(path, sha).then { true } unless File.exist?(File.join(path, ".git"))
25
26
 
26
27
  git(path, "checkout", "--detach", "--force", sha)
27
28
  git(path, "clean", "-fd")
29
+ false
28
30
  end
29
31
 
30
32
  # Has git forget the worktrees whose directories are gone.
@@ -4,7 +4,8 @@ module FunCi
4
4
  module Setup
5
5
  # What `fun-ci init` tells a coding agent working in the project, in the
6
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.
7
+ # or CLAUDE.md when that is the only one. The marker keeps it to once, and
8
+ # finds a section an older fun-ci wrote, to bring it up to date.
8
9
  module AgentInstructions
9
10
  MARKER = "<!-- fun-ci: agent instructions -->"
10
11
  SECTION = <<~MD.freeze
@@ -13,7 +14,8 @@ module FunCi
13
14
  #{MARKER}
14
15
  This project runs fun-ci on every commit. After each commit, run the
15
16
  `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
+ code: 0 passed, 1 failed, 2 over budget, 3 undecided. A newer commit on
18
+ the branch cancels the run and the wait moves on to the newer one's.
17
19
  `fun-ci wait --need all` must exit 0 before the work is called done.
18
20
  Before calling work done, run `fun-ci wait --need all --trunk`; 6 means
19
21
  the commit conflicts with the trunk, which has to be integrated first,
@@ -24,14 +26,28 @@ module FunCi
24
26
  why one failed.
25
27
  MD
26
28
 
29
+ # A section in the text: from the heading over the marker, if it has
30
+ # one, to the next heading or the end.
31
+ WRITTEN = /^(?:## fun-ci\n\s*)?#{Regexp.escape(MARKER)}\n.*?(?=^#|\z)/m
32
+
27
33
  def self.file_for(names) = names.include?("CLAUDE.md") && !names.include?("AGENTS.md") ? "CLAUDE.md" : "AGENTS.md"
28
34
 
29
- # The text with the section added, or nil when it has it already.
35
+ # The text with the section added, or brought up to date; nil when it
36
+ # has this one already.
30
37
  def self.merged(text)
31
- return nil if text.include?(MARKER)
38
+ merged = text.include?(MARKER) ? replaced(text) : added(text)
39
+ merged unless merged == text
40
+ end
32
41
 
33
- text.strip.empty? ? SECTION : "#{text.rstrip}\n\n#{SECTION}"
42
+ def self.said(text, file)
43
+ return "Told agents what to do after a commit, in #{file}." unless text.include?(MARKER)
44
+
45
+ "Brought the fun-ci section of #{file} up to date."
34
46
  end
47
+
48
+ def self.added(text) = text.strip.empty? ? SECTION : "#{text.rstrip}\n\n#{SECTION}"
49
+ def self.replaced(text) = text.sub(WRITTEN) { Regexp.last_match.post_match.empty? ? SECTION : "#{SECTION}\n" }
50
+ private_class_method :added, :replaced
35
51
  end
36
52
  end
37
53
  end
@@ -32,7 +32,9 @@ module FunCi
32
32
  install(args.any? ? [args.first] : HookScript.types)
33
33
  end
34
34
 
35
- def check(_args) = SetupChecker.run(project_root: @project_root, stdout: @stdout)
35
+ def check(_args)
36
+ SetupChecker.run(project_root: @project_root, stdout: @stdout, hooks_dir: @hooks_dir.call(@project_root))
37
+ end
36
38
 
37
39
  private
38
40
 
@@ -7,11 +7,15 @@ module FunCi
7
7
  MARKER = "# fun-ci-managed-hook"
8
8
  NULL_SHA = "0" * 40
9
9
 
10
+ NOT_INSTALLED = %(echo "fun-ci is not installed or not on PATH, so this runs without CI. ) +
11
+ %(Install it with: gem install fun_ci" >&2)
12
+ INSTALLED = "command -v fun-ci >/dev/null 2>&1"
13
+
10
14
  GUARD = <<~SH.freeze
11
15
  #!/bin/sh
12
16
  #{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
17
+ if ! #{INSTALLED}; then
18
+ #{NOT_INSTALLED}
15
19
  exit 0
16
20
  fi
17
21
  SH
@@ -26,7 +30,7 @@ module FunCi
26
30
  # Before a push: the fast verdict of each commit git says it pushes
27
31
  # (acceptance-tests.md, AT-9.13). A deleted ref pushes the null SHA, and
28
32
  # exit 5 is a project not set up for fun-ci, which pushes without CI.
29
- PRE_PUSH = <<~SH.freeze
33
+ PRE_PUSH_WAITS = <<~SH.freeze
30
34
  status=0
31
35
  while read -r local_ref local_sha remote_ref remote_sha; do
32
36
  [ "$local_sha" = "#{NULL_SHA}" ] && continue
@@ -34,9 +38,10 @@ module FunCi
34
38
  code=$?
35
39
  [ "$code" -eq 0 ] || [ "$code" -eq 5 ] || status=1
36
40
  done
37
- exit $status
38
41
  SH
39
42
 
43
+ PRE_PUSH = "#{PRE_PUSH_WAITS}exit $status\n".freeze
44
+
40
45
  BODIES = { "post-commit" => POST_COMMIT, "pre-push" => PRE_PUSH }.freeze
41
46
 
42
47
  def self.types = BODIES.keys
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require_relative "hook_script"
5
+ require_relative "lfs_hook"
5
6
 
6
7
  module FunCi
7
8
  module Setup
@@ -23,8 +24,9 @@ module FunCi
23
24
  return reject("Unknown hook type: #{@hook_type}") unless HookScript.types.include?(@hook_type)
24
25
  return skip("Hook #{@hook_type} already exists from another tool, so it was left alone.") if foreign_hook?
25
26
 
26
- write_hook
27
- @stdout.puts "Installed #{@hook_type} hook."
27
+ lfs = lfs?
28
+ write_hook(lfs ? LfsHook.script(@hook_type) : HookScript.for(@hook_type))
29
+ @stdout.puts "Installed #{@hook_type} hook#{", which runs git-lfs's too" if lfs}."
28
30
  0
29
31
  end
30
32
 
@@ -33,13 +35,19 @@ module FunCi
33
35
  def git_repo? = !@hooks_dir.nil?
34
36
  def hook_path = File.join(@hooks_dir, @hook_type)
35
37
 
38
+ def existing = File.exist?(hook_path) ? File.read(hook_path) : ""
39
+
40
+ # git-lfs's own hook is not left alone: fun-ci's runs it.
36
41
  def foreign_hook?
37
- File.exist?(hook_path) && !HookScript.managed?(File.read(hook_path))
42
+ script = existing
43
+ !script.empty? && !HookScript.managed?(script) && !LfsHook.stock?(script, @hook_type)
38
44
  end
39
45
 
40
- def write_hook
46
+ def lfs? = LfsHook.runs_lfs?(existing, @hook_type)
47
+
48
+ def write_hook(script)
41
49
  FileUtils.mkdir_p(@hooks_dir)
42
- File.write(hook_path, HookScript.for(@hook_type))
50
+ File.write(hook_path, script)
43
51
  File.chmod(0o755, hook_path)
44
52
  end
45
53
 
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lfs_hook"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # The post-commit and pre-push hooks git runs, as `fun-ci check` warns of
8
+ # them: one that doesn't run fun-ci tests no commit, or lets a push
9
+ # through without a verdict. `fun-ci install-hooks` writes either when it
10
+ # is missing or git-lfs's own; another tool's has to call fun-ci itself.
11
+ class InstalledHooks
12
+ WHAT = { "post-commit" => "No commit is tested", "pre-push" => "No push waits for a verdict" }.freeze
13
+ INSTALL = "Run `fun-ci install-hooks`"
14
+ CALL = { "post-commit" => "`fun-ci trigger --background <commit> <branch>`",
15
+ "pre-push" => "`fun-ci wait <commit> --need fast` for each commit pushed" }.freeze
16
+
17
+ # hooks_dir: where git runs the project's hooks from; nil outside a git repository.
18
+ def initialize(hooks_dir)
19
+ @hooks_dir = hooks_dir
20
+ end
21
+
22
+ def warnings
23
+ return [] unless @hooks_dir
24
+
25
+ WHAT.keys.map { |type| warning(type, script(type)) }.compact
26
+ end
27
+
28
+ private
29
+
30
+ def script(type)
31
+ path = File.join(@hooks_dir, type)
32
+ File.exist?(path) ? File.read(path) : ""
33
+ end
34
+
35
+ def warning(type, script)
36
+ return nil if script.include?("fun-ci")
37
+ return "#{WHAT[type]}: the #{type} hook doesn't run fun-ci. #{INSTALL}" if install_fixes?(script, type)
38
+
39
+ "#{WHAT[type]}: the #{type} hook is another tool's and doesn't run fun-ci. Have it run #{CALL[type]}"
40
+ end
41
+
42
+ def install_fixes?(script, type) = script.empty? || LfsHook.stock?(script, type)
43
+ end
44
+ end
45
+ end
@@ -44,11 +44,12 @@ module FunCi
44
44
  def tell_agents
45
45
  file = AgentInstructions.file_for(Dir.children(@project_root))
46
46
  path = File.join(@project_root, file)
47
- merged = AgentInstructions.merged(File.exist?(path) ? File.read(path) : "")
47
+ told = File.exist?(path) ? File.read(path) : ""
48
+ merged = AgentInstructions.merged(told)
48
49
  return unless merged
49
50
 
50
51
  File.write(path, merged)
51
- @stdout.puts "Told agents what to do after a commit, in #{file}."
52
+ @stdout.puts AgentInstructions.said(told, file)
52
53
  end
53
54
 
54
55
  def initialised?
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hook_script"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # git-lfs's own post-commit and pre-push hooks, as `git lfs install`
8
+ # writes them, and the hook fun-ci writes in their place, which runs
9
+ # fun-ci's, then git-lfs's. A push reads its ref list from git once, so
10
+ # the pre-push hook keeps it in a file and gives it to both.
11
+ module LfsHook
12
+ GUARD = %r{\Acommand -v git-lfs >/dev/null 2>&1 \|\| \{.*; exit 2; \}\z}
13
+ MISSING = %(command -v git-lfs >/dev/null 2>&1 || { echo "This repository is configured for Git LFS ) +
14
+ %(but 'git-lfs' was not found on your path." >&2; exit 2; })
15
+
16
+ RUN = {
17
+ "post-commit" => "if #{HookScript::INSTALLED}; then fun_ci; else #{HookScript::NOT_INSTALLED}; fi\n",
18
+ "pre-push" => %(if #{HookScript::INSTALLED}; then fun_ci < "$refs" || exit 1; ) +
19
+ "else #{HookScript::NOT_INSTALLED}; fi\n"
20
+ }.freeze
21
+
22
+ KEEP_REFS = { "post-commit" => "", "pre-push" => <<~SH }.freeze
23
+ refs=$(mktemp) || exit 1
24
+ trap 'rm -f "$refs"' EXIT
25
+ cat > "$refs"
26
+ SH
27
+
28
+ FUN_CI = { "post-commit" => HookScript::POST_COMMIT, "pre-push" => "#{HookScript::PRE_PUSH_WAITS}return $status\n" }
29
+ .freeze
30
+ LFS_INPUT = { "post-commit" => "", "pre-push" => %( < "$refs") }.freeze
31
+
32
+ # Whether `script` is the hook `git lfs install` writes for `hook_type`
33
+ # and nothing more.
34
+ def self.stock?(script, hook_type)
35
+ lines = script.lines(chomp: true).reject { |line| line.strip.empty? }
36
+ lines.size == 3 && lines.first == "#!/bin/sh" && GUARD.match?(lines[1]) && lines.last == call(hook_type)
37
+ end
38
+
39
+ def self.runs_lfs?(script, hook_type) = script.include?(call(hook_type))
40
+
41
+ def self.script(hook_type)
42
+ "#!/bin/sh\n#{HookScript::MARKER}\n# fun-ci's #{hook_type} hook, then git-lfs's, which it replaced.\n" \
43
+ "#{KEEP_REFS.fetch(hook_type)}fun_ci() {\n#{FUN_CI.fetch(hook_type)}}\n" \
44
+ "#{RUN.fetch(hook_type)}#{MISSING}\n#{call(hook_type)}#{LFS_INPUT.fetch(hook_type)}\n"
45
+ end
46
+
47
+ def self.call(hook_type) = %(git lfs #{hook_type} "$@")
48
+ end
49
+ end
50
+ end
@@ -20,12 +20,17 @@ module FunCi
20
20
  Dir.exist?(@fun_ci_dir)
21
21
  end
22
22
 
23
+ # What stops a pipeline: no .fun-ci/, or a stage script it can't run.
23
24
  def validate
24
25
  return ["No .fun-ci/ folder found in #{@project_root}"] unless folder_exists?
25
26
 
26
- REQUIRED_SCRIPTS.flat_map { |script| script_errors(script) } + settings.errors
27
+ REQUIRED_SCRIPTS.flat_map { |script| script_errors(script) }
27
28
  end
28
29
 
30
+ # Mistakes in .fun-ci/config, which never stop a pipeline: the setting
31
+ # takes its default instead.
32
+ def settings_errors = settings.errors
33
+
29
34
  # Mistakes in the `evidence` key, which `fun-ci check` reports but which
30
35
  # never stop a pipeline: the entry with the mistake is left out instead.
31
36
  def evidence_errors = Evidence::Settings.load(File.join(@fun_ci_dir, "config")).errors(root: @project_root)
@@ -46,6 +51,7 @@ module FunCi
46
51
  def worktree_slots = settings.worktree_slots
47
52
  def trunk = settings.trunk
48
53
  def trunk_fetch = settings.trunk_fetch
54
+ def job_spacing = settings.job_spacing
49
55
 
50
56
  def script_path(stage)
51
57
  File.join(@fun_ci_dir, "#{stage}.sh")
@@ -6,7 +6,8 @@ module FunCi
6
6
  module Setup
7
7
  # .fun-ci/config: settings for this machine's runs, as YAML. Each one left
8
8
  # out takes its default; one that is wrong is reported and the default used,
9
- # as are all of them when the file isn't YAML.
9
+ # as are all of them when the file can't be read as YAML. Anchors and
10
+ # aliases are YAML, so they are read.
10
11
  class Settings
11
12
  DEFAULTS = { "worktree_slots" => 2 }.freeze
12
13
  FETCH_EVERY = 300
@@ -20,7 +21,7 @@ module FunCi
20
21
  @text = text
21
22
  end
22
23
 
23
- def worktree_slots = errors.empty? ? values.fetch("worktree_slots") : DEFAULTS.fetch("worktree_slots")
24
+ def worktree_slots = slots || DEFAULTS.fetch("worktree_slots")
24
25
 
25
26
  # The trunk's name, or nil when none is given or it is no name. A mistake
26
27
  # in the trunk settings never stops a pipeline, so none is among `errors`.
@@ -37,22 +38,51 @@ module FunCi
37
38
  seconds(given) || FETCH_EVERY
38
39
  end
39
40
 
40
- def errors
41
- return [".fun-ci/config must be a mapping such as `worktree_slots: 2`"] unless raw.is_a?(Hash)
41
+ # Seconds between the starts of the jobs a commit starts, 0 for all at
42
+ # once (Jobs::Schedule); nil when none is given, or none it can read,
43
+ # and the jobs share the day evenly (Jobs::DueJobs).
44
+ def job_spacing = seconds(setting("job_spacing"))
42
45
 
43
- slots = values["worktree_slots"]
44
- return [] if slots.is_a?(Integer) && slots.positive?
45
-
46
- [".fun-ci/config: worktree_slots must be a whole number above 0, not #{slots.inspect}"]
46
+ def errors
47
+ setting_errors
47
48
  rescue Psych::SyntaxError => e
48
49
  [".fun-ci/config is not YAML: #{e.problem} (line #{e.line})"]
50
+ rescue Psych::DisallowedClass => e
51
+ [".fun-ci/config can't be read: #{e.message} (quote a value such as a date to read it as text)"]
52
+ rescue Psych::Exception => e
53
+ [".fun-ci/config can't be read: #{e.message}"]
49
54
  end
50
55
 
51
56
  private
52
57
 
58
+ def setting_errors
59
+ return [".fun-ci/config must be a mapping such as `worktree_slots: 2`"] unless raw.is_a?(Hash)
60
+
61
+ [slots_error, spacing_error].compact
62
+ end
63
+
64
+ # The slots the file asks for, when that is a whole number above 0.
65
+ def slots
66
+ given = setting("worktree_slots")
67
+ given if given.is_a?(Integer) && given.positive?
68
+ end
69
+
70
+ def slots_error
71
+ given = setting("worktree_slots")
72
+ ".fun-ci/config: worktree_slots must be a whole number above 0, not #{given.inspect}" unless given.nil? || slots
73
+ end
74
+
75
+ # Unlike the trunk's, since taking turns is the jobs' whole point (AT-13.28).
76
+ def spacing_error
77
+ given = setting("job_spacing")
78
+ return nil if given.nil? || seconds(given)
79
+
80
+ ".fun-ci/config: job_spacing must be seconds, or a number with s, m or h, not #{given.inspect}"
81
+ end
82
+
53
83
  def setting(key)
54
84
  raw.is_a?(Hash) ? raw[key] : nil
55
- rescue Psych::SyntaxError
85
+ rescue Psych::Exception
56
86
  nil
57
87
  end
58
88
 
@@ -61,8 +91,7 @@ module FunCi
61
91
  match && (match[1].to_i * UNITS.fetch(match[2]))
62
92
  end
63
93
 
64
- def raw = (@text && YAML.safe_load(@text)) || {}
65
- def values = DEFAULTS.merge(raw)
94
+ def raw = (@text && YAML.safe_load(@text, aliases: true)) || {}
66
95
  end
67
96
  end
68
97
  end
@@ -2,13 +2,22 @@
2
2
 
3
3
  require_relative "project_config"
4
4
  require_relative "legacy_hooks"
5
+ require_relative "installed_hooks"
5
6
  require_relative "trunk_report"
6
7
 
7
8
  module FunCi
8
9
  module Setup
9
10
  class SetupChecker
10
- def self.run(project_root:, stdout: $stdout)
11
- new(config: ProjectConfig.new(project_root), hooks: LegacyHooks.new(project_root), stdout: stdout,
11
+ # The hooks it warns of: fun-ci 1.x's, and any in `hooks_dir` that
12
+ # doesn't run fun-ci.
13
+ Hooks = Data.define(:sources) do
14
+ def warnings = sources.flat_map(&:warnings)
15
+ end
16
+
17
+ # hooks_dir: where git runs the project's hooks from; nil outside a git repository.
18
+ def self.run(project_root:, stdout: $stdout, hooks_dir: nil)
19
+ hooks = Hooks.new([LegacyHooks.new(project_root), InstalledHooks.new(hooks_dir)])
20
+ new(config: ProjectConfig.new(project_root), hooks: hooks, stdout: stdout,
12
21
  trunk: TrunkReport.new(project_root)).run
13
22
  end
14
23
 
@@ -22,7 +31,7 @@ module FunCi
22
31
 
23
32
  # Warnings are printed but don't fail the check.
24
33
  def run
25
- errors = @config.validate + @config.evidence_errors + @config.job_errors
34
+ errors = problems
26
35
  @stdout.puts(errors.empty? ? "All OK. The project is configured." : errors)
27
36
  @stdout.puts(@trunk.lines)
28
37
  list_presets
@@ -33,6 +42,8 @@ module FunCi
33
42
 
34
43
  private
35
44
 
45
+ def problems = @config.validate + @config.settings_errors + @config.evidence_errors + @config.job_errors
46
+
36
47
  def list_presets
37
48
  lists = { "this project" => @config.presets, "any project's output" => @config.any_project_presets }
38
49
  lists.each { |what, names| @stdout.puts "Evidence presets for #{what}: #{names.join(", ")}" if names.any? }
@@ -8,8 +8,9 @@ module FunCi
8
8
  # rather than stored, how many seconds ago its trunk tip was seen (nil
9
9
  # when it had none), the project's last fetch (a LastFetch, nil if none), and
10
10
  # the SHA the trunk has moved to since, if it has.
11
- # A run whose check hasn't been recorded is checking until its fetch and
12
- # merge are past their time, and then unknown.
11
+ # A run whose check hasn't been recorded is checking while its foreground
12
+ # stages run and until its fetch and merge are past their time, and then
13
+ # unknown.
13
14
  Shown = Data.define(:state, :check, :age, :fetch, :moved_to)
14
15
 
15
16
  class Shown
@@ -21,8 +22,10 @@ module FunCi
21
22
  fetch: fetch, moved_to: moved_to)
22
23
  end
23
24
 
24
- def self.unchecked(started:, now:)
25
- if now - started <= FETCH_DEADLINE + CHECK_BUDGET
25
+ # stages_running: whether the run's foreground stages, which the check
26
+ # is recorded after, are still going.
27
+ def self.unchecked(started:, now:, stages_running: false)
28
+ if stages_running || now - started <= FETCH_DEADLINE + CHECK_BUDGET
26
29
  return new(state: "checking", check: nil, age: nil, fetch: nil, moved_to: nil)
27
30
  end
28
31