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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +141 -0
- data/README.md +19 -11
- data/lib/fun_ci/agent/cancel_command.rb +52 -0
- data/lib/fun_ci/agent/commands.rb +3 -1
- data/lib/fun_ci/agent/job_events.rb +26 -7
- data/lib/fun_ci/agent/job_json.rb +10 -1
- data/lib/fun_ci/agent/job_report.rb +10 -6
- data/lib/fun_ci/agent/job_why_text.rb +11 -3
- data/lib/fun_ci/agent/jobs_text.rb +8 -3
- data/lib/fun_ci/agent/live_pipeline.rb +7 -4
- data/lib/fun_ci/agent/snapshots.rb +5 -1
- data/lib/fun_ci/agent/starts_in.rb +14 -0
- data/lib/fun_ci/agent/status_json.rb +1 -1
- data/lib/fun_ci/agent/status_text.rb +15 -3
- data/lib/fun_ci/agent/trunk_reading.rb +2 -1
- data/lib/fun_ci/agent/wait_command.rb +2 -1
- data/lib/fun_ci/cli.rb +3 -1
- data/lib/fun_ci/cli_command_help.rb +51 -0
- data/lib/fun_ci/cli_help.rb +11 -10
- data/lib/fun_ci/console/board_data.rb +7 -2
- data/lib/fun_ci/console/job_message.rb +12 -6
- data/lib/fun_ci/console/job_order.rb +5 -4
- data/lib/fun_ci/console/key_handler.rb +3 -1
- data/lib/fun_ci/console/trunk_marks.rb +3 -1
- data/lib/fun_ci/evidence/about_stage.rb +1 -1
- data/lib/fun_ci/evidence/collector.rb +6 -3
- data/lib/fun_ci/evidence/outcome.rb +6 -3
- data/lib/fun_ci/evidence/settings.rb +1 -1
- data/lib/fun_ci/jobs/due.rb +4 -3
- data/lib/fun_ci/jobs/due_jobs.rb +26 -2
- data/lib/fun_ci/jobs/job_fork.rb +19 -12
- data/lib/fun_ci/jobs/job_run.rb +26 -7
- data/lib/fun_ci/jobs/locks.rb +1 -1
- data/lib/fun_ci/jobs/schedule.rb +25 -0
- data/lib/fun_ci/jobs/site.rb +10 -3
- data/lib/fun_ci/jobs/standings.rb +4 -0
- data/lib/fun_ci/jobs/state.rb +3 -2
- data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
- data/lib/fun_ci/persistence/active_jobs.rb +15 -18
- data/lib/fun_ci/persistence/active_runs.rb +5 -3
- data/lib/fun_ci/persistence/died.rb +21 -0
- data/lib/fun_ci/persistence/job_recorder.rb +1 -0
- data/lib/fun_ci/persistence/job_runs.rb +12 -0
- data/lib/fun_ci/persistence/null_recorder.rb +1 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +1 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +13 -9
- data/lib/fun_ci/persistence/stage_job.rb +12 -0
- data/lib/fun_ci/pipeline/budgets.rb +9 -0
- data/lib/fun_ci/pipeline/group_kill.rb +38 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +14 -9
- data/lib/fun_ci/pipeline/priorities.rb +23 -0
- data/lib/fun_ci/pipeline/process_runner.rb +13 -6
- data/lib/fun_ci/pipeline/process_tree.rb +37 -0
- data/lib/fun_ci/pipeline/progress_reporter.rb +5 -0
- data/lib/fun_ci/pipeline/run_canceller.rb +15 -3
- data/lib/fun_ci/pipeline/slot.rb +11 -0
- data/lib/fun_ci/pipeline/slot_run.rb +19 -4
- data/lib/fun_ci/pipeline/stage_end.rb +2 -1
- data/lib/fun_ci/pipeline/trigger.rb +17 -6
- data/lib/fun_ci/pipeline/trigger_command.rb +6 -2
- data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
- data/lib/fun_ci/pipeline/workspaces.rb +2 -1
- data/lib/fun_ci/pipeline/worktree_pool.rb +22 -5
- data/lib/fun_ci/pipeline/worktree_prune.rb +9 -4
- data/lib/fun_ci/pipeline/worktrees.rb +39 -6
- data/lib/fun_ci/setup/agent_instructions.rb +21 -5
- data/lib/fun_ci/setup/commands.rb +19 -8
- data/lib/fun_ci/setup/eslint_detection.rb +38 -0
- data/lib/fun_ci/setup/hook_script.rb +9 -4
- data/lib/fun_ci/setup/hook_writer.rb +13 -5
- data/lib/fun_ci/setup/init_options.rb +34 -0
- data/lib/fun_ci/setup/installed_hooks.rb +45 -0
- data/lib/fun_ci/setup/installer.rb +44 -9
- data/lib/fun_ci/setup/lfs_hook.rb +50 -0
- data/lib/fun_ci/setup/placeholders.rb +30 -0
- data/lib/fun_ci/setup/project_config.rb +8 -1
- data/lib/fun_ci/setup/settings.rb +59 -10
- data/lib/fun_ci/setup/setup_checker.rb +16 -3
- data/lib/fun_ci/setup/stage_templates/javascript.rb +14 -4
- data/lib/fun_ci/setup/stage_templates/placeholders.rb +21 -0
- data/lib/fun_ci/setup/stage_templates.rb +6 -4
- data/lib/fun_ci/setup/template_writer.rb +3 -5
- data/lib/fun_ci/trunk/shown.rb +7 -4
- data/lib/fun_ci/version.rb +1 -1
- data/libexec/fun-ci-renderer +0 -0
- 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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
38
|
-
|
|
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,
|
|
46
|
-
environment: ENV.to_h, clock:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
38
|
+
merged = text.include?(MARKER) ? replaced(text) : added(text)
|
|
39
|
+
merged unless merged == text
|
|
40
|
+
end
|
|
32
41
|
|
|
33
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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)
|
|
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 !
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
42
|
+
script = existing
|
|
43
|
+
!script.empty? && !HookScript.managed?(script) && !LfsHook.stock?(script, @hook_type)
|
|
38
44
|
end
|
|
39
45
|
|
|
40
|
-
def
|
|
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,
|
|
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
|