fun_ci 2.0.0 → 2.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c32c8feba3da028e17991565e2221423f97cab25535eb940667306b8f21205b
4
- data.tar.gz: 83ec5dc09376e04feb8d08e7d863c6a574c2814754e9e57f3745d8719d4fb01e
3
+ metadata.gz: 1ec70009244a286fd23264c7cd99becf5a5a437ee8fb2a636652d1050f2b4d78
4
+ data.tar.gz: c354caa2011c5d7d8d39d2637f9c3423cae3c7e8a8c6fcce83232aa83d169cd5
5
5
  SHA512:
6
- metadata.gz: 352c02313f711498642a148a7639e559ab4c1dbbd92c12f3c83f6a1cefd78263545c617428c0293ab5c2da4e6f2b70984f6bbe8db402538c84bb3d243cce6314
7
- data.tar.gz: 6d873b080a2d5417b046aab0516cb116a92ae705b72207ab634b14196d8d18856540411f864bd424c1eb995d34018b89a07e7059eef47f1eeacdf4a5851b2088
6
+ metadata.gz: aa78fbd513340af9d0cb8cd1c0d071a6eb2c487f4fb2216a7108acdc3b71f1ba19682cb7746f5fdb024a1a0a7ea8f32d15c76f41f64a900f16ab6fc8594beaec
7
+ data.tar.gz: efb2b61788bd0e2ba361646a2391976c4fd29de3b91f1256d9425a5efc0687b6b731ccc6bbeeca22f52e12a091d705f10035298e2a622be63ef714a8f55f59d9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.2] - 2026-09-29
11
+
12
+ ### Fixed
13
+ - In a Gradle project set up by `fun-ci init`, the fast suite could fail
14
+ before running a test, with Gradle unable to hash a file in its own build
15
+ directory (`build/kotlin/compileTestKotlin/...`). The fast and slow suites
16
+ run side by side in one worktree, and both compiled the test code into the
17
+ same place, each deleting what the other was reading. The build script now
18
+ compiles the test code (`./gradlew assemble testClasses`), so neither suite
19
+ has to. Maven, .NET, Elixir, Dart and Swift projects could clash the same
20
+ way, and their scripts change too: Maven builds with `mvn test-compile` and
21
+ runs `mvn surefire:test` and `mvn failsafe:integration-test
22
+ failsafe:verify`, so its slow suite now runs only the integration tests
23
+ failsafe finds (the classes named `*IT`) rather than every test again;
24
+ Elixir compiles its test environment in the build; Dart precompiles its test
25
+ runner and runs the slow suite from source; the .NET and Swift suites run
26
+ with `--no-build` and `--skip-build`.
27
+ - Lint runs side by side with the build, and the lint `fun-ci init` wrote for
28
+ Maven (`mvn verify -DskipTests`) and .NET (`dotnet format`) compiled into
29
+ the same directories as the build. Lint now works on the source code alone.
30
+ Maven's runs the linter the POM configures (detekt, ktlint, checkstyle or
31
+ PMD) or else `mvn validate`, and no longer picks spotbugs, which reads
32
+ compiled classes. .NET's checks whitespace with `dotnet format whitespace
33
+ --folder`. Gradle's runs ktlint, spotless or detekt when the build applies
34
+ one, and otherwise says there is none, where it ran `check`, which compiles.
35
+ A linter that needs compiled code goes in `build.sh` or `fast.sh`.
36
+ - Which stages run side by side, and so what each may read and write, is now
37
+ spelled out: in a new section of `docs/design.md`, in the README, in what
38
+ `fun-ci init` prints, and in a comment at the top of each script it writes.
39
+ Scripts already in `.fun-ci/` are left as they are, so an existing project
40
+ picks up these fixes by editing them to match `docs/stacks.md`.
41
+
42
+ ## [2.0.1] - 2026-09-29
43
+
44
+ ### Fixed
45
+ - `fun-ci console` could show a blank screen and stay that way. When a new
46
+ commit cancelled a run in the same second one of its stages started, the
47
+ stage was recorded as ending before it began. The renderer rejected the
48
+ negative duration and, with it, every board Ruby sent, so nothing was drawn
49
+ while that run was listed; the only sign was a line per second in
50
+ `.fun-ci/console.log`. Ends are now recorded to the millisecond, and a stage
51
+ already recorded that way reads as taking no time, so an existing database
52
+ needs no repair. `fun-ci status`, `why` and `events` no longer report a
53
+ negative time for such a stage either.
54
+ - A commit made on a detached HEAD, as every commit a rebase replays is, was
55
+ recorded with no branch name, so `fun-ci status` ended in "on" and the
56
+ console showed a gap where the branch goes. Such runs are now on the branch
57
+ `detached`, whether a hook or `fun-ci wait` started them, and runs already
58
+ recorded without a name are renamed when fun-ci next opens its database.
59
+
10
60
  ## [2.0.0] - 2026-09-29
11
61
 
12
62
  fun-ci 2.0 has two ways in. People watch a new console, drawn by a Rust
data/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ [![Gem Version](https://badge.fury.io/rb/fun_ci.svg)](https://badge.fury.io/rb/fun_ci)
2
+
1
3
  # Fun-CI
2
4
 
3
5
  Opinionated local CI that checks your code before it leaves your machine. Every commit runs lint, build, a fast suite and a slow suite on your own computer, each held to a strict time budget, so the feedback stays fast.
@@ -41,6 +43,8 @@ Upgrading from 1.x? Read [Upgrading to 2.0](#upgrading-to-20) first.
41
43
  | Fast | 10 s | after lint and build pass | the push |
42
44
  | Slow | 5 min | in the background, beside the fast suite | nothing |
43
45
 
46
+ Lint and build run side by side on the commit's checkout, and then the fast and slow suites run side by side on what the build left, all in one worktree. Two stages running at once must not write the same files, so `lint.sh` works on the source code only and `build.sh` compiles everything the suites need, test code included. A linter that needs compiled code belongs in `build.sh` or `fast.sh`. The scripts `fun-ci init` writes keep to this; [Stages side by side](docs/design.md#stages-side-by-side) has the details.
47
+
44
48
  A stage that overruns its budget is killed and reported as over budget, in yellow rather than red: a fast suite that takes 12 seconds has grown too heavy, which is a different problem from a failing test.
45
49
 
46
50
  After each commit, the `post-commit` hook starts the pipeline in the background and returns at once, so a commit is never held up. The `pre-push` hook waits for the verdict of each commit you push and stops the push if lint, build or the fast suite failed. A commit whose run has already finished goes straight through. If fun-ci isn't installed, both hooks say so and let git carry on.
@@ -16,8 +16,9 @@ module FunCi
16
16
  def subject(sha) = git("log", "-1", "--format=%s", sha).to_s
17
17
  def toplevel = File.realpath(git("rev-parse", "--show-toplevel"))
18
18
 
19
- # The branch checked out, as the git hooks name it; "unknown" when HEAD is detached.
20
- def branch = git("branch", "--show-current").to_s.then { |name| name.empty? ? "unknown" : name }
19
+ # The branch checked out, as the git hooks name it: "" when HEAD is
20
+ # detached, which trigger names (Pipeline::Commit).
21
+ def branch = git("branch", "--show-current").to_s
21
22
 
22
23
  private
23
24
 
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "time"
5
4
  require_relative "../evidence/document"
5
+ require_relative "../persistence/stage_job"
6
6
 
7
7
  module FunCi
8
8
  module Agent
@@ -62,9 +62,7 @@ module FunCi
62
62
  end
63
63
 
64
64
  def self.seconds(job)
65
- return nil unless job[:started_at] && job[:completed_at]
66
-
67
- (Time.parse(job[:completed_at]) - Time.parse(job[:started_at])).round(1)
65
+ Persistence::StageJob.elapsed_duration(job)&.round(1)
68
66
  end
69
67
  private_class_method :kept, :seconds
70
68
  end
@@ -42,12 +42,12 @@ module FunCi
42
42
  # Records the slow stage failed, unless its result was recorded meanwhile.
43
43
  def self.slow_suite_died(db, job_id)
44
44
  db.execute("UPDATE stage_jobs SET status = 'failed', completed_at = ? WHERE id = ? AND status = 'running'",
45
- [Time.now.utc.iso8601, job_id])
45
+ [Time.now.utc.iso8601(3), job_id])
46
46
  end
47
47
 
48
48
  def self.cancelled(db, run)
49
49
  db.execute("UPDATE stage_jobs SET status = 'cancelled', completed_at = ? " \
50
- "WHERE pipeline_run_id = ? AND #{ACTIVE}", [Time.now.utc.iso8601, run.id])
50
+ "WHERE pipeline_run_id = ? AND #{ACTIVE}", [Time.now.utc.iso8601(3), run.id])
51
51
  PipelineRun.update_status(db, run.id, "cancelled")
52
52
  end
53
53
 
@@ -50,9 +50,13 @@ module FunCi
50
50
  with_setup_lock(db.filename("main")) do
51
51
  [PIPELINE_RUNS_TABLE, STAGE_JOBS_TABLE].each { |table| db.execute(table) }
52
52
  ADDED_COLUMNS.each { |table, column, type| add_column_if_missing(db, table, column, type) }
53
+ db.execute(NAME_DETACHED)
53
54
  end
54
55
  end
55
56
 
57
+ # Runs started on a detached HEAD before 2.0.1 were kept with no branch name.
58
+ NAME_DETACHED = "UPDATE pipeline_runs SET branch = 'detached' WHERE branch = ''"
59
+
56
60
  def self.with_setup_lock(db_path)
57
61
  File.open("#{db_path}.setup-lock", File::RDWR | File::CREAT, 0o644) do |lock|
58
62
  lock.flock(File::LOCK_EX)
@@ -76,10 +76,12 @@ module FunCi
76
76
  [new_status, Time.now.utc.iso8601(3), id])
77
77
  end
78
78
 
79
+ # Never less than nothing: rows kept before ends were recorded to the
80
+ # millisecond can end in the second they started, before their start.
79
81
  def self.elapsed_duration(job)
80
82
  return nil unless job[:started_at] && job[:completed_at]
81
83
 
82
- Time.parse(job[:completed_at]) - Time.parse(job[:started_at])
84
+ [Time.parse(job[:completed_at]) - Time.parse(job[:started_at]), 0].max
83
85
  end
84
86
 
85
87
  def self.row_to_hash(row)
@@ -14,6 +14,14 @@ module FunCi
14
14
 
15
15
  Commit = Data.define(:sha, :branch)
16
16
 
17
+ # On a detached HEAD git names no branch, and the hooks pass "": the run
18
+ # is kept, shown and cancelled by others on the branch "detached".
19
+ class Commit
20
+ DETACHED = "detached"
21
+
22
+ def initialize(sha:, branch:) = super(sha: sha, branch: branch.empty? ? DETACHED : branch)
23
+ end
24
+
17
25
  Io = Data.define(:stdout, :stderr) do
18
26
  def initialize(stdout: $stdout, stderr: $stderr) = super
19
27
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Setup
5
+ # The source linter a Gradle build applies, whose task lint.sh runs. Lint
6
+ # runs beside the build on the source alone (design.md, Stages side by
7
+ # side), so only linters that compile nothing are named here.
8
+ class GradleLinterDetector
9
+ LINTERS = [
10
+ { plugin: "org.jlleitschuh.gradle.ktlint", command: "./gradlew ktlintCheck" },
11
+ { plugin: "com.diffplug.spotless", command: "./gradlew spotlessCheck" },
12
+ { plugin: "io.gitlab.arturbosch.detekt", command: "./gradlew detekt" }
13
+ ].freeze
14
+
15
+ def initialize(build_file)
16
+ @build_file = build_file
17
+ end
18
+
19
+ # The linter's command, or nil for a build that applies none.
20
+ def lint_command = LINTERS.find { |linter| @build_file.include?(linter[:plugin]) }&.fetch(:command)
21
+ end
22
+ end
23
+ end
@@ -2,12 +2,18 @@
2
2
 
3
3
  require_relative "project_detector"
4
4
  require_relative "template_writer"
5
- require_relative "maven_linter_detector"
5
+ require_relative "lint_detection"
6
6
  require_relative "agent_instructions"
7
7
 
8
8
  module FunCi
9
9
  module Setup
10
10
  class Installer
11
+ # Which stages run side by side, and so what each may touch (design.md,
12
+ # Stages side by side).
13
+ SIDE_BY_SIDE = ["lint.sh runs beside build.sh, so it reads the source alone.",
14
+ "Then fast.sh runs beside slow.sh, both on what build.sh built: have build.sh " \
15
+ "compile everything they need, test code included."].freeze
16
+
11
17
  def self.run(project_root:, stdout: $stdout)
12
18
  new(project_root: project_root, stdout: stdout).run
13
19
  end
@@ -51,21 +57,15 @@ module FunCi
51
57
 
52
58
  def write_templates(detected)
53
59
  @stdout.puts "Detected: #{detected.to_s.tr("_", " ")}"
54
- TemplateWriter.new(detected, @project_root, lint_override: detect_maven_linter(detected)).write
55
- report("Created .fun-ci/ with template scripts.", 0)
60
+ TemplateWriter.new(detected, @project_root, lint_override: LintDetection.command(detected, @project_root)).write
61
+ @stdout.puts "Created .fun-ci/ with template scripts.", *SIDE_BY_SIDE
62
+ 0
56
63
  end
57
64
 
58
65
  def report(message, exit_code)
59
66
  @stdout.puts message
60
67
  exit_code
61
68
  end
62
-
63
- def detect_maven_linter(detected)
64
- return nil unless detected == :jvm_maven
65
-
66
- command = MavenLinterDetector.new(File.read(File.join(@project_root, "pom.xml"))).lint_command
67
- command == MavenLinterDetector::DEFAULT_COMMAND ? nil : command
68
- end
69
69
  end
70
70
  end
71
71
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "maven_linter_detector"
4
+ require_relative "gradle_linter_detector"
5
+
6
+ module FunCi
7
+ module Setup
8
+ # The lint command init writes in place of the template's, from the source
9
+ # linter the project's build configures; nil keeps the template's.
10
+ module LintDetection
11
+ GRADLE_FILES = %w[build.gradle.kts build.gradle].freeze
12
+
13
+ def self.command(detected, project_root)
14
+ case detected
15
+ when :jvm_maven then maven(project_root)
16
+ when :jvm_gradle_kotlin, :jvm_gradle_groovy then gradle(project_root)
17
+ end
18
+ end
19
+
20
+ def self.maven(project_root)
21
+ command = MavenLinterDetector.new(File.read(File.join(project_root, "pom.xml"))).lint_command
22
+ command == MavenLinterDetector::DEFAULT_COMMAND ? nil : command
23
+ end
24
+
25
+ def self.gradle(project_root)
26
+ file = GRADLE_FILES.map { File.join(project_root, _1) }.find { File.exist?(_1) }
27
+ file && GradleLinterDetector.new(File.read(file)).lint_command
28
+ end
29
+ private_class_method :maven, :gradle
30
+ end
31
+ end
32
+ end
@@ -2,16 +2,18 @@
2
2
 
3
3
  module FunCi
4
4
  module Setup
5
+ # The linter a POM configures, whose goal lint.sh runs. Lint runs beside the
6
+ # build on the source alone (design.md, Stages side by side), so linters that
7
+ # read compiled classes, such as spotbugs, are left to build.sh or fast.sh.
5
8
  class MavenLinterDetector
6
9
  LINTERS = [
7
10
  { artifact_id: "detekt-maven-plugin", command: "mvn detekt:check" },
8
11
  { artifact_id: "ktlint-maven-plugin", command: "mvn ktlint:check" },
9
12
  { artifact_id: "maven-checkstyle-plugin", command: "mvn checkstyle:check" },
10
- { artifact_id: "spotbugs-maven-plugin", command: "mvn spotbugs:check" },
11
13
  { artifact_id: "maven-pmd-plugin", command: "mvn pmd:check" }
12
14
  ].freeze
13
15
 
14
- DEFAULT_COMMAND = "mvn verify -DskipTests"
16
+ DEFAULT_COMMAND = "mvn validate"
15
17
 
16
18
  def initialize(pom_content)
17
19
  @pom_content = pom_content
@@ -6,18 +6,23 @@ module FunCi
6
6
  module Setup
7
7
  # Stacks that run on a virtual machine: the JVM, .NET, the BEAM and Dart's.
8
8
  module StageTemplates
9
- GRADLE = stages("./gradlew check -x test", "./gradlew assemble", "./gradlew test", "./gradlew integrationTest")
10
- .freeze
9
+ NO_GRADLE_LINTER = 'echo "No source linter in the build: put ktlint, spotless or detekt in .fun-ci/lint.sh"'
10
+
11
+ GRADLE = stages(NO_GRADLE_LINTER, "./gradlew assemble testClasses",
12
+ "./gradlew test", "./gradlew integrationTest").freeze
11
13
 
12
14
  MANAGED = {
13
15
  jvm_gradle_kotlin: GRADLE,
14
16
  jvm_gradle_groovy: GRADLE,
15
- jvm_maven: stages("mvn verify -DskipTests", "mvn compile", "mvn test", "mvn verify"),
16
- dotnet: stages("dotnet format --verify-no-changes", "dotnet build",
17
- 'dotnet test --filter "Category!=Slow"', 'dotnet test --filter "Category=Slow"'),
18
- elixir_mix: stages("mix format --check-formatted", "mix deps.get && mix compile",
17
+ jvm_maven: stages("mvn validate", "mvn test-compile", "mvn surefire:test",
18
+ "mvn failsafe:integration-test failsafe:verify"),
19
+ dotnet: stages("dotnet format whitespace --folder --verify-no-changes", "dotnet build",
20
+ 'dotnet test --no-build --filter "Category!=Slow"',
21
+ 'dotnet test --no-build --filter "Category=Slow"'),
22
+ elixir_mix: stages("mix format --check-formatted", "mix deps.get && MIX_ENV=test mix compile",
19
23
  "mix test --exclude slow", "mix test --only slow"),
20
- dart: stages("dart analyze", "dart pub get", "dart test --exclude-tags slow", "dart test --tags slow")
24
+ dart: stages("dart analyze", "dart pub get --precompile", "dart test --exclude-tags slow",
25
+ "dart test --compiler source --tags slow")
21
26
  }.freeze
22
27
  end
23
28
  end
@@ -15,7 +15,7 @@ module FunCi
15
15
  "cargo test", "cargo test -- --ignored"),
16
16
  go: stages("go vet ./...", "go build ./...", "go test -short ./...", "go test ./..."),
17
17
  swift: stages("swift format lint --recursive --strict .", "swift build --build-tests",
18
- "swift test --skip Slow", "swift test --filter Slow"),
18
+ "swift test --skip-build --skip Slow", "swift test --skip-build --filter Slow"),
19
19
  cmake: stages(CLANG_FORMAT, "cmake -S . -B build && cmake --build build",
20
20
  "#{CTEST} -LE slow", "#{CTEST} -L slow"),
21
21
  make: stages("make lint", "make", "make test", "make test-slow")
@@ -4,11 +4,20 @@ module FunCi
4
4
  module Setup
5
5
  # What every stack's stage scripts are made of.
6
6
  module StageTemplates
7
- def self.script(body) = "#!/bin/sh\n#{body}\n"
7
+ # What each stage runs beside, said where its script is edited (design.md,
8
+ # Stages side by side).
9
+ SIDE_BY_SIDE = {
10
+ "lint.sh" => "Runs beside build.sh, so it reads the source alone.",
11
+ "build.sh" => "Runs beside lint.sh; then fast.sh and slow.sh run side by side on what it builds.",
12
+ "fast.sh" => "Runs beside slow.sh, on what build.sh built.",
13
+ "slow.sh" => "Runs beside fast.sh, on what build.sh built."
14
+ }.freeze
15
+
16
+ def self.script(name, body) = "#!/bin/sh\n# #{SIDE_BY_SIDE.fetch(name)}\n#{body}\n"
8
17
 
9
18
  def self.stages(lint, build, fast, slow)
10
- { "lint.sh" => lint, "build.sh" => build, "fast.sh" => fast, "slow.sh" => slow }.transform_values do |body|
11
- script(body)
19
+ { "lint.sh" => lint, "build.sh" => build, "fast.sh" => fast, "slow.sh" => slow }.to_h do |name, body|
20
+ [name, script(name, body)]
12
21
  end
13
22
  end
14
23
  end
@@ -18,7 +18,7 @@ module FunCi
18
18
  # { "lint.sh" => script, ... }; +lint_override+ replaces the lint command.
19
19
  def self.scripts(template_id, lint_override: nil)
20
20
  scripts = TEMPLATES.fetch(template_id)
21
- lint_override ? scripts.merge("lint.sh" => script(lint_override)) : scripts
21
+ lint_override ? scripts.merge("lint.sh" => script("lint.sh", lint_override)) : scripts
22
22
  end
23
23
  end
24
24
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Loaded on its own by the gemspec, so it must not require anything.
4
4
  module FunCi
5
- VERSION = "2.0.0"
5
+ VERSION = "2.0.2"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fun_ci
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erik Thyge Madsen
@@ -237,10 +237,12 @@ files:
237
237
  - lib/fun_ci/setup/agent_instructions.rb
238
238
  - lib/fun_ci/setup/commands.rb
239
239
  - lib/fun_ci/setup/git_hooks.rb
240
+ - lib/fun_ci/setup/gradle_linter_detector.rb
240
241
  - lib/fun_ci/setup/hook_script.rb
241
242
  - lib/fun_ci/setup/hook_writer.rb
242
243
  - lib/fun_ci/setup/installer.rb
243
244
  - lib/fun_ci/setup/legacy_hooks.rb
245
+ - lib/fun_ci/setup/lint_detection.rb
244
246
  - lib/fun_ci/setup/maven_linter_detector.rb
245
247
  - lib/fun_ci/setup/project_config.rb
246
248
  - lib/fun_ci/setup/project_detector.rb