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.
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ctree
4
+ module Delete
5
+ module_function
6
+
7
+ def run(name:, force: false)
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
+ if source_root.basename.to_s == name
17
+ Log.die "refusing to remove '#{name}' — that's the current source project itself"
18
+ end
19
+
20
+ parent_dir = source_root.parent
21
+ target_project = Naming.sanitize_compose_project_name(name)
22
+
23
+ # Resolve the actual worktree path from git's registration first — the
24
+ # registered absolute path may differ from parent_dir/name if the
25
+ # worktree was created from a different pwd or via a symlinked directory.
26
+ # Fall back to constructing the path if git has no matching entry.
27
+ worktree_list_out, _, _ = Sh.capture3("git", "-C", source_root.to_s, "worktree", "list", "--porcelain")
28
+ wt_records = worktree_list_out.split(/\n\n+/).filter_map do |block|
29
+ record = {}
30
+ block.each_line do |line|
31
+ line = line.chomp
32
+ record[:path] = line.delete_prefix("worktree ") if line.start_with?("worktree ")
33
+ record[:branch] = line.delete_prefix("branch refs/heads/") if line.start_with?("branch refs/heads/")
34
+ end
35
+ record unless record.empty?
36
+ end
37
+ matching_record = wt_records.find { |r| r[:path] && File.basename(r[:path]) == name }
38
+ registered_wt_path = matching_record&.fetch(:path, nil)
39
+ worktree_branch = matching_record&.fetch(:branch, nil)
40
+ target_path = registered_wt_path ? Pathname.new(registered_wt_path) : (parent_dir / name)
41
+
42
+ # Discover what currently exists for this name.
43
+ docker_ok = Sh.system("docker", "info", out: File::NULL, err: File::NULL)
44
+ Log.warn_ "docker not reachable — volumes and compose stack will not be touched" unless docker_ok
45
+
46
+ # Read the worktree's compose override file (if present) to find any
47
+ # `external: true` volumes whose explicit `name:` is NOT under the
48
+ # target project prefix. Those are source-shared (e.g. yarn-cache
49
+ # mounted from the source project) and must be preserved during removal.
50
+ config = Config.load(source_root)
51
+ Log.log_prefix = config[:log_prefix]
52
+ override_rel = config[:compose_override_file].to_s
53
+ shared_external_vols = []
54
+ override_path = override_rel.empty? ? nil : (target_path / override_rel)
55
+ if override_path&.file?
56
+ begin
57
+ override_doc = YAML.safe_load(File.read(override_path.to_s), aliases: true)
58
+ if override_doc.is_a?(Hash) && override_doc["volumes"].is_a?(Hash)
59
+ override_doc["volumes"].each_value do |v|
60
+ next unless v.is_a?(Hash) && v["external"]
61
+ nm = v["name"]
62
+ next unless nm.is_a?(String) && !nm.empty?
63
+ next if nm.start_with?("#{target_project}_")
64
+ shared_external_vols << nm
65
+ end
66
+ end
67
+ rescue Psych::SyntaxError => e
68
+ Log.warn_ "could not parse #{override_path}: #{e.message}; cannot identify shared volumes for preservation"
69
+ end
70
+ shared_external_vols.uniq!
71
+ end
72
+
73
+ vols = []
74
+ if docker_ok
75
+ vols_out, _, st = Sh.capture3("docker", "volume", "ls", "--format", "{{.Name}}")
76
+ Log.die "docker volume ls failed" unless st.success?
77
+ prefix_re = /\A#{Regexp.escape(target_project)}_/
78
+ vols = vols_out.lines.map(&:strip).reject(&:empty?).select { |v| v =~ prefix_re }.sort
79
+ # Belt-and-suspenders: explicitly drop any source-shared volume even
80
+ # though the prefix filter above should already exclude them.
81
+ vols -= shared_external_vols
82
+ end
83
+
84
+ containers = []
85
+ if docker_ok
86
+ ps_out, _, st = Sh.capture3(
87
+ "docker", "ps", "--all",
88
+ "--filter", "label=com.docker.compose.project=#{target_project}",
89
+ "--format", "{{.Names}}"
90
+ )
91
+ containers = ps_out.lines.map(&:strip).reject(&:empty?).sort if st.success?
92
+ end
93
+
94
+ tagged_images = []
95
+ if docker_ok
96
+ imgs_out, _, st = Sh.capture3("docker", "images",
97
+ "--format", "{{.Repository}}",
98
+ "--filter", "reference=#{target_project}-*")
99
+ tagged_images = imgs_out.lines.map(&:strip).reject(&:empty?).sort if st.success?
100
+ end
101
+
102
+ branch_to_check = worktree_branch || name
103
+ _, _, st = Sh.capture3("git", "-C", source_root.to_s, "show-ref", "--verify", "--quiet", "refs/heads/#{branch_to_check}")
104
+ branch_exists = st.success?
105
+
106
+ target_real = target_path.exist? ? target_path.realpath.to_s : target_path.to_s
107
+ worktree_registered = !registered_wt_path.nil?
108
+
109
+ # Branches are never deleted by remove (see below), so the presence
110
+ # of a same-named branch alone is not a reason to do work.
111
+ nothing_to_do = vols.empty? && containers.empty? && tagged_images.empty? && !worktree_registered && !target_path.exist?
112
+ if nothing_to_do
113
+ Log.info "nothing to remove for '#{name}' — no worktree, branch, containers, or volumes found"
114
+ exit 0
115
+ end
116
+
117
+ # Show the user exactly what will happen.
118
+ summary = []
119
+ summary << ""
120
+ summary << "[#{PROG}] About to delete '#{name}':"
121
+ summary << ""
122
+ summary << " worktree path: #{target_path}"
123
+ summary << " - dir: #{target_path.exist? ? "EXISTS — will be deleted" : "absent"}"
124
+ summary << " - registered: #{worktree_registered ? "YES — will run `git worktree remove --force`" : "no"}"
125
+ summary << ""
126
+ if branch_exists
127
+ summary << " git branch: #{branch_to_check} — preserved (run `git branch -D #{branch_to_check}` manually to delete)"
128
+ else
129
+ summary << " git branch: #{branch_to_check} — not found"
130
+ end
131
+ summary << ""
132
+ summary << " compose stack: -p #{target_project}"
133
+ if containers.empty?
134
+ summary << " - containers: none running for this project"
135
+ else
136
+ summary << " - containers (will be stopped and removed via `docker compose down`):"
137
+ containers.each { |c| summary << " #{c}" }
138
+ end
139
+ summary << ""
140
+ if vols.empty?
141
+ summary << " docker volumes: none matching ^#{target_project}_"
142
+ else
143
+ summary << " docker volumes (#{vols.size}, all will be PERMANENTLY deleted):"
144
+ vols.each { |v| summary << " #{v}" }
145
+ end
146
+ summary << ""
147
+ unless shared_external_vols.empty?
148
+ summary << " shared volumes (mounted from source via override yml; will NOT be touched):"
149
+ shared_external_vols.sort.each { |v| summary << " #{v}" }
150
+ summary << ""
151
+ end
152
+ if tagged_images.empty?
153
+ summary << " docker images: no tags matching #{target_project}-*"
154
+ else
155
+ summary << " docker image tags (#{tagged_images.size}, tags will be deleted — image layers preserved):"
156
+ tagged_images.each { |img| summary << " #{img}" }
157
+ end
158
+ summary << ""
159
+ summary << " Source project (#{source_root}) will NOT be touched."
160
+ summary << ""
161
+
162
+ Log.section(summary.join("\n"), interactive: true, force: force)
163
+
164
+ unless Prompt.confirm("Type 'yes' to confirm deletion of '#{name}':", default: nil, force: force)
165
+ warn "[#{PROG}] aborted: confirmation not given. No changes made."
166
+ exit 1
167
+ end
168
+
169
+ # === execute ===
170
+
171
+ failures = []
172
+
173
+ if docker_ok && !containers.empty?
174
+ _, err, st = Spinner.with_spinner("stopping compose stack -p #{target_project}") do
175
+ Sh.capture3("docker", "compose", "-p", target_project, "down", chdir: target_path.to_s)
176
+ end
177
+ if st.success?
178
+ Log.info "compose stack stopped"
179
+ else
180
+ Log.warn_ "compose down returned non-zero: #{err.strip}"
181
+ failures << "compose down"
182
+ end
183
+ end
184
+
185
+ if docker_ok && !tagged_images.empty?
186
+ _, rmi_err, rmi_st = Spinner.with_spinner("removing #{tagged_images.size} image tag(s)") do
187
+ Sh.capture3("docker", "rmi", *tagged_images)
188
+ end
189
+ if rmi_st.success?
190
+ Log.info "deleted #{tagged_images.size} image tag(s): #{tagged_images.join(", ")}"
191
+ else
192
+ Log.warn_ "docker rmi returned non-zero: #{rmi_err.strip}"
193
+ failures << "image rmi"
194
+ end
195
+ end
196
+
197
+ if docker_ok && !vols.empty?
198
+ # Batch removal: single subprocess call, single round-trip to the
199
+ # docker daemon. Falls back to per-volume on partial failure so
200
+ # error messages remain attributable.
201
+ _, batch_err, batch_st = Spinner.with_spinner("removing #{vols.size} volume(s)") do
202
+ Sh.capture3("docker", "volume", "rm", *vols)
203
+ end
204
+ if batch_st.success?
205
+ Log.info "deleted #{vols.size} volume(s): #{vols.join(", ")}"
206
+ else
207
+ Log.warn_ "batch volume removal failed; retrying one at a time: #{batch_err.strip}"
208
+ vols.each do |v|
209
+ _, _, exists_st = Sh.capture3("docker", "volume", "inspect", v)
210
+ unless exists_st.success?
211
+ Log.info "deleted volume #{v} (already gone)"
212
+ next
213
+ end
214
+ _, err, st = Spinner.with_spinner("removing volume #{v}") do
215
+ Sh.capture3("docker", "volume", "rm", v)
216
+ end
217
+ if st.success?
218
+ Log.info "deleted volume #{v}"
219
+ else
220
+ Log.warn_ "failed to remove volume #{v}: #{err.strip}"
221
+ failures << "volume rm #{v}"
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ # Worktree directory removal: rename + background-detached rm -rf.
228
+ # On the same filesystem, rename(2) is O(1) so remove returns in
229
+ # seconds regardless of how many files are in the worktree. The
230
+ # detached rm survives shell exit via Process.spawn's pgroup: true.
231
+ # On cross-filesystem rename (EXDEV) or rename failure for any other
232
+ # reason, we fall back to synchronous `git worktree remove --force`.
233
+ if worktree_registered || target_path.exist?
234
+ remove_failed = false
235
+
236
+ if target_path.exist?
237
+ quarantine = quarantine_path_for(target_path)
238
+
239
+ begin
240
+ File.rename(target_path.to_s, quarantine.to_s)
241
+ Log.info "worktree dir renamed -> #{quarantine.basename}"
242
+
243
+ bg_pid = Sh.spawn(
244
+ "rm", "-rf", quarantine.to_s,
245
+ out: File::NULL, err: File::NULL, in: File::NULL,
246
+ pgroup: true
247
+ )
248
+ Sh.detach(bg_pid)
249
+ Log.info "queued background worktree dir delete job (pid #{bg_pid})"
250
+ rescue Errno::EXDEV, Errno::EACCES, Errno::EPERM, SystemCallError => e
251
+ Log.warn_ "rename failed (#{e.class}: #{e.message}); falling back to synchronous removal"
252
+ _, fallback_err, fallback_st = Spinner.with_spinner("removing worktree #{target_path}") do
253
+ Sh.capture3("git", "-C", source_root.to_s, "worktree", "remove", "--force", target_path.to_s)
254
+ end
255
+ unless fallback_st.success?
256
+ Log.warn_ "git worktree remove failed: #{fallback_err.strip}"
257
+ begin
258
+ FileUtils.rm_rf(target_path.to_s, secure: true)
259
+ if target_path.exist?
260
+ Log.warn_ "fallback rm_rf left #{target_path} behind"
261
+ remove_failed = true
262
+ else
263
+ Log.info "removed directory #{target_path} (fallback)"
264
+ end
265
+ rescue StandardError => rmerr
266
+ Log.warn_ "fallback rm_rf failed for #{target_path}: #{rmerr.message}"
267
+ remove_failed = true
268
+ end
269
+ end
270
+ end
271
+ end
272
+
273
+ # Prune always — cleans up the dangling registration after the
274
+ # rename, or any leftover from a prior failed removal.
275
+ _, prune_err, prune_st = Sh.capture3("git", "-C", source_root.to_s, "worktree", "prune")
276
+ if prune_st.success?
277
+ Log.info "pruned worktree registration"
278
+ else
279
+ Log.warn_ "git worktree prune failed: #{prune_err.strip}"
280
+ failures << "worktree prune"
281
+ end
282
+
283
+ failures << "worktree remove" if remove_failed
284
+ end
285
+
286
+ # Branches are intentionally preserved — see the README "remove"
287
+ # section for rationale. Even an auto-derived branch (created when
288
+ # `ctree add` is called without an explicit branch name) can carry
289
+ # commits the user wants to keep, push, or merge later. To clean up
290
+ # an orphan, run `git branch -D <name>` manually.
291
+
292
+ puts unless LogFile.enabled?
293
+ if failures.empty?
294
+ exit 0
295
+ else
296
+ Log.warn_ "removal completed with failures: #{failures.join(", ")}"
297
+ exit 2
298
+ end
299
+ end
300
+
301
+ # Builds a unique sibling path adjacent to target_path used as the
302
+ # rename destination. Includes the destroying process's pid and a
303
+ # short random suffix so concurrent destroys of distinct worktrees
304
+ # never collide and a re-run after a stuck prior attempt doesn't
305
+ # either.
306
+ def quarantine_path_for(target_path)
307
+ base = target_path.basename.to_s
308
+ parent = target_path.parent
309
+ suffix = ".ctree-destroying-#{Process.pid}-#{rand(2**32).to_s(36)}"
310
+ parent / "#{base}#{suffix}"
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ctree
4
+ module Domain
5
+ module_function
6
+
7
+ RESOLVER_DIR = "/etc/resolver"
8
+
9
+ def macos?
10
+ RUBY_PLATFORM.include?("darwin")
11
+ end
12
+
13
+ def apply_config_log_prefix!
14
+ config = Config.load(Pathname.pwd)
15
+ Log.log_prefix = config[:log_prefix]
16
+ end
17
+
18
+ def add(tld:)
19
+ Log.die "ctree domain is only supported on macOS" unless macos?
20
+ apply_config_log_prefix!
21
+ container, conf_path, port = find_dnsmasq!
22
+
23
+ entry = "address=/.#{tld}/127.0.0.1"
24
+ lines = read_conf(conf_path)
25
+ if lines.any? { |l| l.strip == entry }
26
+ Log.info "dnsmasq already has entry for .#{tld}; skipping"
27
+ else
28
+ File.write(conf_path, (lines + ["#{entry}\n"]).join)
29
+ Log.info "added #{entry} to #{conf_path}"
30
+ restart_container(container)
31
+ end
32
+
33
+ write_resolver(tld, port)
34
+ end
35
+
36
+ def remove(tld:)
37
+ Log.die "ctree domain is only supported on macOS" unless macos?
38
+ apply_config_log_prefix!
39
+ container, conf_path, _ = find_dnsmasq!
40
+
41
+ entry = "address=/.#{tld}/127.0.0.1"
42
+ lines = read_conf(conf_path)
43
+ filtered = lines.reject { |l| l.strip == entry }
44
+ if filtered.size == lines.size
45
+ Log.info "no dnsmasq entry found for .#{tld}"
46
+ else
47
+ File.write(conf_path, filtered.join)
48
+ Log.info "removed #{entry} from #{conf_path}"
49
+ restart_container(container)
50
+ end
51
+
52
+ remove_resolver(tld)
53
+ end
54
+
55
+ def list
56
+ Log.die "ctree domain is only supported on macOS" unless macos?
57
+ apply_config_log_prefix!
58
+ _, conf_path, _ = find_dnsmasq!
59
+ lines = read_conf(conf_path)
60
+ tlds = lines.filter_map do |line|
61
+ m = line.strip.match(/\Aaddress=\/\.(\S+)\/127\.0\.0\.1\z/)
62
+ m[1] if m
63
+ end
64
+
65
+ if tlds.empty?
66
+ puts "no domains configured in #{conf_path}"
67
+ return
68
+ end
69
+
70
+ tlds.each do |tld|
71
+ resolver_path = File.join(RESOLVER_DIR, tld)
72
+ status = File.exist?(resolver_path) ? "ok" : "missing"
73
+ puts "#{tld} (resolver: #{status})"
74
+ end
75
+ end
76
+
77
+ def find_dnsmasq!
78
+ out, _, st = Sh.capture3("docker", "ps", "--quiet", "--filter", "status=running")
79
+ Log.die "docker not available" unless st.success?
80
+
81
+ ids = out.lines.map(&:strip).reject(&:empty?)
82
+ Log.die "no running containers found; is Docker running?" if ids.empty?
83
+
84
+ ids.each do |id|
85
+ inspect, _, ist = Sh.capture3("docker", "inspect", id,
86
+ "--format",
87
+ "{{.Name}}\t{{.Config.Image}}\t{{json .HostConfig.Binds}}\t{{json .HostConfig.PortBindings}}")
88
+ next unless ist.success?
89
+
90
+ name, image, binds_json, ports_json = inspect.strip.split("\t", 4)
91
+ next unless image&.include?("dnsmasq")
92
+
93
+ binds = JSON.parse(binds_json || "[]")
94
+ conf_path = binds.filter_map { |b| b.split(":").first if b.include?("/etc/dnsmasq.conf") }.first
95
+ Log.die "found dnsmasq container #{name} but cannot locate config file via volume mounts" unless conf_path
96
+
97
+ ports = JSON.parse(ports_json || "{}")
98
+ port = ports.values.flatten.first&.dig("HostPort") || "53"
99
+
100
+ return [name.delete_prefix("/"), conf_path, port]
101
+ end
102
+
103
+ Log.die "no running dnsmasq container found; is traefik-dns running?"
104
+ end
105
+
106
+ def read_conf(path)
107
+ File.exist?(path) ? File.readlines(path) : []
108
+ end
109
+
110
+ def restart_container(name)
111
+ _, err, st = Spinner.with_spinner("restarting #{name}") do
112
+ Sh.capture3("docker", "restart", name)
113
+ end
114
+ if st.success?
115
+ Log.info "restarted #{name}"
116
+ else
117
+ Log.warn_ "docker restart #{name} failed: #{err.strip}"
118
+ end
119
+ end
120
+
121
+ def write_resolver(tld, port)
122
+ path = File.join(RESOLVER_DIR, tld)
123
+ content = "nameserver 127.0.0.1\nport #{port}\n"
124
+
125
+ if File.exist?(path) && File.read(path) == content
126
+ Log.info "#{path} already configured; skipping"
127
+ return
128
+ end
129
+
130
+ _, err, st = Sh.capture3("sudo", "tee", path, stdin_data: content)
131
+ if st.success?
132
+ Log.info "wrote #{path}"
133
+ else
134
+ Log.warn_ "could not write #{path} (#{err.strip})"
135
+ Log.warn_ "run manually: sudo bash -c 'printf \"nameserver 127.0.0.1\\nport #{port}\\n\" > #{path}'"
136
+ end
137
+ end
138
+
139
+ def remove_resolver(tld)
140
+ path = File.join(RESOLVER_DIR, tld)
141
+ unless File.exist?(path)
142
+ Log.info "#{path} does not exist; skipping"
143
+ return
144
+ end
145
+
146
+ _, err, st = Sh.capture3("sudo", "rm", path)
147
+ if st.success?
148
+ Log.info "removed #{path}"
149
+ else
150
+ Log.warn_ "could not remove #{path} (#{err.strip})"
151
+ Log.warn_ "run manually: sudo rm #{path}"
152
+ end
153
+ end
154
+
155
+ private_class_method :find_dnsmasq!, :read_conf, :restart_container,
156
+ :write_resolver, :remove_resolver, :apply_config_log_prefix!
157
+ end
158
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ctree
4
+ module EnvCmd
5
+ module_function
6
+
7
+ def run(subcommand, force: false)
8
+ target_path, source_root = resolve_worktree!
9
+ config = Config.load(source_root)
10
+ Log.log_prefix = config[:log_prefix]
11
+ skip_keys = config[:skip_env_keys]
12
+
13
+ case subcommand
14
+ when "list" then cmd_list(target_path, source_root, skip_keys, config[:env_filename])
15
+ when "check" then cmd_check(target_path, source_root, config)
16
+ when "fix" then cmd_fix(target_path, source_root, config, force: force)
17
+ else Log.die "unknown subcommand '#{subcommand}'; use: list, check, or fix"
18
+ end
19
+ end
20
+
21
+ def resolve_worktree!
22
+ target_path = Pathname.pwd
23
+ toplevel_out, _, st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--show-toplevel")
24
+ Log.die "not inside a git repository" unless st.success?
25
+ git_top = Pathname.new(toplevel_out.strip)
26
+ unless target_path.realpath == git_top.realpath
27
+ Log.die "must be invoked from the worktree top-level"
28
+ end
29
+ common_out, _, c_st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--git-common-dir")
30
+ Log.die "could not resolve git common dir" unless c_st.success?
31
+ common = Pathname.new(common_out.strip)
32
+ common = (target_path / common) unless common.absolute?
33
+ source_root = common.parent.realpath
34
+ if source_root == target_path.realpath
35
+ Log.die "already in the source project; run ctree env from inside a worktree"
36
+ end
37
+ [target_path.realpath, source_root]
38
+ end
39
+
40
+ def ctree_managed_keys(config)
41
+ keys = ["COMPOSE_PROJECT_NAME"]
42
+ host_name_key = config[:host_name].to_s.strip
43
+ keys << host_name_key unless host_name_key.empty?
44
+ keys << "HOST_NAME_SUFFIX" unless config[:host_name_suffix].to_s.strip.empty?
45
+ domain_key = config[:host_domain_env_key].to_s.strip
46
+ keys << domain_key unless domain_key.empty?
47
+ keys.uniq
48
+ end
49
+
50
+ def cmd_list(target_path, source_root, skip_keys, env_filename)
51
+ tgt_env_path = target_path / env_filename
52
+ Log.die "no #{env_filename} found at #{tgt_env_path}" unless tgt_env_path.file?
53
+ src_env_path = source_root / env_filename
54
+ Log.die "no #{env_filename} found in source repo at #{src_env_path}" unless src_env_path.file?
55
+
56
+ tgt_env = EnvFile.parse(tgt_env_path.to_s)
57
+ tgt_env.each { |k, v| puts "#{k}=#{v}" }
58
+ exit 0
59
+ end
60
+
61
+ def cmd_check(target_path, source_root, config)
62
+ env_filename = config[:env_filename]
63
+ tgt_env_path = target_path / env_filename
64
+ Log.die "no #{env_filename} found at #{tgt_env_path}" unless tgt_env_path.file?
65
+ src_env_path = source_root / env_filename
66
+ Log.die "no #{env_filename} found in source repo at #{src_env_path}" unless src_env_path.file?
67
+
68
+ tgt_env = EnvFile.parse(tgt_env_path.to_s)
69
+ src_env = EnvFile.parse(src_env_path.to_s)
70
+
71
+ managed = ctree_managed_keys(config)
72
+ missing = src_env.keys - tgt_env.keys
73
+ extra = tgt_env.keys - src_env.keys - managed - config[:skip_env_keys]
74
+
75
+ if missing.empty? && extra.empty?
76
+ Log.info "env is in sync"
77
+ exit 0
78
+ else
79
+ missing.each { |k| puts "[missing] #{k}" }
80
+ extra.each { |k| puts "[extra] #{k}" }
81
+ exit 1
82
+ end
83
+ end
84
+
85
+ def cmd_fix(target_path, source_root, config, force: false)
86
+ env_filename = config[:env_filename]
87
+ tgt_env_path = target_path / env_filename
88
+ Log.die "no #{env_filename} found at #{tgt_env_path}" unless tgt_env_path.file?
89
+ src_env_path = source_root / env_filename
90
+ Log.die "no #{env_filename} found in source repo at #{src_env_path}" unless src_env_path.file?
91
+
92
+ tgt_env = EnvFile.parse(tgt_env_path.to_s)
93
+ src_env = EnvFile.parse(src_env_path.to_s)
94
+
95
+ managed = ctree_managed_keys(config)
96
+ effective_skip = (config[:skip_env_keys] + managed).uniq
97
+ missing = src_env.keys - tgt_env.keys - managed
98
+ orphaned = tgt_env.keys - src_env.keys - effective_skip
99
+
100
+ if missing.empty? && orphaned.empty?
101
+ Log.info "env is in sync; nothing to fix"
102
+ exit 0
103
+ end
104
+
105
+ missing.each do |key|
106
+ new_value = Prompt.for_env_var_change(key, src_env[key], force: force)
107
+ EnvFile.upsert(tgt_env_path.to_s, key, new_value)
108
+ end
109
+
110
+ orphaned.each do |key|
111
+ if Prompt.confirm("#{key}=#{tgt_env[key]} (not in source #{env_filename}, delete? [y/N]):", default: :no, force: force)
112
+ EnvFile.delete(tgt_env_path.to_s, key)
113
+ end
114
+ end
115
+
116
+ Log.info "updated #{env_filename}"
117
+ exit 0
118
+ end
119
+
120
+ private_class_method :resolve_worktree!, :ctree_managed_keys,
121
+ :cmd_list, :cmd_check, :cmd_fix
122
+ end
123
+ end
data/lib/ctree/free.rb ADDED
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Ctree
6
+ module Free
7
+ module_function
8
+
9
+ def run
10
+ target_path = Pathname.pwd
11
+ toplevel_out, _, status = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--show-toplevel")
12
+ Log.die "not inside a git repository" unless status.success?
13
+ git_top = Pathname.new(toplevel_out.strip)
14
+ unless target_path.realpath == git_top.realpath
15
+ Log.die "must be invoked from the worktree top-level (cwd=#{target_path}, top=#{git_top})"
16
+ end
17
+
18
+ common_out, _, c_st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--git-common-dir")
19
+ Log.die "could not resolve git common dir" unless c_st.success?
20
+ common = Pathname.new(common_out.strip)
21
+ common = (target_path / common) unless common.absolute?
22
+ source_root = common.parent.realpath
23
+
24
+ if source_root == target_path.realpath
25
+ Log.die "already in the source project; run ctree free from inside a worktree"
26
+ end
27
+
28
+ config = Config.load(source_root)
29
+ Log.log_prefix = config[:log_prefix]
30
+ prefix = config[:free_branch_prefix]
31
+
32
+ current_branch_out, _, cb_st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--abbrev-ref", "HEAD")
33
+ Log.die "could not resolve current branch" unless cb_st.success?
34
+ current_branch = current_branch_out.strip
35
+ if !prefix.empty? && current_branch.start_with?(prefix)
36
+ Log.info "already on a free branch (#{current_branch}); nothing to do"
37
+ exit 0
38
+ end
39
+
40
+ branches_out, _, branches_st = Sh.capture3("git", "-C", source_root.to_s, "branch", "--list", "#{prefix}*")
41
+ Log.die "git branch --list failed" unless branches_st.success?
42
+ all_free = branches_out.lines.map { |l| l.sub(/\A[*+ ] /, "").strip }.reject(&:empty?).sort
43
+
44
+ worktree_raw, _, wt_st = Sh.capture3("git", "-C", source_root.to_s, "worktree", "list", "--porcelain")
45
+ Log.die "git worktree list failed" unless wt_st.success?
46
+ occupied = List.parse_worktree_porcelain(worktree_raw).filter_map { |e| e[:branch] }.to_set
47
+
48
+ branch = first_available_branch(all_free, occupied)
49
+ if branch
50
+ _, err, st = Spinner.with_spinner("checking out #{branch}") do
51
+ Sh.capture3("git", "-C", target_path.to_s, "checkout", branch)
52
+ end
53
+ Log.die "git checkout #{branch} failed: #{err.strip}" unless st.success?
54
+ else
55
+ branch = next_sequential_branch(prefix, all_free)
56
+ _, err, st = Spinner.with_spinner("checking out #{branch}") do
57
+ Sh.capture3("git", "-C", target_path.to_s, "checkout", "-b", branch)
58
+ end
59
+ Log.die "git checkout -b #{branch} failed: #{err.strip}" unless st.success?
60
+ end
61
+ Log.info "checked out #{branch}"
62
+
63
+ default_branch = Git.detect_default_branch(source_root)
64
+ Rebase.rebase_branch_onto_default(target_path, branch, default_branch)
65
+ end
66
+
67
+ def first_available_branch(all_free, occupied)
68
+ all_free.sort.find { |b| !occupied.include?(b) }
69
+ end
70
+
71
+ def next_sequential_branch(prefix, all_free)
72
+ numeric_pattern = /\A#{Regexp.escape(prefix)}(\d+)\z/
73
+ numbers = all_free.filter_map { |b| b.match(numeric_pattern)&.then { |m| m[1].to_i } }.to_set
74
+ n = 1
75
+ n += 1 while numbers.include?(n)
76
+ "#{prefix}#{n.to_s.rjust(3, "0")}"
77
+ end
78
+ end
79
+ end