ctree 0.5.3
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 +7 -0
- data/CHANGELOG.md +63 -0
- data/LICENSE +21 -0
- data/README.md +491 -0
- data/bin/ctree +7 -0
- data/lib/ctree/cli.rb +402 -0
- data/lib/ctree/compose_config_cmd.rb +136 -0
- data/lib/ctree/config.rb +242 -0
- data/lib/ctree/config.yml +111 -0
- data/lib/ctree/create.rb +714 -0
- data/lib/ctree/delete.rb +313 -0
- data/lib/ctree/domain.rb +158 -0
- data/lib/ctree/env_cmd.rb +123 -0
- data/lib/ctree/free.rb +79 -0
- data/lib/ctree/list.rb +101 -0
- data/lib/ctree/rebase.rb +221 -0
- data/lib/ctree/sync.rb +17 -0
- data/lib/ctree/update.rb +354 -0
- data/lib/ctree/version.rb +5 -0
- data/lib/ctree.rb +935 -0
- metadata +78 -0
data/lib/ctree/list.rb
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ctree
|
|
4
|
+
module List
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def run(filter: nil, show_current: true)
|
|
8
|
+
source_root = Pathname.pwd
|
|
9
|
+
toplevel_out, _, status = Sh.capture3("git", "-C", source_root.to_s, "rev-parse", "--show-toplevel")
|
|
10
|
+
Log.die "not inside a git repository" unless status.success?
|
|
11
|
+
git_top = Pathname.new(toplevel_out.strip)
|
|
12
|
+
unless source_root.realpath == git_top.realpath
|
|
13
|
+
Log.die "must be invoked from project top-level (cwd=#{source_root}, top=#{git_top})"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
cfg = Config.load(source_root)
|
|
17
|
+
Log.log_prefix = cfg[:log_prefix]
|
|
18
|
+
empty_prefix = cfg[:free_branch_prefix]
|
|
19
|
+
|
|
20
|
+
raw, _, st = Sh.capture3("git", "-C", source_root.to_s, "worktree", "list", "--porcelain")
|
|
21
|
+
Log.die "git worktree list failed" unless st.success?
|
|
22
|
+
|
|
23
|
+
entries = parse_worktree_porcelain(raw)
|
|
24
|
+
|
|
25
|
+
current_top = git_top.realpath.to_s
|
|
26
|
+
entries.each do |entry|
|
|
27
|
+
entry[:current] = (Pathname.new(entry[:path]).realpath.to_s == current_top)
|
|
28
|
+
rescue SystemCallError
|
|
29
|
+
entry[:current] = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if !empty_prefix.nil? && !empty_prefix.empty?
|
|
33
|
+
entries.each do |entry|
|
|
34
|
+
branch = entry[:branch] || ""
|
|
35
|
+
entry[:free] = branch.start_with?(empty_prefix)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
visible = case filter
|
|
40
|
+
when :free then entries.select { |e| e[:free] }
|
|
41
|
+
when :used then entries.reject { |e| e[:free] }
|
|
42
|
+
else entries
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
puts
|
|
46
|
+
puts "=== worktrees ==="
|
|
47
|
+
puts
|
|
48
|
+
|
|
49
|
+
if (filter == :free || filter == :used) && visible.empty?
|
|
50
|
+
if empty_prefix.nil? || empty_prefix.empty?
|
|
51
|
+
puts " (free_branch_prefix not set in .ctree/config.yml)"
|
|
52
|
+
elsif filter == :free
|
|
53
|
+
puts " no free worktrees matching \"#{empty_prefix}\""
|
|
54
|
+
else
|
|
55
|
+
puts " no used worktrees"
|
|
56
|
+
end
|
|
57
|
+
puts
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
name_width = visible.map { |e| File.basename(e[:path]).length }.max.to_i
|
|
62
|
+
|
|
63
|
+
visible.each do |entry|
|
|
64
|
+
name = File.basename(entry[:path]).ljust(name_width)
|
|
65
|
+
branch = entry[:branch] || "detached"
|
|
66
|
+
branch_label = (entry[:free] ? "#{branch} (free)" : branch)
|
|
67
|
+
tags = []
|
|
68
|
+
tags << "source" if entry[:source]
|
|
69
|
+
tags << "current" if entry[:current] && show_current
|
|
70
|
+
marker = tags.empty? ? "" : " ← #{tags.join(", ")}"
|
|
71
|
+
puts " #{name} #{entry[:path]} [#{branch_label}]#{marker}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
puts
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse_worktree_porcelain(raw)
|
|
78
|
+
entries = []
|
|
79
|
+
current = {}
|
|
80
|
+
|
|
81
|
+
raw.each_line do |line|
|
|
82
|
+
line = line.chomp
|
|
83
|
+
if line.empty?
|
|
84
|
+
entries << current unless current.empty?
|
|
85
|
+
current = {}
|
|
86
|
+
elsif line.start_with?("worktree ")
|
|
87
|
+
current[:path] = line.sub("worktree ", "")
|
|
88
|
+
elsif line.start_with?("branch ")
|
|
89
|
+
current[:branch] = line.sub("branch refs/heads/", "").sub("branch ", "")
|
|
90
|
+
elsif line == "detached"
|
|
91
|
+
current[:branch] = nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
entries << current unless current.empty?
|
|
95
|
+
|
|
96
|
+
# Mark the first entry (main worktree) as source.
|
|
97
|
+
entries.first[:source] = true if entries.any?
|
|
98
|
+
entries
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/ctree/rebase.rb
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ctree
|
|
4
|
+
module Rebase
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Runs from INSIDE a worktree. Rebases the worktree's feature branch onto
|
|
8
|
+
# the source's local master, then rebases each embedded repo (discovered
|
|
9
|
+
# via rebase config) from its source counterpart.
|
|
10
|
+
#
|
|
11
|
+
# All operations are local — no network calls. The source is the authority:
|
|
12
|
+
# rebase the source repo first, then run `ctree rebase` from the worktree
|
|
13
|
+
# to catch it up.
|
|
14
|
+
def run(force: false)
|
|
15
|
+
target_path = Pathname.pwd
|
|
16
|
+
toplevel_out, _, status = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--show-toplevel")
|
|
17
|
+
Log.die "not inside a git repository" unless status.success?
|
|
18
|
+
git_top = Pathname.new(toplevel_out.strip)
|
|
19
|
+
unless target_path.realpath == git_top.realpath
|
|
20
|
+
Log.die "must be invoked from the worktree top-level (cwd=#{target_path}, top=#{git_top})"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
common_out, _, c_st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--git-common-dir")
|
|
24
|
+
Log.die "could not resolve git common dir" unless c_st.success?
|
|
25
|
+
common = Pathname.new(common_out.strip)
|
|
26
|
+
common = (target_path / common) unless common.absolute?
|
|
27
|
+
source_root = common.parent.realpath
|
|
28
|
+
|
|
29
|
+
if source_root == target_path.realpath
|
|
30
|
+
Log.die "already in the source project; run ctree rebase from inside a worktree"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
branch_out, _, _ = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--abbrev-ref", "HEAD")
|
|
34
|
+
current_branch = branch_out.strip
|
|
35
|
+
if current_branch == "master" || current_branch == "main"
|
|
36
|
+
Log.die "worktree is on '#{current_branch}' — ctree rebase is for feature branches only; " \
|
|
37
|
+
"run `git pull --rebase origin #{current_branch}` directly"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
config = Config.load(target_path)
|
|
41
|
+
Log.debug_mode = config[:log_level] == "debug"
|
|
42
|
+
Log.log_prefix = config[:log_prefix]
|
|
43
|
+
rebase_repo_paths = config[:rebase]
|
|
44
|
+
|
|
45
|
+
Log.info "rebasing worktree #{target_path.basename} from #{source_root.basename}"
|
|
46
|
+
Log.info "worktree: #{target_path} [#{current_branch}]"
|
|
47
|
+
Log.info "source: #{source_root}"
|
|
48
|
+
|
|
49
|
+
# === uncommitted-changes check ===
|
|
50
|
+
|
|
51
|
+
dirty = []
|
|
52
|
+
dirty << target_path.to_s unless working_tree_clean?(target_path)
|
|
53
|
+
embedded_repos(target_path, rebase_repo_paths).each do |wt_repo|
|
|
54
|
+
dirty << wt_repo.to_s unless working_tree_clean?(wt_repo)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
unless dirty.empty?
|
|
58
|
+
Log.warn_ "uncommitted changes in:"
|
|
59
|
+
dirty.each { |d| Log.warn_ " #{d}" }
|
|
60
|
+
unless Prompt.confirm("proceed anyway? [y/N]:", default: :no, force: force)
|
|
61
|
+
Log.info "rebase aborted"
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
results = []
|
|
67
|
+
|
|
68
|
+
default_branch = Git.detect_default_branch(source_root)
|
|
69
|
+
|
|
70
|
+
# === rebase worktree feature branch onto source's default branch ===
|
|
71
|
+
|
|
72
|
+
status = rebase_branch_onto_default(target_path, current_branch, default_branch)
|
|
73
|
+
results << [status, target_path.basename.to_s, status == :skipped ? "already up to date" : ""]
|
|
74
|
+
|
|
75
|
+
# === rebase each embedded repo from its source counterpart ===
|
|
76
|
+
|
|
77
|
+
embedded_repos(target_path, rebase_repo_paths).each do |wt_repo|
|
|
78
|
+
rel = wt_repo.relative_path_from(target_path)
|
|
79
|
+
src_repo = source_root / rel
|
|
80
|
+
|
|
81
|
+
unless src_repo.directory? && (src_repo / ".git").exist?
|
|
82
|
+
Log.warn_ "no source counterpart for #{rel}; skipping"
|
|
83
|
+
results << [:skipped, rel.to_s, "no source counterpart"]
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
_, f_err, f_st = Spinner.with_spinner("fetching #{rel}") do
|
|
88
|
+
Sh.capture3("git", "-C", wt_repo.to_s, "fetch", src_repo.to_s, default_branch)
|
|
89
|
+
end
|
|
90
|
+
unless f_st.success?
|
|
91
|
+
Log.warn_ "git fetch #{src_repo} failed for #{rel}: #{f_err.strip}"
|
|
92
|
+
results << [:failed, rel.to_s, f_err.strip]
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
wt_head, _, _ = Sh.capture3("git", "-C", wt_repo.to_s, "rev-parse", "HEAD")
|
|
97
|
+
fetch_head, _, _ = Sh.capture3("git", "-C", wt_repo.to_s, "rev-parse", "FETCH_HEAD")
|
|
98
|
+
if wt_head.strip == fetch_head.strip
|
|
99
|
+
Log.info "skipped #{rel} (up to date)"
|
|
100
|
+
results << [:skipped, rel.to_s, "already up to date"]
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
_, r_err, r_st = Spinner.with_spinner("rebasing #{rel}") do
|
|
105
|
+
Sh.capture3("git", "-C", wt_repo.to_s, "rebase", "FETCH_HEAD")
|
|
106
|
+
end
|
|
107
|
+
if r_st.success?
|
|
108
|
+
Log.info "rebased #{rel}"
|
|
109
|
+
results << [:ok, rel.to_s, ""]
|
|
110
|
+
else
|
|
111
|
+
Log.warn_ "git rebase failed for #{rel}: #{r_err.strip}"
|
|
112
|
+
results << [:failed, rel.to_s, r_err.strip]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if Log.debug?
|
|
117
|
+
lines = []
|
|
118
|
+
lines << ""
|
|
119
|
+
lines << "=== rebase summary ==="
|
|
120
|
+
results.each do |status, name, msg|
|
|
121
|
+
line = " [#{status.to_s.ljust(7)}] #{name}"
|
|
122
|
+
line += " (#{msg})" unless msg.empty?
|
|
123
|
+
lines << line
|
|
124
|
+
end
|
|
125
|
+
lines << ""
|
|
126
|
+
# Debug-only console report: never write the block to the log file.
|
|
127
|
+
Log.section(lines.join("\n"), interactive: true, force: force)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# === post-rebase hooks ===
|
|
131
|
+
failed = results.any? { |st, _, _| st == :failed }
|
|
132
|
+
if !failed && config[:post_rebase_hooks].any?
|
|
133
|
+
post_rebase_hooks = config[:post_rebase_hooks]
|
|
134
|
+
post_rebase_hooks.each do |cmd|
|
|
135
|
+
out = nil
|
|
136
|
+
if LogFile.enabled?
|
|
137
|
+
# Console output is already suppressed under --log-file,
|
|
138
|
+
# so the hook's own stdout/stderr must be captured here —
|
|
139
|
+
# otherwise a failing hook's real error output (e.g. a
|
|
140
|
+
# migration stack trace) never reaches the log file at all.
|
|
141
|
+
out, status = Sh.capture2e(cmd)
|
|
142
|
+
else
|
|
143
|
+
status = Spinner.with_spinner("running post-rebase hook: #{cmd}") do
|
|
144
|
+
Sh.system(cmd)
|
|
145
|
+
$?
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if status.success?
|
|
150
|
+
Log.info "post-rebase hook completed: #{cmd}"
|
|
151
|
+
next
|
|
152
|
+
end
|
|
153
|
+
detail = out.to_s.strip
|
|
154
|
+
# A failing hook's output is its diagnostic — show it on the
|
|
155
|
+
# console in every mode (also already in the log file under
|
|
156
|
+
# --log-file, where this line surfaces the failure inline).
|
|
157
|
+
warn detail unless LogFile.enabled?
|
|
158
|
+
summary = "post-rebase hook failed (exit #{status.exitstatus}): #{cmd}"
|
|
159
|
+
Log.die detail.empty? ? summary : "#{summary}\n#{detail}"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
exit(failed ? 2 : 0)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Rebases current_branch (checked out at target_path) onto the
|
|
167
|
+
# source's default branch. Returns :skipped if already up to date,
|
|
168
|
+
# :ok if rebased. Dies (and aborts the rebase) on conflict.
|
|
169
|
+
def rebase_branch_onto_default(target_path, current_branch, default_branch)
|
|
170
|
+
_, _, ancestor_st = Sh.capture3("git", "-C", target_path.to_s, "merge-base", "--is-ancestor", default_branch, "HEAD")
|
|
171
|
+
if ancestor_st.success?
|
|
172
|
+
Log.info "#{current_branch} already up to date with #{default_branch}"
|
|
173
|
+
return :skipped
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
_, err, st = Spinner.with_spinner("rebasing #{current_branch} onto #{default_branch}") do
|
|
177
|
+
Sh.capture3("git", "-C", target_path.to_s, "rebase", default_branch)
|
|
178
|
+
end
|
|
179
|
+
if st.success?
|
|
180
|
+
Log.info "rebased #{current_branch} onto #{default_branch}"
|
|
181
|
+
:ok
|
|
182
|
+
else
|
|
183
|
+
Sh.capture3("git", "-C", target_path.to_s, "rebase", "--abort")
|
|
184
|
+
Log.die "rebase conflict in #{target_path.basename} — rebase aborted; resolve the conflict manually and re-run ctree rebase"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def working_tree_clean?(path)
|
|
189
|
+
out, _, st = Sh.capture3("git", "-C", path.to_s, "status", "--porcelain")
|
|
190
|
+
st.success? && out.strip.empty?
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Returns Pathname objects for each immediate subdirectory under each
|
|
194
|
+
# rebase entry that has a .git entry and is not a submodule.
|
|
195
|
+
def embedded_repos(root, repo_paths)
|
|
196
|
+
submodule_relpaths = load_submodule_paths(root)
|
|
197
|
+
repos = []
|
|
198
|
+
repo_paths.each do |rel_dir|
|
|
199
|
+
scan_dir = root / rel_dir
|
|
200
|
+
next unless scan_dir.directory?
|
|
201
|
+
scan_dir.children.select(&:directory?).sort.each do |subdir|
|
|
202
|
+
next unless (subdir / ".git").exist?
|
|
203
|
+
rel = subdir.relative_path_from(root).to_s
|
|
204
|
+
next if submodule_relpaths.include?(rel)
|
|
205
|
+
repos << subdir
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
repos
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def load_submodule_paths(root)
|
|
212
|
+
gitmodules = root / ".gitmodules"
|
|
213
|
+
return [] unless gitmodules.file?
|
|
214
|
+
File.readlines(gitmodules.to_s)
|
|
215
|
+
.grep(/^\s*path\s*=/)
|
|
216
|
+
.map { |l| l.split("=", 2).last.strip }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private_class_method :working_tree_clean?, :embedded_repos, :load_submodule_paths
|
|
220
|
+
end
|
|
221
|
+
end
|
data/lib/ctree/sync.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ctree
|
|
4
|
+
module Sync
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def run(force: false)
|
|
8
|
+
begin
|
|
9
|
+
Ctree::Rebase.run(force: force)
|
|
10
|
+
rescue SystemExit => e
|
|
11
|
+
exit e.status unless e.success?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
Ctree::Update.run(force: force)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/ctree/update.rb
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ctree
|
|
4
|
+
module Update
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Runs from INSIDE a worktree. The worktree is a `git worktree` of the
|
|
8
|
+
# source repo, so the source root is the parent of git's common dir. The
|
|
9
|
+
# worktree's own .ctree config (on its branch) governs the update.
|
|
10
|
+
def run(force: false)
|
|
11
|
+
target_path = Pathname.pwd
|
|
12
|
+
toplevel_out, _, status = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--show-toplevel")
|
|
13
|
+
Log.die "not inside a git repository" unless status.success?
|
|
14
|
+
git_top = Pathname.new(toplevel_out.strip)
|
|
15
|
+
unless target_path.realpath == git_top.realpath
|
|
16
|
+
Log.die "must be invoked from the worktree top-level (cwd=#{target_path}, top=#{git_top})"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The main worktree (source) lives one level up from the git common dir.
|
|
20
|
+
common_out, _, c_st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--git-common-dir")
|
|
21
|
+
Log.die "could not resolve git common dir" unless c_st.success?
|
|
22
|
+
common = Pathname.new(common_out.strip)
|
|
23
|
+
common = (target_path / common) unless common.absolute?
|
|
24
|
+
source_root = common.parent.realpath
|
|
25
|
+
|
|
26
|
+
if source_root == target_path.realpath
|
|
27
|
+
Log.die "already in the source project; run ctree update from inside a worktree"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
config = Config.load(target_path)
|
|
31
|
+
Log.debug_mode = config[:log_level] == "debug"
|
|
32
|
+
Log.log_prefix = config[:log_prefix]
|
|
33
|
+
share_volumes = config[:share_volumes]
|
|
34
|
+
update_volumes = config[:update_volumes]
|
|
35
|
+
empty_volumes = config[:empty_volumes]
|
|
36
|
+
override_rel = config[:compose_override_file].to_s
|
|
37
|
+
env_filename = config[:env_filename]
|
|
38
|
+
|
|
39
|
+
source_env_vars = EnvFile.parse(source_root / env_filename)
|
|
40
|
+
env_source_project = source_env_vars["COMPOSE_PROJECT_NAME"]
|
|
41
|
+
source_project = if env_source_project && !env_source_project.empty?
|
|
42
|
+
env_source_project
|
|
43
|
+
else
|
|
44
|
+
Naming.sanitize_compose_project_name(source_root.basename.to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
target_env_vars = EnvFile.parse(target_path / env_filename)
|
|
48
|
+
env_target_project = target_env_vars["COMPOSE_PROJECT_NAME"]
|
|
49
|
+
target_project = if env_target_project && !env_target_project.empty?
|
|
50
|
+
env_target_project
|
|
51
|
+
else
|
|
52
|
+
Naming.sanitize_compose_project_name(target_path.basename.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
unless ComposeOverride.valid?(
|
|
56
|
+
target_path: target_path, override_rel: override_rel,
|
|
57
|
+
share_volumes: share_volumes, source_project: source_project
|
|
58
|
+
)
|
|
59
|
+
Log.die "invalid ctree override file. run 'ctree compose-config fix' to fix"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Log.info "updating worktree #{target_path.basename} from #{source_root.basename}"
|
|
63
|
+
Log.info "worktree: #{target_path}"
|
|
64
|
+
Log.info "source: #{source_root}"
|
|
65
|
+
|
|
66
|
+
# === docker preflight ===
|
|
67
|
+
|
|
68
|
+
docker_ok = Sh.system("docker", "info", out: File::NULL, err: File::NULL)
|
|
69
|
+
unless docker_ok
|
|
70
|
+
Log.warn_ "docker not reachable — skipping update"
|
|
71
|
+
exit 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
src_branch_out, _, _ = Sh.capture3("git", "-C", source_root.to_s, "rev-parse", "--abbrev-ref", "HEAD")
|
|
75
|
+
src_branch = src_branch_out.strip
|
|
76
|
+
default_branch = Git.detect_default_branch(source_root)
|
|
77
|
+
if !src_branch.empty? && src_branch != default_branch
|
|
78
|
+
Log.warn_ "source repo is on branch '#{src_branch}' (not '#{default_branch}')"
|
|
79
|
+
unless Prompt.confirm("proceed with update? [y/N]:", default: :no, force: force)
|
|
80
|
+
Log.info "update aborted"
|
|
81
|
+
exit 1
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
src_ps_out, _, src_ps_st = Sh.capture3(
|
|
86
|
+
"docker", "ps",
|
|
87
|
+
"--filter", "label=com.docker.compose.project=#{source_project}",
|
|
88
|
+
"--quiet"
|
|
89
|
+
)
|
|
90
|
+
Log.die "docker ps failed" unless src_ps_st.success?
|
|
91
|
+
unless src_ps_out.strip.empty?
|
|
92
|
+
Log.warn_ "source compose stack has running containers"
|
|
93
|
+
if Prompt.confirm("stop source containers before updating? [Y/n]:", default: :yes, force: force)
|
|
94
|
+
_, err, st = Spinner.with_spinner("stopping source compose stack") do
|
|
95
|
+
Sh.capture3("docker", "compose", "-p", source_project, "down", chdir: source_root.to_s)
|
|
96
|
+
end
|
|
97
|
+
if st.success?
|
|
98
|
+
Log.info "stopped source compose stack"
|
|
99
|
+
else
|
|
100
|
+
Log.die "failed to stop source compose stack: #{err.strip}"
|
|
101
|
+
end
|
|
102
|
+
else
|
|
103
|
+
Log.warn_ "source containers still running; aborting"
|
|
104
|
+
exit 1
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
ps_out, _, status = Sh.capture3(
|
|
109
|
+
"docker", "ps",
|
|
110
|
+
"--filter", "label=com.docker.compose.project=#{target_project}",
|
|
111
|
+
"--quiet"
|
|
112
|
+
)
|
|
113
|
+
Log.die "docker ps failed" unless status.success?
|
|
114
|
+
unless ps_out.strip.empty?
|
|
115
|
+
_, err, st = Spinner.with_spinner("stopping worktree compose stack") do
|
|
116
|
+
Sh.capture3("docker", "compose", "-p", target_project, "down")
|
|
117
|
+
end
|
|
118
|
+
if st.success?
|
|
119
|
+
Log.info "stopped worktree compose stack"
|
|
120
|
+
else
|
|
121
|
+
Log.die "failed to stop worktree compose stack: #{err.strip}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# === re-tag source images so the worktree picks up freshly built layers ===
|
|
126
|
+
|
|
127
|
+
tagged = Images.tag_to_target(source_project, target_project)
|
|
128
|
+
Log.info "tagged #{tagged.size} docker images" if tagged.any? && !Log.debug?
|
|
129
|
+
|
|
130
|
+
# === identify volumes mounted externally from source via override yml ===
|
|
131
|
+
|
|
132
|
+
shared_suffixes = share_volumes.dup
|
|
133
|
+
override_path = override_rel.empty? ? nil : (target_path / override_rel)
|
|
134
|
+
if override_path&.file?
|
|
135
|
+
begin
|
|
136
|
+
override_doc = YAML.safe_load(File.read(override_path.to_s), aliases: true)
|
|
137
|
+
if override_doc.is_a?(Hash) && override_doc["volumes"].is_a?(Hash)
|
|
138
|
+
override_doc["volumes"].each do |key, v|
|
|
139
|
+
next unless v.is_a?(Hash) && v["external"]
|
|
140
|
+
nm = v["name"]
|
|
141
|
+
next unless nm.is_a?(String) && !nm.empty?
|
|
142
|
+
next if nm.start_with?("#{target_project}_")
|
|
143
|
+
shared_suffixes << key
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
rescue Psych::SyntaxError => e
|
|
147
|
+
Log.warn_ "could not parse #{override_path}: #{e.message}; using only built-in skip list"
|
|
148
|
+
end
|
|
149
|
+
shared_suffixes.uniq!
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
vols_out, _, status = Sh.capture3("docker", "volume", "ls", "--format", "{{.Name}}")
|
|
153
|
+
Log.die "docker volume ls failed" unless status.success?
|
|
154
|
+
prefix_re = /\A#{Regexp.escape(source_project)}_/
|
|
155
|
+
source_volumes = vols_out.lines.map(&:strip).reject(&:empty?).select { |v| v =~ prefix_re }
|
|
156
|
+
|
|
157
|
+
if source_volumes.empty?
|
|
158
|
+
Log.info "no source volumes found with prefix '#{source_project}_'"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
to_sync = []
|
|
162
|
+
sync_results = []
|
|
163
|
+
to_skip_buckets = {
|
|
164
|
+
"not in sync allowlist" => [],
|
|
165
|
+
"shared from source" => [],
|
|
166
|
+
"no target volume" => []
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
source_volumes.each do |src_vol|
|
|
170
|
+
suffix = src_vol.sub(prefix_re, "")
|
|
171
|
+
tgt_vol = "#{target_project}_#{suffix}"
|
|
172
|
+
|
|
173
|
+
if empty_volumes.include?(suffix)
|
|
174
|
+
sync_results << [src_vol, tgt_vol, :empty, "empty volume"]
|
|
175
|
+
next
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if shared_suffixes.include?(suffix)
|
|
179
|
+
to_skip_buckets["shared from source"] << [src_vol, tgt_vol]
|
|
180
|
+
next
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
unless update_volumes.include?(suffix)
|
|
184
|
+
to_skip_buckets["not in sync allowlist"] << [src_vol, tgt_vol]
|
|
185
|
+
next
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
_, _, st = Sh.capture3("docker", "volume", "inspect", tgt_vol)
|
|
189
|
+
unless st.success?
|
|
190
|
+
to_skip_buckets["no target volume"] << [src_vol, tgt_vol]
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
to_sync << [src_vol, tgt_vol]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
Volume.ensure_rsync_image! if to_sync.any?
|
|
198
|
+
|
|
199
|
+
volume_bytes = 0
|
|
200
|
+
updated_vol_count = 0
|
|
201
|
+
|
|
202
|
+
to_sync.each do |src_vol, tgt_vol|
|
|
203
|
+
st, err, bytes = Volume.rsync_with_progress(src_vol, tgt_vol)
|
|
204
|
+
if st.success?
|
|
205
|
+
volume_bytes += bytes.to_i
|
|
206
|
+
updated_vol_count += 1
|
|
207
|
+
sync_results << [src_vol, tgt_vol, :updated, ""]
|
|
208
|
+
else
|
|
209
|
+
Log.warn_ "rsync failed for #{tgt_vol}: #{err.strip}"
|
|
210
|
+
sync_results << [src_vol, tgt_vol, :failed, "rsync failed: #{err.strip}"]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
["not in sync allowlist", "shared from source", "no target volume"].each do |reason|
|
|
215
|
+
to_skip_buckets[reason].each do |src_vol, tgt_vol|
|
|
216
|
+
Log.debug "skipped #{src_vol} (#{reason})"
|
|
217
|
+
sync_results << [src_vol, tgt_vol, :skipped, reason]
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if updated_vol_count > 0
|
|
222
|
+
Log.info "updated #{updated_vol_count} volume#{updated_vol_count == 1 ? "" : "s"}#{Sizes.note(volume_bytes)}" unless Log.debug?
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
sync_file_results = []
|
|
226
|
+
updated_file_count = 0
|
|
227
|
+
file_bytes = 0
|
|
228
|
+
config[:update].each do |rel|
|
|
229
|
+
if !override_rel.empty? && rel == override_rel
|
|
230
|
+
Log.debug "skipped #{rel} (compose override file managed by ctree create)"
|
|
231
|
+
sync_file_results << [rel, :skipped, "compose override file"]
|
|
232
|
+
next
|
|
233
|
+
end
|
|
234
|
+
if rel == ".ctree" || rel.start_with?(".ctree/")
|
|
235
|
+
Log.debug "skipped #{rel} (reserved directory managed by ctree create)"
|
|
236
|
+
sync_file_results << [rel, :skipped, "reserved directory"]
|
|
237
|
+
next
|
|
238
|
+
end
|
|
239
|
+
src = source_root / rel
|
|
240
|
+
tgt = target_path / rel
|
|
241
|
+
if src.directory?
|
|
242
|
+
FileUtils.mkdir_p(tgt.to_s)
|
|
243
|
+
_, err, st = Spinner.with_spinner("rsync #{rel}/") do
|
|
244
|
+
Sh.capture3("rsync", "-a", "--checksum", "--delete", "#{src}/", "#{tgt}/")
|
|
245
|
+
end
|
|
246
|
+
if st.success?
|
|
247
|
+
bytes = Sizes.path_bytes(src)
|
|
248
|
+
Log.debug "updated #{rel}/#{Sizes.note(bytes)}"
|
|
249
|
+
file_bytes += bytes
|
|
250
|
+
updated_file_count += 1
|
|
251
|
+
sync_file_results << [rel, :updated, ""]
|
|
252
|
+
else
|
|
253
|
+
Log.warn_ "rsync failed for #{rel}: #{err.strip}"
|
|
254
|
+
sync_file_results << [rel, :failed, "rsync failed: #{err.strip}"]
|
|
255
|
+
end
|
|
256
|
+
elsif src.file?
|
|
257
|
+
_, err, st = Spinner.with_spinner("rsync #{rel}") do
|
|
258
|
+
Sh.capture3("rsync", "-a", "--checksum", src.to_s, tgt.to_s)
|
|
259
|
+
end
|
|
260
|
+
if st.success?
|
|
261
|
+
bytes = Sizes.path_bytes(src)
|
|
262
|
+
Log.debug "updated #{rel}#{Sizes.note(bytes)}"
|
|
263
|
+
file_bytes += bytes
|
|
264
|
+
updated_file_count += 1
|
|
265
|
+
sync_file_results << [rel, :updated, ""]
|
|
266
|
+
else
|
|
267
|
+
Log.warn_ "rsync failed for #{rel}: #{err.strip}"
|
|
268
|
+
sync_file_results << [rel, :failed, "rsync failed: #{err.strip}"]
|
|
269
|
+
end
|
|
270
|
+
else
|
|
271
|
+
Log.warn_ "update: source not found: #{rel}"
|
|
272
|
+
sync_file_results << [rel, :missing, "source not found"]
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
if updated_file_count > 0 && !Log.debug?
|
|
276
|
+
Log.info "updated #{updated_file_count} file#{updated_file_count == 1 ? "" : "s"}#{Sizes.note(file_bytes)}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
failed = sync_results.any? { |_, _, st, _| st == :failed } ||
|
|
280
|
+
sync_file_results.any? { |_, st, _| st == :failed }
|
|
281
|
+
|
|
282
|
+
post_update_hooks = config[:post_update_hooks]
|
|
283
|
+
unless post_update_hooks.empty?
|
|
284
|
+
post_update_hooks.each_with_index do |cmd, idx|
|
|
285
|
+
label = "post-update hook #{idx + 1}/#{post_update_hooks.size}"
|
|
286
|
+
out = nil
|
|
287
|
+
if LogFile.enabled?
|
|
288
|
+
# Console output is already suppressed under --log-file
|
|
289
|
+
# (Log.info/Log.warn_ route to the file only), so the hook's
|
|
290
|
+
# own stdout/stderr must be captured here — otherwise a
|
|
291
|
+
# failing hook's real error output (e.g. a migration stack
|
|
292
|
+
# trace) never reaches the log file at all.
|
|
293
|
+
out, status = Sh.capture2e(cmd)
|
|
294
|
+
else
|
|
295
|
+
status = Spinner.with_progress("running #{label}: #{cmd}") do
|
|
296
|
+
Sh.system(cmd)
|
|
297
|
+
$?
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
detail = out.to_s.strip
|
|
302
|
+
Log.info detail unless detail.empty?
|
|
303
|
+
if status.success?
|
|
304
|
+
next
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# A failing hook's output is its diagnostic — show it on the
|
|
308
|
+
# console in every mode (also already in the log file under
|
|
309
|
+
# --log-file, where this line surfaces the failure inline).
|
|
310
|
+
warn detail unless LogFile.enabled?
|
|
311
|
+
summary = "post-update hook failed (exit #{status.exitstatus}): #{cmd}"
|
|
312
|
+
Log.die detail.empty? ? summary : "#{summary}\n#{detail}"
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
if Log.debug?
|
|
317
|
+
lines = []
|
|
318
|
+
lines << ""
|
|
319
|
+
lines << "=== update summary ==="
|
|
320
|
+
lines << "worktree: #{target_path}"
|
|
321
|
+
|
|
322
|
+
if sync_results.any?
|
|
323
|
+
lines << "volumes:"
|
|
324
|
+
sync_results.each do |src, tgt, st, msg|
|
|
325
|
+
line = " [#{st.to_s.ljust(7)}] #{src} -> #{tgt}"
|
|
326
|
+
line += " (#{msg})" unless msg.to_s.empty?
|
|
327
|
+
lines << line
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
if sync_file_results.any?
|
|
332
|
+
lines << "files/dirs:"
|
|
333
|
+
sync_file_results.each do |rel, st, msg|
|
|
334
|
+
line = " [#{st.to_s.ljust(7)}] #{rel}"
|
|
335
|
+
line += " (#{msg})" unless msg.to_s.empty?
|
|
336
|
+
lines << line
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
if post_update_hooks.any?
|
|
341
|
+
lines << "post-update hooks:"
|
|
342
|
+
post_update_hooks.each do |cmd|
|
|
343
|
+
lines << " [ran ] #{cmd}"
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Debug-only console report: never write the block to the log file.
|
|
348
|
+
Log.section(lines.join("\n"), interactive: true, force: force)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
exit(failed ? 2 : 0)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|