patch_util 0.1.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 +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +285 -0
- data/Rakefile +8 -0
- data/SKILL.md +157 -0
- data/exe/patch_util +12 -0
- data/lib/patch_util/cli.rb +22 -0
- data/lib/patch_util/diff.rb +132 -0
- data/lib/patch_util/git/cli.rb +664 -0
- data/lib/patch_util/git/rewrite_cli.rb +393 -0
- data/lib/patch_util/git/rewrite_session_manager.rb +480 -0
- data/lib/patch_util/git/rewrite_state_store.rb +81 -0
- data/lib/patch_util/git/rewriter.rb +233 -0
- data/lib/patch_util/git.rb +11 -0
- data/lib/patch_util/parser.rb +412 -0
- data/lib/patch_util/selection.rb +98 -0
- data/lib/patch_util/source.rb +69 -0
- data/lib/patch_util/split/applier.rb +38 -0
- data/lib/patch_util/split/cli.rb +167 -0
- data/lib/patch_util/split/emitter.rb +52 -0
- data/lib/patch_util/split/inspector.rb +203 -0
- data/lib/patch_util/split/plan.rb +33 -0
- data/lib/patch_util/split/plan_store.rb +106 -0
- data/lib/patch_util/split/planner.rb +24 -0
- data/lib/patch_util/split/projector.rb +252 -0
- data/lib/patch_util/split/verifier.rb +133 -0
- data/lib/patch_util/split.rb +21 -0
- data/lib/patch_util/version.rb +5 -0
- data/lib/patch_util.rb +21 -0
- metadata +92 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module PatchUtil
|
|
6
|
+
module Git
|
|
7
|
+
class RewriteSessionManager
|
|
8
|
+
Result = Data.define(:branch, :old_head, :new_head, :backup_ref, :commits)
|
|
9
|
+
AbortResult = Data.define(:branch, :backup_ref, :worktree_path, :worktree_removed)
|
|
10
|
+
RestoreResult = Data.define(:branch, :old_head, :restored_head, :backup_ref, :worktree_path,
|
|
11
|
+
:worktree_removed)
|
|
12
|
+
ResolveResult = Data.define(:branch, :worktree_path, :side, :resolved_paths, :remaining_unresolved_paths)
|
|
13
|
+
ResolveBlockResult = Data.define(:branch, :worktree_path, :path, :block_id, :side, :remaining_blocks,
|
|
14
|
+
:staged)
|
|
15
|
+
ConflictBlocksResult = Data.define(:branch, :worktree_path, :blocks)
|
|
16
|
+
ExportBlockResult = Data.define(:branch, :worktree_path, :path, :block_id, :output_path)
|
|
17
|
+
ApplyBlockEditResult = Data.define(:branch, :worktree_path, :path, :block_id, :input_path, :remaining_blocks,
|
|
18
|
+
:staged)
|
|
19
|
+
ExportBlockSessionResult = Data.define(:branch, :worktree_path, :output_path, :blocks, :files)
|
|
20
|
+
ApplyBlockSessionEditResult = Data.define(:branch, :worktree_path, :input_path, :applied_blocks,
|
|
21
|
+
:remaining_blocks_by_file, :staged_paths, :files)
|
|
22
|
+
SessionSummaryResult = Data.define(:branch, :worktree_path, :blocks, :files)
|
|
23
|
+
Status = Data.define(:branch, :head_sha, :state, :head_matches, :worktree_exists, :worktree_clean,
|
|
24
|
+
:cherry_pick_in_progress, :current_revision, :unresolved_paths,
|
|
25
|
+
:conflict_marker_details)
|
|
26
|
+
|
|
27
|
+
def initialize(git_cli: Cli.new, clock: -> { Time.now.utc })
|
|
28
|
+
@git_cli = git_cli
|
|
29
|
+
@clock = clock
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def abort_rewrite(repo_path:)
|
|
33
|
+
branch = @git_cli.current_branch(repo_path)
|
|
34
|
+
raise PatchUtil::ValidationError, 'rewrite abort requires a checked out branch' if branch.empty?
|
|
35
|
+
|
|
36
|
+
state_store = RewriteStateStore.new(git_dir: @git_cli.git_dir(repo_path))
|
|
37
|
+
state = state_store.find_branch(branch)
|
|
38
|
+
raise PatchUtil::ValidationError, "no retained rewrite state for branch #{branch}" unless state
|
|
39
|
+
|
|
40
|
+
worktree_removed = remove_retained_worktree(repo_path, state.worktree_path)
|
|
41
|
+
state_store.clear_branch(branch)
|
|
42
|
+
|
|
43
|
+
AbortResult.new(
|
|
44
|
+
branch: branch,
|
|
45
|
+
backup_ref: state.backup_ref,
|
|
46
|
+
worktree_path: state.worktree_path,
|
|
47
|
+
worktree_removed: worktree_removed
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def continue_rewrite(repo_path:)
|
|
52
|
+
branch, head, state_store, state = resume_state_for(repo_path)
|
|
53
|
+
worktree = state.worktree_path
|
|
54
|
+
pending_revisions = state.pending_revisions.dup
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
if @git_cli.cherry_pick_in_progress?(worktree)
|
|
58
|
+
current_revision = @git_cli.cherry_pick_head(worktree)
|
|
59
|
+
pending_revisions = normalize_pending_revisions(repo_path, state, current_revision, pending_revisions)
|
|
60
|
+
@git_cli.cherry_pick_continue(worktree)
|
|
61
|
+
pending_revisions.shift if pending_revisions.first == current_revision
|
|
62
|
+
elsif !@git_cli.worktree_clean?(worktree)
|
|
63
|
+
raise PatchUtil::ValidationError,
|
|
64
|
+
'retained rewrite worktree has unresolved changes; finish the in-progress cherry-pick or clean it before continuing'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
until pending_revisions.empty?
|
|
68
|
+
revision = pending_revisions.first
|
|
69
|
+
@git_cli.cherry_pick(worktree, revision)
|
|
70
|
+
pending_revisions.shift
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
finalize_rewrite(repo_path, branch, head, state.backup_ref, worktree, state_store)
|
|
74
|
+
rescue PatchUtil::Error, PatchUtil::ValidationError => e
|
|
75
|
+
state_store.record_failure(
|
|
76
|
+
RewriteStateStore::State.new(
|
|
77
|
+
branch: state.branch,
|
|
78
|
+
target_sha: state.target_sha,
|
|
79
|
+
head_sha: state.head_sha,
|
|
80
|
+
backup_ref: state.backup_ref,
|
|
81
|
+
worktree_path: state.worktree_path,
|
|
82
|
+
status: 'failed',
|
|
83
|
+
message: e.message,
|
|
84
|
+
created_at: @clock.call.iso8601,
|
|
85
|
+
pending_revisions: pending_revisions
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
raise PatchUtil::Error,
|
|
89
|
+
continue_failure_message(state, worktree, e.message)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def status(repo_path:)
|
|
94
|
+
branch = @git_cli.current_branch(repo_path)
|
|
95
|
+
raise PatchUtil::ValidationError, 'rewrite-status requires a checked out branch' if branch.empty?
|
|
96
|
+
|
|
97
|
+
state_store = RewriteStateStore.new(git_dir: @git_cli.git_dir(repo_path))
|
|
98
|
+
state = state_store.find_branch(branch)
|
|
99
|
+
return nil unless state
|
|
100
|
+
|
|
101
|
+
head_sha = @git_cli.head_sha(repo_path)
|
|
102
|
+
worktree_exists = File.directory?(state.worktree_path)
|
|
103
|
+
cherry_pick_in_progress = worktree_exists && @git_cli.cherry_pick_in_progress?(state.worktree_path)
|
|
104
|
+
current_revision = cherry_pick_in_progress ? @git_cli.cherry_pick_head(state.worktree_path) : nil
|
|
105
|
+
worktree_clean = worktree_exists ? @git_cli.worktree_clean?(state.worktree_path) : nil
|
|
106
|
+
unresolved_paths = worktree_exists ? @git_cli.unresolved_paths(state.worktree_path) : []
|
|
107
|
+
conflict_marker_details = if worktree_exists
|
|
108
|
+
@git_cli.conflict_marker_details(state.worktree_path,
|
|
109
|
+
file_paths: unresolved_paths)
|
|
110
|
+
else
|
|
111
|
+
[]
|
|
112
|
+
end
|
|
113
|
+
if worktree_exists && conflict_marker_details.empty?
|
|
114
|
+
conflict_marker_details = @git_cli.conflict_marker_details(state.worktree_path)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
Status.new(
|
|
118
|
+
branch: branch,
|
|
119
|
+
head_sha: head_sha,
|
|
120
|
+
state: state,
|
|
121
|
+
head_matches: head_sha == state.head_sha,
|
|
122
|
+
worktree_exists: worktree_exists,
|
|
123
|
+
worktree_clean: worktree_clean,
|
|
124
|
+
cherry_pick_in_progress: cherry_pick_in_progress,
|
|
125
|
+
current_revision: current_revision,
|
|
126
|
+
unresolved_paths: unresolved_paths,
|
|
127
|
+
conflict_marker_details: conflict_marker_details
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def restore_rewrite(repo_path:)
|
|
132
|
+
unless @git_cli.worktree_clean?(repo_path)
|
|
133
|
+
raise PatchUtil::ValidationError,
|
|
134
|
+
'restore-rewrite requires a clean worktree'
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
branch = @git_cli.current_branch(repo_path)
|
|
138
|
+
raise PatchUtil::ValidationError, 'restore-rewrite requires a checked out branch' if branch.empty?
|
|
139
|
+
|
|
140
|
+
state_store = RewriteStateStore.new(git_dir: @git_cli.git_dir(repo_path))
|
|
141
|
+
state = state_store.find_branch(branch)
|
|
142
|
+
raise PatchUtil::ValidationError, "no retained rewrite state for branch #{branch}" unless state
|
|
143
|
+
|
|
144
|
+
old_head = @git_cli.head_sha(repo_path)
|
|
145
|
+
restored_head = resolve_backup_ref(repo_path, state.backup_ref)
|
|
146
|
+
|
|
147
|
+
@git_cli.reset_hard(repo_path, restored_head)
|
|
148
|
+
worktree_removed = remove_retained_worktree(repo_path, state.worktree_path)
|
|
149
|
+
state_store.clear_branch(branch)
|
|
150
|
+
|
|
151
|
+
RestoreResult.new(
|
|
152
|
+
branch: branch,
|
|
153
|
+
old_head: old_head,
|
|
154
|
+
restored_head: restored_head,
|
|
155
|
+
backup_ref: state.backup_ref,
|
|
156
|
+
worktree_path: state.worktree_path,
|
|
157
|
+
worktree_removed: worktree_removed
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def resolve_conflicts(repo_path:, side:, paths: nil, all_unresolved: false)
|
|
162
|
+
branch = @git_cli.current_branch(repo_path)
|
|
163
|
+
raise PatchUtil::ValidationError, 'rewrite-resolve requires a checked out branch' if branch.empty?
|
|
164
|
+
|
|
165
|
+
state_store = RewriteStateStore.new(git_dir: @git_cli.git_dir(repo_path))
|
|
166
|
+
state = state_store.find_branch(branch)
|
|
167
|
+
raise PatchUtil::ValidationError, "no retained rewrite state for branch #{branch}" unless state
|
|
168
|
+
|
|
169
|
+
unless File.directory?(state.worktree_path)
|
|
170
|
+
raise PatchUtil::ValidationError,
|
|
171
|
+
"retained rewrite worktree is missing: #{state.worktree_path}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
unresolved_paths = @git_cli.unresolved_paths(state.worktree_path)
|
|
175
|
+
if unresolved_paths.empty?
|
|
176
|
+
raise PatchUtil::ValidationError,
|
|
177
|
+
'no unresolved paths found in the retained worktree'
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
resolved_paths = if all_unresolved
|
|
181
|
+
unresolved_paths
|
|
182
|
+
else
|
|
183
|
+
normalized_paths = Array(paths).map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
|
184
|
+
if normalized_paths.empty?
|
|
185
|
+
raise PatchUtil::ValidationError,
|
|
186
|
+
'provide --path PATH[,PATH...] or --all'
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
unknown_paths = normalized_paths - unresolved_paths
|
|
190
|
+
unless unknown_paths.empty?
|
|
191
|
+
raise PatchUtil::ValidationError,
|
|
192
|
+
"paths are not currently unresolved: #{unknown_paths.join(', ')}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
normalized_paths
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
@git_cli.checkout_conflict_side(state.worktree_path, side, resolved_paths)
|
|
199
|
+
@git_cli.add_paths(state.worktree_path, resolved_paths)
|
|
200
|
+
remaining_unresolved_paths = @git_cli.unresolved_paths(state.worktree_path)
|
|
201
|
+
|
|
202
|
+
ResolveResult.new(
|
|
203
|
+
branch: branch,
|
|
204
|
+
worktree_path: state.worktree_path,
|
|
205
|
+
side: side,
|
|
206
|
+
resolved_paths: resolved_paths,
|
|
207
|
+
remaining_unresolved_paths: remaining_unresolved_paths
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def resolve_conflict_block(repo_path:, path:, block_id:, side:)
|
|
212
|
+
branch = @git_cli.current_branch(repo_path)
|
|
213
|
+
raise PatchUtil::ValidationError, 'rewrite-resolve-block requires a checked out branch' if branch.empty?
|
|
214
|
+
|
|
215
|
+
state_store = RewriteStateStore.new(git_dir: @git_cli.git_dir(repo_path))
|
|
216
|
+
state = state_store.find_branch(branch)
|
|
217
|
+
raise PatchUtil::ValidationError, "no retained rewrite state for branch #{branch}" unless state
|
|
218
|
+
|
|
219
|
+
unless File.directory?(state.worktree_path)
|
|
220
|
+
raise PatchUtil::ValidationError,
|
|
221
|
+
"retained rewrite worktree is missing: #{state.worktree_path}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
details = @git_cli.conflict_block_details(state.worktree_path, file_paths: [path])
|
|
225
|
+
raise PatchUtil::ValidationError, "no conflict blocks found for #{path}" if details.empty?
|
|
226
|
+
|
|
227
|
+
result = @git_cli.resolve_conflict_block(state.worktree_path, file_path: path, block_id: block_id, side: side)
|
|
228
|
+
|
|
229
|
+
ResolveBlockResult.new(
|
|
230
|
+
branch: branch,
|
|
231
|
+
worktree_path: state.worktree_path,
|
|
232
|
+
path: path,
|
|
233
|
+
block_id: block_id,
|
|
234
|
+
side: side,
|
|
235
|
+
remaining_blocks: result[:remaining_blocks],
|
|
236
|
+
staged: result[:staged]
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def conflict_blocks(repo_path:, paths: nil)
|
|
241
|
+
branch = @git_cli.current_branch(repo_path)
|
|
242
|
+
raise PatchUtil::ValidationError, 'rewrite-conflict-blocks requires a checked out branch' if branch.empty?
|
|
243
|
+
|
|
244
|
+
state_store = RewriteStateStore.new(git_dir: @git_cli.git_dir(repo_path))
|
|
245
|
+
state = state_store.find_branch(branch)
|
|
246
|
+
raise PatchUtil::ValidationError, "no retained rewrite state for branch #{branch}" unless state
|
|
247
|
+
|
|
248
|
+
unless File.directory?(state.worktree_path)
|
|
249
|
+
raise PatchUtil::ValidationError,
|
|
250
|
+
"retained rewrite worktree is missing: #{state.worktree_path}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
selected_paths = Array(paths).map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
|
254
|
+
selected_paths = nil if selected_paths.empty?
|
|
255
|
+
blocks = @git_cli.conflict_block_details(state.worktree_path, file_paths: selected_paths)
|
|
256
|
+
|
|
257
|
+
ConflictBlocksResult.new(
|
|
258
|
+
branch: branch,
|
|
259
|
+
worktree_path: state.worktree_path,
|
|
260
|
+
blocks: blocks
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def export_conflict_block(repo_path:, path:, block_id:, output_path:)
|
|
265
|
+
branch, state = retained_state_for(repo_path, command_name: 'rewrite-export-block')
|
|
266
|
+
@git_cli.export_conflict_block_template(state.worktree_path, file_path: path, block_id: block_id,
|
|
267
|
+
output_path: output_path)
|
|
268
|
+
|
|
269
|
+
ExportBlockResult.new(
|
|
270
|
+
branch: branch,
|
|
271
|
+
worktree_path: state.worktree_path,
|
|
272
|
+
path: path,
|
|
273
|
+
block_id: block_id,
|
|
274
|
+
output_path: File.expand_path(output_path)
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def apply_conflict_block_edit(repo_path:, path:, block_id:, input_path:)
|
|
279
|
+
branch, state = retained_state_for(repo_path, command_name: 'rewrite-apply-block-edit')
|
|
280
|
+
details = @git_cli.conflict_block_details(state.worktree_path, file_paths: [path])
|
|
281
|
+
raise PatchUtil::ValidationError, "no conflict blocks found for #{path}" if details.empty?
|
|
282
|
+
|
|
283
|
+
result = @git_cli.apply_conflict_block_edit(state.worktree_path, file_path: path, block_id: block_id,
|
|
284
|
+
input_path: input_path)
|
|
285
|
+
|
|
286
|
+
ApplyBlockEditResult.new(
|
|
287
|
+
branch: branch,
|
|
288
|
+
worktree_path: state.worktree_path,
|
|
289
|
+
path: path,
|
|
290
|
+
block_id: block_id,
|
|
291
|
+
input_path: File.expand_path(input_path),
|
|
292
|
+
remaining_blocks: result[:remaining_blocks],
|
|
293
|
+
staged: result[:staged]
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def export_conflict_block_session(repo_path:, output_path:, paths: nil)
|
|
298
|
+
branch, state = retained_state_for(repo_path, command_name: 'rewrite-export-session')
|
|
299
|
+
selected_paths = Array(paths).map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
|
300
|
+
selected_paths = nil if selected_paths.empty?
|
|
301
|
+
result = @git_cli.export_conflict_block_session_template(state.worktree_path, file_paths: selected_paths,
|
|
302
|
+
output_path: output_path)
|
|
303
|
+
|
|
304
|
+
ExportBlockSessionResult.new(
|
|
305
|
+
branch: branch,
|
|
306
|
+
worktree_path: state.worktree_path,
|
|
307
|
+
output_path: result[:output_path],
|
|
308
|
+
blocks: result[:blocks],
|
|
309
|
+
files: summarize_conflict_blocks_by_file(result[:blocks])
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def apply_conflict_block_session_edit(repo_path:, input_path:)
|
|
314
|
+
branch, state = retained_state_for(repo_path, command_name: 'rewrite-apply-session-edit')
|
|
315
|
+
result = @git_cli.apply_conflict_block_session_edit(state.worktree_path, input_path: input_path)
|
|
316
|
+
|
|
317
|
+
ApplyBlockSessionEditResult.new(
|
|
318
|
+
branch: branch,
|
|
319
|
+
worktree_path: state.worktree_path,
|
|
320
|
+
input_path: File.expand_path(input_path),
|
|
321
|
+
applied_blocks: result[:applied_blocks],
|
|
322
|
+
remaining_blocks_by_file: result[:remaining_blocks_by_file],
|
|
323
|
+
staged_paths: result[:staged_paths],
|
|
324
|
+
files: summarize_applied_blocks_by_file(
|
|
325
|
+
applied_blocks: result[:applied_blocks],
|
|
326
|
+
remaining_blocks_by_file: result[:remaining_blocks_by_file],
|
|
327
|
+
staged_paths: result[:staged_paths]
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def conflict_block_session_summary(repo_path:, paths: nil)
|
|
333
|
+
branch, state = retained_state_for(repo_path, command_name: 'rewrite-session-summary')
|
|
334
|
+
selected_paths = Array(paths).map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
|
335
|
+
selected_paths = nil if selected_paths.empty?
|
|
336
|
+
blocks = @git_cli.conflict_block_details(state.worktree_path, file_paths: selected_paths)
|
|
337
|
+
|
|
338
|
+
SessionSummaryResult.new(
|
|
339
|
+
branch: branch,
|
|
340
|
+
worktree_path: state.worktree_path,
|
|
341
|
+
blocks: blocks,
|
|
342
|
+
files: summarize_conflict_blocks_by_file(blocks)
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def next_action(status)
|
|
347
|
+
return 'run restore-rewrite or abort-rewrite; retained worktree is missing' unless status.worktree_exists
|
|
348
|
+
return 'run restore-rewrite before continuing; branch head changed' unless status.head_matches
|
|
349
|
+
return 'resolve conflicts in retained worktree, then run continue-rewrite' if status.cherry_pick_in_progress
|
|
350
|
+
if status.conflict_marker_details.any?
|
|
351
|
+
return 'inspect conflict markers with rewrite-conflicts, resolve them in the retained worktree, then run continue-rewrite'
|
|
352
|
+
end
|
|
353
|
+
if status.unresolved_paths.any?
|
|
354
|
+
return 'resolve unresolved paths in the retained worktree, then run continue-rewrite'
|
|
355
|
+
end
|
|
356
|
+
return 'clean the retained worktree or abort-rewrite before continuing' if status.worktree_clean == false
|
|
357
|
+
return 'run continue-rewrite to replay the remaining descendant commits' if status.state.pending_revisions.any?
|
|
358
|
+
|
|
359
|
+
'run continue-rewrite to finalize the retained rewrite'
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
private
|
|
363
|
+
|
|
364
|
+
def retained_state_for(repo_path, command_name:)
|
|
365
|
+
branch = @git_cli.current_branch(repo_path)
|
|
366
|
+
raise PatchUtil::ValidationError, "#{command_name} requires a checked out branch" if branch.empty?
|
|
367
|
+
|
|
368
|
+
state_store = RewriteStateStore.new(git_dir: @git_cli.git_dir(repo_path))
|
|
369
|
+
state = state_store.find_branch(branch)
|
|
370
|
+
raise PatchUtil::ValidationError, "no retained rewrite state for branch #{branch}" unless state
|
|
371
|
+
|
|
372
|
+
unless File.directory?(state.worktree_path)
|
|
373
|
+
raise PatchUtil::ValidationError,
|
|
374
|
+
"retained rewrite worktree is missing: #{state.worktree_path}"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
[branch, state]
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def summarize_conflict_blocks_by_file(blocks)
|
|
381
|
+
blocks.group_by(&:path).transform_values do |file_blocks|
|
|
382
|
+
{
|
|
383
|
+
block_ids: file_blocks.map(&:block_id).sort,
|
|
384
|
+
block_count: file_blocks.length,
|
|
385
|
+
has_ancestor: file_blocks.any? { |block| !block.ancestor.empty? }
|
|
386
|
+
}
|
|
387
|
+
end.sort.to_h
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def summarize_applied_blocks_by_file(applied_blocks:, remaining_blocks_by_file:, staged_paths:)
|
|
391
|
+
all_paths = (applied_blocks.map { |entry| entry[:path] } + remaining_blocks_by_file.keys).uniq.sort
|
|
392
|
+
summary = {}
|
|
393
|
+
|
|
394
|
+
all_paths.each do |path|
|
|
395
|
+
file_applied_blocks = applied_blocks.select { |entry| entry[:path] == path }
|
|
396
|
+
.sort_by { |entry| entry[:block_id] }
|
|
397
|
+
remaining_blocks = remaining_blocks_by_file.fetch(path, []).sort_by(&:block_id)
|
|
398
|
+
|
|
399
|
+
summary[path] = {
|
|
400
|
+
applied_block_ids: file_applied_blocks.map { |entry| entry[:block_id] },
|
|
401
|
+
applied_block_count: file_applied_blocks.length,
|
|
402
|
+
remaining_block_ids: remaining_blocks.map(&:block_id),
|
|
403
|
+
remaining_block_count: remaining_blocks.length,
|
|
404
|
+
staged: staged_paths.include?(path)
|
|
405
|
+
}
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
summary
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def resolve_backup_ref(repo_path, backup_ref)
|
|
412
|
+
@git_cli.rev_parse(repo_path, backup_ref)
|
|
413
|
+
rescue PatchUtil::Error
|
|
414
|
+
raise PatchUtil::ValidationError, "backup ref #{backup_ref} no longer exists"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def remove_retained_worktree(repo_path, worktree_path)
|
|
418
|
+
return false unless File.directory?(worktree_path)
|
|
419
|
+
|
|
420
|
+
@git_cli.worktree_remove(repo_path, worktree_path)
|
|
421
|
+
true
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def continue_failure_message(state, worktree, message)
|
|
425
|
+
lines = [message, "backup ref: #{state.backup_ref}", "retained worktree: #{state.worktree_path}"]
|
|
426
|
+
unresolved_paths = @git_cli.unresolved_paths(worktree)
|
|
427
|
+
if unresolved_paths.any?
|
|
428
|
+
lines << "unresolved paths: #{unresolved_paths.join(', ')}"
|
|
429
|
+
lines << 'next step: resolve the listed paths in the retained worktree, then run rewrite-status or continue-rewrite'
|
|
430
|
+
else
|
|
431
|
+
lines << 'next step: run rewrite-status to inspect the retained state before continuing or aborting'
|
|
432
|
+
end
|
|
433
|
+
lines.join("\n")
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def resume_state_for(repo_path)
|
|
437
|
+
unless @git_cli.worktree_clean?(repo_path)
|
|
438
|
+
raise PatchUtil::ValidationError,
|
|
439
|
+
'continue-rewrite requires a clean worktree'
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
branch = @git_cli.current_branch(repo_path)
|
|
443
|
+
raise PatchUtil::ValidationError, 'continue-rewrite requires a checked out branch' if branch.empty?
|
|
444
|
+
|
|
445
|
+
state_store = RewriteStateStore.new(git_dir: @git_cli.git_dir(repo_path))
|
|
446
|
+
state = state_store.find_branch(branch)
|
|
447
|
+
raise PatchUtil::ValidationError, "no retained rewrite state for branch #{branch}" unless state
|
|
448
|
+
|
|
449
|
+
head = @git_cli.head_sha(repo_path)
|
|
450
|
+
unless head == state.head_sha
|
|
451
|
+
raise PatchUtil::ValidationError,
|
|
452
|
+
"branch #{branch} moved from #{state.head_sha} to #{head}; run restore-rewrite to restore from #{state.backup_ref} before continuing"
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
unless File.directory?(state.worktree_path)
|
|
456
|
+
raise PatchUtil::ValidationError,
|
|
457
|
+
"retained rewrite worktree is missing: #{state.worktree_path}; run restore-rewrite or abort-rewrite"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
[branch, head, state_store, state]
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def normalize_pending_revisions(repo_path, state, current_revision, pending_revisions)
|
|
464
|
+
return pending_revisions if pending_revisions.first == current_revision
|
|
465
|
+
|
|
466
|
+
[current_revision, *@git_cli.rev_list(repo_path, "#{current_revision}..#{state.head_sha}")]
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def finalize_rewrite(repo_path, branch, old_head, backup_ref, worktree, state_store)
|
|
470
|
+
new_head = @git_cli.head_sha(worktree)
|
|
471
|
+
@git_cli.update_ref(repo_path, "refs/heads/#{branch}", new_head, old_head)
|
|
472
|
+
@git_cli.reset_hard(repo_path, new_head)
|
|
473
|
+
@git_cli.worktree_remove(repo_path, worktree)
|
|
474
|
+
state_store.clear_branch(branch)
|
|
475
|
+
|
|
476
|
+
Result.new(branch: branch, old_head: old_head, new_head: new_head, backup_ref: backup_ref, commits: [])
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module PatchUtil
|
|
7
|
+
module Git
|
|
8
|
+
class RewriteStateStore
|
|
9
|
+
State = Data.define(:branch, :target_sha, :head_sha, :backup_ref, :worktree_path, :status, :message, :created_at,
|
|
10
|
+
:pending_revisions)
|
|
11
|
+
|
|
12
|
+
def initialize(git_dir:)
|
|
13
|
+
@git_dir = File.expand_path(git_dir)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def path
|
|
17
|
+
File.join(@git_dir, 'patch_util', 'rewrite_state.json')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def load
|
|
21
|
+
return [] unless File.exist?(path)
|
|
22
|
+
|
|
23
|
+
payload = JSON.parse(File.read(path))
|
|
24
|
+
payload.fetch('states', []).map do |item|
|
|
25
|
+
State.new(
|
|
26
|
+
branch: item.fetch('branch'),
|
|
27
|
+
target_sha: item.fetch('target_sha'),
|
|
28
|
+
head_sha: item.fetch('head_sha'),
|
|
29
|
+
backup_ref: item.fetch('backup_ref'),
|
|
30
|
+
worktree_path: item.fetch('worktree_path'),
|
|
31
|
+
status: item.fetch('status'),
|
|
32
|
+
message: item.fetch('message'),
|
|
33
|
+
created_at: item.fetch('created_at'),
|
|
34
|
+
pending_revisions: item.fetch('pending_revisions', [])
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def record_failure(state)
|
|
40
|
+
states = load.reject { |item| item.branch == state.branch }
|
|
41
|
+
states << state
|
|
42
|
+
save(states)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def clear_branch(branch)
|
|
46
|
+
states = load.reject { |item| item.branch == branch }
|
|
47
|
+
save(states)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def find_branch(branch)
|
|
51
|
+
load.find { |state| state.branch == branch }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def save(states)
|
|
57
|
+
if states.empty?
|
|
58
|
+
FileUtils.rm_f(path)
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
63
|
+
File.write(path, JSON.pretty_generate('states' => states.map { |state| serialize(state) }) + "\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def serialize(state)
|
|
67
|
+
{
|
|
68
|
+
'branch' => state.branch,
|
|
69
|
+
'target_sha' => state.target_sha,
|
|
70
|
+
'head_sha' => state.head_sha,
|
|
71
|
+
'backup_ref' => state.backup_ref,
|
|
72
|
+
'worktree_path' => state.worktree_path,
|
|
73
|
+
'status' => state.status,
|
|
74
|
+
'message' => state.message,
|
|
75
|
+
'created_at' => state.created_at,
|
|
76
|
+
'pending_revisions' => state.pending_revisions
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|