kettle-family 1.3.1 → 1.3.3

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: ce61863e305027b86eef8097ce594d8a9b77ba0e9e043ecb1617be9722a5ae38
4
- data.tar.gz: 2b61292cbe3fcabb567c8f47ab7bac6170782dcedf75366452bb171b29b5a96a
3
+ metadata.gz: 2fd15138050afedb68be9d6d294f7e3a01dc1554949c8607b1176ddf8d061581
4
+ data.tar.gz: 272997658ef8c5a96b14fbc1dcc8d124b545abe50d5bc0ca423823c8dffc3ad8
5
5
  SHA512:
6
- metadata.gz: 47d6f2b5ebe4cbb0480c75037f702aba4eacf8185758df0724922d5cf2780d15a6c742d92e4d0bbdb55e64d3272c41a9ee3db8d46ad4e3bb39fedcc82427c25e
7
- data.tar.gz: 1b0db917fd48cfe90aee4f6d00909ea667b9aab0bd9ac13704039a0117878f0f46d6ef6a5f1ef3f7e23a81858ef6f3aa2966d219127826a664c7c640f15da160
6
+ metadata.gz: a5bac59bea82eec800e67069ae00f8810c54a29a7fe3119b4c9ef93f94924af677b0da411635928841d9c0df47d4bfb11853d622cccaf1c72faf6f6419a552d6
7
+ data.tar.gz: c4d9b06b6fed1f0822bdc62ec6d22b3cb379bc1fe7b49e388038e58780c567ecbe3f589e11faa7845e92b2b6b7d365499271f17d0ad8e942636d632f25dbf950
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,90 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.3.3] - 2026-09-28
34
+
35
+ - TAG: [v1.3.3][1.3.3t]
36
+ - COVERAGE: 93.25% -- 6538/7011 lines in 37 files
37
+ - BRANCH COVERAGE: 77.76% -- 2660/3421 branches in 37 files
38
+ - 33.57% documented
39
+
40
+ ### Fixed
41
+
42
+ - Compare filesystem paths by canonical identity throughout family workflows.
43
+
44
+ ## [1.3.2] - 2026-09-28
45
+
46
+ - TAG: [v1.3.2][1.3.2t]
47
+ - COVERAGE: 93.25% -- 6537/7010 lines in 37 files
48
+ - BRANCH COVERAGE: 77.77% -- 2659/3419 branches in 37 files
49
+ - 33.57% documented
50
+
51
+ ### Added
52
+
53
+ - kettle-jem-template-20260913-001 - Templating now also surfaces a review
54
+ entry in `dependency_conflicts.resolve` when a direct development
55
+ dependency doesn't support one or more of this project's declared
56
+ `engines:` and has no template-managed modular home (e.g. `sqlite3` on
57
+ `jruby`). Review each entry and pick a resolution per the project's own
58
+ engine support needs.
59
+
60
+ ### Changed
61
+
62
+ - [kc] kettle-jem/prepare: updated 13 project files:
63
+ - dependencies (13)
64
+
65
+ - [kc] kettle-jem/template: updated 14 project files:
66
+ - code and tests (2)
67
+ - dependencies (2)
68
+ - other (1)
69
+ - workflows (9)
70
+
71
+ ### Fixed
72
+
73
+ - Run local Kettle Jem through a prepared member bundle so local StructuredMerge dependencies are activated during templating.
74
+
75
+ - Pin bupb to an installed stable Bundler version, including its managed child updates.
76
+
77
+ - [kc] nomono-self-upgrade-bundler-env: Preserve RUBYOPT bundle activation for local Kettle Jem execution, but omit eager setup from nomono self-upgrade lockfile updates so the stale self-version can be reconciled without falling back from the local template stack.
78
+
79
+ - Recover template lockfiles with Bundler's valid bundle lock --update GEM syntax instead of passing gem names as invalid positional arguments.
80
+
81
+ - Template preparation now starts local Kettle Jem from its own bundle when the destination lockfile path-locks Nomono, allowing bootstrap repair before the destination bundle loads.
82
+
83
+ - Refresh dependent lockfiles before committing newly raised family dependency floors.
84
+
85
+ - Handle macOS canonical temp paths, Unix socket limits, and PTY EOF during family workflows.
86
+
87
+ - Canonicalize Windows system temporary paths for Git-based specs, launch the Mise integration fixture on Windows, and include WEBrick for local registry specs.
88
+
89
+ - Readiness checks accept existing binstub files on Windows, where Unix executable-bit checks are not portable.
90
+
91
+ - Make family specs portable on Windows and use valid generated gemspec fixtures.
92
+
93
+ - Support Windows pipe-driven interactive commands, absolute local-path lockfile remotes, and equivalent filesystem path spellings in family workflows.
94
+
95
+ - Apply workflow environment overrides portably after mise and normalize Windows filesystem paths in discovery and branch configuration.
96
+
97
+ - Normalize filesystem glob separators across supported platforms so family discovery, installs, and workflow asset lookup find paths on Windows.
98
+
99
+ - Normalize Windows path aliases and subprocess line endings in family workflows.
100
+
101
+ - Close the child input stream when a configured OTP provider returns no code, preventing interactive release commands from hanging.
102
+
103
+ - Start the Windows environment wrapper without inherited RUBYOPT so Bundler cannot activate the parent bundle before wrapper overrides are applied.
104
+
105
+ - Windows family workflows now reset lockfiles without relying on the POSIX env utility, terminate failed interactive child processes, and apply member exclusion patterns case-insensitively.
106
+
107
+ - Windows family workflow specs now preserve command arguments and handle case-insensitive checkout paths.
108
+
109
+ - Detect Windows drive-absolute local paths when preparing release lockfile normalization.
110
+
111
+ - Delegate canonical, identity, and containment path checks to kettle-dev so family workflows consistently handle symlink aliases and platform path semantics.
112
+
113
+ - Windows family discovery now applies exclusion globs across path separators, and branch-target config lookup uses shared path normalization.
114
+
115
+ - Resolve family-relative paths by filesystem identity so discovery exclusions and branch-stack configs work with Windows path aliases.
116
+
33
117
  ## [1.3.1] - 2026-09-10
34
118
 
35
119
  - TAG: [v1.3.1][1.3.1t]
@@ -2746,7 +2830,11 @@ Please file a bug if you notice a violation of semantic versioning.
2746
2830
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
2747
2831
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
2748
2832
 
2749
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.1...HEAD
2833
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.3...HEAD
2834
+ [1.3.3]: https://github.com/kettle-dev/kettle-family/compare/v1.3.2...v1.3.3
2835
+ [1.3.3t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.3
2836
+ [1.3.2]: https://github.com/kettle-dev/kettle-family/compare/v1.3.1...v1.3.2
2837
+ [1.3.2t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.2
2750
2838
  [1.3.1]: https://github.com/kettle-dev/kettle-family/compare/v1.3.0...v1.3.1
2751
2839
  [1.3.1t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.1
2752
2840
  [1.3.0]: https://github.com/kettle-dev/kettle-family/compare/v1.2.100...v1.3.0
data/README.md CHANGED
@@ -1186,7 +1186,7 @@ Thanks for RTFM. ☺️
1186
1186
  [📌gitmoji]: https://gitmoji.dev
1187
1187
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1188
1188
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1189
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.795-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1189
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.011-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1190
1190
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1191
1191
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1192
1192
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "yaml"
5
+ require_relative "paths"
5
6
 
6
7
  module Kettle
7
8
  module Family
@@ -54,7 +55,7 @@ module Kettle
54
55
  return false if left.to_s.empty? || right.to_s.empty?
55
56
  return false unless File.file?(left) && File.file?(right)
56
57
 
57
- File.realpath(left) == File.realpath(right)
58
+ Paths.same?(left, right)
58
59
  end
59
60
 
60
61
  def member_local_release_config_from_branch(member)
@@ -75,20 +76,22 @@ module Kettle
75
76
  raise Error, "could not determine git root for #{member.root}: #{stderr}" unless status.success?
76
77
 
77
78
  File.realpath(stdout.strip)
79
+ rescue SystemCallError
80
+ raise Error, "could not determine git root for #{member.root}: invalid Git root response"
78
81
  end
79
82
 
80
83
  def member_relative_root(member, root)
81
- member_root = File.realpath(member.root)
82
- return "." if member_root == root
83
- return member_root.delete_prefix("#{root}/") if member_root.start_with?("#{root}/")
84
+ relative = Paths.relative(member.root, root)
85
+ raise Error, "member root #{member.root} is outside git root #{root}" unless relative
84
86
 
85
- raise Error, "member root #{member.root} is outside git root #{root}"
87
+ relative
86
88
  end
87
89
 
88
90
  def member_local_config_paths(root, relative_root)
89
91
  branches = local_branches(root)
90
92
  candidates = Config::DEFAULT_PATHS.map do |path|
91
- (relative_root == ".") ? path : File.join(relative_root, path)
93
+ candidate = (relative_root == ".") ? path : File.join(relative_root, path)
94
+ candidate.tr("\\", "/")
92
95
  end
93
96
  branches.each_with_object([]) do |branch, memo|
94
97
  candidates.each do |path|
@@ -11,6 +11,17 @@ module Kettle
11
11
  "KETTLE_RELEASE_GEM_SIGNING_PASSPHRASE"
12
12
  ].freeze
13
13
  MISE_SESSION_ENV_PREFIX = "__MISE_"
14
+ WINDOWS_ENV_EXEC_SCRIPT = <<~RUBY
15
+ unset_count = Integer(ARGV.shift)
16
+ unset_count.times { ENV.delete(ARGV.shift) }
17
+ assignment_count = Integer(ARGV.shift)
18
+ assignment_count.times do
19
+ key, value = ARGV.shift.split("=", 2)
20
+ ENV[key] = value
21
+ end
22
+ abort "missing command after environment arguments" if ARGV.shift != "--"
23
+ exec(*ARGV)
24
+ RUBY
14
25
 
15
26
  class OtpCoordinator
16
27
  def initialize(input: $stdin, output: $stdout, queue_total: nil, secrets_provider: nil, event_handler: nil)
@@ -371,7 +382,7 @@ module Kettle
371
382
  end
372
383
  end
373
384
  end
374
- rescue Errno::EIO
385
+ rescue Errno::EIO, EOFError
375
386
  # PTY raises EIO when the child process exits after closing the slave.
376
387
  rescue Error => error
377
388
  stderr << "#{error.message}\n"
@@ -386,6 +397,19 @@ module Kettle
386
397
  end
387
398
 
388
399
  def run_interactive_open3(env:, argv:, chdir:, member_name:, process_options:, stdout_line_handler:, log_io: nil, passthrough_output: true)
400
+ if Gem.win_platform?
401
+ return run_interactive_open3_threads(
402
+ env: env,
403
+ argv: argv,
404
+ chdir: chdir,
405
+ member_name: member_name,
406
+ process_options: process_options,
407
+ stdout_line_handler: stdout_line_handler,
408
+ log_io: log_io,
409
+ passthrough_output: passthrough_output
410
+ )
411
+ end
412
+
389
413
  captured_stdout = +""
390
414
  captured_stderr = +""
391
415
  stdout_line_buffer = +""
@@ -431,6 +455,62 @@ module Kettle
431
455
  [captured_stdout, captured_stderr, status]
432
456
  end
433
457
 
458
+ def run_interactive_open3_threads(env:, argv:, chdir:, member_name:, process_options:, stdout_line_handler:, log_io: nil, passthrough_output: true)
459
+ captured_stdout = +""
460
+ captured_stderr = +""
461
+ stdout_line_buffer = +""
462
+ prompt_buffer = +""
463
+ status = nil
464
+ Open3.popen3(env, *argv, chdir: chdir, **process_options) do |input, output, stderr_stream, wait_thread|
465
+ streams = [output, stderr_stream]
466
+ streams << $stdin if $stdin.tty? && !otp_coordinator
467
+ queue = Queue.new
468
+ readers = streams.map do |stream|
469
+ Thread.new do # rubocop:disable ThreadSafety/NewThread -- Windows pipes are not selectable with IO.select.
470
+ loop { queue << [stream, stream.readpartial(1024)] }
471
+ rescue IOError
472
+ queue << [stream, nil]
473
+ end
474
+ end
475
+ input_reader = readers[streams.index($stdin)] if streams.include?($stdin)
476
+ active_streams = streams.dup
477
+ begin
478
+ until active_streams.empty?
479
+ stream, chunk = queue.pop
480
+ unless chunk
481
+ active_streams.delete(stream)
482
+ next
483
+ end
484
+ if stream.equal?($stdin)
485
+ input.write(chunk)
486
+ next
487
+ end
488
+ if stream.equal?(output)
489
+ captured_stdout << chunk
490
+ transcript_write(log_io, chunk)
491
+ stdout_line_buffer = print_interactive_stdout(stdout_line_buffer, chunk, stdout_line_handler, passthrough_output: passthrough_output)
492
+ else
493
+ captured_stderr << chunk
494
+ transcript_write(log_io, chunk)
495
+ $stderr.print(chunk) if passthrough_output
496
+ end
497
+ prompt_buffer = handle_interactive_prompt(input, chunk, member_name: member_name, prompt_buffer: prompt_buffer)
498
+ end
499
+ status = wait_thread.value
500
+ rescue Error => exception
501
+ captured_stderr << "#{exception.message}\n"
502
+ transcript_write(log_io, "#{exception.message}\n")
503
+ terminate_process(wait_thread.pid)
504
+ status = failure_status
505
+ ensure
506
+ input_reader&.kill if input_reader&.alive?
507
+ readers.each(&:join)
508
+ end
509
+ flush_interactive_stdout(stdout_line_buffer, stdout_line_handler, passthrough_output: passthrough_output)
510
+ end
511
+ [captured_stdout, captured_stderr, status]
512
+ end
513
+
434
514
  def read_interactive_stream(
435
515
  reader,
436
516
  output,
@@ -562,7 +642,10 @@ module Kettle
562
642
 
563
643
  def write_otp_response(input, chunk, member_name:)
564
644
  response = otp_coordinator.request(member_name: member_name || "release", chunk: chunk)
565
- return if response.to_s.empty?
645
+ if response.to_s.empty?
646
+ input.close unless input.closed?
647
+ return
648
+ end
566
649
 
567
650
  input.write("#{response}\n")
568
651
  input.flush
@@ -586,7 +669,7 @@ module Kettle
586
669
  end
587
670
 
588
671
  def normalize_output(output)
589
- output.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
672
+ output.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "").gsub("\r\n", "\n")
590
673
  end
591
674
 
592
675
  def failure_status
@@ -594,8 +677,8 @@ module Kettle
594
677
  end
595
678
 
596
679
  def terminate_process(pid)
597
- Process.kill("TERM", pid)
598
- rescue Errno::ESRCH, Errno::EPERM
680
+ Process.kill(Gem.win_platform? ? "KILL" : "TERM", pid)
681
+ rescue Errno::ESRCH, Errno::EPERM, Errno::EINVAL
599
682
  nil
600
683
  end
601
684
 
@@ -612,6 +695,23 @@ module Kettle
612
695
  mise_argv = ["mise", "exec", "-C", member.root, "--"]
613
696
  return [*mise_argv, *argv] if injected_env.empty?
614
697
 
698
+ if Gem.win_platform?
699
+ return [
700
+ *mise_argv,
701
+ "ruby",
702
+ "--disable=rubyopt",
703
+ "-e",
704
+ WINDOWS_ENV_EXEC_SCRIPT,
705
+ "--",
706
+ unset_env.length.to_s,
707
+ *unset_env.map { |key, _value| key.to_s },
708
+ set_env.length.to_s,
709
+ *set_env.map { |key, value| "#{key}=#{value}" },
710
+ "--",
711
+ *argv
712
+ ]
713
+ end
714
+
615
715
  [*mise_argv, "env", *injected_env, *argv]
616
716
  end
617
717
 
@@ -402,9 +402,7 @@ module Kettle
402
402
  private
403
403
 
404
404
  def path_within_root?(path)
405
- expanded_path = File.expand_path(path)
406
- expanded_root = File.expand_path(root)
407
- expanded_path == expanded_root || expanded_path.start_with?("#{expanded_root}/")
405
+ Paths.within?(path, root)
408
406
  end
409
407
 
410
408
  def expand_config_relative_path(value)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require_relative "paths"
4
5
 
5
6
  module Kettle
6
7
  module Family
@@ -36,7 +37,7 @@ module Kettle
36
37
 
37
38
  (configured + discovered).each do |member|
38
39
  existing = by_name[member.name]
39
- raise Error, "duplicate family member #{member.name.inspect}" if existing && existing.root != member.root
40
+ raise Error, "duplicate family member #{member.name.inspect}" if existing && !same_directory?(existing.root, member.root)
40
41
 
41
42
  by_name[member.name] = member
42
43
  end
@@ -46,7 +47,7 @@ module Kettle
46
47
 
47
48
  def discover_members
48
49
  gemspecs = config.discover_member_roots.flat_map do |root|
49
- Dir.glob(File.join(root, "**", "*.gemspec"))
50
+ Paths.glob(root, "**", "*.gemspec")
50
51
  end
51
52
  gemspecs.reject! { |path| excluded_gemspec?(path) }
52
53
  gemspecs.map { |path| member_from_gemspec(path) }
@@ -82,7 +83,7 @@ module Kettle
82
83
  end
83
84
 
84
85
  def primary_gemspec(root)
85
- gemspecs = Dir.glob(File.join(root, "*.gemspec"))
86
+ gemspecs = Paths.glob(root, "*.gemspec")
86
87
  raise Error, "no gemspec found in #{root}" if gemspecs.empty?
87
88
  return gemspecs.first if gemspecs.one?
88
89
 
@@ -94,9 +95,8 @@ module Kettle
94
95
 
95
96
  # Selection limits evaluation, not membership in the configuration.
96
97
  roots = configured.map(&:root) + config.configured_member_roots + config.explicit_members.map { |entry| entry.fetch("root") }
97
- configured_roots = roots.map { |root| normalized_path(root) }
98
98
  discovered.each do |member|
99
- next if configured_roots.include?(normalized_path(member.root))
99
+ next if roots.any? { |root| same_directory?(root, member.root) }
100
100
 
101
101
  warnings << {
102
102
  "kind" => "unlisted_discovered_member",
@@ -108,7 +108,11 @@ module Kettle
108
108
  end
109
109
 
110
110
  def normalized_path(path)
111
- File.expand_path(path)
111
+ Paths.canonical(path).tr("\\", "/")
112
+ end
113
+
114
+ def same_directory?(left, right)
115
+ Paths.same?(left, right)
112
116
  end
113
117
 
114
118
  def member_from_gemspec(path)
@@ -172,7 +176,7 @@ module Kettle
172
176
  canonical = File.join(root, "lib", gem_name.tr("-", "_"), "version.rb")
173
177
  return canonical if File.file?(canonical)
174
178
 
175
- candidates = Dir.glob(File.join(root, "lib", "**", "version.rb"))
179
+ candidates = Paths.glob(root, "lib", "**", "version.rb")
176
180
  candidates.min
177
181
  end
178
182
 
@@ -220,8 +224,36 @@ module Kettle
220
224
 
221
225
  def path_matches_pattern?(path, pattern)
222
226
  relative_candidates(path).any? do |relative|
223
- File.fnmatch?(pattern, relative, File::FNM_DOTMATCH | File::FNM_EXTGLOB)
227
+ path_glob_match?(pattern, relative)
228
+ end
229
+ end
230
+
231
+ def path_glob_match?(pattern, path)
232
+ pattern_parts = pattern.tr("\\", "/").split("/")
233
+ path_parts = path.tr("\\", "/").split("/")
234
+ if Gem.win_platform?
235
+ pattern_parts.map!(&:downcase)
236
+ path_parts.map!(&:downcase)
224
237
  end
238
+
239
+ path_glob_parts_match?(pattern_parts, path_parts, {})
240
+ end
241
+
242
+ def path_glob_parts_match?(pattern_parts, path_parts, memo)
243
+ key = [pattern_parts.length, path_parts.length]
244
+ return memo[key] if memo.key?(key)
245
+ return memo[key] = path_parts.empty? if pattern_parts.empty?
246
+
247
+ pattern_part = pattern_parts.first
248
+ matched = if pattern_part == "**"
249
+ path_glob_parts_match?(pattern_parts.drop(1), path_parts, memo) ||
250
+ (!path_parts.empty? && path_glob_parts_match?(pattern_parts, path_parts.drop(1), memo))
251
+ else
252
+ !path_parts.empty? &&
253
+ File.fnmatch?(pattern_part, path_parts.first, File::FNM_DOTMATCH | File::FNM_EXTGLOB) &&
254
+ path_glob_parts_match?(pattern_parts.drop(1), path_parts.drop(1), memo)
255
+ end
256
+ memo[key] = matched
225
257
  end
226
258
 
227
259
  def relative_candidates(path)
@@ -231,12 +263,7 @@ module Kettle
231
263
  end
232
264
 
233
265
  def relative_path(path, root)
234
- expanded_path = File.expand_path(path)
235
- expanded_root = File.expand_path(root)
236
- prefix = "#{expanded_root}/"
237
- return unless expanded_path.start_with?(prefix)
238
-
239
- expanded_path.delete_prefix(prefix)
266
+ Paths.relative(path, root)
240
267
  end
241
268
 
242
269
  class GemfileDependencyCollector
@@ -7,6 +7,7 @@ require "open3"
7
7
  require "time"
8
8
 
9
9
  require_relative "concurrency"
10
+ require_relative "paths"
10
11
 
11
12
  module Kettle
12
13
  module Family
@@ -140,7 +141,7 @@ module Kettle
140
141
 
141
142
  raise Error, "install local dependency does not exist: #{path}" unless Dir.exist?(expanded)
142
143
 
143
- gemspecs = Dir.glob(File.join(expanded, "*.gemspec"))
144
+ gemspecs = Paths.glob(expanded, "*.gemspec")
144
145
  raise Error, "no gemspec found for install local dependency: #{path}" if gemspecs.empty?
145
146
  raise Error, "multiple gemspecs found for install local dependency: #{path}" if gemspecs.size > 1
146
147
 
@@ -163,7 +164,7 @@ module Kettle
163
164
  canonical = File.join(root, "lib", gem_name.tr("-", "_"), "version.rb")
164
165
  return canonical if File.file?(canonical)
165
166
 
166
- Dir.glob(File.join(root, "lib", "**", "version.rb")).min
167
+ Paths.glob(root, "lib", "**", "version.rb").min
167
168
  end
168
169
 
169
170
  def required_ruby_version(spec)
@@ -3,6 +3,8 @@
3
3
  require "rubygems"
4
4
  require "kettle/dev"
5
5
 
6
+ require_relative "paths"
7
+
6
8
  module Kettle
7
9
  module Family
8
10
  class NomonoBootstrap
@@ -91,7 +93,7 @@ module Kettle
91
93
  end
92
94
 
93
95
  def local_gemfile_edits(member)
94
- Dir.glob(File.join(member.root, "gemfiles/modular/**/*_local.gemfile")).sort.flat_map do |path|
96
+ Paths.glob(member.root, "gemfiles/modular/**/*_local.gemfile").sort.flat_map do |path|
95
97
  source = File.read(path)
96
98
  parse_result = Kettle::Dev::VersionBump.parse_source(source, path)
97
99
  Kettle::Dev::VersionBump.each_node(parse_result.value).filter_map do |node|
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "kettle/dev"
5
+
6
+ module Kettle
7
+ module Family
8
+ module Paths
9
+ module_function
10
+
11
+ def canonical(path, base: nil)
12
+ Kettle::Dev::Paths.canonical(path, base: base)
13
+ end
14
+
15
+ def same?(left, right, base: nil)
16
+ Kettle::Dev::Paths.same?(left, right, base: base)
17
+ end
18
+
19
+ def within?(path, root, base: nil)
20
+ Kettle::Dev::Paths.within?(path, root, base: base)
21
+ end
22
+
23
+ # Computes a relative path by walking filesystem ancestors. Component
24
+ # string comparisons are insufficient on Windows, where equivalent
25
+ # paths may have different drive-letter casing or filesystem aliases.
26
+ def relative(path, root)
27
+ candidate = canonical(path)
28
+ boundary = canonical(root)
29
+ return "." if same?(candidate, boundary)
30
+
31
+ is_directory = File.directory?(candidate)
32
+ current = is_directory ? candidate : File.dirname(candidate)
33
+ parts = is_directory ? [] : [File.basename(candidate)]
34
+
35
+ until same?(current, boundary)
36
+ parent = File.dirname(current)
37
+ return unless parent != current
38
+
39
+ parts.unshift(File.basename(current))
40
+ current = parent
41
+ end
42
+
43
+ File.join(*parts).tr("\\", "/")
44
+ end
45
+
46
+ def glob(*parts)
47
+ Dir.glob(File.join(*parts).tr("\\", "/"))
48
+ end
49
+
50
+ def local_path_remote?(remote)
51
+ text = remote.to_s
52
+ drive_absolute = text.length >= 3 && text[1] == ":" && ["/", "\\"].include?(text[2])
53
+ drive_absolute || text.start_with?("/", "./", "../", ".\\", "..\\") || Pathname.new(text).absolute?
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "paths"
4
+
3
5
  module Kettle
4
6
  module Family
5
7
  class ReadinessCheck
@@ -43,7 +45,8 @@ module Kettle
43
45
  def missing_required_bins
44
46
  required_bins.filter_map do |path|
45
47
  full_path = File.join(member.root, path)
46
- next if File.file?(full_path) && File.executable?(full_path)
48
+ executable = Gem.win_platform? || File.executable?(full_path)
49
+ next if File.file?(full_path) && executable
47
50
 
48
51
  "missing executable binstub #{path}"
49
52
  end
@@ -100,7 +103,8 @@ module Kettle
100
103
  return [] unless File.file?(lockfile)
101
104
 
102
105
  File.readlines(lockfile).filter_map.with_index(1) do |line, index|
103
- next unless line.start_with?(" remote: /", " remote: ./", " remote: ../")
106
+ next unless line.start_with?(" remote:")
107
+ next unless Paths.local_path_remote?(line.split("remote:", 2).last.to_s.strip)
104
108
  next if allowed_local_path?(line)
105
109
 
106
110
  "release lockfile has local path remote at Gemfile.lock:#{index}"
@@ -112,17 +116,14 @@ module Kettle
112
116
  remote_path = normalized_path(remote, base: member.root)
113
117
  return false unless remote_path
114
118
 
115
- allowed_local_path_roots.any? { |root| remote_path == root || remote_path.start_with?("#{root}/") }
119
+ allowed_local_path_roots.any? { |root| Paths.within?(remote_path, root) }
116
120
  end
117
121
 
118
122
  def normalized_path(path, base: nil)
119
123
  text = path.to_s
120
124
  return nil if text.empty? || text.casecmp("false").zero?
121
125
 
122
- expanded = File.expand_path(text, base)
123
- File.realpath(expanded)
124
- rescue Errno::ENOENT
125
- expanded
126
+ Paths.canonical(text, base: base)
126
127
  end
127
128
 
128
129
  def required_files
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "paths"
4
5
 
5
6
  module Kettle
6
7
  module Family
@@ -57,7 +58,7 @@ module Kettle
57
58
  raise Error, "#{name} release graph requires a local path selector" if selector_env.empty?
58
59
 
59
60
  selector_env.each_value do |value|
60
- next if local_path_roots.any? { |root| value == root || value.start_with?("#{root}/") }
61
+ next if local_path_roots.any? { |root| Paths.within?(value, root) }
61
62
 
62
63
  raise Error, "#{name} selector #{value.inspect} is outside its declared local path roots"
63
64
  end
@@ -66,17 +67,14 @@ module Kettle
66
67
 
67
68
  raise Error, "monorepo_ci_local release graph requires a CI root" if ci_root.to_s.empty?
68
69
  local_path_roots.each do |path|
69
- next if path == ci_root || path.start_with?("#{ci_root}/")
70
+ next if Paths.within?(path, ci_root)
70
71
 
71
72
  raise Error, "monorepo_ci_local path #{path.inspect} is outside CI root #{ci_root.inspect}"
72
73
  end
73
74
  end
74
75
 
75
76
  def canonical_path(path)
76
- expanded = File.expand_path(path)
77
- File.realpath(expanded)
78
- rescue Errno::ENOENT
79
- expanded
77
+ Paths.canonical(path)
80
78
  end
81
79
  end
82
80
  end