brainiac 0.0.27 → 0.0.29
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 +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +14 -3
- data/bin/brainiac +163 -12
- data/docs/resume.md +87 -0
- data/lib/brainiac/cron.rb +3 -1
- data/lib/brainiac/handlers/shared/belt.rb +409 -0
- data/lib/brainiac/handlers/shared/git.rb +52 -27
- data/lib/brainiac/helpers.rb +221 -32
- data/lib/brainiac/intent.rb +1 -1
- data/lib/brainiac/model_parser.rb +109 -0
- data/lib/brainiac/version.rb +1 -1
- data/monitor/waybar/session_slot.rb +1 -3
- data/receiver.rb +4 -0
- data/templates/cli-providers/codex.json.example +32 -0
- data/templates/cli-providers/grok.json.example +1 -0
- data/templates/cli-providers/kiro.json.example +1 -0
- metadata +5 -1
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Belt application detection and environment management utilities.
|
|
4
|
+
#
|
|
5
|
+
# Belt is a Ruby framework for building serverless applications on AWS Lambda.
|
|
6
|
+
# Projects using Belt have infrastructure defined in `config/routes.rb` or the
|
|
7
|
+
# legacy `infrastructure/routes.tf.rb` path.
|
|
8
|
+
#
|
|
9
|
+
# This module provides detection and ephemeral environment helpers that plugins
|
|
10
|
+
# (brainiac-fizzy, brainiac-github, brainiac-basecamp) can use.
|
|
11
|
+
|
|
12
|
+
# Module containing Belt-related helpers accessible from Sinatra routes.
|
|
13
|
+
module BeltHelpers
|
|
14
|
+
# Detect if a directory is a Belt application by checking for routes file.
|
|
15
|
+
#
|
|
16
|
+
# Belt apps are detected by the presence of any of these files:
|
|
17
|
+
# - config/routes.rb (current)
|
|
18
|
+
# - config/routes.tf.rb (legacy)
|
|
19
|
+
# - infrastructure/routes.tf.rb (legacy)
|
|
20
|
+
#
|
|
21
|
+
# @param path [String] Path to check (repo root or worktree)
|
|
22
|
+
# @return [Boolean] True if the path contains a Belt application
|
|
23
|
+
def belt_app?(path)
|
|
24
|
+
return false unless path && File.directory?(path)
|
|
25
|
+
|
|
26
|
+
candidates = [
|
|
27
|
+
File.join(path, "config/routes.rb"),
|
|
28
|
+
File.join(path, "config/routes.tf.rb"),
|
|
29
|
+
File.join(path, "infrastructure/routes.tf.rb")
|
|
30
|
+
]
|
|
31
|
+
candidates.any? { |f| File.exist?(f) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if a Belt app routes file exists and contains actual route definitions.
|
|
35
|
+
# This is a stricter check than belt_app? — it verifies the file has Belt routes,
|
|
36
|
+
# not just a Rails routes.rb file (which wouldn't have Belt-style route definitions).
|
|
37
|
+
#
|
|
38
|
+
# @param path [String] Path to check
|
|
39
|
+
# @return [Boolean] True if the path contains a Belt routes file with Belt syntax
|
|
40
|
+
def belt_routes_file?(path)
|
|
41
|
+
routes_file = belt_routes_path(path)
|
|
42
|
+
return false unless routes_file && File.exist?(routes_file)
|
|
43
|
+
|
|
44
|
+
# Check for Belt-specific syntax (e.g., `app.get`, `api`, `resources`, etc.)
|
|
45
|
+
content = File.read(routes_file, 4096) # Read first 4KB
|
|
46
|
+
content.match?(/\bapp\.(get|post|put|patch|delete)\b/) ||
|
|
47
|
+
content.match?(/\bapi\s+do\b/) ||
|
|
48
|
+
content.match?(/\bresources?\s+:/) ||
|
|
49
|
+
content.match?(/\bBelt\.routes\b/)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get the path to the Belt routes file (checking all possible locations).
|
|
55
|
+
#
|
|
56
|
+
# @param path [String] Path to check
|
|
57
|
+
# @return [String, nil] Path to routes file or nil
|
|
58
|
+
def belt_routes_path(path)
|
|
59
|
+
return nil unless path && File.directory?(path)
|
|
60
|
+
|
|
61
|
+
candidates = [
|
|
62
|
+
File.join(path, "config/routes.rb"),
|
|
63
|
+
File.join(path, "config/routes.tf.rb"),
|
|
64
|
+
File.join(path, "infrastructure/routes.tf.rb")
|
|
65
|
+
]
|
|
66
|
+
candidates.find { |f| File.exist?(f) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Configuration for Belt ephemeral environments.
|
|
71
|
+
# Stored in ~/.brainiac/basecamp.json under the "deploy" key.
|
|
72
|
+
module BeltConfig
|
|
73
|
+
BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
74
|
+
BASECAMP_CONFIG_FILE = File.join(BRAINIAC_DIR, "basecamp.json")
|
|
75
|
+
|
|
76
|
+
class << self
|
|
77
|
+
# Load the basecamp config, caching for performance.
|
|
78
|
+
# @return [Hash]
|
|
79
|
+
def load_config
|
|
80
|
+
return {} unless File.exist?(BASECAMP_CONFIG_FILE)
|
|
81
|
+
|
|
82
|
+
JSON.parse(File.read(BASECAMP_CONFIG_FILE))
|
|
83
|
+
rescue JSON::ParserError => e
|
|
84
|
+
LOG.error "[Belt] Failed to parse basecamp.json: #{e.message}" if defined?(LOG)
|
|
85
|
+
{}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get the parent environment for a project (used as base for ephemeral envs).
|
|
89
|
+
# This is where `belt g environment <name> <parent>` copies settings from.
|
|
90
|
+
#
|
|
91
|
+
# @param project_key [String] Brainiac project key
|
|
92
|
+
# @return [String, nil] Parent environment name (e.g., "dev02", "dev") or nil
|
|
93
|
+
def parent_env_for(project_key)
|
|
94
|
+
config = load_config
|
|
95
|
+
config.dig("deploy", "project_envs", project_key)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check if ephemeral deploys are enabled globally.
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def ephemeral_deploys_enabled?
|
|
101
|
+
config = load_config
|
|
102
|
+
# Default to true if deploy section exists but enabled isn't specified
|
|
103
|
+
deploy_config = config["deploy"] || {}
|
|
104
|
+
deploy_config.fetch("ephemeral_enabled", true)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Track an ephemeral environment in deployment state.
|
|
108
|
+
# @param env_name [String] Environment name (e.g., "fizzy-123")
|
|
109
|
+
# @param metadata [Hash] Metadata about the ephemeral env
|
|
110
|
+
def track_ephemeral_env(env_name, metadata = {})
|
|
111
|
+
state_file = File.join(BRAINIAC_DIR, "ephemeral_envs.json")
|
|
112
|
+
state = File.exist?(state_file) ? JSON.parse(File.read(state_file)) : {}
|
|
113
|
+
|
|
114
|
+
state[env_name] = {
|
|
115
|
+
"created_at" => Time.now.iso8601,
|
|
116
|
+
"status" => "active"
|
|
117
|
+
}.merge(metadata)
|
|
118
|
+
|
|
119
|
+
File.write(state_file, JSON.pretty_generate(state))
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
LOG.error "[Belt] Failed to track ephemeral env: #{e.message}" if defined?(LOG)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check if an environment is ephemeral.
|
|
125
|
+
# @param env_name [String] Environment name
|
|
126
|
+
# @return [Boolean]
|
|
127
|
+
def ephemeral_env?(env_name)
|
|
128
|
+
state_file = File.join(BRAINIAC_DIR, "ephemeral_envs.json")
|
|
129
|
+
return false unless File.exist?(state_file)
|
|
130
|
+
|
|
131
|
+
state = JSON.parse(File.read(state_file))
|
|
132
|
+
state.key?(env_name) && state[env_name]["status"] == "active"
|
|
133
|
+
rescue StandardError
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get ephemeral environment metadata.
|
|
138
|
+
# @param env_name [String] Environment name
|
|
139
|
+
# @return [Hash, nil]
|
|
140
|
+
def ephemeral_env_info(env_name)
|
|
141
|
+
state_file = File.join(BRAINIAC_DIR, "ephemeral_envs.json")
|
|
142
|
+
return nil unless File.exist?(state_file)
|
|
143
|
+
|
|
144
|
+
state = JSON.parse(File.read(state_file))
|
|
145
|
+
state[env_name]
|
|
146
|
+
rescue StandardError
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Mark an ephemeral environment as destroyed.
|
|
151
|
+
# @param env_name [String] Environment name
|
|
152
|
+
def mark_ephemeral_destroyed(env_name)
|
|
153
|
+
state_file = File.join(BRAINIAC_DIR, "ephemeral_envs.json")
|
|
154
|
+
return unless File.exist?(state_file)
|
|
155
|
+
|
|
156
|
+
state = JSON.parse(File.read(state_file))
|
|
157
|
+
return unless state[env_name]
|
|
158
|
+
|
|
159
|
+
state[env_name]["status"] = "destroyed"
|
|
160
|
+
state[env_name]["destroyed_at"] = Time.now.iso8601
|
|
161
|
+
|
|
162
|
+
File.write(state_file, JSON.pretty_generate(state))
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
LOG.error "[Belt] Failed to mark ephemeral env destroyed: #{e.message}" if defined?(LOG)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Find ephemeral environment by card number.
|
|
168
|
+
# @param card_number [Integer, String] Fizzy card number
|
|
169
|
+
# @return [String, nil] Environment name or nil
|
|
170
|
+
def ephemeral_env_for_card(card_number)
|
|
171
|
+
"fizzy-#{card_number}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Find ephemeral environment by epic number.
|
|
175
|
+
# @param epic_number [Integer, String] Epic number
|
|
176
|
+
# @return [String, nil] Environment name or nil
|
|
177
|
+
def ephemeral_env_for_epic(epic_number)
|
|
178
|
+
"epic-#{epic_number}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Belt environment operations (create, deploy, destroy).
|
|
184
|
+
# These wrap the `belt` CLI commands for ephemeral environment management.
|
|
185
|
+
module BeltEnvironment
|
|
186
|
+
class << self
|
|
187
|
+
include BeltHelpers
|
|
188
|
+
|
|
189
|
+
# Check whether an environment is configured in a worktree.
|
|
190
|
+
# `belt g environment` writes infrastructure/<env_name>/; that directory
|
|
191
|
+
# is the source of truth, not the ephemeral_envs.json tracking file.
|
|
192
|
+
#
|
|
193
|
+
# @param worktree [String] Path to the worktree
|
|
194
|
+
# @param env_name [String] Environment name (e.g. "fizzy-1299")
|
|
195
|
+
# @return [Boolean]
|
|
196
|
+
def environment_configured?(worktree:, env_name:)
|
|
197
|
+
return false unless worktree && env_name && File.directory?(worktree)
|
|
198
|
+
|
|
199
|
+
File.directory?(File.join(worktree, "infrastructure", env_name.to_s))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Create an ephemeral environment from a parent environment.
|
|
203
|
+
#
|
|
204
|
+
# @param worktree [String] Path to the worktree
|
|
205
|
+
# @param env_name [String] Name for the ephemeral environment
|
|
206
|
+
# @param parent_env [String] Parent environment to copy from
|
|
207
|
+
# @return [Boolean] True on success
|
|
208
|
+
def create_environment(worktree:, env_name:, parent_env:)
|
|
209
|
+
return false unless belt_app?(worktree)
|
|
210
|
+
|
|
211
|
+
LOG.info "[Belt] Creating ephemeral environment '#{env_name}' from parent '#{parent_env}'"
|
|
212
|
+
|
|
213
|
+
_, stderr, status = Open3.capture3("belt", "g", "environment", env_name, parent_env, chdir: worktree)
|
|
214
|
+
|
|
215
|
+
if status.success?
|
|
216
|
+
LOG.info "[Belt] Successfully created environment '#{env_name}'"
|
|
217
|
+
true
|
|
218
|
+
else
|
|
219
|
+
LOG.error "[Belt] Failed to create environment '#{env_name}': #{stderr.strip}"
|
|
220
|
+
false
|
|
221
|
+
end
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
LOG.error "[Belt] Error creating environment: #{e.message}"
|
|
224
|
+
false
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Deploy to an environment.
|
|
228
|
+
#
|
|
229
|
+
# Always non-interactive: `belt deploy` prompts "Apply these changes? [y/N]"
|
|
230
|
+
# unless `--auto` is passed. Open3.capture3 provides empty stdin, so without
|
|
231
|
+
# `--auto` belt prints "Cancelled." and still exits 0 — a silent no-op.
|
|
232
|
+
# Frontend-only uses `belt deploy frontend <env>` (subcommand first).
|
|
233
|
+
#
|
|
234
|
+
# @param worktree [String] Path to the worktree
|
|
235
|
+
# @param env_name [String] Environment name
|
|
236
|
+
# @param frontend_only [Boolean] If true, only deploy frontend
|
|
237
|
+
# @return [Boolean] True on success
|
|
238
|
+
def deploy(worktree:, env_name:, frontend_only: false, capture3: nil)
|
|
239
|
+
return false unless belt_app?(worktree)
|
|
240
|
+
|
|
241
|
+
cmd = deploy_command(env_name, frontend_only: frontend_only)
|
|
242
|
+
|
|
243
|
+
LOG.info "[Belt] Deploying to '#{env_name}'#{" (frontend only)" if frontend_only}"
|
|
244
|
+
LOG.info "[Belt] Running: #{cmd.join(" ")} (in #{worktree})"
|
|
245
|
+
|
|
246
|
+
runner = capture3 || Open3.method(:capture3)
|
|
247
|
+
stdout, stderr, status = runner.call(*cmd, chdir: worktree)
|
|
248
|
+
log_cli_tail(stdout, stderr)
|
|
249
|
+
|
|
250
|
+
if deploy_cancelled?(stdout, stderr)
|
|
251
|
+
LOG.error "[Belt] Deploy to '#{env_name}' cancelled — non-interactive belt deploy needs --auto"
|
|
252
|
+
return false
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
if status.success?
|
|
256
|
+
LOG.info "[Belt] Successfully deployed to '#{env_name}'"
|
|
257
|
+
true
|
|
258
|
+
else
|
|
259
|
+
LOG.error "[Belt] Failed to deploy to '#{env_name}': #{stderr.strip}"
|
|
260
|
+
false
|
|
261
|
+
end
|
|
262
|
+
rescue StandardError => e
|
|
263
|
+
LOG.error "[Belt] Error deploying: #{e.message}"
|
|
264
|
+
false
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# argv for `belt deploy`. Extracted so tests can assert the command without
|
|
268
|
+
# stubbing Open3 for every call site.
|
|
269
|
+
def deploy_command(env_name, frontend_only: false)
|
|
270
|
+
if frontend_only
|
|
271
|
+
["belt", "deploy", "frontend", env_name]
|
|
272
|
+
else
|
|
273
|
+
["belt", "deploy", env_name, "--auto"]
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Destroy an ephemeral environment.
|
|
278
|
+
#
|
|
279
|
+
# @param worktree [String] Path to the worktree (infrastructure lives here)
|
|
280
|
+
# @param env_name [String] Environment name
|
|
281
|
+
# @return [Boolean] True on success
|
|
282
|
+
def destroy_environment(worktree:, env_name:)
|
|
283
|
+
return false unless belt_app?(worktree)
|
|
284
|
+
|
|
285
|
+
LOG.info "[Belt] Destroying ephemeral environment '#{env_name}'"
|
|
286
|
+
|
|
287
|
+
_, stderr, status = Open3.capture3("belt", "destroy", "environment", env_name, "--full", chdir: worktree)
|
|
288
|
+
|
|
289
|
+
if status.success?
|
|
290
|
+
LOG.info "[Belt] Successfully destroyed environment '#{env_name}'"
|
|
291
|
+
BeltConfig.mark_ephemeral_destroyed(env_name)
|
|
292
|
+
true
|
|
293
|
+
else
|
|
294
|
+
LOG.error "[Belt] Failed to destroy environment '#{env_name}': #{stderr.strip}"
|
|
295
|
+
false
|
|
296
|
+
end
|
|
297
|
+
rescue StandardError => e
|
|
298
|
+
LOG.error "[Belt] Error destroying environment: #{e.message}"
|
|
299
|
+
false
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Check if changes are frontend-only by examining the diff.
|
|
303
|
+
# Frontend-only changes can be deployed faster with `belt deploy frontend <env>`.
|
|
304
|
+
#
|
|
305
|
+
# @param worktree [String] Path to the worktree
|
|
306
|
+
# @param base_branch [String, nil] Optional base (e.g. "master", "origin/main").
|
|
307
|
+
# When omitted, uses origin/HEAD, then origin/main, then origin/master.
|
|
308
|
+
# @return [Boolean] True if changes are frontend-only
|
|
309
|
+
def frontend_only_changes?(worktree:, base_branch: nil)
|
|
310
|
+
base_ref = resolve_frontend_diff_base(worktree, base_branch)
|
|
311
|
+
unless base_ref
|
|
312
|
+
LOG.warn "[Belt] No base ref for frontend-only check in #{worktree}"
|
|
313
|
+
return false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
stdout, stderr, status = Open3.capture3("git", "diff", "--name-only", base_ref, "--", chdir: worktree)
|
|
317
|
+
unless status.success?
|
|
318
|
+
LOG.warn "[Belt] Could not diff against #{base_ref}: #{stderr.strip}"
|
|
319
|
+
return false
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
changed_files = stdout.strip.split("\n")
|
|
323
|
+
return false if changed_files.empty?
|
|
324
|
+
|
|
325
|
+
# Frontend directories that don't affect backend
|
|
326
|
+
frontend_patterns = %w[
|
|
327
|
+
frontend/
|
|
328
|
+
app/javascript/
|
|
329
|
+
app/assets/
|
|
330
|
+
public/
|
|
331
|
+
static/
|
|
332
|
+
src/
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
# Backend patterns that require full deploy
|
|
336
|
+
backend_patterns = %w[
|
|
337
|
+
lambda/
|
|
338
|
+
infrastructure/
|
|
339
|
+
config/routes
|
|
340
|
+
config/contracts
|
|
341
|
+
Gemfile
|
|
342
|
+
*.gemspec
|
|
343
|
+
Rakefile
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
# Check if all changes are frontend-only
|
|
347
|
+
changed_files.all? do |file|
|
|
348
|
+
frontend_patterns.any? { |pattern| file.start_with?(pattern) } &&
|
|
349
|
+
backend_patterns.none? { |pattern| file.start_with?(pattern.delete("*")) || File.fnmatch?(pattern, file) }
|
|
350
|
+
end
|
|
351
|
+
rescue StandardError => e
|
|
352
|
+
LOG.warn "[Belt] Error checking frontend-only changes: #{e.message}"
|
|
353
|
+
false
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Resolve a git ref to diff against. Never assumes origin/main.
|
|
357
|
+
# Prefer an explicit PR/base branch, then origin/HEAD, then main/master.
|
|
358
|
+
def resolve_frontend_diff_base(worktree, explicit)
|
|
359
|
+
candidates = []
|
|
360
|
+
if explicit && !explicit.to_s.strip.empty?
|
|
361
|
+
ref = explicit.to_s.strip
|
|
362
|
+
ref = "origin/#{ref}" unless ref.include?("/")
|
|
363
|
+
candidates << ref
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
head = origin_head_branch(worktree)
|
|
367
|
+
candidates << "origin/#{head}" if head
|
|
368
|
+
candidates.push("origin/main", "origin/master")
|
|
369
|
+
candidates.uniq.find { |ref| git_commit?(worktree, ref) }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def origin_head_branch(worktree)
|
|
373
|
+
stdout, _stderr, status = Open3.capture3(
|
|
374
|
+
"git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD",
|
|
375
|
+
chdir: worktree
|
|
376
|
+
)
|
|
377
|
+
return nil unless status.success?
|
|
378
|
+
|
|
379
|
+
name = stdout.strip.delete_prefix("origin/")
|
|
380
|
+
name.empty? ? nil : name
|
|
381
|
+
rescue StandardError
|
|
382
|
+
nil
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def git_commit?(worktree, ref)
|
|
386
|
+
_stdout, _stderr, status = Open3.capture3(
|
|
387
|
+
"git", "rev-parse", "--verify", "#{ref}^{commit}",
|
|
388
|
+
chdir: worktree
|
|
389
|
+
)
|
|
390
|
+
status.success?
|
|
391
|
+
rescue StandardError
|
|
392
|
+
false
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def deploy_cancelled?(stdout, stderr)
|
|
396
|
+
[stdout, stderr].any? { |s| s.to_s.match?(/\bCancelled\.?\s*$/) }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def log_cli_tail(stdout, stderr, limit: 25)
|
|
400
|
+
lines = []
|
|
401
|
+
lines.concat(stdout.to_s.lines) unless stdout.to_s.strip.empty?
|
|
402
|
+
lines.concat(stderr.to_s.lines.map { |l| "stderr: #{l}" }) unless stderr.to_s.strip.empty?
|
|
403
|
+
return if lines.empty?
|
|
404
|
+
|
|
405
|
+
tail = lines.last(limit).join
|
|
406
|
+
LOG.info "[Belt] Output (last #{[lines.size, limit].min} lines):\n#{tail}"
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
@@ -172,37 +172,12 @@ def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path:
|
|
|
172
172
|
base_ref ||= "origin/#{get_default_branch(repo_path)}"
|
|
173
173
|
|
|
174
174
|
worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
175
|
-
|
|
176
|
-
if File.directory?(worktree_path)
|
|
177
|
-
is_tracked = worktree_list.include?(worktree_path)
|
|
178
|
-
|
|
179
|
-
if is_tracked
|
|
180
|
-
LOG.info "Worktree directory #{worktree_path} is tracked by git"
|
|
181
|
-
else
|
|
182
|
-
LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
|
|
183
|
-
begin
|
|
184
|
-
FileUtils.rm_rf(worktree_path)
|
|
185
|
-
LOG.info "Successfully removed orphaned directory"
|
|
186
|
-
rescue StandardError => e
|
|
187
|
-
LOG.error "Failed to remove orphaned directory: #{e.message}"
|
|
188
|
-
raise
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
end
|
|
175
|
+
cleanup_orphaned_worktree(worktree_path, worktree_list) if File.directory?(worktree_path)
|
|
192
176
|
|
|
193
177
|
branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
|
|
194
178
|
|
|
195
179
|
if branch_exists
|
|
196
|
-
|
|
197
|
-
worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
198
|
-
has_worktree = worktree_list.lines.any? { |line| line.strip == "worktree #{worktree_path}" }
|
|
199
|
-
|
|
200
|
-
if has_worktree && File.directory?(worktree_path)
|
|
201
|
-
LOG.info "Reusing existing worktree at #{worktree_path}"
|
|
202
|
-
else
|
|
203
|
-
LOG.info "Creating worktree from existing branch #{branch}"
|
|
204
|
-
run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
|
|
205
|
-
end
|
|
180
|
+
attach_or_create_worktree_for_branch(repo_path, branch, worktree_path)
|
|
206
181
|
else
|
|
207
182
|
LOG.info "Creating new branch #{branch} and worktree"
|
|
208
183
|
run_cmd("git", "worktree", "add", "-b", branch, worktree_path, base_ref, chdir: repo_path)
|
|
@@ -215,6 +190,56 @@ def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path:
|
|
|
215
190
|
worktree_path
|
|
216
191
|
end
|
|
217
192
|
|
|
193
|
+
# Remove an orphaned worktree directory (exists on disk but not tracked by git).
|
|
194
|
+
# If the directory is tracked, logs and leaves it alone.
|
|
195
|
+
def cleanup_orphaned_worktree(worktree_path, worktree_list)
|
|
196
|
+
resolved_path = begin
|
|
197
|
+
File.realpath(worktree_path)
|
|
198
|
+
rescue StandardError
|
|
199
|
+
worktree_path
|
|
200
|
+
end
|
|
201
|
+
is_tracked = worktree_list.include?(worktree_path) || worktree_list.include?(resolved_path)
|
|
202
|
+
|
|
203
|
+
if is_tracked
|
|
204
|
+
LOG.info "Worktree directory #{worktree_path} is tracked by git"
|
|
205
|
+
else
|
|
206
|
+
LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
|
|
207
|
+
begin
|
|
208
|
+
FileUtils.rm_rf(worktree_path)
|
|
209
|
+
LOG.info "Successfully removed orphaned directory"
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
LOG.error "Failed to remove orphaned directory: #{e.message}"
|
|
212
|
+
raise
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Attach an existing branch to a worktree, reusing it if already present.
|
|
218
|
+
def attach_or_create_worktree_for_branch(repo_path, branch, worktree_path)
|
|
219
|
+
LOG.info "Branch #{branch} already exists, checking for existing worktree"
|
|
220
|
+
worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
221
|
+
# Resolve symlinks for comparison (macOS /var/folders vs /private/var/folders)
|
|
222
|
+
resolved_wt = begin
|
|
223
|
+
File.realpath(worktree_path)
|
|
224
|
+
rescue StandardError
|
|
225
|
+
worktree_path
|
|
226
|
+
end
|
|
227
|
+
has_worktree = worktree_list.lines.any? do |line|
|
|
228
|
+
stripped = line.strip
|
|
229
|
+
next false unless stripped.start_with?("worktree ")
|
|
230
|
+
|
|
231
|
+
listed_path = stripped.sub("worktree ", "")
|
|
232
|
+
listed_path == worktree_path || listed_path == resolved_wt
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if has_worktree && File.directory?(worktree_path)
|
|
236
|
+
LOG.info "Reusing existing worktree at #{worktree_path}"
|
|
237
|
+
else
|
|
238
|
+
LOG.info "Creating worktree from existing branch #{branch}"
|
|
239
|
+
run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
218
243
|
# Find an existing worktree for a card by scanning the filesystem.
|
|
219
244
|
def find_worktree_for_card(card_number, repo_path:)
|
|
220
245
|
return nil unless card_number
|