Skip to content

Commit 85e0f8c

Browse files
rheniumhsbt
authored andcommitted
sync_default_gems.rb: update paths and then do cherry-pick
Currently, we try to git cherry-pick the upstream commit and then resolve merge conflicts in the working tree with the help of Git's rename detection. By the nature of heuristics, it does not work reliably when the upstream adds or removes files. Instead, first prepare temporary commit objects with uninteresting files removed and file paths adjusted for ruby/ruby, and then cherry-pick it. The cherry-pick should succeed as long as the mapping rules are correct, the upstream does not contain a funny merge that strictly depends on merge order, and there are no local changes in ruby/ruby.
1 parent b722631 commit 85e0f8c

1 file changed

Lines changed: 141 additions & 152 deletions

File tree

tool/sync_default_gems.rb

Lines changed: 141 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require 'fileutils'
66
require "rbconfig"
77
require "find"
8+
require "tempfile"
89

910
module SyncDefaultGems
1011
include FileUtils
@@ -331,6 +332,10 @@ def replace_rdoc_ref_all
331332
result.inject(false) {|changed, file| changed | replace_rdoc_ref(file)}
332333
end
333334

335+
def replace_rdoc_ref_all_full
336+
Dir.glob("**/*.{c,rb,rdoc}").inject(false) {|changed, file| changed | replace_rdoc_ref(file)}
337+
end
338+
334339
def rubygems_do_fixup
335340
gemspec_content = File.readlines("lib/bundler/bundler.gemspec").map do |line|
336341
next if line =~ /LICENSE\.md/
@@ -419,30 +424,6 @@ def check_prerelease_version(gem)
419424
puts "#{gem}-#{spec.version} is not latest version of rubygems.org" if spec.version.to_s != latest_version
420425
end
421426

422-
def ignore_file_pattern_for(gem)
423-
patterns = []
424-
425-
# Common patterns
426-
patterns << %r[\A(?:
427-
[^/]+ # top-level entries
428-
|\.git.*
429-
|bin/.*
430-
|ext/.*\.java
431-
|rakelib/.*
432-
|test/(?:lib|fixtures)/.*
433-
|tool/(?!bundler/).*
434-
)\z]mx
435-
436-
# Gem-specific patterns
437-
case gem
438-
when nil
439-
end&.tap do |pattern|
440-
patterns << pattern
441-
end
442-
443-
Regexp.union(*patterns)
444-
end
445-
446427
def message_filter(repo, sha, log)
447428
unless repo.count("/") == 1 and /\A\S+\z/ =~ repo
448429
raise ArgumentError, "invalid repository: #{repo}"
@@ -523,28 +504,9 @@ def commits_in_ranges(gem, repo, default_branch, ranges)
523504
#++
524505

525506
def resolve_conflicts(gem, sha, edit)
526-
# Skip this commit if everything has been removed as `ignored_paths`.
507+
# Discover unmerged files: any unstaged changes
527508
changes = porcelain_status()
528-
if changes.empty?
529-
puts "Skip empty commit #{sha}"
530-
return false
531-
end
532-
533-
# We want to skip
534-
# DD: deleted by both
535-
# DU: deleted by us
536-
deleted = changes.grep(/^D[DU] /) {$'}
537-
system(*%W"git rm -f --", *deleted) unless deleted.empty?
538-
539-
# Import UA: added by them
540-
added = changes.grep(/^UA /) {$'}
541-
system(*%W"git add --", *added) unless added.empty?
542-
543-
# Discover unmerged files
544-
# AU: unmerged, added by us
545-
# UU: unmerged, both modified
546-
# AA: unmerged, both added
547-
conflict = changes.grep(/\A(?:A[AU]|UU) /) {$'}
509+
conflict = changes.grep(/\A(?:.[^ ?]) /) {$'}
548510
# If -e option is given, open each conflicted file with an editor
549511
unless conflict.empty?
550512
if edit
@@ -565,134 +527,159 @@ def resolve_conflicts(gem, sha, edit)
565527
return true
566528
end
567529

568-
def preexisting?(base, file)
569-
system(*%w"git cat-file -e", "#{base}:#{file}", err: File::NULL)
570-
end
571-
572-
def filter_pickup_files(changed, ignore_file_pattern, base)
573-
toplevels = {}
574-
remove = []
575-
ignore = []
576-
changed = changed.reject do |f|
577-
case
578-
when toplevels.fetch(top = f[%r[\A[^/]+(?=/|\z)]m]) {
579-
remove << top if toplevels[top] = !preexisting?(base, top)
580-
}
581-
# Remove any new top-level directories.
582-
true
583-
when ignore_file_pattern.match?(f)
584-
# Forcibly reset any changes matching ignore_file_pattern.
585-
(preexisting?(base, f) ? ignore : remove) << f
586-
end
530+
def collect_cacheinfo(tree)
531+
cacheinfo = pipe_readlines(%W"git ls-tree -r -t -z #{tree}").filter_map do |line|
532+
fields, path = line.split("\t", 2)
533+
mode, type, object = fields.split(" ", 3)
534+
next unless type == "blob"
535+
[mode, type, object, path]
587536
end
588-
return changed, remove, ignore
589537
end
590538

591-
def pickup_files(gem, changed, picked)
592-
# Forcibly remove any files that we don't want to copy to this
593-
# repository.
594-
595-
ignore_file_pattern = ignore_file_pattern_for(gem)
596-
597-
base = picked ? "HEAD~" : "HEAD"
598-
changed, remove, ignore = filter_pickup_files(changed, ignore_file_pattern, base)
539+
def rewrite_cacheinfo(gem, blobs)
540+
config = REPOSITORIES[gem]
541+
rewritten = []
542+
ignored = blobs.dup
543+
ignored.delete_if do |mode, type, object, path|
544+
newpath = config.rewrite_for_ruby(path)
545+
next unless newpath
546+
rewritten << [mode, type, object, newpath]
547+
end
548+
[rewritten, ignored]
549+
end
599550

600-
unless remove.empty?
601-
puts "Remove added files: #{remove.join(', ')}"
602-
system(*%w"git rm -fr --", *remove)
603-
if picked
604-
system(*%w"git commit --amend --no-edit --", *remove, %i[out err] => File::NULL)
605-
end
606-
end
551+
def make_commit_info(gem, sha)
552+
config = REPOSITORIES[gem]
553+
headers, orig = IO.popen(%W[git cat-file commit #{sha}], "rb", &:read).split("\n\n", 2)
554+
/^author (?<author_name>.+?) <(?<author_email>.*?)> (?<author_date>.+?)$/ =~ headers or
555+
raise "unable to parse author info for commit #{sha}"
556+
author = {
557+
"GIT_AUTHOR_NAME" => author_name,
558+
"GIT_AUTHOR_EMAIL" => author_email,
559+
"GIT_AUTHOR_DATE" => author_date,
560+
}
561+
message = message_filter(config.upstream, sha, orig)
562+
[author, message]
563+
end
607564

608-
unless ignore.empty?
609-
puts "Reset ignored files: #{ignore.join(', ')}"
610-
system(*%W"git rm -r --", *ignore)
611-
ignore.each {|f| system(*%W"git checkout -f", base, "--", f)}
565+
def fixup_commit(gem, commit)
566+
wt = File.join("tmp", "sync_default_gems-fixup-worktree")
567+
if File.directory?(wt)
568+
IO.popen(%W"git -C #{wt} clean -xdf", "rb", &:read)
569+
IO.popen(%W"git -C #{wt} reset --hard #{commit}", "rb", &:read)
570+
else
571+
IO.popen(%W"git worktree remove --force #{wt}", "rb", err: File::NULL, &:read)
572+
IO.popen(%W"git worktree add --detach #{wt} #{commit}", "rb", &:read)
612573
end
574+
raise "git worktree prepare failed for commit #{commit}" unless $?.success?
613575

614-
if changed.empty?
615-
return nil
576+
Dir.chdir(wt) do
577+
if gem == "rubygems"
578+
rubygems_do_fixup
579+
end
580+
replace_rdoc_ref_all_full
616581
end
617582

618-
return changed
583+
IO.popen(%W"git -C #{wt} add -u", "rb", &:read)
584+
IO.popen(%W"git -C #{wt} commit --amend --no-edit", "rb", &:read)
585+
IO.popen(%W"git -C #{wt} rev-parse HEAD", "rb", &:read).chomp
619586
end
620587

621-
def pickup_commit(gem, sha, edit)
622-
# Attempt to cherry-pick a commit
623-
result = IO.popen(%W"git cherry-pick #{sha}", "rb", &:read)
624-
picked = $?.success?
625-
if result =~ /nothing\ to\ commit/
626-
`git reset`
627-
puts "Skip empty commit #{sha}"
628-
return false
629-
end
588+
def make_and_fixup_commit(gem, original_commit, cacheinfo, parent: nil, message: nil, author: nil)
589+
tree = Tempfile.create("sync_default_gems-#{gem}-index") do |f|
590+
File.unlink(f.path)
591+
IO.popen({"GIT_INDEX_FILE" => f.path},
592+
%W"git update-index --index-info", "wb", out: IO::NULL) do |io|
593+
cacheinfo.each do |mode, type, object, path|
594+
io.puts("#{mode} #{type} #{object}\t#{path}")
595+
end
596+
end
597+
raise "git update-index failed" unless $?.success?
630598

631-
# Skip empty commits
632-
if result.empty?
633-
return false
599+
IO.popen({"GIT_INDEX_FILE" => f.path}, %W"git write-tree --missing-ok", "rb", &:read).chomp
634600
end
635601

636-
if picked
637-
changed = pipe_readlines(%w"git diff-tree --name-only -r -z HEAD~..HEAD --")
638-
else
639-
changed = pipe_readlines(%w"git diff --name-only -r -z HEAD --")
640-
end
602+
args = ["-m", message || "Rewriten commit for #{original_commit}"]
603+
args += ["-p", parent] if parent
604+
commit = IO.popen({**author}, %W"git commit-tree #{tree}" + args, "rb", &:read).chomp
641605

642-
# Pick up files to merge.
643-
unless changed = pickup_files(gem, changed, picked)
644-
puts "Skip commit #{sha} only for tools or toplevel"
645-
if picked
646-
`git reset --hard HEAD~`
647-
else
648-
`git cherry-pick --abort`
649-
end
650-
return false
651-
end
606+
# Apply changes that require a working tree
607+
commit = fixup_commit(gem, commit)
652608

653-
# If the cherry-pick attempt failed, try to resolve conflicts.
654-
# Skip the commit, if it contains unresolved conflicts or no files to pick up.
655-
unless picked or resolve_conflicts(gem, sha, edit)
656-
system(*%w"git --no-pager diff") if !picked && !edit # If failed, show `git diff` unless editing
657-
`git reset` && `git checkout .` && `git clean -fd` # Clean up un-committed diffs
658-
return picked || nil # Fail unless cherry-picked
659-
end
609+
commit
610+
end
660611

661-
# Commit cherry-picked commit
662-
if picked
663-
system(*%w"git commit --amend --no-edit")
664-
elsif porcelain_status().empty?
665-
system(*%w"git cherry-pick --skip")
612+
def rewrite_commit(gem, sha)
613+
config = REPOSITORIES[gem]
614+
author, message = make_commit_info(gem, sha)
615+
new_blobs = collect_cacheinfo("#{sha}")
616+
new_rewritten, new_ignored = rewrite_cacheinfo(gem, new_blobs)
617+
618+
headers, orig_message = IO.popen(%W[git cat-file commit #{sha}], "rb", &:read).split("\n\n", 2)
619+
first_parent = headers[/^parent (.{40})$/, 1]
620+
unless first_parent
621+
# Root commit, first time to sync this repo
622+
return make_and_fixup_commit(gem, sha, new_rewritten, message: message, author: author)
623+
end
624+
625+
old_blobs = collect_cacheinfo(first_parent)
626+
old_rewritten, old_ignored = rewrite_cacheinfo(gem, old_blobs)
627+
if old_ignored != new_ignored
628+
paths = (old_ignored + new_ignored - (old_ignored & new_ignored))
629+
.map {|*_, path| path}.uniq
630+
puts "\e\[1mIgnoring file changes not in mappings: #{paths.join(" ")}\e\[0m"
631+
end
632+
changed_paths = (old_rewritten + new_rewritten - (old_rewritten & new_rewritten))
633+
.map {|*_, path| path}.uniq
634+
if changed_paths.empty?
635+
puts "Skip commit only for tools or toplevel"
666636
return false
667-
else
668-
system(*%w"git cherry-pick --continue --no-edit")
669-
end or return nil
670-
671-
# Amend the commit if RDoc references need to be replaced
672-
head = log_format('%H', %W"-1 HEAD", &:read).chomp
673-
system(*%w"git reset --quiet HEAD~ --")
674-
amend = replace_rdoc_ref_all
675-
system(*%W"git reset --quiet #{head} --")
676-
if amend
677-
`git commit --amend --no-edit --all`
678637
end
679638

639+
# Build commit objects from "cacheinfo"
640+
new_parent = make_and_fixup_commit(gem, first_parent, old_rewritten)
641+
new_commit = make_and_fixup_commit(gem, sha, new_rewritten, parent: new_parent, message: message, author: author)
642+
puts "Created a temporary commit for cherry-pick: #{new_commit}"
643+
new_commit
644+
end
680645

681-
# Update commit message to include links to the original commit
682-
puts "Update commit message: #{sha}"
646+
def pickup_commit(gem, sha, edit)
683647
config = REPOSITORIES[gem]
684-
headers, orig = IO.popen(%W[git cat-file commit #{sha}], "rb", &:read).split("\n\n", 2)
685-
message = message_filter(config.upstream, sha, orig)
686-
IO.popen(%W[git commit --amend --no-edit -F -], "r+b") {|io|
687-
io.write(message)
688-
io.close_write
689-
io.read
690-
}
648+
649+
rewritten = rewrite_commit(gem, sha)
650+
651+
# No changes remaining after rewriting
652+
return false unless rewritten
653+
654+
# Attempt to cherry-pick a commit
655+
result = IO.popen(%W"git cherry-pick #{rewritten}", "rb", err: [:child, :out], &:read)
691656
unless $?.success?
692-
puts "Failed to modify commit message of #{sha}"
693-
return nil
657+
if result =~ /The previous cherry-pick is now empty/
658+
system(*%w"git cherry-pick --skip")
659+
puts "Skip empty commit #{sha}"
660+
return false
661+
end
662+
663+
# If the cherry-pick attempt failed, try to resolve conflicts.
664+
# Skip the commit, if it contains unresolved conflicts or no files to pick up.
665+
unless resolve_conflicts(gem, sha, edit)
666+
system(*%w"git --no-pager diff") if !edit # If failed, show `git diff` unless editing
667+
`git reset` && `git checkout .` && `git clean -fd` # Clean up un-committed diffs
668+
return nil # Fail unless cherry-picked
669+
end
670+
671+
# Commit cherry-picked commit
672+
if porcelain_status().empty?
673+
system(*%w"git cherry-pick --skip")
674+
return false
675+
else
676+
system(*%w"git cherry-pick --continue --no-edit")
677+
return nil unless $?.success?
678+
end
694679
end
695680

681+
new_head = IO.popen(%W"git rev-parse HEAD", "rb", &:read).chomp
682+
puts "Committed cherry-pick as #{new_head}"
696683
return true
697684
end
698685

@@ -727,22 +714,24 @@ def sync_default_gems_with_commits(gem, ranges, edit: nil)
727714

728715
puts "Try to pick these commits:"
729716
puts commits.map{|commit| commit.join(": ")}
730-
puts "----"
731717

732718
failed_commits = []
733719
commits.each do |sha, subject|
734-
puts "Pick #{sha} from #{repo}."
720+
puts "----"
721+
puts "Pick #{sha} #{subject}"
735722
case pickup_commit(gem, sha, edit)
736723
when false
737724
# skipped
738725
when nil
739-
failed_commits << sha
726+
failed_commits << [sha, subject]
740727
end
741728
end
742729

743730
unless failed_commits.empty?
744731
puts "---- failed commits ----"
745-
puts failed_commits
732+
failed_commits.each do |sha, subject|
733+
puts "#{sha} #{subject}"
734+
end
746735
return false
747736
end
748737
return true

0 commit comments

Comments
 (0)