fun_ci 2.0.2-aarch64-linux → 2.1.1-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 +84 -0
- data/README.md +34 -8
- data/lib/fun_ci/agent/command_support.rb +14 -1
- data/lib/fun_ci/agent/commands.rb +1 -0
- data/lib/fun_ci/agent/context.rb +3 -2
- data/lib/fun_ci/agent/events.rb +19 -5
- data/lib/fun_ci/agent/events_command.rb +1 -1
- data/lib/fun_ci/agent/exit_code.rb +1 -1
- data/lib/fun_ci/agent/next_step.rb +26 -0
- data/lib/fun_ci/agent/options.rb +6 -4
- data/lib/fun_ci/agent/output.rb +15 -2
- data/lib/fun_ci/agent/report_stage.rb +1 -1
- data/lib/fun_ci/agent/reports.rb +13 -9
- data/lib/fun_ci/agent/run_details.rb +31 -0
- data/lib/fun_ci/agent/run_report.rb +4 -3
- data/lib/fun_ci/agent/runs_text.rb +7 -4
- data/lib/fun_ci/agent/snapshots.rb +13 -3
- data/lib/fun_ci/agent/status_command.rb +2 -2
- data/lib/fun_ci/agent/status_json.rb +2 -1
- data/lib/fun_ci/agent/status_text.rb +13 -2
- data/lib/fun_ci/agent/trunk_json.rb +32 -0
- data/lib/fun_ci/agent/trunk_reading.rb +53 -0
- data/lib/fun_ci/agent/trunk_text.rb +50 -0
- data/lib/fun_ci/agent/trunk_verdict.rb +20 -0
- data/lib/fun_ci/agent/trunk_why.rb +52 -0
- data/lib/fun_ci/agent/trunk_why_json.rb +23 -0
- data/lib/fun_ci/agent/wait_command.rb +5 -5
- data/lib/fun_ci/agent/why_command.rb +11 -0
- data/lib/fun_ci/cli.rb +1 -1
- data/lib/fun_ci/cli_help.rb +5 -3
- data/lib/fun_ci/console/board_data.rb +22 -8
- data/lib/fun_ci/console/cancelled_folding.rb +19 -0
- data/lib/fun_ci/console/console_events.rb +5 -2
- data/lib/fun_ci/console/console_log.rb +4 -1
- data/lib/fun_ci/console/console_state.rb +7 -1
- data/lib/fun_ci/console/row_order.rb +31 -0
- data/lib/fun_ci/console/run_message.rb +9 -2
- data/lib/fun_ci/console/trunk_events.rb +43 -0
- data/lib/fun_ci/console/trunk_marks.rb +53 -0
- data/lib/fun_ci/console/view.rb +9 -8
- data/lib/fun_ci/evidence/caps.rb +7 -2
- data/lib/fun_ci/persistence/active_runs.rb +5 -4
- data/lib/fun_ci/persistence/database.rb +23 -2
- data/lib/fun_ci/persistence/null_recorder.rb +8 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +3 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +24 -3
- data/lib/fun_ci/persistence/trunk_checks.rb +68 -0
- data/lib/fun_ci/persistence/trunk_fetches.rb +57 -0
- data/lib/fun_ci/persistence/trunk_recording.rb +49 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +17 -2
- data/lib/fun_ci/pipeline/process_runner.rb +18 -4
- data/lib/fun_ci/pipeline/trigger.rb +10 -1
- data/lib/fun_ci/pipeline/trigger_command.rb +9 -5
- data/lib/fun_ci/pipeline/trigger_params.rb +5 -3
- data/lib/fun_ci/pipeline/trunk_run.rb +81 -0
- data/lib/fun_ci/setup/agent_instructions.rb +3 -0
- data/lib/fun_ci/setup/project_config.rb +3 -1
- data/lib/fun_ci/setup/settings.rb +35 -3
- data/lib/fun_ci/setup/setup_checker.rb +7 -2
- data/lib/fun_ci/setup/trunk_report.rb +28 -0
- data/lib/fun_ci/trunk/check.rb +56 -0
- data/lib/fun_ci/trunk/checker.rb +91 -0
- data/lib/fun_ci/trunk/description.rb +52 -0
- data/lib/fun_ci/trunk/fetch.rb +58 -0
- data/lib/fun_ci/trunk/first_fetch.rb +27 -0
- data/lib/fun_ci/trunk/git.rb +102 -0
- data/lib/fun_ci/trunk/local.rb +58 -0
- data/lib/fun_ci/trunk/merge_check.rb +54 -0
- data/lib/fun_ci/trunk/regions.rb +38 -0
- data/lib/fun_ci/trunk/resolver.rb +64 -0
- data/lib/fun_ci/trunk/shown.rb +47 -0
- data/lib/fun_ci/trunk/tip_reader.rb +39 -0
- data/lib/fun_ci/version.rb +1 -1
- data/libexec/fun-ci-renderer +0 -0
- metadata +30 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../trunk/check"
|
|
4
|
+
require_relative "../trunk/checker"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Pipeline
|
|
8
|
+
# A run's check against the trunk (architecture.md, Checking against the
|
|
9
|
+
# trunk). It begins before the stages, where the database may be used
|
|
10
|
+
# (claiming a fetch, recording its process, reading the other branches'
|
|
11
|
+
# heads); finishes in a thread beside them, which touches no database,
|
|
12
|
+
# since the slow suite's fork closes the recorder, checking the other
|
|
13
|
+
# heads too when the trunk has moved; and is recorded once the stages are
|
|
14
|
+
# done, through whichever recorder the run holds then. Whatever goes wrong
|
|
15
|
+
# is recorded as an unknown check rather than raised into the run.
|
|
16
|
+
class TrunkRun
|
|
17
|
+
Result = Trunk::Checker::Result
|
|
18
|
+
# What the thread found: the run's own result, and the other heads' checks.
|
|
19
|
+
Found = Data.define(:result, :rechecks)
|
|
20
|
+
|
|
21
|
+
# For a project that checks no trunk.
|
|
22
|
+
class Skipped
|
|
23
|
+
def finish(_recorder) = nil
|
|
24
|
+
def notice = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# trunk: begins a check (#start(sha, fetches), nil for none), finishes it
|
|
28
|
+
# (#finish(pending)) and checks other heads against a tip (#recheck(heads, tip)).
|
|
29
|
+
def self.start(trunk, sha, recorder)
|
|
30
|
+
pending = trunk.start(sha, recorder.trunk_fetches) { |pid| recorder.trunk_fetch_process(pid) }
|
|
31
|
+
return Skipped.new unless pending
|
|
32
|
+
|
|
33
|
+
recorder.trunk_check_started
|
|
34
|
+
heads = recorder.trunk_heads(except: sha)
|
|
35
|
+
new(Thread.new { found(trunk, pending, sha, heads) }, trunk.notice(pending))
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
new(Thread.new { Found.new(result: failed(sha, e), rechecks: []) })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.found(trunk, pending, sha, heads)
|
|
41
|
+
rechecked(trunk, heads, Found.new(result: finished(trunk, pending, sha), rechecks: []))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.finished(trunk, pending, sha)
|
|
45
|
+
trunk.finish(pending)
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
failed(sha, e)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A recheck that goes wrong costs the other heads a fresher check, never the run its own.
|
|
51
|
+
def self.rechecked(trunk, heads, found)
|
|
52
|
+
tip = found.result.check&.tip
|
|
53
|
+
tip ? found.with(rechecks: trunk.recheck(heads, tip)) : found
|
|
54
|
+
rescue StandardError
|
|
55
|
+
found
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.failed(sha, error)
|
|
59
|
+
check = Trunk::Check.new(commit: sha, tip: nil,
|
|
60
|
+
merge: Trunk::Merge.unknown("the check failed: #{error.message}"))
|
|
61
|
+
Result.new(check: check, fetched: nil)
|
|
62
|
+
end
|
|
63
|
+
private_class_method :found, :finished, :rechecked, :failed
|
|
64
|
+
|
|
65
|
+
attr_reader :notice
|
|
66
|
+
|
|
67
|
+
# notice: what to tell the developer of a first fetch, or nil.
|
|
68
|
+
def initialize(thread, notice = nil)
|
|
69
|
+
@thread = thread
|
|
70
|
+
@notice = notice
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def finish(recorder)
|
|
74
|
+
found = @thread.value
|
|
75
|
+
result = found.result
|
|
76
|
+
recorder.trunk_fetched(result.fetched, result.check&.tip) if result.fetched
|
|
77
|
+
[result.check, *found.rechecks].compact.each { |check| recorder.trunk_checked(check) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -15,6 +15,9 @@ module FunCi
|
|
|
15
15
|
`fun-ci wait` command it prints, in the background, and act on its exit
|
|
16
16
|
code: 0 passed, 1 failed, 2 over budget, 3 undecided, 4 superseded.
|
|
17
17
|
`fun-ci wait --need all` must exit 0 before the work is called done.
|
|
18
|
+
Before calling work done, run `fun-ci wait --need all --trunk`; 6 means
|
|
19
|
+
the commit conflicts with the trunk, which has to be integrated first,
|
|
20
|
+
as it prints.
|
|
18
21
|
`fun-ci runs` lists recent runs, and `fun-ci status` says where one stands.
|
|
19
22
|
MD
|
|
20
23
|
|
|
@@ -38,6 +38,8 @@ module FunCi
|
|
|
38
38
|
def any_project_presets = preset_candidates.reject(&:because).map { |found| found.preset.name }
|
|
39
39
|
|
|
40
40
|
def worktree_slots = settings.worktree_slots
|
|
41
|
+
def trunk = settings.trunk
|
|
42
|
+
def trunk_fetch = settings.trunk_fetch
|
|
41
43
|
|
|
42
44
|
def script_path(stage)
|
|
43
45
|
File.join(@fun_ci_dir, "#{stage}.sh")
|
|
@@ -49,7 +51,7 @@ module FunCi
|
|
|
49
51
|
|
|
50
52
|
private
|
|
51
53
|
|
|
52
|
-
def settings = Settings.
|
|
54
|
+
def settings = Settings.at(File.join(@fun_ci_dir, "config"))
|
|
53
55
|
|
|
54
56
|
def preset_candidates
|
|
55
57
|
evidence = Evidence::Settings.load(File.join(@fun_ci_dir, "config"))
|
|
@@ -9,13 +9,34 @@ module FunCi
|
|
|
9
9
|
# as are all of them when the file isn't YAML.
|
|
10
10
|
class Settings
|
|
11
11
|
DEFAULTS = { "worktree_slots" => 2 }.freeze
|
|
12
|
+
FETCH_EVERY = 300
|
|
13
|
+
UNITS = { "" => 1, "s" => 1, "m" => 60, "h" => 3600 }.freeze
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
# The settings in the file at `path`, which may not exist.
|
|
16
|
+
def self.at(path) = new(File.exist?(path) ? File.read(path) : nil)
|
|
17
|
+
|
|
18
|
+
# text: the YAML, or nil for none.
|
|
19
|
+
def initialize(text)
|
|
20
|
+
@text = text
|
|
15
21
|
end
|
|
16
22
|
|
|
17
23
|
def worktree_slots = errors.empty? ? values.fetch("worktree_slots") : DEFAULTS.fetch("worktree_slots")
|
|
18
24
|
|
|
25
|
+
# The trunk's name, or nil when none is given or it is no name. A mistake
|
|
26
|
+
# in the trunk settings never stops a pipeline, so none is among `errors`.
|
|
27
|
+
def trunk
|
|
28
|
+
given = setting("trunk")
|
|
29
|
+
given.is_a?(String) ? given : nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Seconds between fetches of the trunk, or nil when fun-ci doesn't fetch it.
|
|
33
|
+
def trunk_fetch
|
|
34
|
+
given = setting("trunk_fetch")
|
|
35
|
+
return nil if given == false
|
|
36
|
+
|
|
37
|
+
seconds(given) || FETCH_EVERY
|
|
38
|
+
end
|
|
39
|
+
|
|
19
40
|
def errors
|
|
20
41
|
return [".fun-ci/config must be a mapping such as `worktree_slots: 2`"] unless raw.is_a?(Hash)
|
|
21
42
|
|
|
@@ -29,7 +50,18 @@ module FunCi
|
|
|
29
50
|
|
|
30
51
|
private
|
|
31
52
|
|
|
32
|
-
def
|
|
53
|
+
def setting(key)
|
|
54
|
+
raw.is_a?(Hash) ? raw[key] : nil
|
|
55
|
+
rescue Psych::SyntaxError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def seconds(given)
|
|
60
|
+
match = /\A(\d+)([smh]?)\z/.match(given.to_s)
|
|
61
|
+
match && (match[1].to_i * UNITS.fetch(match[2]))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def raw = (@text && YAML.safe_load(@text)) || {}
|
|
33
65
|
def values = DEFAULTS.merge(raw)
|
|
34
66
|
end
|
|
35
67
|
end
|
|
@@ -2,24 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "project_config"
|
|
4
4
|
require_relative "legacy_hooks"
|
|
5
|
+
require_relative "trunk_report"
|
|
5
6
|
|
|
6
7
|
module FunCi
|
|
7
8
|
module Setup
|
|
8
9
|
class SetupChecker
|
|
9
10
|
def self.run(project_root:, stdout: $stdout)
|
|
10
|
-
new(config: ProjectConfig.new(project_root), hooks: LegacyHooks.new(project_root), stdout: stdout
|
|
11
|
+
new(config: ProjectConfig.new(project_root), hooks: LegacyHooks.new(project_root), stdout: stdout,
|
|
12
|
+
trunk: TrunkReport.new(project_root)).run
|
|
11
13
|
end
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
# trunk: describes the project's trunk (#lines).
|
|
16
|
+
def initialize(config:, hooks:, stdout:, trunk:)
|
|
14
17
|
@config = config
|
|
15
18
|
@hooks = hooks
|
|
16
19
|
@stdout = stdout
|
|
20
|
+
@trunk = trunk
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
# Warnings are printed but don't fail the check.
|
|
20
24
|
def run
|
|
21
25
|
errors = @config.validate + @config.evidence_errors
|
|
22
26
|
@stdout.puts(errors.empty? ? "All OK. The project is configured." : errors)
|
|
27
|
+
@stdout.puts(@trunk.lines)
|
|
23
28
|
list_presets
|
|
24
29
|
@hooks.warnings.each { |warning| @stdout.puts "Warning: #{warning}" }
|
|
25
30
|
errors.empty? ? 0 : 1
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "project_config"
|
|
4
|
+
require_relative "../trunk/description"
|
|
5
|
+
require_relative "../trunk/git"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Setup
|
|
9
|
+
# The project's trunk as `fun-ci check` describes it. Git is asked only
|
|
10
|
+
# inside a repository: outside one there is no trunk to find.
|
|
11
|
+
class TrunkReport
|
|
12
|
+
NO_REFS = Trunk::Refs.new(remotes: [], branches: [], heads: {})
|
|
13
|
+
|
|
14
|
+
def initialize(project_root)
|
|
15
|
+
@project_root = project_root
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def lines
|
|
19
|
+
config = ProjectConfig.new(@project_root)
|
|
20
|
+
Trunk::Description.lines(setting: config.trunk, interval: config.trunk_fetch, refs: refs)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def refs = File.exist?(File.join(@project_root, ".git")) ? Trunk::Git.new(@project_root).refs : NO_REFS
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Trunk
|
|
5
|
+
# Seconds a run's fetch of the trunk may take, and then its merge.
|
|
6
|
+
FETCH_DEADLINE = 20
|
|
7
|
+
CHECK_BUDGET = 5
|
|
8
|
+
|
|
9
|
+
# The trunk as fun-ci saw it: its branch, on a remote or (remote nil)
|
|
10
|
+
# local, the SHA at its tip, and when fun-ci first saw that SHA.
|
|
11
|
+
Tip = Data.define(:remote, :branch, :sha, :seen_at)
|
|
12
|
+
|
|
13
|
+
class Tip
|
|
14
|
+
def ref = remote ? "#{remote}/#{branch}" : branch
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# What merging a commit with a trunk tip would do: ahead and behind count
|
|
18
|
+
# the commits each has that the other lacks, and outcome is "clean",
|
|
19
|
+
# "conflicts" (in `files`) or "unknown" (for `reason`).
|
|
20
|
+
Merge = Data.define(:outcome, :ahead, :behind, :files, :reason)
|
|
21
|
+
|
|
22
|
+
class Merge
|
|
23
|
+
def self.clean(ahead:, behind:) = new(outcome: "clean", ahead: ahead, behind: behind, files: [], reason: nil)
|
|
24
|
+
|
|
25
|
+
def self.conflicts(files, ahead:, behind:)
|
|
26
|
+
new(outcome: "conflicts", ahead: ahead, behind: behind, files: files, reason: nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.unknown(reason) = new(outcome: "unknown", ahead: nil, behind: nil, files: [], reason: reason)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The last fetch of a project's trunk fun-ci tried: when one last
|
|
33
|
+
# succeeded (nil if none has), and why the last one failed, if it did.
|
|
34
|
+
LastFetch = Data.define(:fetched_at, :error)
|
|
35
|
+
|
|
36
|
+
# A conflict with the trunk explained: git's messages about the merge, and
|
|
37
|
+
# each conflicted file as the merge leaves it, by path.
|
|
38
|
+
Explained = Data.define(:messages, :files)
|
|
39
|
+
|
|
40
|
+
# What merge-tree prints: the merged tree's SHA, the conflicted paths, and
|
|
41
|
+
# after a blank line git's messages.
|
|
42
|
+
MergeOutput = Data.define(:tree, :paths, :messages)
|
|
43
|
+
|
|
44
|
+
class MergeOutput
|
|
45
|
+
def self.parse(out)
|
|
46
|
+
lines = out.lines(chomp: true)
|
|
47
|
+
paths = lines.drop(1).take_while { |line| !line.empty? }
|
|
48
|
+
new(tree: lines.first, paths: paths, messages: lines.drop(paths.size + 2).reject(&:empty?))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One check of a commit against one trunk tip (architecture.md, Checking against the trunk);
|
|
53
|
+
# the tip is nil when there was none to check against.
|
|
54
|
+
Check = Data.define(:commit, :tip, :merge)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../setup/project_config"
|
|
4
|
+
require_relative "check"
|
|
5
|
+
require_relative "fetch"
|
|
6
|
+
require_relative "git"
|
|
7
|
+
require_relative "merge_check"
|
|
8
|
+
require_relative "resolver"
|
|
9
|
+
require_relative "tip_reader"
|
|
10
|
+
require_relative "description"
|
|
11
|
+
|
|
12
|
+
module FunCi
|
|
13
|
+
module Trunk
|
|
14
|
+
# Checks a commit against the project's trunk (architecture.md, Checking against the trunk):
|
|
15
|
+
# the ref `trunk:` names in .fun-ci/config, or the one the repository
|
|
16
|
+
# has; none at all when it says `none`. A check starts where the database
|
|
17
|
+
# may be used (resolving the trunk, claiming a fetch, recording its pid)
|
|
18
|
+
# and finishes where it may not (waiting for the fetch, merging).
|
|
19
|
+
class Checker
|
|
20
|
+
SET_IT = "set trunk: in .fun-ci/config"
|
|
21
|
+
|
|
22
|
+
# What a check found, or nil for none, and how its fetch went, or nil when it made none.
|
|
23
|
+
Result = Data.define(:check, :fetched)
|
|
24
|
+
# A check begun: the trunk it found (nil for none), its fetch if started, and the fetch before it.
|
|
25
|
+
Pending = Data.define(:sha, :ref, :started, :last)
|
|
26
|
+
|
|
27
|
+
def self.for(project, env: ENV.to_h)
|
|
28
|
+
new(Git.new(project), Setup::ProjectConfig.new(project), Fetch.new(project, env: env), -> { Time.now })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# config: answers #trunk and #trunk_fetch; fetch: starts and finishes a Fetch; clock: answers the time.
|
|
32
|
+
def initialize(git, config, fetch, clock)
|
|
33
|
+
@git = git
|
|
34
|
+
@config = config
|
|
35
|
+
@fetch = fetch
|
|
36
|
+
@clock = clock
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# fetches: claims a fetch and knows the last (Persistence::TrunkFetches). Yields a fetch's pid.
|
|
40
|
+
# Nil when the project checks no trunk.
|
|
41
|
+
def start(sha, fetches, &)
|
|
42
|
+
setting = @config.trunk
|
|
43
|
+
return nil if setting == "none"
|
|
44
|
+
|
|
45
|
+
ref = Resolver.pick(setting, @git.refs)
|
|
46
|
+
started = fetch?(ref, fetches) ? @fetch.start(ref, &) : nil
|
|
47
|
+
Pending.new(sha: sha, ref: ref, started: started, last: fetches.last)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# What to tell the developer of a project's first fetch, so it never comes as a surprise; else nil.
|
|
51
|
+
def notice(pending)
|
|
52
|
+
return nil unless pending&.started && pending.last.nil?
|
|
53
|
+
|
|
54
|
+
Description.first_fetch(pending.ref, @config.trunk_fetch)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def finish(pending)
|
|
58
|
+
return Result.new(check: unknown(pending.sha, "no trunk found; #{SET_IT}"), fetched: nil) unless pending.ref
|
|
59
|
+
|
|
60
|
+
fetched = pending.started && @fetch.finish(pending.started, deadline: FETCH_DEADLINE)
|
|
61
|
+
Result.new(check: against(pending, fetched), fetched: fetched)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# heads: { commit => trunk SHA it was last checked against }. Each checked
|
|
65
|
+
# against another SHA is checked against `tip`, which it hasn't met yet.
|
|
66
|
+
def recheck(heads, tip)
|
|
67
|
+
heads.reject { |_, sha| sha == tip.sha }.keys.map do |commit|
|
|
68
|
+
Check.new(commit: commit, tip: tip, merge: MergeCheck.new(@git).merge(commit, tip.sha, tip.ref))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def fetch?(ref, fetches)
|
|
75
|
+
interval = @config.trunk_fetch
|
|
76
|
+
ref&.remote && interval && fetches.claim(now: @clock.call, interval: interval)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def against(pending, fetched)
|
|
80
|
+
ref = pending.ref
|
|
81
|
+
tip = TipReader.new(@git, @clock).tip(ref, fetching: !@config.trunk_fetch.nil?, fetched: fetched,
|
|
82
|
+
last: pending.last)
|
|
83
|
+
return unknown(pending.sha, "#{ref} doesn't exist; #{SET_IT}") unless tip
|
|
84
|
+
|
|
85
|
+
Check.new(commit: pending.sha, tip: tip, merge: MergeCheck.new(@git).merge(pending.sha, tip.sha, ref.to_s))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def unknown(sha, reason) = Check.new(commit: sha, tip: nil, merge: Merge.unknown(reason))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resolver"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Trunk
|
|
7
|
+
# What `fun-ci check` says about the trunk (design.md, The
|
|
8
|
+
# trunk): which ref it is, why that one, and how fun-ci keeps it fresh,
|
|
9
|
+
# so the network access never comes as a surprise; or a warning, which
|
|
10
|
+
# never fails the check, when there is none.
|
|
11
|
+
module Description
|
|
12
|
+
WHY = { configured: "trunk: in .fun-ci/config", default: "%s's default branch",
|
|
13
|
+
usual: "the first usual trunk name on %s", local: "a local branch" }.freeze
|
|
14
|
+
NONE = "Trunk: none (trunk: none in .fun-ci/config), so runs check none."
|
|
15
|
+
NOT_FOUND = "Warning: no trunk found, so runs check none; set trunk: in .fun-ci/config"
|
|
16
|
+
UNITS = [[3600, "hour"], [60, "minute"], [1, "second"]].freeze
|
|
17
|
+
|
|
18
|
+
# interval: seconds between fetches, nil when fun-ci doesn't fetch.
|
|
19
|
+
def self.lines(setting:, interval:, refs:)
|
|
20
|
+
return [NONE] if setting == "none"
|
|
21
|
+
|
|
22
|
+
ref = Resolver.pick(setting, refs)
|
|
23
|
+
return [NOT_FOUND] unless ref
|
|
24
|
+
|
|
25
|
+
["Trunk: #{ref} (#{WHY.fetch(Resolver.why(setting, refs)).sub("%s",
|
|
26
|
+
ref.remote.to_s)}).#{fetching(ref, interval)}"]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# What a project's first fetch says, so it never comes as a surprise.
|
|
30
|
+
def self.first_fetch(ref, interval)
|
|
31
|
+
"fun-ci: fetching #{ref} into refs/fun-ci/ now and at most every #{every(interval)}, " \
|
|
32
|
+
"touching none of your refs; set trunk_fetch: false in .fun-ci/config to stop."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.fetching(ref, interval)
|
|
36
|
+
return "" unless ref.remote
|
|
37
|
+
return " fun-ci doesn't fetch it (trunk_fetch: false)." unless interval
|
|
38
|
+
|
|
39
|
+
" fun-ci fetches it in the background, at most every #{every(interval)}; " \
|
|
40
|
+
"set trunk_fetch: false in .fun-ci/config to stop."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# "5 minutes", "1 hour".
|
|
44
|
+
def self.every(seconds)
|
|
45
|
+
size, unit = UNITS.find { |unit_seconds, _| (seconds % unit_seconds).zero? }
|
|
46
|
+
count = seconds / size
|
|
47
|
+
"#{count} #{unit}#{"s" unless count == 1}"
|
|
48
|
+
end
|
|
49
|
+
private_class_method :fetching
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require_relative "../pipeline/process_runner"
|
|
5
|
+
require_relative "resolver"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Trunk
|
|
9
|
+
# How a fetch of the trunk went: nil error when it fetched.
|
|
10
|
+
Fetched = Data.define(:error)
|
|
11
|
+
|
|
12
|
+
# Fetches the trunk into a ref of fun-ci's own (architecture.md, Checking against the trunk,
|
|
13
|
+
# Keeping the trunk fresh). An empty refmap keeps git from moving the
|
|
14
|
+
# developer's remote-tracking ref too, and nothing may ask them anything:
|
|
15
|
+
# ssh runs in batch mode unless they have an ssh command of their own.
|
|
16
|
+
# Started in one thread, so its pid can be recorded, and finished in
|
|
17
|
+
# another, killed with its process group at the deadline.
|
|
18
|
+
class Fetch
|
|
19
|
+
QUIET = { "GIT_TERMINAL_PROMPT" => "0", "SSH_ASKPASS_REQUIRE" => "never", "GCM_INTERACTIVE" => "never" }.freeze
|
|
20
|
+
NO_PROMPT_SSH = 'if [ -z "$GIT_SSH_COMMAND" ] && ! git config --get core.sshCommand >/dev/null; then ' \
|
|
21
|
+
'export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=10"; fi; '
|
|
22
|
+
OPTIONS = %w[--quiet --no-tags --no-write-fetch-head --no-recurse-submodules --no-auto-maintenance
|
|
23
|
+
--refmap=].freeze
|
|
24
|
+
|
|
25
|
+
def self.ref_for(ref) = "refs/fun-ci/trunk/#{ref.remote}/#{ref.branch}"
|
|
26
|
+
|
|
27
|
+
# Why a fetch failed, from what git printed: its fatal line, else its first, else that it failed.
|
|
28
|
+
def self.reason(output)
|
|
29
|
+
lines = output.lines.map(&:strip).reject(&:empty?)
|
|
30
|
+
(lines.find { |line| line.start_with?("fatal:") } || lines.first || "git fetch failed").to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# env: what the fetch finds in its environment besides fun-ci's own.
|
|
34
|
+
def initialize(dir, env:)
|
|
35
|
+
@launch = Pipeline::ProcessRunner::Launch.new(chdir: dir, env: QUIET.merge(env))
|
|
36
|
+
@runner = Object.new.extend(Pipeline::ProcessRunner)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Yields the fetch's pid before it runs.
|
|
40
|
+
def start(ref, &) = @runner.start_process(command(ref), @launch, &)
|
|
41
|
+
|
|
42
|
+
def finish(started, deadline:, timer: Pipeline::ProcessRunner::BUDGET)
|
|
43
|
+
output, status, over = @runner.await_process(started, deadline, launch: @launch, timer: timer)
|
|
44
|
+
return Fetched.new(error: "no answer within #{deadline} s") if over
|
|
45
|
+
|
|
46
|
+
Fetched.new(error: status.success? ? nil : self.class.reason(output))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def command(ref)
|
|
52
|
+
refspec = "+refs/heads/#{ref.branch}:#{self.class.ref_for(ref)}"
|
|
53
|
+
fetch = Shellwords.join(["git", "fetch", *OPTIONS, ref.remote, refspec])
|
|
54
|
+
"sh -c #{Shellwords.escape("#{NO_PROMPT_SSH}exec #{fetch}")}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../setup/project_config"
|
|
4
|
+
require_relative "../persistence/trunk_fetches"
|
|
5
|
+
require_relative "description"
|
|
6
|
+
require_relative "git"
|
|
7
|
+
require_relative "resolver"
|
|
8
|
+
|
|
9
|
+
module FunCi
|
|
10
|
+
module Trunk
|
|
11
|
+
# What to say before a project's first fetch of its trunk, where the
|
|
12
|
+
# developer sees it (architecture.md, Checking against the trunk):
|
|
13
|
+
# the post-commit hook's run is in the background, and what it prints is not.
|
|
14
|
+
module FirstFetch
|
|
15
|
+
# Nil unless the project fetches a remote trunk and never has yet; git lists
|
|
16
|
+
# the project's refs.
|
|
17
|
+
def self.notice(project, db, git: Git.new(project))
|
|
18
|
+
config = Setup::ProjectConfig.new(project)
|
|
19
|
+
interval = config.trunk_fetch
|
|
20
|
+
return nil if interval.nil? || config.trunk == "none" || Persistence::TrunkFetches.new(db, project).last
|
|
21
|
+
|
|
22
|
+
ref = Resolver.pick(config.trunk, git.refs)
|
|
23
|
+
ref&.remote ? Description.first_fetch(ref, interval) : nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require_relative "../pipeline/process_runner"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require_relative "../pipeline/git_environment"
|
|
8
|
+
require_relative "merge_check"
|
|
9
|
+
require_relative "resolver"
|
|
10
|
+
|
|
11
|
+
module FunCi
|
|
12
|
+
module Trunk
|
|
13
|
+
# The git the trunk check asks, in the project's repository. A merge
|
|
14
|
+
# writes its tree and conflicted blobs into a scratch object directory
|
|
15
|
+
# that borrows the repository's objects, so the repository keeps nothing.
|
|
16
|
+
class Git
|
|
17
|
+
REF_PREFIXES = %w[refs/heads/ refs/remotes/].freeze
|
|
18
|
+
|
|
19
|
+
# timer: whether a merge finished within its budget (ProcessRunner's timer), for tests to decide.
|
|
20
|
+
def initialize(dir, timer: Pipeline::ProcessRunner::BUDGET)
|
|
21
|
+
@dir = dir
|
|
22
|
+
@timer = timer
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def counts(commit, trunk) = run("rev-list", "--left-right", "--count", "#{commit}...#{trunk}")
|
|
26
|
+
|
|
27
|
+
def merge_tree(commit, trunk) = scratch { |env| merged(commit, trunk, env) }
|
|
28
|
+
|
|
29
|
+
# The merge's messages and each conflicted file as the merge leaves it.
|
|
30
|
+
def explain(commit, trunk)
|
|
31
|
+
scratch do |env|
|
|
32
|
+
output = MergeOutput.parse(merged(commit, trunk, env).out)
|
|
33
|
+
files = output.paths.to_h { |path| [path, run("cat-file", "-p", "#{output.tree}:#{path}", env: env).out] }
|
|
34
|
+
Explained.new(messages: output.messages, files: files)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The repository's top directory, or nil outside one.
|
|
39
|
+
def toplevel
|
|
40
|
+
answer = run("rev-parse", "--show-toplevel")
|
|
41
|
+
answer.status.zero? ? answer.out.strip : nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def version = run("--version").out[/\d+(?:\.\d+)+/]
|
|
45
|
+
|
|
46
|
+
def refs
|
|
47
|
+
remotes = run("remote").out.split
|
|
48
|
+
Refs.new(remotes: remotes, branches: branches, heads: remotes.to_h { |remote| [remote, head(remote)] }.compact)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The commit a ref names, or nil when there is no such ref.
|
|
52
|
+
def rev(ref)
|
|
53
|
+
answer = run("rev-parse", "--verify", "--quiet", "#{ref}^{commit}")
|
|
54
|
+
answer.status.zero? ? answer.out.strip : nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# When the ref last moved, from its reflog, or else when its commit was made.
|
|
58
|
+
def moved_at(ref)
|
|
59
|
+
moved = run("reflog", "-1", "--date=unix", "--format=%gd", ref).out[/@\{(\d+)\}/, 1]
|
|
60
|
+
Time.at(Integer(moved || run("log", "-1", "--format=%ct", ref).out.strip))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def branches
|
|
66
|
+
names = run("for-each-ref", "--format=%(refname)", *REF_PREFIXES).out.split("\n")
|
|
67
|
+
names.reject { |name| name.end_with?("/HEAD") }.map { |name| strip_prefix(name) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def strip_prefix(name) = REF_PREFIXES.reduce(name) { |short, prefix| short.delete_prefix(prefix) }
|
|
71
|
+
|
|
72
|
+
def head(remote)
|
|
73
|
+
answer = run("symbolic-ref", "--quiet", "refs/remotes/#{remote}/HEAD")
|
|
74
|
+
answer.status.zero? ? answer.out.strip.delete_prefix("refs/remotes/#{remote}/") : nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Within the check's budget, killed with its process group past it; what it
|
|
78
|
+
# prints to stderr comes with what it prints, which is all git says when it fails.
|
|
79
|
+
def merged(commit, trunk, env)
|
|
80
|
+
command = Shellwords.join(["git", "merge-tree", "--write-tree", "--name-only", "--messages", commit, trunk])
|
|
81
|
+
launch = Pipeline::ProcessRunner::Launch.new(chdir: @dir, env: env)
|
|
82
|
+
output, status, over = Object.new.extend(Pipeline::ProcessRunner)
|
|
83
|
+
.run_process_with_timeout(command, CHECK_BUDGET, launch: launch, timer: @timer)
|
|
84
|
+
over ? MergeCheck::OVER_BUDGET : MergeCheck::Answer.new(status: status.exitstatus, out: output, err: output)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Yields an environment whose new objects go to a scratch directory that is gone afterwards.
|
|
88
|
+
def scratch
|
|
89
|
+
Dir.mktmpdir("fun-ci-merge") do |dir|
|
|
90
|
+
yield({ "GIT_OBJECT_DIRECTORY" => dir, "GIT_ALTERNATE_OBJECT_DIRECTORIES" => objects })
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def objects = File.expand_path(File.join(run("rev-parse", "--git-common-dir").out.strip, "objects"), @dir)
|
|
95
|
+
|
|
96
|
+
def run(*, env: {})
|
|
97
|
+
out, err, status = Open3.capture3(Pipeline::GitEnvironment::CLEAN.merge(env), "git", *, chdir: @dir)
|
|
98
|
+
MergeCheck::Answer.new(status: status.exitstatus, out: out, err: err)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../setup/project_config"
|
|
4
|
+
require_relative "checker"
|
|
5
|
+
require_relative "git"
|
|
6
|
+
require_relative "tip_reader"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Trunk
|
|
10
|
+
# The trunk as the project's git has it now, for the agent commands
|
|
11
|
+
# (architecture.md, Checking against the trunk): where
|
|
12
|
+
# its ref points, read the way a check reads it, and a check against it
|
|
13
|
+
# that never fetches, since a command an agent runs must not wait on the network.
|
|
14
|
+
class Local
|
|
15
|
+
# Lets no fetch go, and knows the last one.
|
|
16
|
+
NoFetch = Data.define(:fetches)
|
|
17
|
+
|
|
18
|
+
# Reopened rather than given as a block to Data.define, so tools that
|
|
19
|
+
# read the source (mutineer) see these as NoFetch's methods.
|
|
20
|
+
class NoFetch
|
|
21
|
+
def claim(**) = nil
|
|
22
|
+
def last = fetches.last
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# dir: the project's directory, or one inside it.
|
|
26
|
+
def initialize(dir)
|
|
27
|
+
@dir = dir
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The SHA the tip's trunk points at now, or nil when it is gone.
|
|
31
|
+
def now_at(tip)
|
|
32
|
+
ref = Ref.new(remote: tip.remote, branch: tip.branch)
|
|
33
|
+
TipReader.new(git, -> { Time.now }).tip(ref, fetching: !config.trunk_fetch.nil?)&.sha
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# fetches: the project's (Persistence::TrunkFetches), for when the last fetch was.
|
|
37
|
+
def check(sha, fetches)
|
|
38
|
+
checker = Checker.for(root)
|
|
39
|
+
pending = checker.start(sha, NoFetch.new(fetches: fetches))
|
|
40
|
+
pending && checker.finish(pending).check
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The conflict between the commit and the tip it was checked against, merged
|
|
44
|
+
# again; nil when either is gone from the repository (a force push, then gc).
|
|
45
|
+
def explain(sha, tip)
|
|
46
|
+
return nil unless git.rev(sha) && git.rev(tip.sha)
|
|
47
|
+
|
|
48
|
+
git.explain(sha, tip.sha)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def root = @root ||= Git.new(@dir).toplevel || @dir
|
|
54
|
+
def git = Git.new(root)
|
|
55
|
+
def config = Setup::ProjectConfig.new(root)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|