carson 2.24.0 → 2.25.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.
@@ -0,0 +1,93 @@
1
+ module Carson
2
+ class Runtime
3
+ module Local
4
+ def sync!
5
+ fingerprint_status = block_if_outsider_fingerprints!
6
+ return fingerprint_status unless fingerprint_status.nil?
7
+
8
+ unless working_tree_clean?
9
+ puts_line "BLOCK: working tree is dirty; commit/stash first, then run carson sync."
10
+ return EXIT_BLOCK
11
+ end
12
+ start_branch = current_branch
13
+ switched = false
14
+ git_system!( "fetch", config.git_remote, "--prune" )
15
+ if start_branch != config.main_branch
16
+ git_system!( "switch", config.main_branch )
17
+ switched = true
18
+ end
19
+ git_system!( "pull", "--ff-only", config.git_remote, config.main_branch )
20
+ ahead_count, behind_count, error_text = main_sync_counts
21
+ if error_text
22
+ puts_line "BLOCK: unable to verify main sync state (#{error_text})."
23
+ return EXIT_BLOCK
24
+ end
25
+ if ahead_count.zero? && behind_count.zero?
26
+ puts_line "OK: local #{config.main_branch} is now in sync with #{config.git_remote}/#{config.main_branch}."
27
+ return EXIT_OK
28
+ end
29
+ puts_line "BLOCK: local #{config.main_branch} still diverges (ahead=#{ahead_count}, behind=#{behind_count})."
30
+ EXIT_BLOCK
31
+ ensure
32
+ git_system!( "switch", start_branch ) if switched && branch_exists?( branch_name: start_branch )
33
+ end
34
+
35
+ private
36
+
37
+ # Returns ahead/behind counts for local main versus configured remote main.
38
+ def main_sync_counts
39
+ target = "#{config.main_branch}...#{config.git_remote}/#{config.main_branch}"
40
+ stdout_text, stderr_text, success, = git_run( "rev-list", "--left-right", "--count", target )
41
+ unless success
42
+ error_text = stderr_text.to_s.strip
43
+ error_text = "git rev-list failed" if error_text.empty?
44
+ return [ 0, 0, error_text ]
45
+ end
46
+ counts = stdout_text.to_s.strip.split( /\s+/ )
47
+ return [ 0, 0, "unexpected rev-list output: #{stdout_text.to_s.strip}" ] if counts.length < 2
48
+
49
+ [ counts[ 0 ].to_i, counts[ 1 ].to_i, nil ]
50
+ end
51
+
52
+ def working_tree_clean?
53
+ git_capture!( "status", "--porcelain" ).strip.empty?
54
+ end
55
+
56
+ def inside_git_work_tree?
57
+ stdout_text, = git_capture_soft( "rev-parse", "--is-inside-work-tree" )
58
+ stdout_text.to_s.strip == "true"
59
+ end
60
+
61
+ # Uses `git remote get-url` as existence check to avoid parsing remote lists.
62
+ def git_remote_exists?( remote_name: )
63
+ _, _, success, = git_run( "remote", "get-url", remote_name.to_s )
64
+ success
65
+ end
66
+
67
+ # In outsider mode, Carson must not leave Carson-owned fingerprints in host repositories.
68
+ def block_if_outsider_fingerprints!
69
+ return nil unless outsider_mode?
70
+
71
+ violations = outsider_fingerprint_violations
72
+ return nil if violations.empty?
73
+
74
+ violations.each { |entry| puts_line "BLOCK: #{entry}" }
75
+ EXIT_BLOCK
76
+ end
77
+
78
+ # Carson source repository itself is excluded from host-repository fingerprint checks.
79
+ def outsider_mode?
80
+ File.expand_path( repo_root ) != File.expand_path( tool_root )
81
+ end
82
+
83
+ # Detects Carson-owned host artefacts that violate outsider boundary.
84
+ def outsider_fingerprint_violations
85
+ violations = []
86
+ violations << "forbidden file .carson.yml detected" if File.file?( File.join( repo_root, ".carson.yml" ) )
87
+ violations << "forbidden file bin/carson detected" if File.file?( File.join( repo_root, "bin", "carson" ) )
88
+ violations << "forbidden directory .tools/carson detected" if Dir.exist?( File.join( repo_root, ".tools", "carson" ) )
89
+ violations
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,347 @@
1
+ module Carson
2
+ class Runtime
3
+ module Local
4
+ TEMPLATE_SYNC_BRANCH = "carson/template-sync".freeze
5
+
6
+ SUPERSEDED = [
7
+ ".github/carson-instructions.md",
8
+ ".github/workflows/carson-lint.yml",
9
+ ".github/.mega-linter.yml"
10
+ ].freeze
11
+
12
+ # Read-only template drift check; returns block when managed files are out of sync.
13
+ def template_check!
14
+ fingerprint_status = block_if_outsider_fingerprints!
15
+ return fingerprint_status unless fingerprint_status.nil?
16
+
17
+ puts_verbose ""
18
+ puts_verbose "[Template Sync Check]"
19
+ results = template_results
20
+ stale = template_superseded_present
21
+ drift_count = results.count { |entry| entry.fetch( :status ) == "drift" }
22
+ error_count = results.count { |entry| entry.fetch( :status ) == "error" }
23
+ stale_count = stale.count
24
+ results.each do |entry|
25
+ puts_verbose "template_file: #{entry.fetch( :file )} status=#{entry.fetch( :status )} reason=#{entry.fetch( :reason )}"
26
+ end
27
+ stale.each { |file| puts_verbose "template_file: #{file} status=stale reason=superseded" }
28
+ puts_verbose "template_summary: total=#{results.count} drift=#{drift_count} stale=#{stale_count} error=#{error_count}"
29
+ unless verbose?
30
+ if ( drift_count + stale_count ).positive?
31
+ summary_parts = []
32
+ summary_parts << "#{drift_count} of #{results.count} drifted" if drift_count.positive?
33
+ summary_parts << "#{stale_count} stale" if stale_count.positive?
34
+ puts_line "Templates: #{summary_parts.join( ", " )}"
35
+ results.select { |entry| entry.fetch( :status ) == "drift" }.each { |entry| puts_line " #{entry.fetch( :file )}" }
36
+ stale.each { |file| puts_line " #{file} — superseded" }
37
+ else
38
+ puts_line "Templates: #{results.count} files in sync"
39
+ end
40
+ end
41
+ return EXIT_ERROR if error_count.positive?
42
+
43
+ ( drift_count + stale_count ).positive? ? EXIT_BLOCK : EXIT_OK
44
+ end
45
+
46
+ # Applies managed template files as full-file writes from Carson sources.
47
+ # Also removes superseded files that are no longer part of the managed set.
48
+ def template_apply!( push_prep: false )
49
+ fingerprint_status = block_if_outsider_fingerprints!
50
+ return fingerprint_status unless fingerprint_status.nil?
51
+
52
+ puts_verbose ""
53
+ puts_verbose "[Template Sync Apply]"
54
+ results = template_results
55
+ stale = template_superseded_present
56
+ applied = 0
57
+ results.each do |entry|
58
+ if entry.fetch( :status ) == "error"
59
+ puts_verbose "template_file: #{entry.fetch( :file )} status=error reason=#{entry.fetch( :reason )}"
60
+ next
61
+ end
62
+
63
+ file_path = File.join( repo_root, entry.fetch( :file ) )
64
+ if entry.fetch( :status ) == "ok"
65
+ puts_verbose "template_file: #{entry.fetch( :file )} status=ok reason=in_sync"
66
+ next
67
+ end
68
+
69
+ FileUtils.mkdir_p( File.dirname( file_path ) )
70
+ File.write( file_path, entry.fetch( :applied_content ) )
71
+ puts_verbose "template_file: #{entry.fetch( :file )} status=updated reason=#{entry.fetch( :reason )}"
72
+ applied += 1
73
+ end
74
+
75
+ removed = 0
76
+ stale.each do |file|
77
+ file_path = resolve_repo_path!( relative_path: file, label: "superseded file #{file}" )
78
+ File.delete( file_path )
79
+ puts_verbose "template_file: #{file} status=removed reason=superseded"
80
+ removed += 1
81
+ end
82
+
83
+ error_count = results.count { |entry| entry.fetch( :status ) == "error" }
84
+ puts_verbose "template_apply_summary: updated=#{applied} removed=#{removed} error=#{error_count}"
85
+ unless verbose?
86
+ if applied.positive? || removed.positive?
87
+ summary_parts = []
88
+ summary_parts << "#{applied} updated" if applied.positive?
89
+ summary_parts << "#{removed} removed" if removed.positive?
90
+ puts_line "Templates applied (#{summary_parts.join( ", " )})."
91
+ else
92
+ puts_line "Templates in sync."
93
+ end
94
+ end
95
+ return EXIT_ERROR if error_count.positive?
96
+
97
+ return EXIT_BLOCK if push_prep && push_prep_commit!
98
+ EXIT_OK
99
+ end
100
+
101
+ private
102
+
103
+ # Orchestrates worktree-based template propagation to the remote.
104
+ def template_propagate!( drift_count: )
105
+ if drift_count.zero?
106
+ puts_verbose "template_propagate: skip (no drift)"
107
+ return { status: :skip, reason: "no drift" }
108
+ end
109
+
110
+ unless git_remote_exists?( remote_name: config.git_remote )
111
+ puts_verbose "template_propagate: skip (no remote #{config.git_remote})"
112
+ return { status: :skip, reason: "no remote" }
113
+ end
114
+
115
+ worktree_dir = nil
116
+ begin
117
+ worktree_dir = template_propagate_create_worktree!
118
+ template_propagate_write_files!( worktree_dir: worktree_dir )
119
+ committed = template_propagate_commit!( worktree_dir: worktree_dir )
120
+ unless committed
121
+ puts_verbose "template_propagate: skip (no changes after write)"
122
+ return { status: :skip, reason: "no changes" }
123
+ end
124
+ result = template_propagate_deliver!( worktree_dir: worktree_dir )
125
+ template_propagate_report!( result: result )
126
+ result
127
+ rescue StandardError => e
128
+ puts_verbose "template_propagate: error (#{e.message})"
129
+ { status: :error, reason: e.message }
130
+ ensure
131
+ template_propagate_cleanup!( worktree_dir: worktree_dir ) if worktree_dir
132
+ end
133
+ end
134
+
135
+ def template_propagate_create_worktree!
136
+ worktree_dir = File.join( Dir.tmpdir, "carson-template-sync-#{Process.pid}-#{Time.now.to_i}" )
137
+ wt_git = Adapters::Git.new( repo_root: worktree_dir )
138
+
139
+ git_system!( "fetch", config.git_remote, config.main_branch )
140
+ git_system!( "worktree", "add", "--detach", worktree_dir, "#{config.git_remote}/#{config.main_branch}" )
141
+ wt_git.run( "checkout", "-B", TEMPLATE_SYNC_BRANCH )
142
+ wt_git.run( "config", "core.hooksPath", "/dev/null" )
143
+ puts_verbose "template_propagate: worktree created at #{worktree_dir}"
144
+ worktree_dir
145
+ end
146
+
147
+ def template_propagate_write_files!( worktree_dir: )
148
+ config.template_managed_files.each do |managed_file|
149
+ template_path = template_source_path( managed_file: managed_file )
150
+ next if template_path.nil?
151
+
152
+ target_path = File.join( worktree_dir, managed_file )
153
+ FileUtils.mkdir_p( File.dirname( target_path ) )
154
+ expected_content = normalize_text( text: File.read( template_path ) )
155
+ File.write( target_path, expected_content )
156
+ puts_verbose "template_propagate: wrote #{managed_file}"
157
+ end
158
+
159
+ template_superseded_present_in( root: worktree_dir ).each do |file|
160
+ file_path = File.join( worktree_dir, file )
161
+ File.delete( file_path )
162
+ puts_verbose "template_propagate: removed superseded #{file}"
163
+ end
164
+ end
165
+
166
+ def template_propagate_commit!( worktree_dir: )
167
+ wt_git = Adapters::Git.new( repo_root: worktree_dir )
168
+ wt_git.run( "add", "--all" )
169
+
170
+ _, _, no_diff, = wt_git.run( "diff", "--cached", "--quiet" )
171
+ return false if no_diff
172
+
173
+ wt_git.run( "commit", "-m", "chore: sync Carson #{Carson::VERSION} managed templates" )
174
+ puts_verbose "template_propagate: committed"
175
+ true
176
+ end
177
+
178
+ def template_propagate_deliver!( worktree_dir: )
179
+ if config.workflow_style == "trunk"
180
+ template_propagate_deliver_trunk!( worktree_dir: worktree_dir )
181
+ else
182
+ template_propagate_deliver_branch!( worktree_dir: worktree_dir )
183
+ end
184
+ end
185
+
186
+ def template_propagate_deliver_trunk!( worktree_dir: )
187
+ wt_git = Adapters::Git.new( repo_root: worktree_dir )
188
+ stdout_text, stderr_text, success, = wt_git.run( "push", config.git_remote, "HEAD:refs/heads/#{config.main_branch}" )
189
+ unless success
190
+ error_text = stderr_text.to_s.strip
191
+ error_text = "push to #{config.main_branch} failed" if error_text.empty?
192
+ raise error_text
193
+ end
194
+ puts_verbose "template_propagate: pushed to #{config.main_branch}"
195
+ { status: :pushed, ref: config.main_branch }
196
+ end
197
+
198
+ def template_propagate_deliver_branch!( worktree_dir: )
199
+ wt_git = Adapters::Git.new( repo_root: worktree_dir )
200
+ stdout_text, stderr_text, success, = wt_git.run( "push", "--force-with-lease", config.git_remote, "#{TEMPLATE_SYNC_BRANCH}:#{TEMPLATE_SYNC_BRANCH}" )
201
+ unless success
202
+ error_text = stderr_text.to_s.strip
203
+ error_text = "push #{TEMPLATE_SYNC_BRANCH} failed" if error_text.empty?
204
+ raise error_text
205
+ end
206
+ puts_verbose "template_propagate: pushed #{TEMPLATE_SYNC_BRANCH}"
207
+
208
+ pr_url = template_propagate_ensure_pr!( worktree_dir: worktree_dir )
209
+ { status: :pr, branch: TEMPLATE_SYNC_BRANCH, pr_url: pr_url }
210
+ end
211
+
212
+ def template_propagate_ensure_pr!( worktree_dir: )
213
+ wt_gh = Adapters::GitHub.new( repo_root: worktree_dir )
214
+
215
+ stdout_text, _, success, = wt_gh.run(
216
+ "pr", "list",
217
+ "--head", TEMPLATE_SYNC_BRANCH,
218
+ "--base", config.main_branch,
219
+ "--state", "open",
220
+ "--json", "url",
221
+ "--jq", ".[0].url"
222
+ )
223
+ existing_url = stdout_text.to_s.strip
224
+ if success && !existing_url.empty?
225
+ puts_verbose "template_propagate: existing PR #{existing_url}"
226
+ return existing_url
227
+ end
228
+
229
+ stdout_text, stderr_text, success, = wt_gh.run(
230
+ "pr", "create",
231
+ "--head", TEMPLATE_SYNC_BRANCH,
232
+ "--base", config.main_branch,
233
+ "--title", "chore: sync Carson #{Carson::VERSION} managed templates",
234
+ "--body", "Auto-generated by `carson refresh`.\n\nUpdates managed template files to match Carson #{Carson::VERSION}."
235
+ )
236
+ unless success
237
+ error_text = stderr_text.to_s.strip
238
+ error_text = "gh pr create failed" if error_text.empty?
239
+ raise error_text
240
+ end
241
+ pr_url = stdout_text.to_s.strip
242
+ puts_verbose "template_propagate: created PR #{pr_url}"
243
+ pr_url
244
+ end
245
+
246
+ def template_propagate_cleanup!( worktree_dir: )
247
+ git_run( "worktree", "remove", "--force", worktree_dir )
248
+ git_run( "branch", "-D", TEMPLATE_SYNC_BRANCH )
249
+ puts_verbose "template_propagate: worktree and local branch cleaned up"
250
+ rescue StandardError => e
251
+ puts_verbose "template_propagate: cleanup warning (#{e.message})"
252
+ end
253
+
254
+ def template_propagate_report!( result: )
255
+ case result.fetch( :status )
256
+ when :pushed
257
+ puts_line "Templates pushed to #{result.fetch( :ref )}."
258
+ when :pr
259
+ puts_line "Template sync PR: #{result.fetch( :pr_url )}"
260
+ end
261
+ end
262
+
263
+ def template_superseded_present_in( root: )
264
+ SUPERSEDED.select do |file|
265
+ File.file?( File.join( root, file ) )
266
+ end
267
+ end
268
+
269
+ def template_results
270
+ config.template_managed_files.map { |managed_file| template_result_for_file( managed_file: managed_file ) }
271
+ end
272
+
273
+ def template_superseded_present
274
+ SUPERSEDED.select do |file|
275
+ file_path = resolve_repo_path!( relative_path: file, label: "superseded file #{file}" )
276
+ File.file?( file_path )
277
+ end
278
+ end
279
+
280
+ def template_result_for_file( managed_file: )
281
+ template_path = template_source_path( managed_file: managed_file )
282
+ return { file: managed_file, status: "error", reason: "missing template #{File.basename( managed_file )}", applied_content: nil } if template_path.nil?
283
+
284
+ expected_content = normalize_text( text: File.read( template_path ) )
285
+ file_path = resolve_repo_path!( relative_path: managed_file, label: "template.managed_files entry #{managed_file}" )
286
+ return { file: managed_file, status: "drift", reason: "missing_file", applied_content: expected_content } unless File.file?( file_path )
287
+
288
+ current_content = normalize_text( text: File.read( file_path ) )
289
+ return { file: managed_file, status: "ok", reason: "in_sync", applied_content: current_content } if current_content == expected_content
290
+
291
+ { file: managed_file, status: "drift", reason: "content_mismatch", applied_content: expected_content }
292
+ end
293
+
294
+ def normalize_text( text: )
295
+ "#{text.to_s.gsub( "\r\n", "\n" ).rstrip}\n"
296
+ end
297
+
298
+ def github_templates_dir
299
+ File.join( tool_root, "templates", ".github" )
300
+ end
301
+
302
+ def template_source_path( managed_file: )
303
+ relative_within_github = managed_file.delete_prefix( ".github/" )
304
+
305
+ canonical = config.template_canonical
306
+ if canonical && !canonical.empty?
307
+ canonical_path = File.join( canonical, relative_within_github )
308
+ return canonical_path if File.file?( canonical_path )
309
+ end
310
+
311
+ template_path = File.join( github_templates_dir, relative_within_github )
312
+ return template_path if File.file?( template_path )
313
+
314
+ basename_path = File.join( github_templates_dir, File.basename( managed_file ) )
315
+ return basename_path if File.file?( basename_path )
316
+
317
+ nil
318
+ end
319
+
320
+ def push_prep_commit!
321
+ return if current_branch == config.main_branch
322
+
323
+ dirty = managed_dirty_paths
324
+ return if dirty.empty?
325
+
326
+ git_system!( "add", *dirty )
327
+ git_system!( "commit", "-m", "chore: sync Carson managed files" )
328
+ puts_line "Carson committed managed file updates. Push again to include them."
329
+ true
330
+ end
331
+
332
+ def managed_dirty_paths
333
+ template_paths = config.template_managed_files + SUPERSEDED
334
+ linters_glob = Dir.glob( File.join( repo_root, ".github/linters/**/*" ) )
335
+ .select { |p| File.file?( p ) }
336
+ .map { |p| p.delete_prefix( "#{repo_root}/" ) }
337
+ candidates = ( template_paths + linters_glob ).uniq
338
+ return [] if candidates.empty?
339
+
340
+ stdout_text, = git_capture_soft( "status", "--porcelain", "--", *candidates )
341
+ stdout_text.to_s.lines
342
+ .map { |l| l[ 3.. ].strip }
343
+ .reject( &:empty? )
344
+ end
345
+ end
346
+ end
347
+ end