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.
- checksums.yaml +4 -4
- data/.github/workflows/carson_policy.yml +5 -14
- data/API.md +6 -36
- data/MANUAL.md +10 -38
- data/README.md +9 -22
- data/RELEASE.md +9 -0
- data/SKILL.md +5 -7
- data/VERSION +1 -1
- data/lib/carson/cli.rb +1 -59
- data/lib/carson/config.rb +25 -50
- data/lib/carson/runtime/audit.rb +1 -59
- data/lib/carson/runtime/govern.rb +7 -36
- data/lib/carson/runtime/local/hooks.rb +142 -0
- data/lib/carson/runtime/local/onboard.rb +356 -0
- data/lib/carson/runtime/local/prune.rb +292 -0
- data/lib/carson/runtime/local/sync.rb +93 -0
- data/lib/carson/runtime/local/template.rb +347 -0
- data/lib/carson/runtime/local.rb +6 -1225
- data/lib/carson/runtime/review/gate_support.rb +1 -1
- data/lib/carson/runtime/review/sweep_support.rb +1 -1
- data/lib/carson/runtime/review/utility.rb +2 -2
- data/lib/carson/runtime/setup.rb +8 -2
- data/lib/carson/runtime.rb +0 -1
- data/templates/.github/carson.md +0 -1
- metadata +6 -2
- data/lib/carson/runtime/lint.rb +0 -154
|
@@ -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
|