55require 'fileutils'
66require "rbconfig"
77require "find"
8+ require "tempfile"
89
910module 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