fun_ci 2.2.0-aarch64-linux → 2.4.0-aarch64-linux

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +141 -0
  3. data/README.md +19 -11
  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 +3 -1
  19. data/lib/fun_ci/cli_command_help.rb +51 -0
  20. data/lib/fun_ci/cli_help.rb +11 -10
  21. data/lib/fun_ci/console/board_data.rb +7 -2
  22. data/lib/fun_ci/console/job_message.rb +12 -6
  23. data/lib/fun_ci/console/job_order.rb +5 -4
  24. data/lib/fun_ci/console/key_handler.rb +3 -1
  25. data/lib/fun_ci/console/trunk_marks.rb +3 -1
  26. data/lib/fun_ci/evidence/about_stage.rb +1 -1
  27. data/lib/fun_ci/evidence/collector.rb +6 -3
  28. data/lib/fun_ci/evidence/outcome.rb +6 -3
  29. data/lib/fun_ci/evidence/settings.rb +1 -1
  30. data/lib/fun_ci/jobs/due.rb +4 -3
  31. data/lib/fun_ci/jobs/due_jobs.rb +26 -2
  32. data/lib/fun_ci/jobs/job_fork.rb +19 -12
  33. data/lib/fun_ci/jobs/job_run.rb +26 -7
  34. data/lib/fun_ci/jobs/locks.rb +1 -1
  35. data/lib/fun_ci/jobs/schedule.rb +25 -0
  36. data/lib/fun_ci/jobs/site.rb +10 -3
  37. data/lib/fun_ci/jobs/standings.rb +4 -0
  38. data/lib/fun_ci/jobs/state.rb +3 -2
  39. data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
  40. data/lib/fun_ci/persistence/active_jobs.rb +15 -18
  41. data/lib/fun_ci/persistence/active_runs.rb +5 -3
  42. data/lib/fun_ci/persistence/died.rb +21 -0
  43. data/lib/fun_ci/persistence/job_recorder.rb +1 -0
  44. data/lib/fun_ci/persistence/job_runs.rb +12 -0
  45. data/lib/fun_ci/persistence/null_recorder.rb +1 -0
  46. data/lib/fun_ci/persistence/pipeline_recorder.rb +1 -0
  47. data/lib/fun_ci/persistence/pipeline_run.rb +13 -9
  48. data/lib/fun_ci/persistence/stage_job.rb +12 -0
  49. data/lib/fun_ci/pipeline/budgets.rb +9 -0
  50. data/lib/fun_ci/pipeline/group_kill.rb +38 -0
  51. data/lib/fun_ci/pipeline/pipeline_forker.rb +14 -9
  52. data/lib/fun_ci/pipeline/priorities.rb +23 -0
  53. data/lib/fun_ci/pipeline/process_runner.rb +13 -6
  54. data/lib/fun_ci/pipeline/process_tree.rb +37 -0
  55. data/lib/fun_ci/pipeline/progress_reporter.rb +5 -0
  56. data/lib/fun_ci/pipeline/run_canceller.rb +15 -3
  57. data/lib/fun_ci/pipeline/slot.rb +11 -0
  58. data/lib/fun_ci/pipeline/slot_run.rb +19 -4
  59. data/lib/fun_ci/pipeline/stage_end.rb +2 -1
  60. data/lib/fun_ci/pipeline/trigger.rb +17 -6
  61. data/lib/fun_ci/pipeline/trigger_command.rb +6 -2
  62. data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
  63. data/lib/fun_ci/pipeline/workspaces.rb +2 -1
  64. data/lib/fun_ci/pipeline/worktree_pool.rb +22 -5
  65. data/lib/fun_ci/pipeline/worktree_prune.rb +9 -4
  66. data/lib/fun_ci/pipeline/worktrees.rb +39 -6
  67. data/lib/fun_ci/setup/agent_instructions.rb +21 -5
  68. data/lib/fun_ci/setup/commands.rb +19 -8
  69. data/lib/fun_ci/setup/eslint_detection.rb +38 -0
  70. data/lib/fun_ci/setup/hook_script.rb +9 -4
  71. data/lib/fun_ci/setup/hook_writer.rb +13 -5
  72. data/lib/fun_ci/setup/init_options.rb +34 -0
  73. data/lib/fun_ci/setup/installed_hooks.rb +45 -0
  74. data/lib/fun_ci/setup/installer.rb +44 -9
  75. data/lib/fun_ci/setup/lfs_hook.rb +50 -0
  76. data/lib/fun_ci/setup/placeholders.rb +30 -0
  77. data/lib/fun_ci/setup/project_config.rb +8 -1
  78. data/lib/fun_ci/setup/settings.rb +59 -10
  79. data/lib/fun_ci/setup/setup_checker.rb +16 -3
  80. data/lib/fun_ci/setup/stage_templates/javascript.rb +14 -4
  81. data/lib/fun_ci/setup/stage_templates/placeholders.rb +21 -0
  82. data/lib/fun_ci/setup/stage_templates.rb +6 -4
  83. data/lib/fun_ci/setup/template_writer.rb +3 -5
  84. data/lib/fun_ci/trunk/shown.rb +7 -4
  85. data/lib/fun_ci/version.rb +1 -1
  86. data/libexec/fun-ci-renderer +0 -0
  87. metadata +17 -1
@@ -58,7 +58,8 @@ module FunCi
58
58
  status = finished.status
59
59
  Evidence::Outcome.new(state: STATES.fetch(outcome(finished)), exit_status: status&.exitstatus,
60
60
  signal: status&.termsig && Signal.signame(status.termsig),
61
- alongside: @recorder.alongside(@job_id), overrun: finished.overrun)
61
+ alongside: @recorder.alongside(@job_id),
62
+ elsewhere: @recorder.alongside_elsewhere(@job_id), overrun: finished.overrun)
62
63
  end
63
64
 
64
65
  def keep_exit(status)
@@ -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)
@@ -15,7 +15,8 @@ module FunCi
15
15
  def self.for(project, sha)
16
16
  return InPlace.new(project) if sha == NULL_SHA
17
17
 
18
- WorktreePool.new(Worktrees.new(project), size: Setup::ProjectConfig.new(project).worktree_slots)
18
+ config = Setup::ProjectConfig.new(project)
19
+ WorktreePool.new(Worktrees.new(project), size: config.worktree_slots, clean: config.clean)
19
20
  end
20
21
  end
21
22
  end
@@ -7,23 +7,33 @@ require_relative "worktrees"
7
7
  module FunCi
8
8
  module Pipeline
9
9
  # Hands each pipeline a worktree slot of its own under
10
- # <git-common-dir>/fun-ci/worktrees, checked out at its commit, waiting
10
+ # Worktrees#root, checked out at its commit, waiting
11
11
  # while every slot is taken.
12
12
  class WorktreePool
13
13
  DEFAULT_SIZE = 2
14
+ # A slot nobody took for longer is cold: its caches are as warm as a
15
+ # run long ago left them, as a slot taken only when runs overlap is.
16
+ IDLE_SECONDS = 86_400
14
17
 
15
18
  attr_reader :size
16
19
 
17
- def initialize(worktrees, size: DEFAULT_SIZE, waiter: -> { sleep 0.25 })
20
+ # Whether a slot last taken at `last_taken` has gone untaken too long.
21
+ def self.idle?(last_taken, now:) = now - last_taken > IDLE_SECONDS
22
+
23
+ # clean: the paths whose ignored files a slot loses when it moves off its
24
+ # line of history (Worktrees#check_out).
25
+ def initialize(worktrees, size: DEFAULT_SIZE, clean: [], waiter: -> { sleep 0.25 })
18
26
  @worktrees = worktrees
19
27
  @size = size
28
+ @clean = clean
20
29
  @waiter = waiter
21
30
  end
22
31
 
32
+ # A ColdSlot when the slot's worktree had to be made, or was cleaned, or
33
+ # nobody took the slot for a day.
23
34
  def acquire(sha)
24
35
  slot = wait_for_free_slot
25
- @worktrees.check_out(slot.path, sha)
26
- slot
36
+ @worktrees.check_out(slot.path, sha, clean: @clean) ? slot.as_cold : slot
27
37
  rescue StandardError
28
38
  slot&.release
29
39
  raise
@@ -49,12 +59,19 @@ module FunCi
49
59
  # The lock stays open for as long as the slot is held, so no block.
50
60
  def try_lock(path)
51
61
  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)
62
+ return taken(path, lock) if lock.flock(File::LOCK_EX | File::LOCK_NB)
53
63
 
54
64
  lock.close
55
65
  nil
56
66
  end
57
67
 
68
+ # Each taker stamps the lock, so its last change is when the slot was last taken.
69
+ def taken(path, lock)
70
+ idle = WorktreePool.idle?(lock.mtime, now: Time.now)
71
+ slot = Slot.new(path, stamped(lock), lock.path)
72
+ idle ? slot.as_cold : slot
73
+ end
74
+
58
75
  def stamped(lock)
59
76
  lock.truncate(0)
60
77
  lock.write(Process.pid.to_s)
@@ -6,8 +6,8 @@ require_relative "worktrees"
6
6
  module FunCi
7
7
  module Pipeline
8
8
  # Removes every slot of a project's worktree pool and every daily or
9
- # weekly job's worktree, and git's entries for them, when no run or job
10
- # holds one. It takes each slot's lock first, so a run
9
+ # weekly job's worktree, those an older fun-ci kept under the git
10
+ # directory too, and git's entries for them, when no run or job holds one. It takes each slot's lock first, so a run
11
11
  # starting meanwhile waits until the slot is gone and then checks it out
12
12
  # afresh.
13
13
  class WorktreePrune
@@ -43,8 +43,13 @@ module FunCi
43
43
  end
44
44
 
45
45
  def slot_paths
46
- (Dir.glob(File.join(@worktrees.root, "slot-*")) + Dir.glob(File.join(@worktrees.jobs_root, "*")))
47
- .map { |path| path.delete_suffix(".lock") }.uniq
46
+ patterns.flat_map { |pattern| Dir.glob(pattern) }.map { |path| path.delete_suffix(".lock") }.uniq
47
+ end
48
+
49
+ def patterns
50
+ legacy = @worktrees.legacy_dir
51
+ [File.join(@worktrees.root, "slot-*"), File.join(@worktrees.jobs_root, "*"),
52
+ File.join(legacy, "worktrees", "slot-*"), File.join(legacy, "jobs", "*")]
48
53
  end
49
54
  end
50
55
  end
@@ -1,16 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "digest"
4
5
  require_relative "git_environment"
6
+ require_relative "../persistence/state_dir"
5
7
 
6
8
  module FunCi
7
9
  module Pipeline
8
- # The git commands a worktree slot needs, run against the project's repository.
10
+ # The git commands a worktree slot needs, run against the project's
11
+ # repository. The worktrees live in fun-ci's state directory, one place
12
+ # per repository (all its git worktrees share it), and not under .git/,
13
+ # which tools such as Vite refuse to serve files from.
9
14
  class Worktrees
10
15
  class GitError < StandardError; end
11
16
 
12
- def initialize(project_root)
17
+ # state_dir: fun-ci's own (Persistence::StateDir).
18
+ def initialize(project_root, state_dir: Persistence::StateDir.path(ENV))
13
19
  @project_root = project_root
20
+ @state_dir = state_dir
14
21
  end
15
22
 
16
23
  def root = File.join(fun_ci_dir, "worktrees")
@@ -18,13 +25,25 @@ module FunCi
18
25
  # Where each daily and weekly job has a worktree of its own (Jobs::Locks).
19
26
  def jobs_root = File.join(fun_ci_dir, "jobs")
20
27
 
28
+ # Where fun-ci before 2.4 kept the worktrees: under the git directory.
29
+ def legacy_dir = File.join(common_dir, "fun-ci")
30
+
21
31
  # 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"))
32
+ # so caches such as vendor/bundle survive between runs in the same slot,
33
+ # except under the +clean+ paths when +sha+ doesn't descend from the
34
+ # slot's last commit: those are outputs only as right as the commit that
35
+ # made them. Answers whether the slot is cold: a worktree it made, which
36
+ # has no caches, or one it cleaned.
37
+ def check_out(path, sha, clean: [])
38
+ return add(path, sha).then { true } unless File.exist?(File.join(path, ".git"))
25
39
 
40
+ moved_off = clean.any? && !descends?(path, sha)
26
41
  git(path, "checkout", "--detach", "--force", sha)
27
42
  git(path, "clean", "-fd")
43
+ return false unless moved_off
44
+
45
+ git(path, "clean", "-fdX", "--", *clean)
46
+ true
28
47
  end
29
48
 
30
49
  # Has git forget the worktrees whose directories are gone.
@@ -32,8 +51,22 @@ module FunCi
32
51
 
33
52
  private
34
53
 
54
+ # <state>/projects/<repository's name>-<digest of its git directory>.
35
55
  def fun_ci_dir
36
- File.join(File.expand_path(git(@project_root, "rev-parse", "--git-common-dir").strip, @project_root), "fun-ci")
56
+ common = common_dir
57
+ name = File.basename(File.dirname(common))
58
+ File.join(@state_dir, "projects", "#{name}-#{Digest::SHA256.hexdigest(common)[0, 12]}")
59
+ end
60
+
61
+ def common_dir
62
+ File.realpath(File.expand_path(git(@project_root, "rev-parse", "--git-common-dir").strip, @project_root))
63
+ end
64
+
65
+ # Whether +sha+ descends from the commit checked out at +path+.
66
+ def descends?(path, sha)
67
+ last = git(path, "rev-parse", "HEAD").strip
68
+ _, status = Open3.capture2e(GitEnvironment::CLEAN, "git", "merge-base", "--is-ancestor", last, sha, chdir: path)
69
+ status.success?
37
70
  end
38
71
 
39
72
  def add(path, sha) = git(@project_root, "worktree", "add", "--detach", "--force", path, sha)
@@ -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
@@ -5,6 +5,7 @@ require_relative "hook_writer"
5
5
  require_relative "git_hooks"
6
6
  require_relative "setup_checker"
7
7
  require_relative "legacy_hooks"
8
+ require_relative "init_options"
8
9
 
9
10
  module FunCi
10
11
  module Setup
@@ -18,13 +19,14 @@ module FunCi
18
19
  end
19
20
 
20
21
  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([])
22
+ options = InitOptions.parse(args)
23
+ code = Installer.run(project_root: @project_root, stdout: @stdout, stack: options.stack)
24
+ return code if !code.zero? || !options.everything
25
+
26
+ hooks_then_check
27
+ rescue InitOptions::Invalid => e
28
+ @stdout.puts "fun-ci init: #{e.message}"
29
+ 64
28
30
  end
29
31
 
30
32
  def install_hooks(args)
@@ -32,10 +34,19 @@ module FunCi
32
34
  install(args.any? ? [args.first] : HookScript.types)
33
35
  end
34
36
 
35
- def check(_args) = SetupChecker.run(project_root: @project_root, stdout: @stdout)
37
+ def check(_args)
38
+ SetupChecker.run(project_root: @project_root, stdout: @stdout, hooks_dir: @hooks_dir.call(@project_root))
39
+ end
36
40
 
37
41
  private
38
42
 
43
+ def hooks_then_check
44
+ code = install_hooks([])
45
+ return code unless code.zero?
46
+
47
+ check([])
48
+ end
49
+
39
50
  def install(types)
40
51
  hooks_dir = @hooks_dir.call(@project_root)
41
52
  types.each do |type|
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "stage_templates"
5
+
6
+ module FunCi
7
+ module Setup
8
+ # The build init writes for a JavaScript project that configures ESLint:
9
+ # the template's install, then ESLint, which loads what the install put
10
+ # in place, so it can't run in lint beside it (StageTemplates). nil keeps
11
+ # the template's build.
12
+ module EslintDetection
13
+ RUNNERS = { node_npm: "npx eslint .", node_pnpm: "pnpm exec eslint .", node_yarn: "yarn eslint .",
14
+ bun: "bunx eslint ." }.freeze
15
+ CONFIGS = %w[eslint.config.js eslint.config.mjs eslint.config.cjs eslint.config.ts eslint.config.mts
16
+ eslint.config.cts .eslintrc .eslintrc.js .eslintrc.cjs .eslintrc.json .eslintrc.yml
17
+ .eslintrc.yaml].freeze
18
+
19
+ # names: the files at the project's top; package_json: its text.
20
+ def self.build(stack, names, package_json)
21
+ runner = RUNNERS[stack]
22
+ return nil unless runner && configured?(names, package_json)
23
+
24
+ "#{StageTemplates.scripts(stack)["build.sh"].lines.last.chomp} && #{runner}"
25
+ end
26
+
27
+ def self.configured?(names, package_json) = names.intersect?(CONFIGS) || in_package?(package_json)
28
+
29
+ def self.in_package?(text)
30
+ parsed = JSON.parse(text)
31
+ parsed.is_a?(Hash) && parsed.key?("eslintConfig")
32
+ rescue JSON::ParserError
33
+ false
34
+ end
35
+ private_class_method :configured?, :in_package?
36
+ end
37
+ end
38
+ end
@@ -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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # The arguments of `fun-ci init [--everything] [--stack NAME]`.
8
+ module InitOptions
9
+ class Invalid < StandardError; end
10
+
11
+ Parsed = Data.define(:everything, :stack)
12
+
13
+ def self.parse(args)
14
+ values = { everything: false, stack: nil }
15
+ left = parser(values).parse(args)
16
+ raise Invalid, "takes no #{left.join(" ")}" if left.any?
17
+
18
+ Parsed.new(**values)
19
+ rescue OptionParser::ParseError => e
20
+ raise Invalid, e.message
21
+ end
22
+
23
+ # Long options only, without OptionParser's own --help and --version.
24
+ def self.parser(values)
25
+ parser = OptionParser.new
26
+ parser.base.long.clear
27
+ parser.on("--everything") { values[:everything] = true }
28
+ parser.on("--stack NAME") { |name| values[:stack] = name }
29
+ parser
30
+ end
31
+ private_class_method :parser
32
+ end
33
+ end
34
+ end
@@ -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