Skip to content

Commit 72d319c

Browse files
committedNov 13, 2019
[CodeExtractor] Add reinsert functionality
This is effectively the "undo" feature of the original project. By that, it allows a user to take a portion (or all) of a previously extracted project, and re-insert it back into the original project from which it came from. This becomes tricky, as we need to filter out the existing commits that existed previously in the old ("target") repository. As a result, 3 `filter-branch` passes are done to achieve this change: 1. After determining all file names that existed for the current extractions, filter commits to down to ones that only include the files we wish to commit. In addition, create a script to move any of those files into a temporary directory, maintaining the directory structure as we go. 2. Take the temporary directory that was created and do a second filter that makes the new root of the project the temporary directory we created in the previous filter. 3. After adding the target repository as a remote, filter the commits out that already exist in that remote. Also in this step, a commit is re-written that is shared between both repos that will be the "re-inject" commit, which all of the injected commits will be based off of. From there, cherry-pick commits onto a new branch that is based off the existing HEAD of the current target remote's branch (most likely `master`) that will be receiving the "injected" commits. This allows the target repo to have the commits that were created "post extraction" that are based off a commit that re-adds the code in a state where it was originally extracted from.
1 parent 89de756 commit 72d319c

File tree

3 files changed

+349
-13
lines changed

3 files changed

+349
-13
lines changed
 

‎lib/code_extractor.rb

+221-12
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# The git history.
55
module CodeExtractor
66
def run
7-
Runner.new.extract
7+
Runner.new.run
88
end
99
module_function :run
1010

@@ -34,7 +34,7 @@ def validate!
3434
end
3535

3636
class GitProject
37-
attr_reader :name, :url, :git_dir, :new_branch, :source_branch
37+
attr_reader :name, :url, :git_dir, :new_branch, :source_branch, :target_name
3838

3939
def initialize name, url
4040
@name = name
@@ -58,7 +58,7 @@ def extract_branch source_branch, new_branch, extractions
5858
@source_branch = source_branch
5959
Dir.chdir git_dir do
6060
`git checkout #{source_branch}`
61-
`git fetch upstream && git rebase upstream/master`
61+
`git fetch upstream && git rebase upstream/#{source_branch}`
6262
if system("git branch | grep #{new_branch}")
6363
`git branch -D #{new_branch}`
6464
end
@@ -85,18 +85,205 @@ def remove_tags
8585
end
8686
end
8787

88-
def filter_branch extractions, upstream_name
88+
def extract_commits extractions, upstream_name
8989
Dir.chdir git_dir do
9090
`time git filter-branch --index-filter '
9191
git read-tree --empty
9292
git reset $GIT_COMMIT -- #{extractions}
93+
' #{msg_filter upstream_name} -- #{source_branch} -- #{extractions}`
94+
end
95+
end
96+
97+
def prune_commits extractions
98+
puts "Pruning commits…"
99+
100+
build_prune_script extractions
101+
102+
Dir.chdir git_dir do
103+
`git checkout -b #{prune_branch} #{@source_branch}`
104+
`git filter-branch -f --prune-empty --tree-filter #{@prune_script} HEAD`
105+
`git filter-branch -f --prune-empty --subdirectory-filter #{@keep_directory}`
106+
end
107+
end
108+
109+
def add_target_remote target_name, target_remote
110+
puts "Add target repo as a remote…"
111+
@target_name = target_name
112+
113+
Dir.chdir git_dir do
114+
puts "git remote add #{target_remote_name} #{target_remote}"
115+
`git remote add #{target_remote_name} #{target_remote}`
116+
`git fetch #{target_remote_name}`
117+
end
118+
end
119+
120+
# "Inject" commits one repo's branch back into the target repo's
121+
#
122+
# Assuming the target remote has been added (see add_target_remote), this
123+
# method does so by doing the following to achieve the "injected" history:
124+
#
125+
# 1. Filters commits that already exist in the target repo. Additionally,
126+
# the last commit that is shared between the two is actually used as the
127+
# "root" commit for the injected commits. The rest are assumed to be new
128+
# from the new repository.
129+
#
130+
# The "root" commit has it's commit message modified to reflect this
131+
# change.
132+
#
133+
# 2. A new branch is checked out that is based off the target remote's
134+
# target branch, but does not track that branch.
135+
#
136+
# 3. The commits that have been filtered are cherry-picked on to this new
137+
# branch, and the "root" commit assumes the parent of the current HEAD of
138+
# the target remote's (master) branch
139+
#
140+
def inject_commits target_base_branch, upstream_name
141+
puts "Injecting commits…"
142+
143+
target_base_branch ||= 'master'
144+
commit_msg_filter = "(transferred from #{upstream_name}"
145+
146+
Dir.chdir git_dir do
147+
reference_target_branch = "#{target_remote_name}/#{target_base_branch}"
148+
previously_extracted_commits = `git log --pretty="%H" --grep="#{commit_msg_filter}"`
149+
150+
# special commit that will get renamed re-worded to:
151+
#
152+
# Re-insert extractions from #{upstream_name}
153+
#
154+
last_extracted_commit = previously_extracted_commits.lines[0].chomp!
155+
first_injected_msg = `git show -s --format="%s%n%n%b" #{last_extracted_commit}`
156+
first_injected_msg = first_injected_msg.lines.reject { |line|
157+
line.include? commit_msg_filter
158+
}.join
159+
first_injected_msg.prepend "*** Original Commit message shown below ***\n\n"
160+
first_injected_msg.prepend "Re-insert extractions from #{target_name}\n\n"
161+
File.write File.expand_path("../LAST_EXTRACTED_COMMIT_MSG", git_dir), first_injected_msg
162+
163+
`time git filter-branch -f --commit-filter '
164+
export was_extracted=$(git show -s --format="%s%n%n%b" $GIT_COMMIT | grep -s "#{commit_msg_filter}")
165+
if [ "$GIT_COMMIT" = "#{last_extracted_commit}" ] || [ "$was_extracted" == "" ]; then
166+
git commit-tree "$@";
167+
else
168+
skip_commit "$@";
169+
fi
170+
' --index-filter '
171+
git read-tree --empty
172+
git reset #{reference_target_branch} -- .
173+
git checkout $GIT_COMMIT -- .
93174
' --msg-filter '
175+
if [ "$GIT_COMMIT" = "#{last_extracted_commit}" ]; then
176+
cat #{File.expand_path File.join("..", "LAST_EXTRACTED_COMMIT_MSG"), git_dir}
177+
else
178+
cat -
179+
fi
180+
echo
181+
echo
182+
echo "(transferred from #{upstream_name}@$GIT_COMMIT)"
183+
' -- #{prune_branch}`
184+
185+
`git checkout --no-track -b #{inject_branch} #{reference_target_branch}`
186+
`git cherry-pick ..#{prune_branch}`
187+
end
188+
end
189+
190+
def run_extra_cmds cmds
191+
Dir.chdir git_dir do
192+
cmds.each { |cmd| system cmd } if cmds
193+
end
194+
end
195+
196+
private
197+
198+
def target_remote_name
199+
@target_remote_name ||= "code_extractor_target_for_#{name}"
200+
end
201+
202+
def prune_branch
203+
@prune_branch ||= "code_extractor_prune_#{name}"
204+
end
205+
alias prune_commits_remote prune_branch
206+
207+
def inject_branch
208+
@inject_branch ||= "code_extractor_inject_#{name}"
209+
end
210+
alias inject_remote inject_branch
211+
212+
# Given a list of extractions, build a script that will move a list of
213+
# files (extractions) from their current location in a given commit to a
214+
# unused directory.
215+
#
216+
# More complicated than it looks, this will be used as part of a two-phased
217+
# `git filter-branch` to:
218+
#
219+
# 1. move extractable files into a subdirectory with `--tree-filter`
220+
# 2. only keep commits for files moved into that subdirectory, and make
221+
# the subdirectory the new project root.
222+
#
223+
# For consistency, we want to keep the subdirectories' structure in the
224+
# same line as what was there previously, so this script helps do that, and
225+
# also creates directories/files when they don't exist.
226+
#
227+
# Returns `true` at the end of the script incase the last `mv` fails (the
228+
# source doesn't exist in this commit, for example)
229+
#
230+
def build_prune_script extractions
231+
require 'fileutils'
232+
233+
@keep_directory = "code_extractor_git_keeps_#{Time.now.to_i}"
234+
git_log_follow = "git log --name-only --format=format: --follow"
235+
prune_mkdirs = Set.new
236+
prune_mvs = []
237+
238+
Dir.chdir git_dir do
239+
extractions.each do |file_or_dir|
240+
if Dir.exist? file_or_dir
241+
files = Dir.glob["#{file_or_dir}/**/*"]
242+
else
243+
files = [file_or_dir]
244+
end
245+
246+
files.each do |extraction_file|
247+
file_and_ancestors = `#{git_log_follow} -- #{extraction_file}`.split("\n").uniq
248+
249+
file_and_ancestors.reject! { |file| file.length == 0 }
250+
251+
file_and_ancestors.each do |file|
252+
file_dir = File.dirname file
253+
prune_mkdirs.add file_dir
254+
prune_mvs << [file, "#{@keep_directory}/#{file_dir}"]
255+
end
256+
end
257+
end
258+
end
259+
260+
@prune_script = File.join Dir.pwd, "code_extractor_#{name}_prune_script.sh"
261+
262+
File.open @prune_script, "w" do |script|
263+
prune_mkdirs.each do |dir|
264+
script.puts "mkdir -p #{File.join @keep_directory, dir}"
265+
end
266+
267+
script.puts
268+
prune_mvs.each do |(file, dir)|
269+
script.puts "mv #{file} #{dir} 2>/dev/null"
270+
end
271+
272+
script.puts
273+
script.puts "true"
274+
end
275+
FileUtils.chmod "+x", @prune_script
276+
end
277+
278+
def msg_filter upstream_name
279+
<<-MSG_FILTER.gsub(/^ {8}/, '').chomp
280+
--msg-filter '
94281
cat -
95282
echo
96283
echo
97284
echo "(transferred from #{upstream_name}@$GIT_COMMIT)"
98-
' -- #{source_branch} -- #{extractions}`
99-
end
285+
'
286+
MSG_FILTER
100287
end
101288
end
102289

@@ -106,17 +293,39 @@ def initialize config = nil
106293
@source_project = GitProject.new @config[:name], @config[:upstream]
107294
end
108295

109-
def extractions
110-
@extractions ||= @config[:extractions].join(' ')
111-
end
112-
113-
def extract
296+
# Either run `.reinsert` or `.extract`
297+
#
298+
# The `.reinsert` method will eject with `nil` unless the config setting to
299+
# run in that mode is set
300+
#
301+
def run
114302
puts @config
303+
115304
@source_project.clone_to @config[:destination]
116305
@source_project.extract_branch @config[:upstream_branch], "extract_#{@config[:name]}", extractions
117306
@source_project.remove_remote
118307
@source_project.remove_tags
119-
@source_project.filter_branch extractions, @config[:upstream_name]
308+
309+
reinsert || extract
310+
end
311+
312+
def extractions
313+
@extractions ||= @config[:extractions].join(' ')
314+
end
315+
316+
def extract
317+
@source_project.extract_commits extractions, @config[:upstream_name]
318+
end
319+
320+
def reinsert
321+
return unless @config[:reinsert]
322+
323+
@source_project.prune_commits @config[:extractions]
324+
@source_project.run_extra_cmds @config[:extra_cmds]
325+
@source_project.add_target_remote @config[:target_name], @config[:target_remote]
326+
@source_project.inject_commits @config[:target_base_branch], @config[:upstream_name]
327+
328+
true
120329
end
121330
end
122331
end

0 commit comments

Comments
 (0)