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.
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
@@ -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
@@ -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