Skip to content

Commit edae161

Browse files
author
Pedro Pombeiro
committed
perf(shell): speed up rebase_all pre-rebase phase
Parallelize expensive read-only git commands (log --simplify-by-decoration, for-each-ref --ahead-behind, merge-base, merge-base --fork-point) using threads. Add memoization caches for branch existence, merge-bases, fork points, parent branches, distances, and ref resolution. Batch branch distance computation via single for-each-ref call. Pre-filter reflog with --grep-reflog. Collapse double sort into single sort_by. Pre-rebase phase: ~36s → ~3.5s
1 parent 1f897c6 commit edae161

File tree

1 file changed

+196
-33
lines changed

1 file changed

+196
-33
lines changed

.shellrc/zshrc.d/functions/scripts/git-helpers.rb

Lines changed: 196 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,62 @@
55
# this script will ensure that branches from the same MR will be rebased correctly, keeping the chain order
66

77
def compute_default_branch
8-
system(*%w[git show-ref -q --verify refs/heads/main]) ? "main" : "master"
8+
@compute_default_branch ||= system(*%w[git show-ref -q --verify refs/heads/main]) ? "main" : "master"
99
end
1010

1111
# compute_parent_branch will determine the closest parent branch,
1212
# ignoring remotes that we're not tracking against, and with a preference
1313
# for the local branch. In scenarios where the parent branch does not
1414
# have a local tracking branch, then the remote is returned.
15+
def preload_parent_branches!(branches)
16+
@compute_parent_branch_cache ||= {}
17+
@remote_names ||= `git remote`.lines.map(&:chomp)
18+
19+
missing = branches.reject { |b| @compute_parent_branch_cache.key?(b) }
20+
return if missing.empty?
21+
22+
threads = missing.map do |branch_name|
23+
Thread.new do
24+
output = `git log --decorate --simplify-by-decoration --oneline #{branch_name}`.lines
25+
[branch_name, output]
26+
end
27+
end
28+
29+
threads.each do |t|
30+
branch_name, output = t.value
31+
resolve_parent_branch_from_log(branch_name, output)
32+
end
33+
end
34+
1535
def compute_parent_branch(branch_name = nil)
36+
@compute_parent_branch_cache ||= {}
1637
branch_name ||= `git rev-parse --abbrev-ref HEAD`
17-
remote_names = `git remote`.lines.map(&:chomp)
18-
active_remote_name = `git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null`.split("/").first
19-
other_remote_names = remote_names - [active_remote_name]
2038

21-
parent_branch_line = `git log --decorate --simplify-by-decoration --oneline #{branch_name}`.lines[1].rstrip
39+
return @compute_parent_branch_cache[branch_name] if @compute_parent_branch_cache.key?(branch_name)
40+
41+
output = `git log --decorate --simplify-by-decoration --oneline #{branch_name}`.lines
42+
resolve_parent_branch_from_log(branch_name, output)
43+
end
44+
45+
def resolve_parent_branch_from_log(branch_name, log_lines)
46+
@compute_parent_branch_cache ||= {}
47+
@remote_names ||= `git remote`.lines.map(&:chomp)
48+
@active_remote_name ||= `git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null`.split("/").first
49+
other_remote_names = @remote_names - [@active_remote_name]
50+
51+
parent_branch_line = log_lines[1]&.rstrip || ""
2252
parent_branch_line = parent_branch_line
2353
.sub("HEAD -> ", "")
2454
.gsub(/tag: [^,)]+(, )?/, "")
25-
return compute_default_branch if parent_branch_line.match?(%r{.* \((origin|security)/(.*)\) .*})
26-
return compute_default_branch unless parent_branch_line.match?(/.* \((.*)\) .*/)
27-
28-
parent_branch_line
29-
.sub(/.* \((.*)\) .*/, '\1')
30-
.split(", ")
31-
.reject { |b| other_remote_names.any? { |remote_name| b.start_with?("#{remote_name}/") } }
32-
.min_by(&:length) || compute_default_branch
55+
return @compute_parent_branch_cache[branch_name] = compute_default_branch if parent_branch_line.match?(%r{.* \((origin|security)/(.*)\) .*})
56+
return @compute_parent_branch_cache[branch_name] = compute_default_branch unless parent_branch_line.match?(/.* \((.*)\) .*/)
57+
58+
@compute_parent_branch_cache[branch_name] =
59+
parent_branch_line
60+
.sub(/.* \((.*)\) .*/, '\1')
61+
.split(", ")
62+
.reject { |b| other_remote_names.any? { |remote_name| b.start_with?("#{remote_name}/") } }
63+
.min_by(&:length) || compute_default_branch
3364
end
3465

3566
class String
@@ -155,16 +186,30 @@ def rebase_mappings
155186
user_name = ENV.fetch("USER", nil)
156187
default_branch = compute_default_branch
157188

189+
preload_local_branches!
190+
158191
mr_pattern = %r{^(security[-/])?#{user_name}/(?<mr_id>\d+)/[a-z0-9\-+_]+$}i
159192
seq_mr_pattern = %r{^(security[-/])?#{user_name}/(?<mr_id>\d+)/(?<mr_seq_nr>\d+)-[a-z0-9\-+_]+$}i
160193
backport_pattern = %r{^(security[-/])?#{user_name}/(?<mr_id>\d+)/[a-z0-9\-+_]+-(?<milestone>\d+[-.]\d+)$}i
161194

162195
local_branches =
163-
`git branch --list`
164-
.lines
165-
.map { |line| line[2..].rstrip }
196+
@branch_exists_cache.keys
166197
.select { |branch| branch.start_with?("#{user_name}/", "security-#{user_name}/", "security/#{user_name}/") }
167-
.sort_by do |branch|
198+
199+
non_seq_branches = local_branches.reject { |b| seq_mr_pattern.match?(b) || backport_pattern.match?(b) }
200+
201+
@remote_names ||= `git remote`.lines.map(&:chomp)
202+
@active_remote_name ||= `git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null`.split("/").first
203+
204+
preload_threads = [
205+
Thread.new { branch_distance_map(default_branch, local_branches) },
206+
Thread.new { preload_merge_bases!(default_branch, local_branches) },
207+
Thread.new { preload_fork_points!(default_branch, local_branches) },
208+
Thread.new { preload_parent_branches!(non_seq_branches) }
209+
]
210+
preload_threads.each(&:join)
211+
212+
local_branches = local_branches.sort_by do |branch|
168213
seq_mr_match_data = seq_mr_pattern.match(branch)
169214
backport_match_data = backport_pattern.match(branch)
170215
mr_match_data = mr_pattern.match(branch)
@@ -238,7 +283,7 @@ def rebase_mappings
238283
prev_branch = seq_map[prev_seq_nr].first
239284
if branch_exists?(prev_branch)
240285
if branch_merged_into?(prev_branch, default_branch)
241-
mb = `git merge-base #{default_branch} #{branch} 2>/dev/null`.strip
286+
mb = merge_base(default_branch, branch)
242287
fork_point = prev_branch unless branch_merged_into?(prev_branch, mb)
243288
rebase_onto = default_branch
244289
else
@@ -251,7 +296,7 @@ def rebase_mappings
251296
prev_seq_pattern = "#{user_name}/#{current_mr_id}/#{current_mr_seq_nr - 1}-"
252297
old_tip = deleted_branch_tip_from_reflog(prev_seq_pattern, branch)
253298
if old_tip
254-
mb = `git merge-base #{default_branch} #{branch} 2>/dev/null`.strip
299+
mb = merge_base(default_branch, branch)
255300
fork_point = old_tip unless branch_merged_into?(old_tip, mb)
256301
end
257302

@@ -269,9 +314,11 @@ def rebase_mappings
269314
end
270315

271316
if fork_point.nil? && (rebase_onto || parent_branch) == default_branch
272-
fp = `git merge-base --fork-point #{default_branch} #{branch} 2>/dev/null`.strip
273-
mb = `git merge-base #{default_branch} #{branch} 2>/dev/null`.strip
274-
if Process.last_status.success? && !fp.empty? && fp != `git rev-parse #{default_branch}`.strip && fp != mb
317+
fp_result = compute_fork_point(default_branch, branch)
318+
fp = fp_result[:value]
319+
mb = merge_base(default_branch, branch)
320+
default_branch_sha = resolve_ref(default_branch)
321+
if fp_result[:success] && !fp.empty? && fp != default_branch_sha && fp != mb
275322
fork_point = fp
276323
rebase_onto = default_branch
277324
end
@@ -292,12 +339,89 @@ def branch_sort_key(branch_info)
292339
[branch_info[:chain_mr_id] || "", branch_info[:chain_mr_seq_nr].nil? ? 0 : -branch_info[:chain_mr_seq_nr]]
293340
end
294341

342+
def preload_local_branches!
343+
@branch_exists_cache ||= {}
344+
return if @branch_exists_preloaded
345+
346+
`git for-each-ref --format='%(refname:short)' refs/heads/`.lines.each do |line|
347+
@branch_exists_cache[line.strip] = true
348+
end
349+
@branch_exists_preloaded = true
350+
end
351+
295352
def branch_exists?(branch)
296-
system(*%W[git rev-parse --verify #{branch}], out: File::NULL, err: File::NULL)
353+
@branch_exists_cache ||= {}
354+
return @branch_exists_cache[branch] if @branch_exists_cache.key?(branch)
355+
356+
@branch_exists_cache[branch] =
357+
system(*%W[git rev-parse --verify #{branch}], out: File::NULL, err: File::NULL)
358+
end
359+
360+
def resolve_ref(ref)
361+
@resolve_ref_cache ||= {}
362+
@resolve_ref_cache[ref] ||= `git rev-parse #{ref}`.strip
363+
end
364+
365+
def merge_base(a, b)
366+
@merge_base_cache ||= {}
367+
key = "#{a}:#{b}"
368+
return @merge_base_cache[key] if @merge_base_cache.key?(key)
369+
370+
@merge_base_cache[key] = `git merge-base #{a} #{b} 2>/dev/null`.strip
371+
end
372+
373+
def preload_merge_bases!(default_branch, branches)
374+
@merge_base_cache ||= {}
375+
missing = branches.reject { |b| @merge_base_cache.key?("#{default_branch}:#{b}") }
376+
return if missing.empty?
377+
378+
threads = missing.map do |branch|
379+
Thread.new do
380+
result = `git merge-base #{default_branch} #{branch} 2>/dev/null`.strip
381+
[branch, result]
382+
end
383+
end
384+
385+
threads.each do |t|
386+
branch, result = t.value
387+
@merge_base_cache["#{default_branch}:#{branch}"] = result
388+
end
389+
end
390+
391+
def preload_fork_points!(default_branch, branches)
392+
@fork_point_cache ||= {}
393+
missing = branches.reject { |b| @fork_point_cache.key?("#{default_branch}:#{b}") }
394+
return if missing.empty?
395+
396+
threads = missing.map do |branch|
397+
Thread.new do
398+
result = `git merge-base --fork-point #{default_branch} #{branch} 2>/dev/null`.strip
399+
success = Process.last_status.success?
400+
[branch, result, success]
401+
end
402+
end
403+
404+
threads.each do |t|
405+
branch, result, success = t.value
406+
@fork_point_cache["#{default_branch}:#{branch}"] = {value: result, success: success}
407+
end
408+
end
409+
410+
def compute_fork_point(default_branch, branch)
411+
@fork_point_cache ||= {}
412+
key = "#{default_branch}:#{branch}"
413+
return @fork_point_cache[key] if @fork_point_cache.key?(key)
414+
415+
result = `git merge-base --fork-point #{default_branch} #{branch} 2>/dev/null`.strip
416+
@fork_point_cache[key] = {value: result, success: Process.last_status.success?}
297417
end
298418

299419
def branch_merged_into?(branch, target)
300-
system(*%W[git merge-base --is-ancestor #{branch} #{target}])
420+
@branch_merged_cache ||= {}
421+
key = "#{branch}:#{target}"
422+
return @branch_merged_cache[key] if @branch_merged_cache.key?(key)
423+
424+
@branch_merged_cache[key] = system(*%W[git merge-base --is-ancestor #{branch} #{target}])
301425
end
302426

303427
def deleted_branch_tip_from_reflog(branch_name_prefix, descendant_branch = nil)
@@ -307,28 +431,67 @@ def deleted_branch_tip_from_reflog(branch_name_prefix, descendant_branch = nil)
307431
/\bcheckout: moving from .+ to #{escaped}/
308432
]
309433

310-
`git reflog --format=%H\\ %gs`.lines.each do |line|
311-
sha, description = line.strip.split(" ", 2)
312-
next unless description
313-
next unless patterns.any? { |p| description.match?(p) }
314-
next if descendant_branch && !system(*%W[git merge-base --is-ancestor #{sha} #{descendant_branch}], out: File::NULL, err: File::NULL)
434+
reflog_cmd = %W[
435+
git reflog --format=%H\ %gs
436+
--grep-reflog=checkout.*#{branch_name_prefix}
437+
--grep-reflog=rebase.*#{branch_name_prefix}
438+
]
439+
440+
IO.popen(reflog_cmd) do |io|
441+
io.each_line do |line|
442+
sha, description = line.strip.split(" ", 2)
443+
next unless description
444+
next unless patterns.any? { |p| description.match?(p) }
445+
next if descendant_branch && !branch_merged_into?(sha, descendant_branch)
315446

316-
return sha
447+
return sha
448+
end
317449
end
318450

319451
nil
320452
end
321453

454+
def branch_distance_map(parent_branch, branches)
455+
@branch_distance_cache ||= {}
456+
457+
missing = branches.uniq.reject { |branch| @branch_distance_cache.key?("#{branch}:#{parent_branch}") }
458+
return if missing.empty?
459+
460+
missing_set = missing.to_h { |branch| [branch, true] }
461+
distances = {}
462+
463+
output = `git for-each-ref --format='%(refname:short) %(ahead-behind:#{parent_branch})' refs/heads/`
464+
if Process.last_status.success?
465+
output.lines.each do |line|
466+
branch, ahead, behind = line.split
467+
next unless branch && ahead && behind
468+
next unless missing_set[branch]
469+
470+
distances[branch] = ahead.to_i
471+
end
472+
end
473+
474+
missing.each do |branch|
475+
key = "#{branch}:#{parent_branch}"
476+
@branch_distance_cache[key] = distances.fetch(branch) do
477+
`git rev-list --count #{parent_branch}..#{branch}`.strip.to_i
478+
end
479+
end
480+
end
481+
322482
def branch_distance(branch, parent_branch)
323-
`git rev-list --count #{parent_branch}..#{branch}`.strip.to_i
483+
@branch_distance_cache ||= {}
484+
key = "#{branch}:#{parent_branch}"
485+
return @branch_distance_cache[key] if @branch_distance_cache.key?(key)
486+
487+
@branch_distance_cache[key] = `git rev-list --count #{parent_branch}..#{branch}`.strip.to_i
324488
end
325489

326490
def rebase_all
327491
require "json"
328492
default_branch = compute_default_branch
329493
mappings = rebase_mappings
330-
.sort { |b1, b2| branch_sort_key(b1) <=> branch_sort_key(b2) }
331-
.sort { |b1, b2| branch_distance(b1[:branch], default_branch) <=> branch_distance(b2[:branch], default_branch) }
494+
.sort_by { |b| [branch_distance(b[:branch], default_branch), branch_sort_key(b)] }
332495
.to_h { |b| [b[:branch], {rebase_onto: b[:rebase_onto], fork_point: b[:fork_point]}] }
333496
rebase_all_per_capture_info(mappings)
334497
end

0 commit comments

Comments
 (0)