fun_ci 2.3.0 → 2.4.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +6 -5
  4. data/lib/fun_ci/agent/status_text.rb +2 -2
  5. data/lib/fun_ci/cli.rb +2 -0
  6. data/lib/fun_ci/cli_command_help.rb +51 -0
  7. data/lib/fun_ci/cli_help.rb +9 -9
  8. data/lib/fun_ci/console/board_data.rb +7 -2
  9. data/lib/fun_ci/console/trunk_marks.rb +3 -1
  10. data/lib/fun_ci/evidence/about_stage.rb +1 -1
  11. data/lib/fun_ci/evidence/collector.rb +6 -3
  12. data/lib/fun_ci/evidence/outcome.rb +6 -3
  13. data/lib/fun_ci/jobs/locks.rb +1 -1
  14. data/lib/fun_ci/persistence/active_jobs.rb +2 -11
  15. data/lib/fun_ci/persistence/active_runs.rb +5 -3
  16. data/lib/fun_ci/persistence/died.rb +21 -0
  17. data/lib/fun_ci/persistence/job_recorder.rb +1 -0
  18. data/lib/fun_ci/persistence/null_recorder.rb +1 -0
  19. data/lib/fun_ci/persistence/pipeline_recorder.rb +1 -0
  20. data/lib/fun_ci/persistence/pipeline_run.rb +11 -8
  21. data/lib/fun_ci/persistence/stage_job.rb +12 -0
  22. data/lib/fun_ci/pipeline/group_kill.rb +38 -0
  23. data/lib/fun_ci/pipeline/pipeline_forker.rb +3 -1
  24. data/lib/fun_ci/pipeline/process_runner.rb +6 -1
  25. data/lib/fun_ci/pipeline/process_tree.rb +37 -0
  26. data/lib/fun_ci/pipeline/progress_reporter.rb +2 -1
  27. data/lib/fun_ci/pipeline/run_canceller.rb +15 -3
  28. data/lib/fun_ci/pipeline/stage_end.rb +2 -1
  29. data/lib/fun_ci/pipeline/workspaces.rb +2 -1
  30. data/lib/fun_ci/pipeline/worktree_pool.rb +22 -5
  31. data/lib/fun_ci/pipeline/worktree_prune.rb +9 -4
  32. data/lib/fun_ci/pipeline/worktrees.rb +38 -7
  33. data/lib/fun_ci/setup/commands.rb +16 -7
  34. data/lib/fun_ci/setup/eslint_detection.rb +38 -0
  35. data/lib/fun_ci/setup/init_options.rb +34 -0
  36. data/lib/fun_ci/setup/installer.rb +41 -7
  37. data/lib/fun_ci/setup/placeholders.rb +30 -0
  38. data/lib/fun_ci/setup/project_config.rb +1 -0
  39. data/lib/fun_ci/setup/settings.rb +21 -1
  40. data/lib/fun_ci/setup/setup_checker.rb +5 -3
  41. data/lib/fun_ci/setup/stage_templates/javascript.rb +14 -4
  42. data/lib/fun_ci/setup/stage_templates/placeholders.rb +21 -0
  43. data/lib/fun_ci/setup/stage_templates.rb +6 -4
  44. data/lib/fun_ci/setup/template_writer.rb +3 -5
  45. data/lib/fun_ci/version.rb +1 -1
  46. metadata +9 -1
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # What to kill besides a stage's process groups: each process descended
6
+ # from one of their members, by parent id, that has left for a group or
7
+ # session of its own, as Node's detached children do. The rows must be
8
+ # read before the kill, since a killed parent's children move to init.
9
+ # A double-forked daemon has moved to init already and is not reached.
10
+ module ProcessTree
11
+ # Signal targets: -pid for an escapee leading its own group, which
12
+ # takes that group with it, and the pid alone of one in any other group.
13
+ def self.escaped(groups, rows)
14
+ escapees = outside(groups, descendants(in_groups(groups, rows), rows))
15
+ leaders = escapees.select { |row| row.pgid == row.pid }.map(&:pid)
16
+ leaders.map(&:-@) + escapees.reject { |row| leaders.include?(row.pgid) }.map(&:pid)
17
+ end
18
+
19
+ def self.in_groups(groups, rows) = rows.select { |row| groups.include?(row.pgid) }
20
+ def self.outside(groups, rows) = rows.reject { |row| groups.include?(row.pgid) }
21
+
22
+ # Bounded by the table's size, so a table whose parent ids form a cycle still ends.
23
+ def self.descendants(found, rows)
24
+ rows.size.times do
25
+ more = children(found.map(&:pid), rows) - found
26
+ return found if more.empty?
27
+
28
+ found += more
29
+ end
30
+ found
31
+ end
32
+
33
+ def self.children(parents, rows) = rows.select { |row| parents.include?(row.ppid) }
34
+ private_class_method :in_groups, :outside, :descendants, :children
35
+ end
36
+ end
37
+ end
@@ -18,7 +18,8 @@ module FunCi
18
18
  end
19
19
 
20
20
  def cold_slot(seconds)
21
- @stdout.puts "fun-ci: a new worktree, whose caches are empty, so lint and build have #{seconds}s this once."
21
+ @stdout.puts "fun-ci: a cold worktree (new, just cleaned, or unused for a day), so lint and build have " \
22
+ "#{seconds}s this once."
22
23
  end
23
24
 
24
25
  def slow_launched
@@ -5,18 +5,24 @@ require_relative "../persistence/active_jobs"
5
5
  require_relative "../persistence/run_status"
6
6
  require_relative "../persistence/write_trouble"
7
7
  require_relative "slot_lock"
8
+ require_relative "process_tree"
9
+ require_relative "../evidence/process_table"
8
10
 
9
11
  module FunCi
10
12
  module Pipeline
11
13
  # Stops every process of a run. The run's own processes go first, so none
12
14
  # of them records the run as failed once its stages die; then each stage's
13
- # process group. A run whose slot nobody holds any more has died already,
15
+ # process group, and last what a stage started that left its group
16
+ # (ProcessTree). A run whose slot nobody holds any more has died already,
14
17
  # and the pids it recorded may belong to other processes now, so it is
15
18
  # left alone; so is this process.
16
19
  class RunCanceller
17
- def initialize(killer: Process.method(:kill), slot_held: SlotLock.method(:held?))
20
+ # process_table: what ps lists now, as Evidence::ProcessTable rows.
21
+ def initialize(killer: Process.method(:kill), slot_held: SlotLock.method(:held?),
22
+ process_table: Evidence::ProcessTable.method(:now))
18
23
  @killer = killer
19
24
  @slot_held = slot_held
25
+ @process_table = process_table
20
26
  end
21
27
 
22
28
  # Stops the run and records it, and its unfinished stages, cancelled.
@@ -60,11 +66,17 @@ module FunCi
60
66
  def stop(run)
61
67
  return unless alive?(run)
62
68
 
63
- (run.processes - [Process.pid] + run.stage_groups.map(&:-@)).each { |pid| kill(pid) }
69
+ (run.processes - [Process.pid] + run.stage_groups.map(&:-@) + escaped(run)).each { |pid| kill(pid) }
64
70
  end
65
71
 
66
72
  private
67
73
 
74
+ def escaped(run)
75
+ return [] if run.stage_groups.empty?
76
+
77
+ ProcessTree.escaped(run.stage_groups, @process_table.call)
78
+ end
79
+
68
80
  # A stage its process started after `run` was read. A run's process
69
81
  # records a stage's group before it lets the stage start, so once those
70
82
  # processes are dead, looking again finds every stage they started.
@@ -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)
@@ -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
 
23
- # A ColdSlot when the slot's worktree had to be made.
32
+ # A ColdSlot when the slot's worktree had to be made, or was cleaned, or
33
+ # nobody took the slot for a day.
24
34
  def acquire(sha)
25
35
  slot = wait_for_free_slot
26
- @worktrees.check_out(slot.path, sha) ? slot.as_cold : 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,15 +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
- # Answers whether it made the worktree, which then has no caches.
24
- def check_out(path, sha)
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: [])
25
38
  return add(path, sha).then { true } unless File.exist?(File.join(path, ".git"))
26
39
 
40
+ moved_off = clean.any? && !descends?(path, sha)
27
41
  git(path, "checkout", "--detach", "--force", sha)
28
42
  git(path, "clean", "-fd")
29
- false
43
+ return false unless moved_off
44
+
45
+ git(path, "clean", "-fdX", "--", *clean)
46
+ true
30
47
  end
31
48
 
32
49
  # Has git forget the worktrees whose directories are gone.
@@ -34,8 +51,22 @@ module FunCi
34
51
 
35
52
  private
36
53
 
54
+ # <state>/projects/<repository's name>-<digest of its git directory>.
37
55
  def fun_ci_dir
38
- 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?
39
70
  end
40
71
 
41
72
  def add(path, sha) = git(@project_root, "worktree", "add", "--detach", "--force", path, sha)
@@ -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)
@@ -38,6 +40,13 @@ module FunCi
38
40
 
39
41
  private
40
42
 
43
+ def hooks_then_check
44
+ code = install_hooks([])
45
+ return code unless code.zero?
46
+
47
+ check([])
48
+ end
49
+
41
50
  def install(types)
42
51
  hooks_dir = @hooks_dir.call(@project_root)
43
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
@@ -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
@@ -3,6 +3,8 @@
3
3
  require_relative "project_detector"
4
4
  require_relative "template_writer"
5
5
  require_relative "lint_detection"
6
+ require_relative "eslint_detection"
7
+ require_relative "stage_templates"
6
8
  require_relative "agent_instructions"
7
9
 
8
10
  module FunCi
@@ -14,13 +16,18 @@ module FunCi
14
16
  "Then fast.sh runs beside slow.sh, both on what build.sh built: have build.sh " \
15
17
  "compile everything they need, test code included."].freeze
16
18
 
17
- def self.run(project_root:, stdout: $stdout)
18
- new(project_root: project_root, stdout: stdout).run
19
+ PLACEHOLDERS = "Could not detect the project type, so .fun-ci/ has placeholder scripts that pass and " \
20
+ "test nothing: put the project's own commands in them, or start again with --stack NAME."
21
+
22
+ # stack: a name of ProjectDetector::STACKS to write in place of the one detected.
23
+ def self.run(project_root:, stdout: $stdout, stack: nil)
24
+ new(project_root: project_root, stdout: stdout, stack: stack).run
19
25
  end
20
26
 
21
- def initialize(project_root:, stdout:)
27
+ def initialize(project_root:, stdout:, stack:)
22
28
  @project_root = project_root
23
29
  @stdout = stdout
30
+ @stack = stack
24
31
  end
25
32
 
26
33
  # A project set up for fun-ci, new or not, also tells agents what to do.
@@ -35,10 +42,26 @@ module FunCi
35
42
  def set_up
36
43
  return report(".fun-ci/ already exists, so init left it as it is.", 0) if initialised?
37
44
 
38
- detected = ProjectDetector.new(Dir.children(@project_root)).detect
39
- return report("Could not detect project type. Create .fun-ci/ manually.", 1) if detected == :unknown
45
+ stack = chosen_stack
46
+ return unknown_stack if stack.nil?
47
+ return write_placeholders if stack == :unknown
48
+
49
+ write_templates(stack)
50
+ end
51
+
52
+ def chosen_stack
53
+ return ProjectDetector.new(Dir.children(@project_root)).detect if @stack.nil?
54
+
55
+ ProjectDetector::STACKS.keys.find { |stack| stack.to_s == @stack }
56
+ end
57
+
58
+ def unknown_stack
59
+ report("fun-ci init: no stack named #{@stack}; the stacks are #{ProjectDetector::STACKS.keys.join(", ")}", 64)
60
+ end
40
61
 
41
- write_templates(detected)
62
+ def write_placeholders
63
+ TemplateWriter.new(StageTemplates::PLACEHOLDERS, @project_root).write
64
+ report(PLACEHOLDERS, 0)
42
65
  end
43
66
 
44
67
  def tell_agents
@@ -58,11 +81,22 @@ module FunCi
58
81
 
59
82
  def write_templates(detected)
60
83
  @stdout.puts "Detected: #{detected.to_s.tr("_", " ")}"
61
- TemplateWriter.new(detected, @project_root, lint_override: LintDetection.command(detected, @project_root)).write
84
+ TemplateWriter.new(scripts_for(detected), @project_root).write
62
85
  @stdout.puts "Created .fun-ci/ with template scripts.", *SIDE_BY_SIDE
63
86
  0
64
87
  end
65
88
 
89
+ def scripts_for(detected)
90
+ StageTemplates.scripts(detected, lint_override: LintDetection.command(detected, @project_root),
91
+ build_override: EslintDetection.build(detected, Dir.children(@project_root),
92
+ package_json))
93
+ end
94
+
95
+ def package_json
96
+ path = File.join(@project_root, "package.json")
97
+ File.exist?(path) ? File.read(path) : "{}"
98
+ end
99
+
66
100
  def report(message, exit_code)
67
101
  @stdout.puts message
68
102
  exit_code
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stage_templates"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # The stage scripts in .fun-ci/ that are still init's placeholders, as
8
+ # `fun-ci check`'s warnings.
9
+ class Placeholders
10
+ STAGES = %w[lint build fast slow].freeze
11
+
12
+ def initialize(project_root)
13
+ @dir = File.join(project_root, ".fun-ci")
14
+ end
15
+
16
+ def warnings = STAGES.select { |stage| placeholder?(stage) }.map { |stage| warning(stage) }
17
+
18
+ private
19
+
20
+ def placeholder?(stage)
21
+ path = File.join(@dir, "#{stage}.sh")
22
+ File.file?(path) && File.read(path).include?(StageTemplates::MARKER)
23
+ end
24
+
25
+ def warning(stage)
26
+ ".fun-ci/#{stage}.sh is init's placeholder, so the #{stage} stage tests nothing: put the project's own in it"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -49,6 +49,7 @@ module FunCi
49
49
  def job_errors = Jobs::Folders.new(@project_root).errors
50
50
 
51
51
  def worktree_slots = settings.worktree_slots
52
+ def clean = settings.clean
52
53
  def trunk = settings.trunk
53
54
  def trunk_fetch = settings.trunk_fetch
54
55
  def job_spacing = settings.job_spacing
@@ -43,6 +43,11 @@ module FunCi
43
43
  # and the jobs share the day evenly (Jobs::DueJobs).
44
44
  def job_spacing = seconds(setting("job_spacing"))
45
45
 
46
+ # The paths, inside the project, whose git-ignored files a slot loses
47
+ # when it moves off its line of history (Pipeline::Worktrees); none
48
+ # when none are given, or what is given is no such list.
49
+ def clean = cleaned || []
50
+
46
51
  def errors
47
52
  setting_errors
48
53
  rescue Psych::SyntaxError => e
@@ -58,7 +63,7 @@ module FunCi
58
63
  def setting_errors
59
64
  return [".fun-ci/config must be a mapping such as `worktree_slots: 2`"] unless raw.is_a?(Hash)
60
65
 
61
- [slots_error, spacing_error].compact
66
+ [slots_error, spacing_error, clean_error].compact
62
67
  end
63
68
 
64
69
  # The slots the file asks for, when that is a whole number above 0.
@@ -80,6 +85,21 @@ module FunCi
80
85
  ".fun-ci/config: job_spacing must be seconds, or a number with s, m or h, not #{given.inspect}"
81
86
  end
82
87
 
88
+ def cleaned
89
+ given = setting("clean")
90
+ given if given.is_a?(Array) && given.all? { |path| inside?(path) }
91
+ end
92
+
93
+ def inside?(path) = path.is_a?(String) && !path.start_with?("/") && !path.split("/").include?("..")
94
+
95
+ def clean_error
96
+ given = setting("clean")
97
+ return nil if given.nil? || cleaned
98
+
99
+ ".fun-ci/config: clean must be a list of paths inside the project, such as [public/assets], " \
100
+ "not #{given.inspect}"
101
+ end
102
+
83
103
  def setting(key)
84
104
  raw.is_a?(Hash) ? raw[key] : nil
85
105
  rescue Psych::Exception
@@ -4,19 +4,21 @@ require_relative "project_config"
4
4
  require_relative "legacy_hooks"
5
5
  require_relative "installed_hooks"
6
6
  require_relative "trunk_report"
7
+ require_relative "placeholders"
7
8
 
8
9
  module FunCi
9
10
  module Setup
10
11
  class SetupChecker
11
- # The hooks it warns of: fun-ci 1.x's, and any in `hooks_dir` that
12
- # doesn't run fun-ci.
12
+ # What it warns of: fun-ci 1.x's hooks, any in `hooks_dir` that doesn't
13
+ # run fun-ci, and stage scripts still init's placeholders.
13
14
  Hooks = Data.define(:sources) do
14
15
  def warnings = sources.flat_map(&:warnings)
15
16
  end
16
17
 
17
18
  # hooks_dir: where git runs the project's hooks from; nil outside a git repository.
18
19
  def self.run(project_root:, stdout: $stdout, hooks_dir: nil)
19
- hooks = Hooks.new([LegacyHooks.new(project_root), InstalledHooks.new(hooks_dir)])
20
+ sources = [LegacyHooks.new(project_root), InstalledHooks.new(hooks_dir), Placeholders.new(project_root)]
21
+ hooks = Hooks.new(sources)
20
22
  new(config: ProjectConfig.new(project_root), hooks: hooks, stdout: stdout,
21
23
  trunk: TrunkReport.new(project_root)).run
22
24
  end
@@ -5,13 +5,23 @@ require_relative "shell"
5
5
  module FunCi
6
6
  module Setup
7
7
  # JavaScript and TypeScript: Deno, Bun, and Node with its package managers.
8
+ # Lint runs beside the build, which installs the packages, so it checks
9
+ # the source alone: a linter that loads them, ESLint, goes in the build
10
+ # after the install (EslintDetection). Deno's lint needs no packages.
8
11
  module StageTemplates
12
+ SOURCES = "find . -name node_modules -prune -o -name .git -prune -o -type f"
13
+ NODE_CHECK = "#{SOURCES} \\( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \\) -print | " \
14
+ 'while read -r file; do node --check "$file" || exit 1; done'.freeze
15
+ BUN_CHECK = "#{SOURCES} \\( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.ts' " \
16
+ "-o -name '*.tsx' \\) -print | " \
17
+ 'while read -r file; do bun build --no-bundle "$file" > /dev/null || exit 1; done'.freeze
18
+
9
19
  JAVASCRIPT = {
10
20
  deno: stages("deno lint", "deno check", "deno test", "deno task test:slow"),
11
- bun: stages("bunx eslint .", "bun install", "bun test", "bun run test:slow"),
12
- node_pnpm: stages("pnpm exec eslint .", "pnpm install", "pnpm test", "pnpm run test:slow"),
13
- node_yarn: stages("yarn eslint .", "yarn install", "yarn test", "yarn run test:slow"),
14
- node_npm: stages("npx eslint .", "npm install", "npm test", "npm run test:slow")
21
+ bun: stages(BUN_CHECK, "bun install", "bun test", "bun run test:slow"),
22
+ node_pnpm: stages(NODE_CHECK, "pnpm install", "pnpm test", "pnpm run test:slow"),
23
+ node_yarn: stages(NODE_CHECK, "yarn install", "yarn test", "yarn run test:slow"),
24
+ node_npm: stages(NODE_CHECK, "npm install", "npm test", "npm run test:slow")
15
25
  }.freeze
16
26
  end
17
27
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shell"
4
+
5
+ module FunCi
6
+ module Setup
7
+ # The scripts `fun-ci init` writes where it knows no stack: each passes,
8
+ # says it tests nothing, and carries MARKER, by which `fun-ci check` warns
9
+ # of it until the project's own command takes its place.
10
+ module StageTemplates
11
+ MARKER = "# fun-ci placeholder"
12
+
13
+ def self.placeholder(stage)
14
+ "#{MARKER}: init knew no stack here, so put the project's #{stage} in place of this.\n" \
15
+ "echo \"fun-ci: .fun-ci/#{stage}.sh is init's placeholder and tests nothing\""
16
+ end
17
+
18
+ PLACEHOLDERS = stages(*%w[lint build fast slow].map { |stage| placeholder(stage) }).freeze
19
+ end
20
+ end
21
+ end