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.
- checksums.yaml +4 -4
- data/.rspec +5 -0
- data/.rubocop.yml +21 -1
- data/.rules/bugs/untestable.md +27 -0
- data/.rules/changelog/2026-03/30/01.md +55 -0
- data/.rules/changelog/2026-03/30/02.md +27 -0
- data/.rules/changelog/2026-03/30/03.md +36 -0
- data/.rules/changelog/2026-03/30/04.md +48 -0
- data/.rules/changelog/2026-03/30/05.md +19 -0
- data/.rules/changelog/2026-03/30/06.md +16 -0
- data/.rules/changelog/2026-03/30/07.md +28 -0
- data/.rules/changelog/2026-03/30/08.md +29 -0
- data/.rules/changelog/2026-03/30/09.md +33 -0
- data/.rules/changelog/2026-03/30/10.md +12 -0
- data/.rules/changelog/2026-03/30/11.md +47 -0
- data/.rules/changelog/2026-03/30/12.md +18 -0
- data/.rules/changelog/2026-03/30/13.md +36 -0
- data/.rules/changelog/2026-03/30/14.md +13 -0
- data/.rules/changelog/2026-03/30/15.md +24 -0
- data/.rules/default/rubocop.md +228 -0
- data/.rules/docs/kobold_api.md +491 -0
- data/README.md +131 -29
- data/Rakefile +19 -2
- data/exe/kobold +3 -57
- data/lib/Kobold/cli/admin_commands.rb +124 -0
- data/lib/Kobold/cli/checkout_commands.rb +73 -0
- data/lib/Kobold/cli/error_handling.rb +50 -0
- data/lib/Kobold/cli/flag_parser.rb +109 -0
- data/lib/Kobold/cli/init_commands.rb +108 -0
- data/lib/Kobold/cli/lifecycle_commands.rb +116 -0
- data/lib/Kobold/cli/list_commands.rb +80 -0
- data/lib/Kobold/cli/output.rb +40 -0
- data/lib/Kobold/cli/repo_commands.rb +101 -0
- data/lib/Kobold/cli/update_commands.rb +71 -0
- data/lib/Kobold/cli.rb +120 -0
- data/lib/Kobold/config.rb +136 -0
- data/lib/Kobold/database.rb +169 -0
- data/lib/Kobold/errors.rb +59 -0
- data/lib/Kobold/fetch.rb +19 -0
- data/lib/Kobold/git_ops.rb +162 -0
- data/lib/Kobold/init.rb +17 -13
- data/lib/Kobold/invoke.rb +12 -192
- data/lib/Kobold/linker.rb +87 -0
- data/lib/Kobold/manager/checkout.rb +78 -0
- data/lib/Kobold/manager/cleaning.rb +47 -0
- data/lib/Kobold/manager/fetching.rb +58 -0
- data/lib/Kobold/manager/invoking.rb +67 -0
- data/lib/Kobold/manager/lifecycle.rb +133 -0
- data/lib/Kobold/manager/registration.rb +32 -0
- data/lib/Kobold/manager.rb +140 -0
- data/lib/Kobold/repo/worktree_helpers.rb +56 -0
- data/lib/Kobold/repo.rb +135 -0
- data/lib/Kobold/settings.rb +103 -0
- data/lib/Kobold/version.rb +2 -2
- data/lib/Kobold.rb +14 -13
- data/prototyping/.kobold +19 -24
- data/sample-project-ideas/.kobold +19 -27
- data/sig/Kobold.rbs +217 -1
- metadata +60 -59
- data/lib/Kobold/first_time_setup.rb +0 -14
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|