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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +15 -8
- 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 +1 -1
- data/lib/fun_ci/cli_help.rb +2 -1
- 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/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/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 +13 -7
- data/lib/fun_ci/persistence/job_runs.rb +12 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +2 -1
- data/lib/fun_ci/pipeline/budgets.rb +9 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +11 -8
- data/lib/fun_ci/pipeline/priorities.rb +23 -0
- data/lib/fun_ci/pipeline/process_runner.rb +7 -5
- data/lib/fun_ci/pipeline/progress_reporter.rb +4 -0
- data/lib/fun_ci/pipeline/slot.rb +11 -0
- data/lib/fun_ci/pipeline/slot_run.rb +19 -4
- 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/worktree_pool.rb +2 -2
- data/lib/fun_ci/pipeline/worktrees.rb +3 -1
- data/lib/fun_ci/setup/agent_instructions.rb +21 -5
- data/lib/fun_ci/setup/commands.rb +3 -1
- 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/installed_hooks.rb +45 -0
- data/lib/fun_ci/setup/installer.rb +3 -2
- data/lib/fun_ci/setup/lfs_hook.rb +50 -0
- data/lib/fun_ci/setup/project_config.rb +7 -1
- data/lib/fun_ci/setup/settings.rb +40 -11
- data/lib/fun_ci/setup/setup_checker.rb +14 -3
- data/lib/fun_ci/trunk/shown.rb +7 -4
- data/lib/fun_ci/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
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
|
|
@@ -32,7 +32,9 @@ module FunCi
|
|
|
32
32
|
install(args.any? ? [args.first] : HookScript.types)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def check(_args)
|
|
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 !
|
|
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,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
|
-
|
|
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
|
|
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) }
|
|
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
|
|
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 =
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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::
|
|
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
|
-
|
|
11
|
-
|
|
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 =
|
|
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? }
|
data/lib/fun_ci/trunk/shown.rb
CHANGED
|
@@ -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
|
|
12
|
-
# merge are past their time, and then
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|