Kobold 0.3.3 → 0.4.0

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +5 -0
  3. data/.rubocop.yml +21 -1
  4. data/.rules/bugs/untestable.md +27 -0
  5. data/.rules/changelog/2026-03/30/01.md +55 -0
  6. data/.rules/changelog/2026-03/30/02.md +27 -0
  7. data/.rules/changelog/2026-03/30/03.md +36 -0
  8. data/.rules/changelog/2026-03/30/04.md +48 -0
  9. data/.rules/changelog/2026-03/30/05.md +19 -0
  10. data/.rules/changelog/2026-03/30/06.md +16 -0
  11. data/.rules/changelog/2026-03/30/07.md +28 -0
  12. data/.rules/changelog/2026-03/30/08.md +29 -0
  13. data/.rules/changelog/2026-03/30/09.md +33 -0
  14. data/.rules/changelog/2026-03/30/10.md +12 -0
  15. data/.rules/changelog/2026-03/30/11.md +47 -0
  16. data/.rules/changelog/2026-03/30/12.md +18 -0
  17. data/.rules/changelog/2026-03/30/13.md +36 -0
  18. data/.rules/changelog/2026-03/30/14.md +13 -0
  19. data/.rules/changelog/2026-03/30/15.md +24 -0
  20. data/.rules/default/rubocop.md +228 -0
  21. data/.rules/docs/kobold_api.md +491 -0
  22. data/README.md +131 -29
  23. data/Rakefile +19 -2
  24. data/exe/kobold +3 -57
  25. data/lib/Kobold/cli/admin_commands.rb +124 -0
  26. data/lib/Kobold/cli/checkout_commands.rb +73 -0
  27. data/lib/Kobold/cli/error_handling.rb +50 -0
  28. data/lib/Kobold/cli/flag_parser.rb +109 -0
  29. data/lib/Kobold/cli/init_commands.rb +108 -0
  30. data/lib/Kobold/cli/lifecycle_commands.rb +116 -0
  31. data/lib/Kobold/cli/list_commands.rb +80 -0
  32. data/lib/Kobold/cli/output.rb +40 -0
  33. data/lib/Kobold/cli/repo_commands.rb +101 -0
  34. data/lib/Kobold/cli/update_commands.rb +71 -0
  35. data/lib/Kobold/cli.rb +120 -0
  36. data/lib/Kobold/config.rb +136 -0
  37. data/lib/Kobold/database.rb +169 -0
  38. data/lib/Kobold/errors.rb +59 -0
  39. data/lib/Kobold/fetch.rb +19 -0
  40. data/lib/Kobold/git_ops.rb +162 -0
  41. data/lib/Kobold/init.rb +17 -13
  42. data/lib/Kobold/invoke.rb +12 -192
  43. data/lib/Kobold/linker.rb +87 -0
  44. data/lib/Kobold/manager/checkout.rb +78 -0
  45. data/lib/Kobold/manager/cleaning.rb +47 -0
  46. data/lib/Kobold/manager/fetching.rb +58 -0
  47. data/lib/Kobold/manager/invoking.rb +67 -0
  48. data/lib/Kobold/manager/lifecycle.rb +133 -0
  49. data/lib/Kobold/manager/registration.rb +32 -0
  50. data/lib/Kobold/manager.rb +140 -0
  51. data/lib/Kobold/repo/worktree_helpers.rb +56 -0
  52. data/lib/Kobold/repo.rb +135 -0
  53. data/lib/Kobold/settings.rb +103 -0
  54. data/lib/Kobold/version.rb +2 -2
  55. data/lib/Kobold.rb +14 -13
  56. data/prototyping/.kobold +19 -24
  57. data/sample-project-ideas/.kobold +19 -27
  58. data/sig/Kobold.rbs +217 -1
  59. metadata +60 -59
  60. data/lib/Kobold/first_time_setup.rb +0 -14
  61. data/lib/Kobold/read_config.rb +0 -15
data/lib/Kobold/invoke.rb CHANGED
@@ -1,200 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tty-config"
4
- require 'fileutils'
5
- require 'git'
6
-
7
- #require_relative "Kobold/vars.rb"
8
-
9
3
  module Kobold
10
4
  class << self
11
- def invoke
12
- Kobold.first_time_setup if !File.directory? "#{KOBOLD_DIR}/repo_cache"
13
- if !File.file? "#{Dir.pwd}/.kobold"
14
- puts "ERROR: Kobold file not found at '#{Dir.pwd}'"
15
- return
16
- end
17
- settings = Kobold.read_config(Dir.pwd)
18
-
19
- if Kobold::FORMAT_VERSION == settings["_kobold_config"]["format_version"]
20
- # iterate over all dependencies
21
- settings.each do |key, value|
22
- if key[0] == '_'
23
- next
24
- end
25
- repo_dir = "#{KOBOLD_DIR}/repo_cache/#{value['repo'].gsub('/', '-')}"
26
-
27
- source_repo = nil;
28
- # check if source exists
29
- if !Dir.exist? "#{repo_dir}/source" # TODO: make this properly check for git repo
30
- # if it doesnt, make it
31
- FileUtils.mkdir_p "#{repo_dir}/source"
32
- FileUtils.mkdir_p "#{repo_dir}/worktrees"
33
- FileUtils.mkdir_p "#{repo_dir}/worktrees/branched"
34
- FileUtils.mkdir_p "#{repo_dir}/worktrees/sha"
35
- FileUtils.mkdir_p "#{repo_dir}/worktrees/labelled"
36
- FileUtils.mkdir_p "#{repo_dir}/branches"
37
- source_repo = clone_git_repo "#{value["source"]}/#{value['repo']}.git", "#{repo_dir}/source"
38
- else
39
- source_repo = Git.open("#{repo_dir}/source")
40
- end
41
-
42
- # must be scoped here, used in various inner scopes
43
- target_symlink = nil
44
-
45
- # Structure of a segment of following code:
46
- # if it declares a branch: make the branch
47
- # if it has a label
48
- # it must have a sha
49
- # if it has a branch
50
- # use that branch + sha
51
- # else
52
- # use source + sha
53
- # end
54
- # else check if it has a branch
55
- # if it has a sha
56
- # make the sha
57
- # else
58
- # make it point to branch
59
- # end
60
- # else check if it has a sha
61
- # make the sha on the source
62
- # else
63
- # use source
64
- # end
65
-
66
- branch_repo = nil
67
- if value["branch"]
68
- branch_repo_path = "#{repo_dir}/branches/#{value["branch"]}"
69
- # check if branch already exists, make it if it doesnt
70
- if !Dir.exist? branch_repo_path
71
- FileUtils.mkdir_p "#{repo_dir}/branches"
72
- source_repo.worktree(branch_repo_path, value["branch"]).add
73
- end
74
- branch_repo = Git.open(branch_repo_path)
75
- end
76
-
77
- target_symlink = nil
78
- # if it has a label
79
- if value["label"]
80
-
81
- if !value["commit"]
82
- raise "Must give a specific sha when making a label. #{key} has no specific sha given"
83
- end
84
- if value["branch"]
85
- worktree_path = "#{repo_dir}/worktrees/labelled/#{value["label"]}/#{value["branch"]}"
86
- _commit_val = value["commit"].to_s.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'")
87
- worktree_sha = branch_repo.object(_commit_val).sha;
88
- target_symlink = "#{worktree_path}/#{worktree_sha}"
89
- if !Dir.exist? target_symlink
90
- FileUtils.mkdir_p "#{worktree_path}"
91
- branch_repo.worktree(target_symlink, worktree_sha).add
92
- end
93
- else
94
- branch = source_repo.branch.name
95
- worktree_path = "#{repo_dir}/worktrees/labelled/#{value["label"]}/#{branch}"
96
- _commit_val = value["commit"].to_s.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'")
97
- worktree_sha = source_repo.object(_commit_val).sha;
98
- target_symlink = "#{worktree_path}/#{worktree_sha}"
99
- if !Dir.exist? target_symlink
100
- FileUtils.mkdir_p "#{worktree_path}"
101
- source_repo.worktree(target_symlink, worktree_sha).add
102
- end
103
- end
104
-
105
- elsif value["branch"]
106
-
107
- if value['sha']
108
- worktree_path = "#{repo_dir}/worktrees/branched/#{branch}"
109
- _commit_val = value["commit"].to_s.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'")
110
- worktree_sha = branch_repo.object(_commit_val).sha;
111
- target_symlink = "#{worktree_path}/#{worktree_sha}"
112
- if !Dir.exist? target_symlink
113
- FileUtils.mkdir_p "#{worktree_path}"
114
- branch_repo.worktree(target_symlink, worktree_sha).add
115
- end
116
- else
117
- target_symlink = "#{repo_dir}/branches/#{value['branch']}"
118
- end
119
-
120
- elsif value["commit"]
121
-
122
- worktree_path = "#{repo_dir}/worktrees/sha"
123
- _commit_val = value["commit"].to_s.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'")
124
- worktree_sha = source_repo.object(_commit_val).sha;
125
- target_symlink = "#{worktree_path}/#{worktree_sha}"
126
- if !Dir.exist? target_symlink
127
- FileUtils.mkdir_p "#{worktree_path}"
128
- source_repo.worktree(target_symlink, worktree_sha).add
129
- end
130
-
131
- else
132
-
133
- target_symlink = "#{repo_dir}/source"
134
-
135
- end
136
-
137
- # build the symlink
138
- if value["dir"].end_with? "/"
139
- FileUtils.mkdir_p value["dir"]
140
- #puts "value: " + value["dir"] + key.split('/').last
141
- #puts !File.symlink?(value["dir"] + key.split('/').last)
142
-
143
- symlink1 = File.symlink?(value["dir"] + value['repo'].split('/').last)
144
- symlink2 = File.symlink? value["dir"]
145
-
146
- if !(symlink1 || symlink2)
147
- File.symlink(target_symlink, "#{value['dir']}/#{value['repo'].split('/').last}")
148
- end
149
- #File.symlink(target_symlink, "#{value['dir']}/#{key.split('/').last}")
150
-
151
- else
152
- dir_components = value["dir"].split "/"
153
- dir_components.pop
154
- dir_components = dir_components.join "/"
155
- FileUtils.mkdir_p dir_components
156
- File.symlink(target_symlink, value["dir"]) if !File.symlink? value["dir"]
157
-
158
- symlink1 = File.symlink?(value["dir"] + value['repo'].split('/').last)
159
- symlink2 = File.symlink? value["dir"]
160
-
161
- if !(symlink1 || symlink2)
162
- File.symlink(target_symlink, value["dir"])
163
- end
164
- #File.symlink(target_symlink, value["dir"])
165
- end
166
-
167
- end
168
-
169
- # iterate over all sub kobold files
170
- sub_kobolds = if settings["_kobold_include"] then settings["_kobold_include"]["files"].strip.split("\n") else [] end
171
- sub_kobolds.each do |path|
172
- Dir.chdir(path.strip) do
173
- invoke
174
- end
175
- end
176
-
177
- else
178
- raise "Wrong format version"
179
- end
5
+ # Process a .kobold config file: clone/fetch repos, create worktrees,
6
+ # and symlink them into the project directory.
7
+ #
8
+ # This is a convenience wrapper around Kobold::Manager#invoke.
9
+ #
10
+ # @param config_path [String] path to the .kobold file (default: current dir)
11
+ # @param database_name [String] which database to use (default: "default")
12
+ # @return [Array<Hash>] results for each dependency processed
13
+ def invoke(config_path: Dir.pwd, database_name: "default")
14
+ manager = Manager.new(database: database_name)
15
+ results = manager.invoke(config_path: config_path)
180
16
  puts "Done!"
181
- end
182
-
183
- private
184
-
185
- def clone_git_repo(url, path)
186
- progress_bar = TTY::ProgressBar.new("[:bar] Cloning: #{url} ", bar_format: :blade, total: nil, width: 45)
187
-
188
- thread = Thread.new(abort_on_exception: true) do
189
- Git.clone url, path
190
- end
191
- progress_bar.start
192
- while thread.status
193
- progress_bar.advance
194
- sleep 0.016
195
- end
196
- puts
197
- return thread.value
17
+ results
198
18
  end
199
19
  end
200
20
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Kobold
6
+ # Handles symlink creation, validation, and cleanup.
7
+ #
8
+ # All methods are module-level (stateless).
9
+ module Linker
10
+ module_function
11
+
12
+ # Create a symlink from target to destination.
13
+ #
14
+ # If destination ends with "/", the symlink is created inside that directory
15
+ # using the basename of the target.
16
+ #
17
+ # @param target [String] what the symlink points to (the worktree path)
18
+ # @param destination [String] where the symlink is placed
19
+ # @return [String] the actual symlink path created
20
+ def link(target, destination)
21
+ symlink_path = resolve_destination(target, destination)
22
+
23
+ if File.symlink?(symlink_path)
24
+ return symlink_path if existing_symlink_matches?(symlink_path, target)
25
+
26
+ File.delete(symlink_path)
27
+ elsif File.exist?(symlink_path)
28
+ remove_existing_path(symlink_path)
29
+ end
30
+
31
+ FileUtils.mkdir_p(File.dirname(symlink_path))
32
+ File.symlink(target, symlink_path)
33
+ symlink_path
34
+ end
35
+
36
+ # Remove a symlink at destination.
37
+ #
38
+ # @param destination [String] the symlink path
39
+ # @return [void]
40
+ def unlink(destination)
41
+ return unless File.symlink?(destination)
42
+
43
+ File.delete(destination)
44
+ end
45
+
46
+ # Remove an existing symlink and create a new one.
47
+ #
48
+ # @param target [String] what the new symlink points to
49
+ # @param destination [String] where the symlink is placed
50
+ # @return [String] the symlink path
51
+ def relink(target, destination)
52
+ symlink_path = resolve_destination(target, destination)
53
+ unlink(symlink_path)
54
+ link(target, destination)
55
+ end
56
+
57
+ # Check if a symlink at destination is valid (exists and points to a real path).
58
+ #
59
+ # @param destination [String] the symlink path
60
+ # @return [Boolean]
61
+ def valid?(destination)
62
+ File.symlink?(destination) && File.exist?(destination)
63
+ end
64
+
65
+ # @api private
66
+ def resolve_destination(target, destination)
67
+ if destination.end_with?("/")
68
+ FileUtils.mkdir_p(destination)
69
+ File.join(destination, File.basename(target))
70
+ else
71
+ destination
72
+ end
73
+ end
74
+
75
+ # @api private
76
+ def existing_symlink_matches?(symlink_path, target)
77
+ existing = File.readlink(symlink_path)
78
+ resolved = File.expand_path(existing, File.dirname(symlink_path))
79
+ resolved == File.expand_path(target)
80
+ end
81
+
82
+ # @api private
83
+ def remove_existing_path(path)
84
+ FileUtils.rm_rf(path)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobold
4
+ class Manager
5
+ # Checkout, branching, and worktree management methods.
6
+ module Checkout
7
+ # Create a worktree for a repo and optionally symlink it.
8
+ #
9
+ # @param repo [String] repo identifier
10
+ # @param branch [String, nil]
11
+ # @param commit [String, nil]
12
+ # @param label [String, nil]
13
+ # @param dir [String, nil] destination path for a symlink
14
+ # @return [Hash] { slug:, worktree_path:, symlink_path: }
15
+ def checkout(repo, branch: nil, commit: nil, label: nil, dir: nil)
16
+ slug = Database.slugify(repo)
17
+ repo_obj = @database.repo(slug)
18
+ repo_obj.clone
19
+
20
+ worktree_path = repo_obj.create_worktree(branch: branch, commit: commit, label: label)
21
+ symlink_path = link_if_dir(worktree_path, dir)
22
+
23
+ { slug: slug, worktree_path: worktree_path, symlink_path: symlink_path }
24
+ end
25
+
26
+ # Create a new branch in a cached repo with worktree + symlink.
27
+ #
28
+ # @param repo [String] repo identifier
29
+ # @param name [String] new branch name
30
+ # @param from [String] source branch/ref (default: "main")
31
+ # @param dir [String, nil] destination path for the worktree symlink
32
+ # @return [Hash] { slug:, branch:, worktree_path:, symlink_path: }
33
+ def create_branch(repo, name:, from: "main", dir: nil)
34
+ slug = Database.slugify(repo)
35
+ repo_obj = @database.repo(slug)
36
+ repo_obj.clone
37
+
38
+ branch_name = repo_obj.create_branch(name, from: from)
39
+ worktree_path = repo_obj.create_worktree(branch: branch_name)
40
+ symlink_path = link_if_dir(worktree_path, dir)
41
+
42
+ { slug: slug, branch: branch_name, worktree_path: worktree_path, symlink_path: symlink_path }
43
+ end
44
+
45
+ # Remove a worktree (and optionally its symlink).
46
+ #
47
+ # @param repo [String] repo identifier
48
+ # @param dir [String] worktree path or symlink path to remove
49
+ # @return [Hash] { slug:, removed_worktree:, removed_symlink: }
50
+ def remove_worktree(repo, dir:)
51
+ slug = Database.slugify(repo)
52
+ repo_obj = @database.repo(slug)
53
+ worktree_path, removed_symlink = resolve_symlink_target(dir)
54
+
55
+ repo_obj.remove_worktree(worktree_path)
56
+ { slug: slug, removed_worktree: worktree_path, removed_symlink: removed_symlink }
57
+ end
58
+
59
+ private
60
+
61
+ def link_if_dir(worktree_path, dir)
62
+ return nil unless dir
63
+
64
+ Linker.link(worktree_path, File.expand_path(dir))
65
+ end
66
+
67
+ def resolve_symlink_target(dir)
68
+ if File.symlink?(dir)
69
+ target = File.readlink(dir)
70
+ Linker.unlink(dir)
71
+ [target, true]
72
+ else
73
+ [dir, false]
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "set"
5
+
6
+ module Kobold
7
+ class Manager
8
+ # Cleanup and purge methods.
9
+ module Cleaning
10
+ # Purge repos not referenced by a .kobold config file.
11
+ def clean(config_path: nil)
12
+ purged = config_path ? purge_unreferenced(config_path) : []
13
+ { purged_repos: purged }
14
+ end
15
+
16
+ private
17
+
18
+ def purge_unreferenced(config_path)
19
+ referenced = referenced_slugs_from_config(config_path)
20
+ purged = []
21
+ @database.list_repos.each_key do |slug|
22
+ next if referenced.include?(slug)
23
+
24
+ @database.repo(slug).purge
25
+ @database.unregister_repo(slug)
26
+ purged << { slug: slug }
27
+ end
28
+ purged
29
+ end
30
+
31
+ def referenced_slugs_from_config(config_path)
32
+ slugs = Set.new
33
+ collect_slugs(config_path, slugs)
34
+ slugs
35
+ end
36
+
37
+ def collect_slugs(config_path, slugs)
38
+ config = resolve_config(config_path)
39
+ config.each_dependency { |_name, dep| slugs.add(Database.slugify(dep["repo"])) }
40
+ base_dir = File.dirname(config.path || File.expand_path(config_path))
41
+ config.includes.each { |sub| collect_slugs(File.expand_path(sub, base_dir), slugs) }
42
+ rescue Errors::ConfigError
43
+ # Config not found or invalid
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobold
4
+ class Manager
5
+ # Fetch methods for single repos and .kobold config batches.
6
+ module Fetching
7
+ # Fetch latest objects for a single repo.
8
+ #
9
+ # @param repo [String] repo identifier
10
+ # @return [Hash] { slug:, action: :fetched | :cloned }
11
+ def fetch(repo)
12
+ slug = Database.slugify(repo)
13
+ repo_obj = @database.repo(slug)
14
+
15
+ if repo_obj.cloned?
16
+ repo_obj.fetch
17
+ { slug: slug, action: :fetched }
18
+ else
19
+ repo_obj.clone
20
+ { slug: slug, action: :cloned }
21
+ end
22
+ end
23
+
24
+ # Fetch all repos referenced in a .kobold config file.
25
+ #
26
+ # @param config_path [String] path to the .kobold file or directory
27
+ # @return [Array<Hash>] one entry per dependency
28
+ def fetch_all(config_path: Dir.pwd)
29
+ config = resolve_config(config_path)
30
+ config.each_dependency.map do |name, dep|
31
+ fetch_single_dep(name, dep)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def fetch_single_dep(name, dep)
38
+ slug = Database.slugify(dep["repo"])
39
+ source_url = build_clone_url(dep["source"], dep["repo"])
40
+ @database.register_repo(slug, source_url)
41
+
42
+ repo_obj = @database.repo(slug)
43
+ action = fetch_or_clone(repo_obj)
44
+ { name: name, slug: slug, action: action }
45
+ end
46
+
47
+ def fetch_or_clone(repo_obj)
48
+ if repo_obj.cloned?
49
+ repo_obj.fetch
50
+ :fetched
51
+ else
52
+ repo_obj.clone
53
+ :cloned
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobold
4
+ class Manager
5
+ # Process .kobold config files: clone/fetch, create worktrees, symlink.
6
+ module Invoking
7
+ # Process a .kobold config file end-to-end.
8
+ #
9
+ # @param config_path [String] path to .kobold file or directory
10
+ # @return [Array<Hash>] one entry per dependency processed
11
+ def invoke(config_path: Dir.pwd)
12
+ config = resolve_config(config_path)
13
+ base_dir = config_base_dir(config, config_path)
14
+ results = invoke_dependencies(config, base_dir)
15
+ results.concat(invoke_includes(config, base_dir))
16
+ results
17
+ end
18
+
19
+ private
20
+
21
+ def invoke_dependencies(config, base_dir)
22
+ config.each_dependency.map do |name, dep|
23
+ invoke_single_dep(name, dep, base_dir)
24
+ end
25
+ end
26
+
27
+ def invoke_single_dep(name, dep, base_dir)
28
+ slug = Database.slugify(dep["repo"])
29
+ source_url = build_clone_url(dep["source"], dep["repo"])
30
+ @database.register_repo(slug, source_url)
31
+
32
+ repo_obj = @database.repo(slug)
33
+ repo_obj.clone
34
+
35
+ worktree_path = repo_obj.create_worktree(
36
+ branch: dep["branch"], commit: dep["commit"], label: dep["label"]
37
+ )
38
+ symlink_path = link_dep_dir(worktree_path, dep["dir"], base_dir, name: name)
39
+
40
+ { name: name, slug: slug, worktree_path: worktree_path, symlink_path: symlink_path }
41
+ end
42
+
43
+ def invoke_includes(config, base_dir)
44
+ config.includes.flat_map do |sub_path|
45
+ invoke(config_path: File.expand_path(sub_path, base_dir))
46
+ end
47
+ end
48
+
49
+ def link_dep_dir(worktree_path, dir, base_dir, name: nil)
50
+ return nil unless dir
51
+
52
+ destination = File.expand_path(dir, base_dir)
53
+ if dir.end_with?("/") && name
54
+ FileUtils.mkdir_p(destination)
55
+ destination = File.join(destination, name)
56
+ elsif dir.end_with?("/")
57
+ destination += "/"
58
+ end
59
+ Linker.link(worktree_path, destination)
60
+ end
61
+
62
+ def config_base_dir(config, config_path)
63
+ File.dirname(config.path || File.expand_path(config_path))
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobold
4
+ class Manager
5
+ # Dependency lifecycle: add, remove, update.
6
+ module Lifecycle
7
+ # Add a dependency to an existing .kobold config file.
8
+ def add(config_path:, repo:, source:, name: nil, **options)
9
+ name ||= repo.split("/").last
10
+ config = resolve_config(config_path)
11
+ dep = build_dep_entry(repo, source, options)
12
+ config.add_dependency(name, dep)
13
+ Config.write(config.path, config)
14
+
15
+ action = register_and_clone(repo, source)
16
+ { config_path: config.path, name: name, slug: Database.slugify(repo), action: action }
17
+ end
18
+
19
+ # Remove a dependency from a .kobold config file.
20
+ def remove(config_path:, dependency_name:, cleanup: false)
21
+ config = resolve_config(config_path)
22
+ dep = config.dependency(dependency_name)
23
+ raise Errors::DependencyNotFound, dependency_name unless dep
24
+
25
+ result = cleanup ? cleanup_dependency(dep, config) : { removed_worktree: nil, removed_symlink: false }
26
+ config.remove_dependency(dependency_name)
27
+ Config.write(config.path, config)
28
+ result.merge(config_path: config.path, name: dependency_name)
29
+ end
30
+
31
+ # Update a dependency's commit SHA to the latest on its branch.
32
+ def update(config_path:, dependency_name:)
33
+ config = resolve_config(config_path)
34
+ dep = config.dependency(dependency_name)
35
+ raise Errors::DependencyNotFound, dependency_name unless dep
36
+ raise Errors::ConfigError, "Dependency '#{dependency_name}' has no branch to update from" unless dep["branch"]
37
+
38
+ result = perform_update(config, dependency_name, dep)
39
+ Config.write(config.path, config)
40
+ result.merge(config_path: config.path, name: dependency_name)
41
+ end
42
+
43
+ # Update all dependencies with a branch specified.
44
+ def update_all(config_path: Dir.pwd)
45
+ config = resolve_config(config_path)
46
+ results = []
47
+ config.each_dependency do |name, dep|
48
+ next unless dep["branch"]
49
+
50
+ results << safe_update(config, name)
51
+ end
52
+ results
53
+ end
54
+
55
+ private
56
+
57
+ def build_dep_entry(repo, source, options)
58
+ dep = { "repo" => repo, "source" => source }
59
+ %i[branch commit label dir].each { |k| dep[k.to_s] = options[k] if options[k] }
60
+ dep
61
+ end
62
+
63
+ def register_and_clone(repo, source)
64
+ slug = Database.slugify(repo)
65
+ @database.register_repo(slug, build_clone_url(source, repo))
66
+ repo_obj = @database.repo(slug)
67
+ return :already_cached if repo_obj.cloned?
68
+
69
+ repo_obj.clone
70
+ :cloned
71
+ end
72
+
73
+ def cleanup_dependency(dep, config)
74
+ slug = Database.slugify(dep["repo"])
75
+ base_dir = File.dirname(config.path)
76
+ removed_symlink = cleanup_symlink(dep, base_dir)
77
+ removed_worktree = cleanup_worktree(dep, slug)
78
+ { removed_worktree: removed_worktree, removed_symlink: removed_symlink }
79
+ end
80
+
81
+ def cleanup_symlink(dep, base_dir)
82
+ return false unless dep["dir"]
83
+
84
+ remove_symlink_at(File.expand_path(dep["dir"], base_dir))
85
+ end
86
+
87
+ def remove_symlink_at(dest)
88
+ return Linker.unlink(dest) || true if File.symlink?(dest)
89
+ return check_nested_symlink(dest) if File.directory?(dest)
90
+
91
+ false
92
+ end
93
+
94
+ def check_nested_symlink(dest)
95
+ link = Dir.children(dest).map { |c| File.join(dest, c) }.find { |p| File.symlink?(p) }
96
+ return false unless link
97
+
98
+ Linker.unlink(link)
99
+ true
100
+ end
101
+
102
+ def cleanup_worktree(dep, slug)
103
+ repo_obj = @database.repo(slug)
104
+ wt = repo_obj.worktree_path_for(branch: dep["branch"], commit: dep["commit"], label: dep["label"])
105
+ return nil unless Dir.exist?(wt)
106
+
107
+ repo_obj.remove_worktree(wt)
108
+ wt
109
+ rescue Errors::RepoNotFound, Errors::GitError
110
+ nil
111
+ end
112
+
113
+ def perform_update(config, name, dep)
114
+ slug = Database.slugify(dep["repo"])
115
+ @database.register_repo(slug, build_clone_url(dep["source"], dep["repo"]))
116
+ repo_obj = @database.repo(slug)
117
+ repo_obj.clone
118
+ repo_obj.fetch
119
+
120
+ old = dep["commit"]
121
+ new_sha = repo_obj.resolve_branch_head(dep["branch"])
122
+ config.update_dependency(name, "commit", new_sha)
123
+ { old_commit: old, new_commit: new_sha, branch: dep["branch"] }
124
+ end
125
+
126
+ def safe_update(config, name)
127
+ update(config_path: config.path, dependency_name: name)
128
+ rescue Errors::GitError, Errors::RepoNotFound => e
129
+ { config_path: config.path, name: name, error: e.message }
130
+ end
131
+ end
132
+ end
133
+ end