makit 0.0.111 → 0.0.112

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.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -41
  3. data/exe/makit +5 -5
  4. data/lib/makit/apache.rb +28 -28
  5. data/lib/makit/cli/build_commands.rb +500 -500
  6. data/lib/makit/cli/generators/base_generator.rb +74 -74
  7. data/lib/makit/cli/generators/dotnet_generator.rb +50 -50
  8. data/lib/makit/cli/generators/generator_factory.rb +49 -49
  9. data/lib/makit/cli/generators/node_generator.rb +50 -50
  10. data/lib/makit/cli/generators/ruby_generator.rb +77 -77
  11. data/lib/makit/cli/generators/rust_generator.rb +50 -50
  12. data/lib/makit/cli/generators/templates/dotnet_templates.rb +167 -167
  13. data/lib/makit/cli/generators/templates/node_templates.rb +161 -161
  14. data/lib/makit/cli/generators/templates/ruby/gemfile.rb +26 -26
  15. data/lib/makit/cli/generators/templates/ruby/gemspec.rb +40 -40
  16. data/lib/makit/cli/generators/templates/ruby/main_lib.rb +33 -33
  17. data/lib/makit/cli/generators/templates/ruby/rakefile.rb +35 -35
  18. data/lib/makit/cli/generators/templates/ruby/readme.rb +63 -63
  19. data/lib/makit/cli/generators/templates/ruby/test.rb +39 -39
  20. data/lib/makit/cli/generators/templates/ruby/test_helper.rb +29 -29
  21. data/lib/makit/cli/generators/templates/ruby/version.rb +29 -29
  22. data/lib/makit/cli/generators/templates/rust_templates.rb +128 -128
  23. data/lib/makit/cli/main.rb +62 -62
  24. data/lib/makit/cli/project_commands.rb +868 -868
  25. data/lib/makit/cli/repository_commands.rb +661 -661
  26. data/lib/makit/cli/utility_commands.rb +521 -521
  27. data/lib/makit/commands/factory.rb +359 -359
  28. data/lib/makit/commands/middleware/base.rb +73 -73
  29. data/lib/makit/commands/middleware/cache.rb +248 -248
  30. data/lib/makit/commands/middleware/command_logger.rb +320 -323
  31. data/lib/makit/commands/middleware/unified_logger.rb +243 -243
  32. data/lib/makit/commands/middleware/validator.rb +269 -269
  33. data/lib/makit/commands/request.rb +254 -254
  34. data/lib/makit/commands/result.rb +323 -323
  35. data/lib/makit/commands/runner.rb +337 -317
  36. data/lib/makit/commands/strategies/base.rb +160 -160
  37. data/lib/makit/commands/strategies/synchronous.rb +134 -134
  38. data/lib/makit/commands.rb +51 -42
  39. data/lib/makit/configuration/gitlab_helper.rb +60 -60
  40. data/lib/makit/configuration/project.rb +127 -127
  41. data/lib/makit/configuration/rakefile_helper.rb +43 -43
  42. data/lib/makit/configuration/step.rb +34 -34
  43. data/lib/makit/configuration.rb +14 -14
  44. data/lib/makit/content/default_gitignore.rb +7 -7
  45. data/lib/makit/content/default_rakefile.rb +13 -13
  46. data/lib/makit/content/gem_rakefile.rb +16 -16
  47. data/lib/makit/context.rb +1 -1
  48. data/lib/makit/data.rb +49 -49
  49. data/lib/makit/directories.rb +141 -141
  50. data/lib/makit/directory.rb +262 -262
  51. data/lib/makit/docs/files.rb +89 -89
  52. data/lib/makit/docs/rake.rb +102 -102
  53. data/lib/makit/dotnet/project.rb +153 -153
  54. data/lib/makit/dotnet/solution.rb +38 -38
  55. data/lib/makit/dotnet/solution_classlib.rb +239 -239
  56. data/lib/makit/dotnet/solution_console.rb +264 -264
  57. data/lib/makit/dotnet/solution_maui.rb +354 -354
  58. data/lib/makit/dotnet/solution_wasm.rb +275 -275
  59. data/lib/makit/dotnet/solution_wpf.rb +304 -304
  60. data/lib/makit/dotnet.rb +102 -102
  61. data/lib/makit/email.rb +90 -90
  62. data/lib/makit/environment.rb +142 -142
  63. data/lib/makit/examples/runner.rb +370 -370
  64. data/lib/makit/exceptions.rb +45 -45
  65. data/lib/makit/fileinfo.rb +24 -24
  66. data/lib/makit/files.rb +43 -43
  67. data/lib/makit/gems.rb +40 -40
  68. data/lib/makit/git/cli.rb +54 -54
  69. data/lib/makit/git/repository.rb +90 -90
  70. data/lib/makit/git.rb +98 -98
  71. data/lib/makit/gitlab_runner.rb +59 -59
  72. data/lib/makit/humanize.rb +137 -137
  73. data/lib/makit/indexer.rb +47 -47
  74. data/lib/makit/logging/configuration.rb +305 -305
  75. data/lib/makit/logging/formatters/base.rb +39 -39
  76. data/lib/makit/logging/formatters/console_formatter.rb +140 -127
  77. data/lib/makit/logging/formatters/json_formatter.rb +65 -65
  78. data/lib/makit/logging/formatters/plain_text_formatter.rb +71 -71
  79. data/lib/makit/logging/formatters/text_formatter.rb +64 -64
  80. data/lib/makit/logging/log_request.rb +115 -115
  81. data/lib/makit/logging/logger.rb +163 -159
  82. data/lib/makit/logging/sinks/console.rb +72 -72
  83. data/lib/makit/logging/sinks/file_sink.rb +92 -92
  84. data/lib/makit/logging/sinks/unified_file_sink.rb +303 -303
  85. data/lib/makit/logging.rb +530 -521
  86. data/lib/makit/markdown.rb +75 -75
  87. data/lib/makit/mp/basic_object_mp.rb +17 -17
  88. data/lib/makit/mp/command_mp.rb +13 -13
  89. data/lib/makit/mp/command_request.mp.rb +17 -17
  90. data/lib/makit/mp/project_mp.rb +199 -199
  91. data/lib/makit/mp/string_mp.rb +193 -348
  92. data/lib/makit/nuget.rb +74 -74
  93. data/lib/makit/port.rb +32 -32
  94. data/lib/makit/process.rb +163 -163
  95. data/lib/makit/protoc.rb +107 -107
  96. data/lib/makit/rake/cli.rb +196 -196
  97. data/lib/makit/rake.rb +25 -25
  98. data/lib/makit/ruby/cli.rb +185 -185
  99. data/lib/makit/ruby.rb +25 -25
  100. data/lib/makit/secrets.rb +51 -51
  101. data/lib/makit/serializer.rb +130 -117
  102. data/lib/makit/services/builder.rb +186 -186
  103. data/lib/makit/services/error_handler.rb +226 -226
  104. data/lib/makit/services/repository_manager.rb +229 -229
  105. data/lib/makit/services/validator.rb +112 -112
  106. data/lib/makit/setup/classlib.rb +53 -53
  107. data/lib/makit/setup/gem.rb +30 -17
  108. data/lib/makit/setup/runner.rb +45 -40
  109. data/lib/makit/setup.rb +5 -0
  110. data/lib/makit/show.rb +110 -110
  111. data/lib/makit/storage.rb +126 -126
  112. data/lib/makit/symbols.rb +170 -161
  113. data/lib/makit/task_info.rb +128 -128
  114. data/lib/makit/tasks/at_exit.rb +13 -13
  115. data/lib/makit/tasks/build.rb +19 -18
  116. data/lib/makit/tasks/clean.rb +11 -11
  117. data/lib/makit/tasks/hook_manager.rb +393 -239
  118. data/lib/makit/tasks/init.rb +47 -47
  119. data/lib/makit/tasks/integrate.rb +17 -15
  120. data/lib/makit/tasks/pull_incoming.rb +11 -12
  121. data/lib/makit/tasks/setup.rb +6 -6
  122. data/lib/makit/tasks/sync.rb +12 -11
  123. data/lib/makit/tasks/tag.rb +15 -0
  124. data/lib/makit/tasks/task_monkey_patch.rb +79 -79
  125. data/lib/makit/tasks.rb +10 -0
  126. data/lib/makit/test_cache.rb +239 -239
  127. data/lib/makit/tree.rb +37 -37
  128. data/lib/makit/v1/makit.v1_pb.rb +34 -34
  129. data/lib/makit/v1/makit.v1_services_pb.rb +27 -27
  130. data/lib/makit/version.rb +5 -5
  131. data/lib/makit/version_util.rb +21 -0
  132. data/lib/makit/wix.rb +95 -95
  133. data/lib/makit/yaml.rb +29 -29
  134. data/lib/makit/zip.rb +17 -17
  135. data/lib/makit copy.rb +44 -0
  136. data/lib/makit.rb +40 -8
  137. metadata +50 -7
  138. data/lib/makit/command_runner.rb +0 -463
  139. data/lib/makit/commands/compatibility.rb +0 -365
  140. data/lib/makit/task_hooks.rb +0 -125
@@ -1,661 +1,661 @@
1
- # frozen_string_literal: true
2
-
3
- require "clamp"
4
- require "json"
5
- require "uri"
6
- require "time"
7
- require "find"
8
-
9
- module Makit
10
- module Cli
11
- # Consolidated repository management commands
12
- # Combines: add, clone, pull, list, remove, import functionality
13
- class RepositoryCommand < Clamp::Command
14
- self.description = <<~DESC
15
- Manage git repositories tracked by Makit.
16
-
17
- Available operations:
18
- add - Add repository URL to tracking list
19
- clone - Clone repository to local directory#{" "}
20
- pull - Pull latest changes from repository
21
- list - List tracked repositories
22
- remove - Remove tracked repositories by pattern
23
- import - Import repositories from filesystem
24
- DESC
25
- end
26
-
27
- # Add repository to tracking list
28
- class AddRepositoryCommand < Clamp::Command
29
- self.description = <<~DESC
30
- Add git repository URLs to the tracked list.
31
-
32
- Examples:
33
- makit repository add https://github.com/user/repo.git
34
- makit repository add git@gitlab.com:group/project.git
35
- DESC
36
-
37
- parameter "GIT_URL", "The git repository URL to track", attribute_name: :git_url, required: true
38
-
39
- def execute
40
- validate_git_url(git_url)
41
-
42
- if add_to_tracked_list(git_url)
43
- puts "✅ Successfully added repository to tracked list: #{git_url}"
44
- else
45
- puts "ℹ️ Repository already tracked: #{git_url}"
46
- end
47
- rescue ArgumentError => e
48
- warn "❌ Invalid repository URL: #{e.message}"
49
- exit 1
50
- rescue StandardError => e
51
- warn "❌ Failed to add repository: #{e.message}"
52
- exit 1
53
- end
54
-
55
- private
56
-
57
- def validate_git_url(url)
58
- raise ArgumentError, "URL cannot be empty" if url.nil? || url.strip.empty?
59
-
60
- url = url.strip
61
- valid_patterns = [
62
- %r{^https?://\w+[\w.-]*\w+/[\w.-]+/[\w.-]+(?:\.git)?/?$}, # HTTPS
63
- %r{^git@[\w.-]+:[\w.-]+/[\w.-]+(?:\.git)?$}, # SSH
64
- %r{^ssh://git@[\w.-]+/[\w.-]+/[\w.-]+(?:\.git)?$}, # SSH with ssh://
65
- %r{^[\w.-]+@[\w.-]+:[\w.-]+/[\w.-]+(?:\.git)?$}, # Generic SSH
66
- ]
67
-
68
- unless valid_patterns.any? { |pattern| url.match?(pattern) }
69
- raise ArgumentError, "URL does not match expected git repository format"
70
- end
71
-
72
- return unless url.include?("github.com") || url.include?("gitlab.com") || url.include?("bitbucket.org")
73
-
74
- validate_hosted_repository_url(url)
75
- end
76
-
77
- def validate_hosted_repository_url(url)
78
- if url.start_with?("http")
79
- path = URI.parse(url).path.sub(%r{^/}, "").sub(/\.git$/, "").sub(%r{/$}, "")
80
- elsif url.include?("@")
81
- path = url.split(":").last.sub(/\.git$/, "")
82
- end
83
-
84
- parts = path.split("/")
85
- raise ArgumentError, "Repository path should be in format 'user/repository'" if parts.length < 2
86
-
87
- parts.each do |part|
88
- if part.empty? || part.match?(/[<>:"|?*]/)
89
- raise ArgumentError, "Invalid characters in repository path: #{part}"
90
- end
91
- end
92
- end
93
-
94
- def add_to_tracked_list(url)
95
- tracked_repos = load_tracked_repositories
96
- return false if tracked_repos.any? { |repo| repo["url"] == url }
97
-
98
- new_repo = {
99
- "url" => url,
100
- "added_at" => Time.now.iso8601,
101
- "status" => "tracked",
102
- }
103
-
104
- tracked_repos << new_repo
105
- save_tracked_repositories(tracked_repos)
106
- true
107
- end
108
-
109
- def load_tracked_repositories
110
- tracked_file = tracked_repositories_file
111
- return [] unless File.exist?(tracked_file)
112
-
113
- begin
114
- JSON.parse(File.read(tracked_file))
115
- rescue JSON::ParserError => e
116
- warn "Warning: Could not parse tracked repositories file: #{e.message}"
117
- []
118
- end
119
- end
120
-
121
- def save_tracked_repositories(repositories)
122
- tracked_file = tracked_repositories_file
123
- FileUtils.mkdir_p(File.dirname(tracked_file))
124
- sorted_repos = repositories.sort_by { |repo| repo["url"] }
125
- File.write(tracked_file, JSON.pretty_generate(sorted_repos))
126
- end
127
-
128
- def tracked_repositories_file
129
- File.join(Makit::Directories::ROOT, "tracked_repositories.json")
130
- end
131
- end
132
-
133
- # Clone repository to local directory
134
- class CloneRepositoryCommand < Clamp::Command
135
- self.description = <<~DESC
136
- Clone git repositories to local directories.
137
-
138
- Examples:
139
- makit repository clone https://github.com/user/repo
140
- makit repository clone git@github.com:user/repo.git
141
- DESC
142
-
143
- parameter "GIT_REPOSITORY", "The git repository URL", attribute_name: :git_repository, required: true
144
-
145
- def execute
146
- clone_dir = Makit::Directories.get_clone_directory(git_repository)
147
-
148
- if Dir.exist?(clone_dir)
149
- puts "Repository already exists at: #{clone_dir}"
150
- return
151
- end
152
-
153
- puts "Cloning repository: #{git_repository} to #{clone_dir}"
154
-
155
- begin
156
- commands = Makit.clone(git_repository)
157
-
158
- if commands.any? && commands.last.exit_code != 0
159
- handle_clone_error(commands.last, clone_dir)
160
- elsif !Dir.exist?(clone_dir)
161
- warn "Clone operation completed but directory not found: #{clone_dir}"
162
- exit 1
163
- else
164
- puts "Successfully cloned repository to: #{clone_dir}"
165
- end
166
- rescue ArgumentError => e
167
- warn "Invalid repository URL: #{e.message}"
168
- exit 1
169
- rescue StandardError => e
170
- warn "Failed to clone repository: #{git_repository}"
171
- puts e.message
172
- exit 1
173
- end
174
- end
175
-
176
- private
177
-
178
- def handle_clone_error(clone_command, clone_dir)
179
- summary = "Failed to clone repository: #{git_repository} to #{clone_dir}\n"
180
- summary += "Please check the URL and your network connection.\n"
181
- summary += "Exit code: #{clone_command.exit_code}\n"
182
- summary += "Output: #{clone_command.output}" if clone_command.respond_to?(:output)
183
- warn summary
184
- exit 1
185
- end
186
- end
187
-
188
- # Pull latest changes from repository
189
- class PullRepositoryCommand < Clamp::Command
190
- self.description = <<~DESC
191
- Pull latest changes from git repositories.
192
-
193
- Examples:
194
- makit repository pull https://github.com/user/repo
195
- DESC
196
-
197
- parameter "GIT_REPOSITORY", "The git repository URL", attribute_name: :git_repository, required: true
198
-
199
- def execute
200
- commands = Makit.pull(git_repository)
201
-
202
- if commands.any? && commands.last.exit_code.zero?
203
- puts "Successfully pulled latest changes from: #{git_repository}"
204
- else
205
- warn "Failed to pull changes from: #{git_repository}"
206
- exit 1
207
- end
208
- rescue ArgumentError => e
209
- warn "Invalid repository URL: #{e.message}"
210
- exit 1
211
- rescue StandardError => e
212
- warn "Failed to pull repository: #{git_repository}"
213
- puts e.message
214
- exit 1
215
- end
216
- end
217
-
218
- # List tracked repositories
219
- class ListRepositoryCommand < Clamp::Command
220
- self.description = <<~DESC
221
- List git repositories tracked by Makit.
222
-
223
- Examples:
224
- makit repository list
225
- makit repository list --details
226
- makit repository list --tracked
227
- DESC
228
-
229
- option ["--details"], :flag, "Show detailed information about each repository"
230
- option ["--paths"], :flag, "Show local paths for each repository"
231
- option ["--tracked"], :flag, "Show tracked repository URLs (not cloned)"
232
- option ["--all"], :flag, "Show both cloned and tracked repositories"
233
- option ["--format"], "FORMAT", "Output format (table, json, plain)", default: "table"
234
-
235
- def execute
236
- repositories = discover_repositories
237
-
238
- if repositories.empty?
239
- puts "No git repositories found."
240
- puts "Use 'makit repository clone <url>' to clone repositories."
241
- return
242
- end
243
-
244
- case format
245
- when "json"
246
- output_json(repositories)
247
- when "plain"
248
- output_plain(repositories)
249
- else
250
- output_table(repositories)
251
- end
252
- end
253
-
254
- private
255
-
256
- def discover_repositories
257
- repositories = []
258
-
259
- if tracked? && !all?
260
- repositories = load_tracked_repositories
261
- elsif all?
262
- repositories = discover_cloned_repositories
263
- tracked_repos = load_tracked_repositories
264
- tracked_repos.each do |tracked_repo|
265
- unless repositories.any? { |repo| repo[:url] == tracked_repo[:url] }
266
- repositories << tracked_repo.merge(type: "tracked")
267
- end
268
- end
269
- else
270
- repositories = discover_cloned_repositories
271
- end
272
-
273
- repositories.sort_by { |repo| repo[:url] || repo[:name] }
274
- end
275
-
276
- def discover_cloned_repositories
277
- repositories = []
278
- clone_base_dir = Makit::Directories::CLONE
279
- return repositories unless Dir.exist?(clone_base_dir)
280
-
281
- Find.find(clone_base_dir) do |path|
282
- next unless File.directory?(path) && File.basename(path) == ".git"
283
-
284
- repo_dir = File.dirname(path)
285
- repo_info = extract_repository_info(repo_dir)
286
- if repo_info
287
- repo_info[:type] = "cloned"
288
- repositories << repo_info
289
- end
290
- Find.prune
291
- end
292
-
293
- repositories
294
- end
295
-
296
- def load_tracked_repositories
297
- tracked_file = File.join(Makit::Directories::ROOT, "tracked_repositories.json")
298
- return [] unless File.exist?(tracked_file)
299
-
300
- begin
301
- tracked_repos = JSON.parse(File.read(tracked_file))
302
- tracked_repos.map do |repo|
303
- {
304
- name: extract_repo_name_from_url(repo["url"]),
305
- url: repo["url"],
306
- added_at: repo["added_at"],
307
- status: repo["status"],
308
- type: "tracked",
309
- }
310
- end
311
- rescue JSON::ParserError => e
312
- warn "Warning: Could not parse tracked repositories file: #{e.message}"
313
- []
314
- end
315
- end
316
-
317
- def extract_repo_name_from_url(url)
318
- if url.include?("/")
319
- url.split("/").last.sub(/\.git$/, "")
320
- else
321
- url
322
- end
323
- end
324
-
325
- def extract_repository_info(repo_dir)
326
- return nil unless Dir.exist?(repo_dir)
327
-
328
- info = {
329
- name: File.basename(repo_dir),
330
- path: repo_dir,
331
- relative_path: repo_dir.sub("#{Makit::Directories::CLONE}/", ""),
332
- }
333
-
334
- begin
335
- Dir.chdir(repo_dir) do
336
- remote_output = `git remote get-url origin 2>/dev/null`.strip
337
- info[:url] = remote_output unless remote_output.empty?
338
-
339
- if details?
340
- info[:branch] = `git branch --show-current 2>/dev/null`.strip
341
- info[:last_commit] = `git log -1 --format="%H %s" 2>/dev/null`.strip
342
- info[:last_commit_date] = `git log -1 --format="%ci" 2>/dev/null`.strip
343
- info[:status] = `git status --porcelain 2>/dev/null`.strip
344
- info[:clean] = info[:status].empty?
345
- end
346
- end
347
- rescue StandardError => e
348
- info[:error] = "Cannot read git info: #{e.message}"
349
- end
350
-
351
- info
352
- end
353
-
354
- def output_table(repositories)
355
- if details?
356
- output_detailed_table(repositories)
357
- else
358
- output_simple_table(repositories)
359
- end
360
- end
361
-
362
- def output_simple_table(repositories)
363
- puts "Tracked Git Repositories (#{repositories.length}):"
364
- puts "=" * 80
365
-
366
- repositories.each_with_index do |repo, index|
367
- type_indicator = repo[:type] == "tracked" ? "📋" : "📁"
368
- puts "#{(index + 1).to_s.rjust(3)}. #{type_indicator} #{repo[:url] || repo[:name]}"
369
- puts " Path: #{repo[:path] || "Not cloned"}" if paths?
370
- puts " Type: #{repo[:type]&.capitalize || "Unknown"}" if all? || tracked?
371
- puts
372
- end
373
- end
374
-
375
- def output_detailed_table(repositories)
376
- puts "Tracked Git Repositories (#{repositories.length}):"
377
- puts "=" * 120
378
-
379
- repositories.each_with_index do |repo, index|
380
- type_indicator = repo[:type] == "tracked" ? "📋" : "📁"
381
- puts "#{(index + 1).to_s.rjust(3)}. #{type_indicator} #{repo[:url] || repo[:name]}"
382
- puts " Type: #{repo[:type]&.capitalize || "Unknown"}"
383
- puts " Path: #{repo[:path] || "Not cloned"}" if repo[:type] != "tracked" || all?
384
- puts " Branch: #{repo[:branch]}" if repo[:branch] && !repo[:branch].empty?
385
- puts " Status: #{repo[:clean] ? "Clean" : "Modified"}" if repo[:type] == "cloned"
386
-
387
- if repo[:last_commit] && !repo[:last_commit].empty?
388
- commit_parts = repo[:last_commit].split(" ", 2)
389
- commit_hash = commit_parts[0][0..7] if commit_parts[0]
390
- commit_msg = commit_parts[1] if commit_parts[1]
391
- puts " Commit: #{commit_hash} - #{commit_msg}"
392
- end
393
-
394
- puts " Date: #{repo[:last_commit_date]}" if repo[:last_commit_date] && !repo[:last_commit_date].empty?
395
- puts " Added: #{repo[:added_at]}" if repo[:added_at]
396
- puts " Error: #{repo[:error]}" if repo[:error]
397
- puts
398
- end
399
- end
400
-
401
- def output_json(repositories)
402
- puts JSON.pretty_generate({
403
- count: repositories.length,
404
- repositories: repositories,
405
- })
406
- end
407
-
408
- def output_plain(repositories)
409
- repositories.each do |repo|
410
- if paths?
411
- puts "#{repo[:url] || repo[:name]} -> #{repo[:path]}"
412
- else
413
- puts repo[:url] || repo[:name]
414
- end
415
- end
416
- end
417
- end
418
-
419
- # Remove tracked repositories by pattern
420
- class RemoveRepositoryCommand < Clamp::Command
421
- self.description = <<~DESC
422
- Remove tracked repositories using pattern matching.
423
-
424
- Examples:
425
- makit repository remove github.com/user/
426
- makit repository remove clamp.git
427
- makit repository remove --missing-rakefile
428
- DESC
429
-
430
- parameter "PATTERN", "Pattern to match repository URLs", attribute_name: :pattern, required: false
431
- option ["--missing-rakefile"], :flag, "Remove repositories without Rakefile"
432
- option ["--dry-run"], :flag, "Show what would be removed without actually removing"
433
- option ["--verbose"], :flag, "Show verbose output"
434
-
435
- def execute
436
- if missing_rakefile?
437
- remove_missing_rakefile_repos
438
- elsif pattern
439
- remove_by_pattern(pattern)
440
- else
441
- puts "Error: Either provide a PATTERN or use --missing-rakefile"
442
- exit 1
443
- end
444
- end
445
-
446
- private
447
-
448
- def remove_by_pattern(pattern)
449
- tracked_repos = load_tracked_repositories
450
- matching_repos = tracked_repos.select { |repo| repo["url"].include?(pattern) }
451
-
452
- if matching_repos.empty?
453
- puts "No repositories found matching pattern: #{pattern}"
454
- return
455
- end
456
-
457
- puts "Found #{matching_repos.length} repositories matching pattern: #{pattern}"
458
- matching_repos.each { |repo| puts " - #{repo["url"]}" }
459
-
460
- return if dry_run?
461
-
462
- remaining_repos = tracked_repos.reject { |repo| repo["url"].include?(pattern) }
463
- save_tracked_repositories(remaining_repos)
464
- puts "✅ Removed #{matching_repos.length} repositories"
465
- end
466
-
467
- def remove_missing_rakefile_repos
468
- tracked_repos = load_tracked_repositories
469
- repos_to_remove = []
470
-
471
- tracked_repos.each do |repo|
472
- clone_dir = Makit::Directories.get_clone_directory(repo["url"])
473
- rakefile_path = File.join(clone_dir, "Rakefile")
474
-
475
- if Dir.exist?(clone_dir) && !File.exist?(rakefile_path)
476
- repos_to_remove << repo
477
- puts " - #{repo["url"]} (no Rakefile)" if verbose?
478
- end
479
- end
480
-
481
- if repos_to_remove.empty?
482
- puts "No repositories found without Rakefile"
483
- return
484
- end
485
-
486
- puts "Found #{repos_to_remove.length} repositories without Rakefile"
487
- return if dry_run?
488
-
489
- remaining_repos = tracked_repos - repos_to_remove
490
- save_tracked_repositories(remaining_repos)
491
- puts "✅ Removed #{repos_to_remove.length} repositories without Rakefile"
492
- end
493
-
494
- def load_tracked_repositories
495
- tracked_file = File.join(Makit::Directories::ROOT, "tracked_repositories.json")
496
- return [] unless File.exist?(tracked_file)
497
-
498
- begin
499
- JSON.parse(File.read(tracked_file))
500
- rescue JSON::ParserError => e
501
- warn "Warning: Could not parse tracked repositories file: #{e.message}"
502
- []
503
- end
504
- end
505
-
506
- def save_tracked_repositories(repositories)
507
- tracked_file = File.join(Makit::Directories::ROOT, "tracked_repositories.json")
508
- FileUtils.mkdir_p(File.dirname(tracked_file))
509
- sorted_repos = repositories.sort_by { |repo| repo["url"] }
510
- File.write(tracked_file, JSON.pretty_generate(sorted_repos))
511
- end
512
- end
513
-
514
- # Import repositories from filesystem
515
- class ImportRepositoryCommand < Clamp::Command
516
- self.description = <<~DESC
517
- Import git repositories from filesystem by discovery.
518
-
519
- Examples:
520
- makit repository import
521
- makit repository import --directory ~/code
522
- makit repository import --skip-rakefile-check
523
- DESC
524
-
525
- option ["--directory"], "DIR", "Directory to search for repositories", default: File.join(Dir.home, "code")
526
- option ["--max-depth"], "DEPTH", "Maximum search depth", default: 3, &method(:Integer)
527
- option ["--skip-rakefile-check"], :flag, "Import all repositories, not just those with Rakefile"
528
- option ["--dry-run"], :flag, "Show what would be imported without actually importing"
529
- option ["--verbose"], :flag, "Show verbose output"
530
-
531
- def execute
532
- search_dir = directory
533
- unless Dir.exist?(search_dir)
534
- puts "Directory does not exist: #{search_dir}"
535
- exit 1
536
- end
537
-
538
- puts "Searching for git repositories in: #{search_dir}"
539
- puts "Max depth: #{max_depth}"
540
- puts "Rakefile required: #{!skip_rakefile_check?}"
541
-
542
- repositories = discover_repositories(search_dir)
543
-
544
- if repositories.empty?
545
- puts "No repositories found"
546
- return
547
- end
548
-
549
- puts "\nFound #{repositories.length} repositories:"
550
- repositories.each { |repo| puts " - #{repo[:url] || repo[:path]}" }
551
-
552
- return if dry_run?
553
-
554
- import_repositories(repositories)
555
- end
556
-
557
- private
558
-
559
- def discover_repositories(base_dir)
560
- repositories = []
561
-
562
- Find.find(base_dir) do |path|
563
- next unless File.directory?(path) && File.basename(path) == ".git"
564
-
565
- repo_dir = File.dirname(path)
566
-
567
- # Check depth
568
- relative_path = repo_dir.sub("#{base_dir}/", "")
569
- depth = relative_path.split("/").length
570
-
571
- if depth <= max_depth
572
- repo_info = extract_repository_info(repo_dir)
573
- repositories << repo_info if repo_info && should_import?(repo_dir)
574
- end
575
-
576
- Find.prune
577
- end
578
-
579
- repositories
580
- end
581
-
582
- def should_import?(repo_dir)
583
- return true if skip_rakefile_check?
584
-
585
- File.exist?(File.join(repo_dir, "Rakefile"))
586
- end
587
-
588
- def extract_repository_info(repo_dir)
589
- return nil unless Dir.exist?(repo_dir)
590
-
591
- info = { path: repo_dir }
592
-
593
- begin
594
- Dir.chdir(repo_dir) do
595
- remote_output = `git remote get-url origin 2>/dev/null`.strip
596
- info[:url] = remote_output unless remote_output.empty?
597
- end
598
- rescue StandardError
599
- # If we can't get git info, skip this repo
600
- return nil
601
- end
602
-
603
- info
604
- end
605
-
606
- def import_repositories(repositories)
607
- tracked_repos = load_tracked_repositories
608
- imported_count = 0
609
-
610
- repositories.each do |repo|
611
- next unless repo[:url]
612
- next if tracked_repos.any? { |tracked| tracked["url"] == repo[:url] }
613
-
614
- new_repo = {
615
- "url" => repo[:url],
616
- "added_at" => Time.now.iso8601,
617
- "status" => "imported",
618
- }
619
-
620
- tracked_repos << new_repo
621
- imported_count += 1
622
- puts " ✅ Imported: #{repo[:url]}" if verbose?
623
- end
624
-
625
- if imported_count.positive?
626
- save_tracked_repositories(tracked_repos)
627
- puts "✅ Successfully imported #{imported_count} repositories"
628
- else
629
- puts "No new repositories to import"
630
- end
631
- end
632
-
633
- def load_tracked_repositories
634
- tracked_file = File.join(Makit::Directories::ROOT, "tracked_repositories.json")
635
- return [] unless File.exist?(tracked_file)
636
-
637
- begin
638
- JSON.parse(File.read(tracked_file))
639
- rescue JSON::ParserError => e
640
- warn "Warning: Could not parse tracked repositories file: #{e.message}"
641
- []
642
- end
643
- end
644
-
645
- def save_tracked_repositories(repositories)
646
- tracked_file = File.join(Makit::Directories::ROOT, "tracked_repositories.json")
647
- FileUtils.mkdir_p(File.dirname(tracked_file))
648
- sorted_repos = repositories.sort_by { |repo| repo["url"] }
649
- File.write(tracked_file, JSON.pretty_generate(sorted_repos))
650
- end
651
- end
652
-
653
- # Add subcommand declarations after all classes are defined
654
- RepositoryCommand.subcommand "add", "Add repository to tracking list", AddRepositoryCommand
655
- RepositoryCommand.subcommand "clone", "Clone repository to local directory", CloneRepositoryCommand
656
- RepositoryCommand.subcommand "pull", "Pull latest changes", PullRepositoryCommand
657
- RepositoryCommand.subcommand "list", "List tracked repositories", ListRepositoryCommand
658
- RepositoryCommand.subcommand "remove", "Remove tracked repositories", RemoveRepositoryCommand
659
- RepositoryCommand.subcommand "import", "Import repositories from filesystem", ImportRepositoryCommand
660
- end
661
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "clamp"
4
+ require "json"
5
+ require "uri"
6
+ require "time"
7
+ require "find"
8
+
9
+ module Makit
10
+ module Cli
11
+ # Consolidated repository management commands
12
+ # Combines: add, clone, pull, list, remove, import functionality
13
+ class RepositoryCommand < Clamp::Command
14
+ self.description = <<~DESC
15
+ Manage git repositories tracked by Makit.
16
+
17
+ Available operations:
18
+ add - Add repository URL to tracking list
19
+ clone - Clone repository to local directory#{" "}
20
+ pull - Pull latest changes from repository
21
+ list - List tracked repositories
22
+ remove - Remove tracked repositories by pattern
23
+ import - Import repositories from filesystem
24
+ DESC
25
+ end
26
+
27
+ # Add repository to tracking list
28
+ class AddRepositoryCommand < Clamp::Command
29
+ self.description = <<~DESC
30
+ Add git repository URLs to the tracked list.
31
+
32
+ Examples:
33
+ makit repository add https://github.com/user/repo.git
34
+ makit repository add git@gitlab.com:group/project.git
35
+ DESC
36
+
37
+ parameter "GIT_URL", "The git repository URL to track", attribute_name: :git_url, required: true
38
+
39
+ def execute
40
+ validate_git_url(git_url)
41
+
42
+ if add_to_tracked_list(git_url)
43
+ puts "✅ Successfully added repository to tracked list: #{git_url}"
44
+ else
45
+ puts "ℹ️ Repository already tracked: #{git_url}"
46
+ end
47
+ rescue ArgumentError => e
48
+ warn "❌ Invalid repository URL: #{e.message}"
49
+ exit 1
50
+ rescue StandardError => e
51
+ warn "❌ Failed to add repository: #{e.message}"
52
+ exit 1
53
+ end
54
+
55
+ private
56
+
57
+ def validate_git_url(url)
58
+ raise ArgumentError, "URL cannot be empty" if url.nil? || url.strip.empty?
59
+
60
+ url = url.strip
61
+ valid_patterns = [
62
+ %r{^https?://\w+[\w.-]*\w+/[\w.-]+/[\w.-]+(?:\.git)?/?$}, # HTTPS
63
+ %r{^git@[\w.-]+:[\w.-]+/[\w.-]+(?:\.git)?$}, # SSH
64
+ %r{^ssh://git@[\w.-]+/[\w.-]+/[\w.-]+(?:\.git)?$}, # SSH with ssh://
65
+ %r{^[\w.-]+@[\w.-]+:[\w.-]+/[\w.-]+(?:\.git)?$}, # Generic SSH
66
+ ]
67
+
68
+ unless valid_patterns.any? { |pattern| url.match?(pattern) }
69
+ raise ArgumentError, "URL does not match expected git repository format"
70
+ end
71
+
72
+ return unless url.include?("github.com") || url.include?("gitlab.com") || url.include?("bitbucket.org")
73
+
74
+ validate_hosted_repository_url(url)
75
+ end
76
+
77
+ def validate_hosted_repository_url(url)
78
+ if url.start_with?("http")
79
+ path = URI.parse(url).path.sub(%r{^/}, "").sub(/\.git$/, "").sub(%r{/$}, "")
80
+ elsif url.include?("@")
81
+ path = url.split(":").last.sub(/\.git$/, "")
82
+ end
83
+
84
+ parts = path.split("/")
85
+ raise ArgumentError, "Repository path should be in format 'user/repository'" if parts.length < 2
86
+
87
+ parts.each do |part|
88
+ if part.empty? || part.match?(/[<>:"|?*]/)
89
+ raise ArgumentError, "Invalid characters in repository path: #{part}"
90
+ end
91
+ end
92
+ end
93
+
94
+ def add_to_tracked_list(url)
95
+ tracked_repos = load_tracked_repositories
96
+ return false if tracked_repos.any? { |repo| repo["url"] == url }
97
+
98
+ new_repo = {
99
+ "url" => url,
100
+ "added_at" => Time.now.iso8601,
101
+ "status" => "tracked",
102
+ }
103
+
104
+ tracked_repos << new_repo
105
+ save_tracked_repositories(tracked_repos)
106
+ true
107
+ end
108
+
109
+ def load_tracked_repositories
110
+ tracked_file = tracked_repositories_file
111
+ return [] unless File.exist?(tracked_file)
112
+
113
+ begin
114
+ JSON.parse(File.read(tracked_file))
115
+ rescue JSON::ParserError => e
116
+ warn "Warning: Could not parse tracked repositories file: #{e.message}"
117
+ []
118
+ end
119
+ end
120
+
121
+ def save_tracked_repositories(repositories)
122
+ tracked_file = tracked_repositories_file
123
+ FileUtils.mkdir_p(File.dirname(tracked_file))
124
+ sorted_repos = repositories.sort_by { |repo| repo["url"] }
125
+ File.write(tracked_file, JSON.pretty_generate(sorted_repos))
126
+ end
127
+
128
+ def tracked_repositories_file
129
+ File.join(Makit::Directories::ROOT, "tracked_repositories.json")
130
+ end
131
+ end
132
+
133
+ # Clone repository to local directory
134
+ class CloneRepositoryCommand < Clamp::Command
135
+ self.description = <<~DESC
136
+ Clone git repositories to local directories.
137
+
138
+ Examples:
139
+ makit repository clone https://github.com/user/repo
140
+ makit repository clone git@github.com:user/repo.git
141
+ DESC
142
+
143
+ parameter "GIT_REPOSITORY", "The git repository URL", attribute_name: :git_repository, required: true
144
+
145
+ def execute
146
+ clone_dir = Makit::Directories.get_clone_directory(git_repository)
147
+
148
+ if Dir.exist?(clone_dir)
149
+ puts "Repository already exists at: #{clone_dir}"
150
+ return
151
+ end
152
+
153
+ puts "Cloning repository: #{git_repository} to #{clone_dir}"
154
+
155
+ begin
156
+ commands = Makit.clone(git_repository)
157
+
158
+ if commands.any? && commands.last.exit_code != 0
159
+ handle_clone_error(commands.last, clone_dir)
160
+ elsif !Dir.exist?(clone_dir)
161
+ warn "Clone operation completed but directory not found: #{clone_dir}"
162
+ exit 1
163
+ else
164
+ puts "Successfully cloned repository to: #{clone_dir}"
165
+ end
166
+ rescue ArgumentError => e
167
+ warn "Invalid repository URL: #{e.message}"
168
+ exit 1
169
+ rescue StandardError => e
170
+ warn "Failed to clone repository: #{git_repository}"
171
+ puts e.message
172
+ exit 1
173
+ end
174
+ end
175
+
176
+ private
177
+
178
+ def handle_clone_error(clone_command, clone_dir)
179
+ summary = "Failed to clone repository: #{git_repository} to #{clone_dir}\n"
180
+ summary += "Please check the URL and your network connection.\n"
181
+ summary += "Exit code: #{clone_command.exit_code}\n"
182
+ summary += "Output: #{clone_command.output}" if clone_command.respond_to?(:output)
183
+ warn summary
184
+ exit 1
185
+ end
186
+ end
187
+
188
+ # Pull latest changes from repository
189
+ class PullRepositoryCommand < Clamp::Command
190
+ self.description = <<~DESC
191
+ Pull latest changes from git repositories.
192
+
193
+ Examples:
194
+ makit repository pull https://github.com/user/repo
195
+ DESC
196
+
197
+ parameter "GIT_REPOSITORY", "The git repository URL", attribute_name: :git_repository, required: true
198
+
199
+ def execute
200
+ commands = Makit.pull(git_repository)
201
+
202
+ if commands.any? && commands.last.exit_code.zero?
203
+ puts "Successfully pulled latest changes from: #{git_repository}"
204
+ else
205
+ warn "Failed to pull changes from: #{git_repository}"
206
+ exit 1
207
+ end
208
+ rescue ArgumentError => e
209
+ warn "Invalid repository URL: #{e.message}"
210
+ exit 1
211
+ rescue StandardError => e
212
+ warn "Failed to pull repository: #{git_repository}"
213
+ puts e.message
214
+ exit 1
215
+ end
216
+ end
217
+
218
+ # List tracked repositories
219
+ class ListRepositoryCommand < Clamp::Command
220
+ self.description = <<~DESC
221
+ List git repositories tracked by Makit.
222
+
223
+ Examples:
224
+ makit repository list
225
+ makit repository list --details
226
+ makit repository list --tracked
227
+ DESC
228
+
229
+ option ["--details"], :flag, "Show detailed information about each repository"
230
+ option ["--paths"], :flag, "Show local paths for each repository"
231
+ option ["--tracked"], :flag, "Show tracked repository URLs (not cloned)"
232
+ option ["--all"], :flag, "Show both cloned and tracked repositories"
233
+ option ["--format"], "FORMAT", "Output format (table, json, plain)", default: "table"
234
+
235
+ def execute
236
+ repositories = discover_repositories
237
+
238
+ if repositories.empty?
239
+ puts "No git repositories found."
240
+ puts "Use 'makit repository clone <url>' to clone repositories."
241
+ return
242
+ end
243
+
244
+ case format
245
+ when "json"
246
+ output_json(repositories)
247
+ when "plain"
248
+ output_plain(repositories)
249
+ else
250
+ output_table(repositories)
251
+ end
252
+ end
253
+
254
+ private
255
+
256
+ def discover_repositories
257
+ repositories = []
258
+
259
+ if tracked? && !all?
260
+ repositories = load_tracked_repositories
261
+ elsif all?
262
+ repositories = discover_cloned_repositories
263
+ tracked_repos = load_tracked_repositories
264
+ tracked_repos.each do |tracked_repo|
265
+ unless repositories.any? { |repo| repo[:url] == tracked_repo[:url] }
266
+ repositories << tracked_repo.merge(type: "tracked")
267
+ end
268
+ end
269
+ else
270
+ repositories = discover_cloned_repositories
271
+ end
272
+
273
+ repositories.sort_by { |repo| repo[:url] || repo[:name] }
274
+ end
275
+
276
+ def discover_cloned_repositories
277
+ repositories = []
278
+ clone_base_dir = Makit::Directories::CLONE
279
+ return repositories unless Dir.exist?(clone_base_dir)
280
+
281
+ Find.find(clone_base_dir) do |path|
282
+ next unless File.directory?(path) && File.basename(path) == ".git"
283
+
284
+ repo_dir = File.dirname(path)
285
+ repo_info = extract_repository_info(repo_dir)
286
+ if repo_info
287
+ repo_info[:type] = "cloned"
288
+ repositories << repo_info
289
+ end
290
+ Find.prune
291
+ end
292
+
293
+ repositories
294
+ end
295
+
296
+ def load_tracked_repositories
297
+ tracked_file = File.join(Makit::Directories::ROOT, "tracked_repositories.json")
298
+ return [] unless File.exist?(tracked_file)
299
+
300
+ begin
301
+ tracked_repos = JSON.parse(File.read(tracked_file))
302
+ tracked_repos.map do |repo|
303
+ {
304
+ name: extract_repo_name_from_url(repo["url"]),
305
+ url: repo["url"],
306
+ added_at: repo["added_at"],
307
+ status: repo["status"],
308
+ type: "tracked",
309
+ }
310
+ end
311
+ rescue JSON::ParserError => e
312
+ warn "Warning: Could not parse tracked repositories file: #{e.message}"
313
+ []
314
+ end
315
+ end
316
+
317
+ def extract_repo_name_from_url(url)
318
+ if url.include?("/")
319
+ url.split("/").last.sub(/\.git$/, "")
320
+ else
321
+ url
322
+ end
323
+ end
324
+
325
+ def extract_repository_info(repo_dir)
326
+ return nil unless Dir.exist?(repo_dir)
327
+
328
+ info = {
329
+ name: File.basename(repo_dir),
330
+ path: repo_dir,
331
+ relative_path: repo_dir.sub("#{Makit::Directories::CLONE}/", ""),
332
+ }
333
+
334
+ begin
335
+ Dir.chdir(repo_dir) do
336
+ remote_output = `git remote get-url origin 2>/dev/null`.strip
337
+ info[:url] = remote_output unless remote_output.empty?
338
+
339
+ if details?
340
+ info[:branch] = `git branch --show-current 2>/dev/null`.strip
341
+ info[:last_commit] = `git log -1 --format="%H %s" 2>/dev/null`.strip
342
+ info[:last_commit_date] = `git log -1 --format="%ci" 2>/dev/null`.strip
343
+ info[:status] = `git status --porcelain 2>/dev/null`.strip
344
+ info[:clean] = info[:status].empty?
345
+ end
346
+ end
347
+ rescue StandardError => e
348
+ info[:error] = "Cannot read git info: #{e.message}"
349
+ end
350
+
351
+ info
352
+ end
353
+
354
+ def output_table(repositories)
355
+ if details?
356
+ output_detailed_table(repositories)
357
+ else
358
+ output_simple_table(repositories)
359
+ end
360
+ end
361
+
362
+ def output_simple_table(repositories)
363
+ puts "Tracked Git Repositories (#{repositories.length}):"
364
+ puts "=" * 80
365
+
366
+ repositories.each_with_index do |repo, index|
367
+ type_indicator = repo[:type] == "tracked" ? "📋" : "📁"
368
+ puts "#{(index + 1).to_s.rjust(3)}. #{type_indicator} #{repo[:url] || repo[:name]}"
369
+ puts " Path: #{repo[:path] || "Not cloned"}" if paths?
370
+ puts " Type: #{repo[:type]&.capitalize || "Unknown"}" if all? || tracked?
371
+ puts
372
+ end
373
+ end
374
+
375
+ def output_detailed_table(repositories)
376
+ puts "Tracked Git Repositories (#{repositories.length}):"
377
+ puts "=" * 120
378
+
379
+ repositories.each_with_index do |repo, index|
380
+ type_indicator = repo[:type] == "tracked" ? "📋" : "📁"
381
+ puts "#{(index + 1).to_s.rjust(3)}. #{type_indicator} #{repo[:url] || repo[:name]}"
382
+ puts " Type: #{repo[:type]&.capitalize || "Unknown"}"
383
+ puts " Path: #{repo[:path] || "Not cloned"}" if repo[:type] != "tracked" || all?
384
+ puts " Branch: #{repo[:branch]}" if repo[:branch] && !repo[:branch].empty?
385
+ puts " Status: #{repo[:clean] ? "Clean" : "Modified"}" if repo[:type] == "cloned"
386
+
387
+ if repo[:last_commit] && !repo[:last_commit].empty?
388
+ commit_parts = repo[:last_commit].split(" ", 2)
389
+ commit_hash = commit_parts[0][0..7] if commit_parts[0]
390
+ commit_msg = commit_parts[1] if commit_parts[1]
391
+ puts " Commit: #{commit_hash} - #{commit_msg}"
392
+ end
393
+
394
+ puts " Date: #{repo[:last_commit_date]}" if repo[:last_commit_date] && !repo[:last_commit_date].empty?
395
+ puts " Added: #{repo[:added_at]}" if repo[:added_at]
396
+ puts " Error: #{repo[:error]}" if repo[:error]
397
+ puts
398
+ end
399
+ end
400
+
401
+ def output_json(repositories)
402
+ puts JSON.pretty_generate({
403
+ count: repositories.length,
404
+ repositories: repositories,
405
+ })
406
+ end
407
+
408
+ def output_plain(repositories)
409
+ repositories.each do |repo|
410
+ if paths?
411
+ puts "#{repo[:url] || repo[:name]} -> #{repo[:path]}"
412
+ else
413
+ puts repo[:url] || repo[:name]
414
+ end
415
+ end
416
+ end
417
+ end
418
+
419
+ # Remove tracked repositories by pattern
420
+ class RemoveRepositoryCommand < Clamp::Command
421
+ self.description = <<~DESC
422
+ Remove tracked repositories using pattern matching.
423
+
424
+ Examples:
425
+ makit repository remove github.com/user/
426
+ makit repository remove clamp.git
427
+ makit repository remove --missing-rakefile
428
+ DESC
429
+
430
+ parameter "PATTERN", "Pattern to match repository URLs", attribute_name: :pattern, required: false
431
+ option ["--missing-rakefile"], :flag, "Remove repositories without Rakefile"
432
+ option ["--dry-run"], :flag, "Show what would be removed without actually removing"
433
+ option ["--verbose"], :flag, "Show verbose output"
434
+
435
+ def execute
436
+ if missing_rakefile?
437
+ remove_missing_rakefile_repos
438
+ elsif pattern
439
+ remove_by_pattern(pattern)
440
+ else
441
+ puts "Error: Either provide a PATTERN or use --missing-rakefile"
442
+ exit 1
443
+ end
444
+ end
445
+
446
+ private
447
+
448
+ def remove_by_pattern(pattern)
449
+ tracked_repos = load_tracked_repositories
450
+ matching_repos = tracked_repos.select { |repo| repo["url"].include?(pattern) }
451
+
452
+ if matching_repos.empty?
453
+ puts "No repositories found matching pattern: #{pattern}"
454
+ return
455
+ end
456
+
457
+ puts "Found #{matching_repos.length} repositories matching pattern: #{pattern}"
458
+ matching_repos.each { |repo| puts " - #{repo["url"]}" }
459
+
460
+ return if dry_run?
461
+
462
+ remaining_repos = tracked_repos.reject { |repo| repo["url"].include?(pattern) }
463
+ save_tracked_repositories(remaining_repos)
464
+ puts "✅ Removed #{matching_repos.length} repositories"
465
+ end
466
+
467
+ def remove_missing_rakefile_repos
468
+ tracked_repos = load_tracked_repositories
469
+ repos_to_remove = []
470
+
471
+ tracked_repos.each do |repo|
472
+ clone_dir = Makit::Directories.get_clone_directory(repo["url"])
473
+ rakefile_path = File.join(clone_dir, "Rakefile")
474
+
475
+ if Dir.exist?(clone_dir) && !File.exist?(rakefile_path)
476
+ repos_to_remove << repo
477
+ puts " - #{repo["url"]} (no Rakefile)" if verbose?
478
+ end
479
+ end
480
+
481
+ if repos_to_remove.empty?
482
+ puts "No repositories found without Rakefile"
483
+ return
484
+ end
485
+
486
+ puts "Found #{repos_to_remove.length} repositories without Rakefile"
487
+ return if dry_run?
488
+
489
+ remaining_repos = tracked_repos - repos_to_remove
490
+ save_tracked_repositories(remaining_repos)
491
+ puts "✅ Removed #{repos_to_remove.length} repositories without Rakefile"
492
+ end
493
+
494
+ def load_tracked_repositories
495
+ tracked_file = File.join(Makit::Directories::ROOT, "tracked_repositories.json")
496
+ return [] unless File.exist?(tracked_file)
497
+
498
+ begin
499
+ JSON.parse(File.read(tracked_file))
500
+ rescue JSON::ParserError => e
501
+ warn "Warning: Could not parse tracked repositories file: #{e.message}"
502
+ []
503
+ end
504
+ end
505
+
506
+ def save_tracked_repositories(repositories)
507
+ tracked_file = File.join(Makit::Directories::ROOT, "tracked_repositories.json")
508
+ FileUtils.mkdir_p(File.dirname(tracked_file))
509
+ sorted_repos = repositories.sort_by { |repo| repo["url"] }
510
+ File.write(tracked_file, JSON.pretty_generate(sorted_repos))
511
+ end
512
+ end
513
+
514
+ # Import repositories from filesystem
515
+ class ImportRepositoryCommand < Clamp::Command
516
+ self.description = <<~DESC
517
+ Import git repositories from filesystem by discovery.
518
+
519
+ Examples:
520
+ makit repository import
521
+ makit repository import --directory ~/code
522
+ makit repository import --skip-rakefile-check
523
+ DESC
524
+
525
+ option ["--directory"], "DIR", "Directory to search for repositories", default: File.join(Dir.home, "code")
526
+ option ["--max-depth"], "DEPTH", "Maximum search depth", default: 3, &method(:Integer)
527
+ option ["--skip-rakefile-check"], :flag, "Import all repositories, not just those with Rakefile"
528
+ option ["--dry-run"], :flag, "Show what would be imported without actually importing"
529
+ option ["--verbose"], :flag, "Show verbose output"
530
+
531
+ def execute
532
+ search_dir = directory
533
+ unless Dir.exist?(search_dir)
534
+ puts "Directory does not exist: #{search_dir}"
535
+ exit 1
536
+ end
537
+
538
+ puts "Searching for git repositories in: #{search_dir}"
539
+ puts "Max depth: #{max_depth}"
540
+ puts "Rakefile required: #{!skip_rakefile_check?}"
541
+
542
+ repositories = discover_repositories(search_dir)
543
+
544
+ if repositories.empty?
545
+ puts "No repositories found"
546
+ return
547
+ end
548
+
549
+ puts "\nFound #{repositories.length} repositories:"
550
+ repositories.each { |repo| puts " - #{repo[:url] || repo[:path]}" }
551
+
552
+ return if dry_run?
553
+
554
+ import_repositories(repositories)
555
+ end
556
+
557
+ private
558
+
559
+ def discover_repositories(base_dir)
560
+ repositories = []
561
+
562
+ Find.find(base_dir) do |path|
563
+ next unless File.directory?(path) && File.basename(path) == ".git"
564
+
565
+ repo_dir = File.dirname(path)
566
+
567
+ # Check depth
568
+ relative_path = repo_dir.sub("#{base_dir}/", "")
569
+ depth = relative_path.split("/").length
570
+
571
+ if depth <= max_depth
572
+ repo_info = extract_repository_info(repo_dir)
573
+ repositories << repo_info if repo_info && should_import?(repo_dir)
574
+ end
575
+
576
+ Find.prune
577
+ end
578
+
579
+ repositories
580
+ end
581
+
582
+ def should_import?(repo_dir)
583
+ return true if skip_rakefile_check?
584
+
585
+ File.exist?(File.join(repo_dir, "Rakefile"))
586
+ end
587
+
588
+ def extract_repository_info(repo_dir)
589
+ return nil unless Dir.exist?(repo_dir)
590
+
591
+ info = { path: repo_dir }
592
+
593
+ begin
594
+ Dir.chdir(repo_dir) do
595
+ remote_output = `git remote get-url origin 2>/dev/null`.strip
596
+ info[:url] = remote_output unless remote_output.empty?
597
+ end
598
+ rescue StandardError
599
+ # If we can't get git info, skip this repo
600
+ return nil
601
+ end
602
+
603
+ info
604
+ end
605
+
606
+ def import_repositories(repositories)
607
+ tracked_repos = load_tracked_repositories
608
+ imported_count = 0
609
+
610
+ repositories.each do |repo|
611
+ next unless repo[:url]
612
+ next if tracked_repos.any? { |tracked| tracked["url"] == repo[:url] }
613
+
614
+ new_repo = {
615
+ "url" => repo[:url],
616
+ "added_at" => Time.now.iso8601,
617
+ "status" => "imported",
618
+ }
619
+
620
+ tracked_repos << new_repo
621
+ imported_count += 1
622
+ puts " ✅ Imported: #{repo[:url]}" if verbose?
623
+ end
624
+
625
+ if imported_count.positive?
626
+ save_tracked_repositories(tracked_repos)
627
+ puts "✅ Successfully imported #{imported_count} repositories"
628
+ else
629
+ puts "No new repositories to import"
630
+ end
631
+ end
632
+
633
+ def load_tracked_repositories
634
+ tracked_file = File.join(Makit::Directories::ROOT, "tracked_repositories.json")
635
+ return [] unless File.exist?(tracked_file)
636
+
637
+ begin
638
+ JSON.parse(File.read(tracked_file))
639
+ rescue JSON::ParserError => e
640
+ warn "Warning: Could not parse tracked repositories file: #{e.message}"
641
+ []
642
+ end
643
+ end
644
+
645
+ def save_tracked_repositories(repositories)
646
+ tracked_file = File.join(Makit::Directories::ROOT, "tracked_repositories.json")
647
+ FileUtils.mkdir_p(File.dirname(tracked_file))
648
+ sorted_repos = repositories.sort_by { |repo| repo["url"] }
649
+ File.write(tracked_file, JSON.pretty_generate(sorted_repos))
650
+ end
651
+ end
652
+
653
+ # Add subcommand declarations after all classes are defined
654
+ RepositoryCommand.subcommand "add", "Add repository to tracking list", AddRepositoryCommand
655
+ RepositoryCommand.subcommand "clone", "Clone repository to local directory", CloneRepositoryCommand
656
+ RepositoryCommand.subcommand "pull", "Pull latest changes", PullRepositoryCommand
657
+ RepositoryCommand.subcommand "list", "List tracked repositories", ListRepositoryCommand
658
+ RepositoryCommand.subcommand "remove", "Remove tracked repositories", RemoveRepositoryCommand
659
+ RepositoryCommand.subcommand "import", "Import repositories from filesystem", ImportRepositoryCommand
660
+ end
661
+ end