fun_ci 2.0.2 → 2.1.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 +69 -0
- data/README.md +31 -5
- 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 +4 -2
- 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
- metadata +30 -1
|
@@ -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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "check"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Trunk
|
|
7
|
+
# What merging a commit with a trunk tip would do (architecture.md, Checking against the trunk,
|
|
8
|
+
# How the check works), from what git answers: the commits each side has
|
|
9
|
+
# that the other lacks, then, when both have some, a merge in memory.
|
|
10
|
+
class MergeCheck
|
|
11
|
+
# What a git command answered: its exit status and what it printed.
|
|
12
|
+
Answer = Data.define(:status, :out, :err)
|
|
13
|
+
# A merge stopped at its budget, with its process group.
|
|
14
|
+
OVER_BUDGET = Answer.new(status: :over_budget, out: "", err: "")
|
|
15
|
+
STOPPED = "the merge ran over its #{CHECK_BUDGET} s budget; " \
|
|
16
|
+
"set trunk: none in .fun-ci/config to stop checking".freeze
|
|
17
|
+
|
|
18
|
+
def initialize(git)
|
|
19
|
+
@git = git
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# name: how the trunk is called in a reason, "origin/main".
|
|
23
|
+
def merge(commit, trunk, name)
|
|
24
|
+
counted = @git.counts(commit, trunk)
|
|
25
|
+
return Merge.unknown("git rev-list: #{first_line(counted)}") unless counted.status.zero?
|
|
26
|
+
|
|
27
|
+
ahead, behind = counted.out.split.map(&:to_i)
|
|
28
|
+
return Merge.clean(ahead: ahead, behind: behind) if ahead.zero? || behind.zero?
|
|
29
|
+
|
|
30
|
+
merged(@git.merge_tree(commit, trunk), name, ahead: ahead, behind: behind)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def merged(answer, name, **counts)
|
|
36
|
+
return Merge.unknown(STOPPED) if answer.equal?(OVER_BUDGET)
|
|
37
|
+
return Merge.clean(**counts) if answer.status.zero?
|
|
38
|
+
return Merge.conflicts(MergeOutput.parse(answer.out).paths, **counts) if answer.status == 1
|
|
39
|
+
|
|
40
|
+
Merge.unknown(reason(answer, name))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reason(answer, name)
|
|
44
|
+
return "#{name} shares no history with this commit; set trunk: in .fun-ci/config" if unrelated?(answer)
|
|
45
|
+
return "needs git 2.38 or later; this is #{@git.version}" if answer.status == 129
|
|
46
|
+
|
|
47
|
+
"git merge-tree: #{first_line(answer)}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def unrelated?(answer) = answer.err.include?("unrelated histories")
|
|
51
|
+
def first_line(answer) = answer.err.lines.first.to_s.chomp
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Trunk
|
|
5
|
+
# The conflicted regions of a file as a merge leaves it (architecture.md, Checking against the trunk,
|
|
6
|
+
# why REV trunk): each conflict from its <<<<<<< line to its >>>>>>> line,
|
|
7
|
+
# with `around` lines either side, regions that touch joined into one.
|
|
8
|
+
module Regions
|
|
9
|
+
# Lines first to last, as [number, text] pairs, numbered from 1.
|
|
10
|
+
Region = Data.define(:first, :last, :lines)
|
|
11
|
+
AROUND = 3
|
|
12
|
+
|
|
13
|
+
def self.of(text, around: AROUND)
|
|
14
|
+
lines = text.lines(chomp: true)
|
|
15
|
+
spans = conflicts(lines).map { |from, to| [[from - around, 0].max, [to + around, lines.size - 1].min] }
|
|
16
|
+
joined(spans).map { |from, to| region(lines, from, to) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.region(lines, from, to)
|
|
20
|
+
Region.new(first: from + 1, last: to + 1, lines: (from..to).map { |index| [index + 1, lines[index]] })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# [index of <<<<<<<, index of >>>>>>>] for each conflict.
|
|
24
|
+
def self.conflicts(lines)
|
|
25
|
+
marked = ->(marker) { lines.each_index.select { |index| lines[index].start_with?(marker) } }
|
|
26
|
+
marked.call("<<<<<<<").zip(marked.call(">>>>>>>"))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.joined(spans)
|
|
30
|
+
spans.each_with_object([]) do |(from, to), joined|
|
|
31
|
+
touching = joined.any? && from <= joined.last[1] + 1
|
|
32
|
+
touching ? joined[-1] = [joined.last[0], [joined.last[1], to].max] : joined << [from, to]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
private_class_method :region, :conflicts, :joined
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|