patch_util 0.1.0 → 0.1.1

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: e6922fb856381f64462a95b3d1cac90be5d9cce4eca63783cb0966da67252047
4
- data.tar.gz: c4b080b73b5e6eacc8bf030c375ae3bd5caf12bc490bb55d6e78b6d2aca00f87
3
+ metadata.gz: bf6570d59e8e6963dff93ccdefb9700ef5e092983ead2247d6d1d1b3089480f8
4
+ data.tar.gz: f93ac6bac66beeb70b20ba5bcaae298c0d6009f83a31a34b2bd55e443ed245e3
5
5
  SHA512:
6
- metadata.gz: 815db1d23e934a049c97f733b7948d8fa88cb040262c52fe00f0f3241f96e6d35ffad69553b4245b35de98007314017716356f8e324987d5358bda7e8a5f9b93
7
- data.tar.gz: d1b3348828a4732ca3250b3a4ab392fa3738c45d273463124e18bdc30f06f1dc1f971f3fdb458a5fa0513a8f63c040e48d97eabc68bb9c25c73549e0ce3cce16
6
+ metadata.gz: e4112a08d973206903cdd2412607f40f2f8832a48878f09bcc34ea25f0ca1107341017776ac0a072703bd0ed7e6387bac8eb85aaec919b11fef91301abe52df2
7
+ data.tar.gz: b1efec788658c8bc301e513200ef257e5d668c585eb9d68c8471c8ac41bafcc02bea3b1459bae6ce9b9b233800fad34c484b07bb53d3f0f8f41c86385324ae28
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
- Initial release.
2
-
1
+ ## v0.1.1
3
2
  - Add GitHub Actions CI for Ruby matrix testing.
3
+ - Fix rewrite replay when git identity is not configured in the environment.
4
+ - Fix sequential replay for split text additions and deletions.
5
+ - Add rewrite preflight verification so non-replayable chunk series fail before history rewrite starts.
6
+
7
+ ## v0.1.0
8
+ - Initial release.
@@ -111,6 +111,14 @@ module PatchUtil
111
111
  stdout
112
112
  end
113
113
 
114
+ def check_patch_text(path, patch_text)
115
+ stdout, stderr, status = Open3.capture3('git', '-C', File.expand_path(path), 'apply', '--check',
116
+ '--whitespace=nowarn', '-', stdin_data: patch_text)
117
+ raise PatchUtil::Error, "git apply --check failed: #{stderr.strip}" unless status.success?
118
+
119
+ stdout
120
+ end
121
+
114
122
  def commit_all(path, message, env: {})
115
123
  add_stdout, add_stderr, add_status = Open3.capture3(env, 'git', '-C', File.expand_path(path), 'add', '-A')
116
124
  raise PatchUtil::Error, "git add failed: #{add_stderr.strip}" unless add_status.success?
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module PatchUtil
6
+ module Git
7
+ class RewritePreflightVerifier
8
+ def initialize(git_cli: Cli.new, applier: PatchUtil::Split::Applier.new, clock: -> { Time.now.utc })
9
+ @git_cli = git_cli
10
+ @applier = applier
11
+ @clock = clock
12
+ end
13
+
14
+ def verify(source:, parent:, diff:, plan_entry:, branch:)
15
+ repo_path = source.repo_path
16
+ worktree = build_worktree_path(repo_path, branch, source.commit_sha)
17
+ output_dir = File.join(worktree, '.patch_util_emitted')
18
+ nil
19
+ worktree_added = false
20
+
21
+ begin
22
+ @git_cli.worktree_add(repo_path, worktree, parent)
23
+ worktree_added = true
24
+ emitted = @applier.apply(diff: diff, plan_entry: plan_entry, output_dir: output_dir)
25
+ emitted.each do |item|
26
+ @git_cli.check_patch_text(worktree, item[:patch_text])
27
+ @git_cli.apply_patch_text(worktree, item[:patch_text])
28
+ rescue PatchUtil::Error => e
29
+ raise PatchUtil::ValidationError, "rewrite preflight failed for chunk #{item[:name]}: #{e.message}"
30
+ end
31
+
32
+ emitted.map do |item|
33
+ { name: item[:name], patch_text: item[:patch_text] }
34
+ end
35
+ ensure
36
+ @git_cli.worktree_remove(repo_path, worktree) if worktree_added
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def timestamp_token
43
+ @clock.call.strftime('%Y%m%d%H%M%S')
44
+ end
45
+
46
+ def build_worktree_path(repo_path, branch, target)
47
+ git_dir = @git_cli.git_dir(repo_path)
48
+ root = File.join(git_dir, 'patch_util', 'rewrite-preflight-worktrees')
49
+ FileUtils.mkdir_p(root)
50
+ File.join(root, "#{timestamp_token}-#{sanitize(branch)}-#{target[0, 12]}")
51
+ end
52
+
53
+ def sanitize(text)
54
+ text.gsub(/[^a-zA-Z0-9._-]+/, '-')
55
+ end
56
+ end
57
+ end
58
+ end
@@ -57,7 +57,7 @@ module PatchUtil
57
57
  if @git_cli.cherry_pick_in_progress?(worktree)
58
58
  current_revision = @git_cli.cherry_pick_head(worktree)
59
59
  pending_revisions = normalize_pending_revisions(repo_path, state, current_revision, pending_revisions)
60
- @git_cli.cherry_pick_continue(worktree)
60
+ @git_cli.cherry_pick_continue(worktree, env: replay_commit_env(repo_path, current_revision))
61
61
  pending_revisions.shift if pending_revisions.first == current_revision
62
62
  elsif !@git_cli.worktree_clean?(worktree)
63
63
  raise PatchUtil::ValidationError,
@@ -66,7 +66,7 @@ module PatchUtil
66
66
 
67
67
  until pending_revisions.empty?
68
68
  revision = pending_revisions.first
69
- @git_cli.cherry_pick(worktree, revision)
69
+ @git_cli.cherry_pick(worktree, revision, env: replay_commit_env(repo_path, revision))
70
70
  pending_revisions.shift
71
71
  end
72
72
 
@@ -466,6 +466,15 @@ module PatchUtil
466
466
  [current_revision, *@git_cli.rev_list(repo_path, "#{current_revision}..#{state.head_sha}")]
467
467
  end
468
468
 
469
+ def replay_commit_env(repo_path, revision)
470
+ original_commit = @git_cli.show_commit_metadata(repo_path, revision)
471
+ {
472
+ 'GIT_COMMITTER_NAME' => original_commit.committer_name,
473
+ 'GIT_COMMITTER_EMAIL' => original_commit.committer_email,
474
+ 'GIT_COMMITTER_DATE' => original_commit.committer_date
475
+ }
476
+ end
477
+
469
478
  def finalize_rewrite(repo_path, branch, old_head, backup_ref, worktree, state_store)
470
479
  new_head = @git_cli.head_sha(worktree)
471
480
  @git_cli.update_ref(repo_path, "refs/heads/#{branch}", new_head, old_head)
@@ -20,11 +20,16 @@ module PatchUtil
20
20
  Status = RewriteSessionManager::Status
21
21
 
22
22
  def initialize(git_cli: Cli.new, applier: PatchUtil::Split::Applier.new, clock: -> { Time.now.utc },
23
- session_manager: nil)
23
+ session_manager: nil, preflight_verifier: nil)
24
24
  @git_cli = git_cli
25
25
  @applier = applier
26
26
  @clock = clock
27
27
  @session_manager = session_manager || RewriteSessionManager.new(git_cli: git_cli, clock: clock)
28
+ @preflight_verifier = preflight_verifier || RewritePreflightVerifier.new(
29
+ git_cli: git_cli,
30
+ applier: applier,
31
+ clock: clock
32
+ )
28
33
  end
29
34
 
30
35
  def rewrite(source:, diff:, plan_entry:)
@@ -52,29 +57,33 @@ module PatchUtil
52
57
  raise PatchUtil::ValidationError,
53
58
  "git rewrite apply does not support descendant merge commits yet: #{merge_descendant} is a merge commit in #{target}..#{head}"
54
59
  end
60
+
61
+ emitted = @preflight_verifier.verify(
62
+ source: source,
63
+ parent: parent,
64
+ diff: diff,
65
+ plan_entry: plan_entry,
66
+ branch: branch
67
+ )
68
+
55
69
  original_commit = @git_cli.show_commit_metadata(repo_path, target)
56
70
  backup_ref = "refs/patch_util-backups/#{branch}/#{timestamp_token}"
57
71
  @git_cli.update_ref(repo_path, backup_ref, head)
58
72
  worktree = build_worktree_path(repo_path, branch, target)
59
- emitted_output_dir = File.join(worktree, '.patch_util_emitted')
60
73
  state_store = RewriteStateStore.new(git_dir: @git_cli.git_dir(repo_path))
61
74
  pending_revisions = descendants.dup
62
75
 
63
76
  begin
64
77
  @git_cli.worktree_add(repo_path, worktree, parent)
65
-
66
- emitted = @applier.apply(diff: diff, plan_entry: plan_entry,
67
- output_dir: emitted_output_dir)
68
78
  emitted.each do |item|
69
79
  @git_cli.apply_patch_text(worktree, item[:patch_text])
70
- FileUtils.rm_rf(emitted_output_dir)
71
80
  @git_cli.commit_all(worktree, build_commit_message(item[:name], target, original_commit),
72
81
  env: split_commit_env(original_commit))
73
82
  end
74
83
 
75
84
  until pending_revisions.empty?
76
85
  revision = pending_revisions.first
77
- @git_cli.cherry_pick(worktree, revision)
86
+ @git_cli.cherry_pick(worktree, revision, env: replay_commit_env(repo_path, revision))
78
87
  pending_revisions.shift
79
88
  end
80
89
 
@@ -214,6 +223,15 @@ module PatchUtil
214
223
  }
215
224
  end
216
225
 
226
+ def replay_commit_env(repo_path, revision)
227
+ original_commit = @git_cli.show_commit_metadata(repo_path, revision)
228
+ {
229
+ 'GIT_COMMITTER_NAME' => original_commit.committer_name,
230
+ 'GIT_COMMITTER_EMAIL' => original_commit.committer_email,
231
+ 'GIT_COMMITTER_DATE' => original_commit.committer_date
232
+ }
233
+ end
234
+
217
235
  def timestamp_token
218
236
  @clock.call.strftime('%Y%m%d%H%M%S')
219
237
  end
@@ -3,6 +3,7 @@
3
3
  module PatchUtil
4
4
  module Git
5
5
  autoload :Cli, 'patch_util/git/cli'
6
+ autoload :RewritePreflightVerifier, 'patch_util/git/rewrite_preflight_verifier'
6
7
  autoload :RewriteStateStore, 'patch_util/git/rewrite_state_store'
7
8
  autoload :RewriteSessionManager, 'patch_util/git/rewrite_session_manager'
8
9
  autoload :Rewriter, 'patch_util/git/rewriter'
@@ -10,6 +10,7 @@ module PatchUtil
10
10
  @diff = diff
11
11
  @plan_entry = plan_entry
12
12
  @chunk_index_by_row_id = {}
13
+ @chunk_indexes_by_file_diff = {}
13
14
 
14
15
  @plan_entry.chunks.each_with_index do |chunk, chunk_index|
15
16
  chunk.row_ids.each do |row_id|
@@ -27,6 +28,8 @@ module PatchUtil
27
28
  projected_hunks = []
28
29
  path_operation_applied_before = path_operation_applied_before?(file_diff, chunk_index)
29
30
  path_operation_applied_after = path_operation_applied_after?(file_diff, chunk_index)
31
+ file_present_before = file_present_before?(file_diff, chunk_index)
32
+ file_present_after = file_present_after?(file_diff, chunk_index)
30
33
 
31
34
  file_diff.hunks.each do |hunk|
32
35
  case hunk.kind
@@ -37,7 +40,14 @@ module PatchUtil
37
40
  projected_hunk = project_binary_hunk(hunk, chunk_index)
38
41
  projected_hunks << projected_hunk if projected_hunk
39
42
  else
40
- changed, projected_hunk = project_text_hunk(hunk, chunk_index, before_offset, after_offset)
43
+ changed, projected_hunk = project_text_hunk(
44
+ hunk,
45
+ chunk_index,
46
+ before_offset,
47
+ after_offset,
48
+ file_present_before,
49
+ file_present_after
50
+ )
41
51
  projected_hunks << projected_hunk if changed
42
52
 
43
53
  before_offset += applied_delta(hunk, max_chunk_index: chunk_index - 1)
@@ -48,15 +58,30 @@ module PatchUtil
48
58
  next if projected_hunks.empty?
49
59
 
50
60
  metadata_lines = projected_hunks.select { |hunk| hunk.kind == :file_operation }.flat_map(&:patch_lines)
51
- metadata_lines = implicit_metadata_lines(file_diff, metadata_lines) if metadata_lines.empty?
52
- before_paths = current_paths(file_diff, path_operation_applied: path_operation_applied_before)
53
- after_paths = current_paths(file_diff, path_operation_applied: path_operation_applied_after)
61
+ if metadata_lines.empty?
62
+ metadata_lines = implicit_metadata_lines(
63
+ file_diff,
64
+ metadata_lines,
65
+ file_present_before: file_present_before,
66
+ file_present_after: file_present_after
67
+ )
68
+ end
69
+ before_path = path_for_state(
70
+ file_diff,
71
+ path_operation_applied: path_operation_applied_before,
72
+ file_present: file_present_before
73
+ )
74
+ after_path = path_for_state(
75
+ file_diff,
76
+ path_operation_applied: path_operation_applied_after,
77
+ file_present: file_present_after
78
+ )
54
79
  binary_only = projected_hunks.all? { |hunk| hunk.kind == :binary }
55
80
 
56
81
  projected_files << ProjectedFile.new(
57
- old_path: before_paths[:old_path],
58
- new_path: after_paths[:new_path],
59
- diff_git_line: build_diff_git_line(before_paths[:old_path], after_paths[:new_path]),
82
+ old_path: before_path,
83
+ new_path: after_path,
84
+ diff_git_line: projected_diff_git_line(file_diff, before_path, after_path),
60
85
  metadata_lines: metadata_lines,
61
86
  hunks: projected_hunks.reject { |hunk| hunk.kind == :file_operation },
62
87
  emit_text_headers: !binary_only || emits_text_headers_for_binary?(file_diff)
@@ -96,7 +121,7 @@ module PatchUtil
96
121
  )
97
122
  end
98
123
 
99
- def project_text_hunk(hunk, chunk_index, before_offset, after_offset)
124
+ def project_text_hunk(hunk, chunk_index, before_offset, after_offset, file_present_before, file_present_after)
100
125
  lines = []
101
126
  changed = false
102
127
  old_count = 0
@@ -124,9 +149,9 @@ module PatchUtil
124
149
  [
125
150
  changed,
126
151
  ProjectedHunk.new(
127
- old_start: hunk.old_start + before_offset,
152
+ old_start: normalize_start(hunk.old_start + before_offset, old_count, file_present_before),
128
153
  old_count: old_count,
129
- new_start: hunk.new_start + after_offset,
154
+ new_start: normalize_start(hunk.new_start + after_offset, new_count, file_present_after),
130
155
  new_count: new_count,
131
156
  lines: lines,
132
157
  kind: :text,
@@ -179,17 +204,6 @@ module PatchUtil
179
204
  end
180
205
  end
181
206
 
182
- def current_paths(file_diff, path_operation_applied:)
183
- operation_hunk = file_diff.path_operation_hunk
184
- return { old_path: file_diff.old_path, new_path: file_diff.new_path } unless operation_hunk
185
-
186
- if path_operation_applied
187
- { old_path: file_diff.new_path, new_path: file_diff.new_path }
188
- else
189
- { old_path: file_diff.old_path, new_path: file_diff.old_path }
190
- end
191
- end
192
-
193
207
  def build_diff_git_line(old_path, new_path)
194
208
  old_git_path = if old_path == '/dev/null'
195
209
  to_git_old_path(new_path)
@@ -209,6 +223,12 @@ module PatchUtil
209
223
  "diff --git #{old_git_path} #{new_git_path}"
210
224
  end
211
225
 
226
+ def projected_diff_git_line(file_diff, old_path, new_path)
227
+ return nil unless file_diff.diff_git_line
228
+
229
+ build_diff_git_line(old_path, new_path)
230
+ end
231
+
212
232
  def path_operation_applied_before?(file_diff, chunk_index)
213
233
  return false if chunk_index.zero?
214
234
 
@@ -224,18 +244,67 @@ module PatchUtil
224
244
  end
225
245
  end
226
246
 
247
+ def file_present_before?(file_diff, chunk_index)
248
+ file_present_at_boundary?(file_diff, chunk_index, after_chunk: false)
249
+ end
250
+
251
+ def file_present_after?(file_diff, chunk_index)
252
+ file_present_at_boundary?(file_diff, chunk_index, after_chunk: true)
253
+ end
254
+
255
+ def file_present_at_boundary?(file_diff, chunk_index, after_chunk:)
256
+ return true unless file_diff.addition? || file_diff.deletion?
257
+
258
+ chunk_indexes = chunk_indexes_for(file_diff)
259
+ return file_diff.deletion? if chunk_indexes.empty?
260
+
261
+ if file_diff.addition?
262
+ created_chunk_index = chunk_indexes.min
263
+ after_chunk ? chunk_index >= created_chunk_index : chunk_index > created_chunk_index
264
+ else
265
+ removed_chunk_index = chunk_indexes.max
266
+ after_chunk ? chunk_index < removed_chunk_index : chunk_index <= removed_chunk_index
267
+ end
268
+ end
269
+
270
+ def chunk_indexes_for(file_diff)
271
+ @chunk_indexes_by_file_diff[file_diff] ||= file_diff.hunks.flat_map(&:change_rows).map do |row|
272
+ @chunk_index_by_row_id.fetch(row.id)
273
+ end.uniq
274
+ end
275
+
276
+ def path_for_state(file_diff, path_operation_applied:, file_present:)
277
+ return '/dev/null' unless file_present
278
+ return file_diff.new_path if path_operation_applied || file_diff.addition?
279
+
280
+ file_diff.old_path
281
+ end
282
+
227
283
  def emits_text_headers_for_binary?(file_diff)
228
284
  file_diff.modification? || file_diff.path_operation_hunk
229
285
  end
230
286
 
231
- def implicit_metadata_lines(file_diff, metadata_lines)
287
+ def implicit_metadata_lines(file_diff, metadata_lines, file_present_before:, file_present_after:)
232
288
  return metadata_lines unless file_diff.addition? || file_diff.deletion?
233
289
 
290
+ if file_diff.addition?
291
+ return [] unless !file_present_before && file_present_after
292
+ else
293
+ return [] unless file_present_before && !file_present_after
294
+ end
295
+
234
296
  file_diff.metadata_lines.select do |line|
235
297
  line.start_with?('new file mode ') || line.start_with?('deleted file mode ')
236
298
  end
237
299
  end
238
300
 
301
+ def normalize_start(start, count, file_present)
302
+ return 0 if count.zero? || !file_present
303
+ return 1 if start.zero?
304
+
305
+ start
306
+ end
307
+
239
308
  def to_git_old_path(path)
240
309
  return '/dev/null' if path == '/dev/null'
241
310
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatchUtil
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patch_util
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - hmdne
@@ -45,6 +45,7 @@ files:
45
45
  - lib/patch_util/git.rb
46
46
  - lib/patch_util/git/cli.rb
47
47
  - lib/patch_util/git/rewrite_cli.rb
48
+ - lib/patch_util/git/rewrite_preflight_verifier.rb
48
49
  - lib/patch_util/git/rewrite_session_manager.rb
49
50
  - lib/patch_util/git/rewrite_state_store.rb
50
51
  - lib/patch_util/git/rewriter.rb