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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/README.md +34 -8
  4. data/lib/fun_ci/agent/command_support.rb +14 -1
  5. data/lib/fun_ci/agent/commands.rb +1 -0
  6. data/lib/fun_ci/agent/context.rb +3 -2
  7. data/lib/fun_ci/agent/events.rb +19 -5
  8. data/lib/fun_ci/agent/events_command.rb +1 -1
  9. data/lib/fun_ci/agent/exit_code.rb +1 -1
  10. data/lib/fun_ci/agent/next_step.rb +26 -0
  11. data/lib/fun_ci/agent/options.rb +6 -4
  12. data/lib/fun_ci/agent/output.rb +15 -2
  13. data/lib/fun_ci/agent/report_stage.rb +1 -1
  14. data/lib/fun_ci/agent/reports.rb +13 -9
  15. data/lib/fun_ci/agent/run_details.rb +31 -0
  16. data/lib/fun_ci/agent/run_report.rb +4 -3
  17. data/lib/fun_ci/agent/runs_text.rb +7 -4
  18. data/lib/fun_ci/agent/snapshots.rb +13 -3
  19. data/lib/fun_ci/agent/status_command.rb +2 -2
  20. data/lib/fun_ci/agent/status_json.rb +2 -1
  21. data/lib/fun_ci/agent/status_text.rb +13 -2
  22. data/lib/fun_ci/agent/trunk_json.rb +32 -0
  23. data/lib/fun_ci/agent/trunk_reading.rb +53 -0
  24. data/lib/fun_ci/agent/trunk_text.rb +50 -0
  25. data/lib/fun_ci/agent/trunk_verdict.rb +20 -0
  26. data/lib/fun_ci/agent/trunk_why.rb +52 -0
  27. data/lib/fun_ci/agent/trunk_why_json.rb +23 -0
  28. data/lib/fun_ci/agent/wait_command.rb +5 -5
  29. data/lib/fun_ci/agent/why_command.rb +11 -0
  30. data/lib/fun_ci/cli.rb +1 -1
  31. data/lib/fun_ci/cli_help.rb +5 -3
  32. data/lib/fun_ci/console/board_data.rb +22 -8
  33. data/lib/fun_ci/console/cancelled_folding.rb +19 -0
  34. data/lib/fun_ci/console/console_events.rb +5 -2
  35. data/lib/fun_ci/console/console_log.rb +4 -1
  36. data/lib/fun_ci/console/console_state.rb +7 -1
  37. data/lib/fun_ci/console/row_order.rb +31 -0
  38. data/lib/fun_ci/console/run_message.rb +9 -2
  39. data/lib/fun_ci/console/trunk_events.rb +43 -0
  40. data/lib/fun_ci/console/trunk_marks.rb +53 -0
  41. data/lib/fun_ci/console/view.rb +9 -8
  42. data/lib/fun_ci/evidence/caps.rb +7 -2
  43. data/lib/fun_ci/persistence/active_runs.rb +5 -4
  44. data/lib/fun_ci/persistence/database.rb +23 -2
  45. data/lib/fun_ci/persistence/null_recorder.rb +8 -0
  46. data/lib/fun_ci/persistence/pipeline_recorder.rb +3 -0
  47. data/lib/fun_ci/persistence/pipeline_run.rb +24 -3
  48. data/lib/fun_ci/persistence/trunk_checks.rb +68 -0
  49. data/lib/fun_ci/persistence/trunk_fetches.rb +57 -0
  50. data/lib/fun_ci/persistence/trunk_recording.rb +49 -0
  51. data/lib/fun_ci/pipeline/pipeline_forker.rb +17 -2
  52. data/lib/fun_ci/pipeline/process_runner.rb +18 -4
  53. data/lib/fun_ci/pipeline/trigger.rb +10 -1
  54. data/lib/fun_ci/pipeline/trigger_command.rb +9 -5
  55. data/lib/fun_ci/pipeline/trigger_params.rb +5 -3
  56. data/lib/fun_ci/pipeline/trunk_run.rb +81 -0
  57. data/lib/fun_ci/setup/agent_instructions.rb +3 -0
  58. data/lib/fun_ci/setup/project_config.rb +3 -1
  59. data/lib/fun_ci/setup/settings.rb +35 -3
  60. data/lib/fun_ci/setup/setup_checker.rb +7 -2
  61. data/lib/fun_ci/setup/trunk_report.rb +28 -0
  62. data/lib/fun_ci/trunk/check.rb +56 -0
  63. data/lib/fun_ci/trunk/checker.rb +91 -0
  64. data/lib/fun_ci/trunk/description.rb +52 -0
  65. data/lib/fun_ci/trunk/fetch.rb +58 -0
  66. data/lib/fun_ci/trunk/first_fetch.rb +27 -0
  67. data/lib/fun_ci/trunk/git.rb +102 -0
  68. data/lib/fun_ci/trunk/local.rb +58 -0
  69. data/lib/fun_ci/trunk/merge_check.rb +54 -0
  70. data/lib/fun_ci/trunk/regions.rb +38 -0
  71. data/lib/fun_ci/trunk/resolver.rb +64 -0
  72. data/lib/fun_ci/trunk/shown.rb +47 -0
  73. data/lib/fun_ci/trunk/tip_reader.rb +39 -0
  74. data/lib/fun_ci/version.rb +1 -1
  75. data/libexec/fun-ci-renderer +0 -0
  76. 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.new(File.join(@fun_ci_dir, "config"))
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
- def initialize(path)
14
- @path = path
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 raw = File.exist?(@path) ? YAML.safe_load_file(@path) || {} : {}
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).run
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
- def initialize(config:, hooks:, stdout:)
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