Kobold 0.3.4 → 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 -63
- 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 +12 -55
- data/lib/Kobold/git_ops.rb +162 -0
- data/lib/Kobold/init.rb +16 -28
- data/lib/Kobold/invoke.rb +12 -204
- 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 -8
- data/lib/Kobold.rb +14 -14
- data/prototyping/.kobold +19 -24
- data/sample-project-ideas/.kobold +19 -27
- data/sig/Kobold.rbs +217 -1
- metadata +59 -59
- data/lib/Kobold/first_time_setup.rb +0 -14
- data/lib/Kobold/read_config.rb +0 -15
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "toml-rb"
|
|
4
|
+
|
|
5
|
+
module Kobold
|
|
6
|
+
# Reads, writes, validates, and generates .kobold TOML configuration files.
|
|
7
|
+
class Config
|
|
8
|
+
REQUIRED_KEYS = %w[repo source].freeze
|
|
9
|
+
OPTIONAL_KEYS = %w[dir branch commit label].freeze
|
|
10
|
+
ALL_DEP_KEYS = (REQUIRED_KEYS + OPTIONAL_KEYS).freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :format_version, :includes, :dependencies, :path
|
|
13
|
+
|
|
14
|
+
def self.read(path)
|
|
15
|
+
full_path = File.expand_path(path)
|
|
16
|
+
raise Errors::ConfigError, "Config file not found: #{full_path}" unless File.file?(full_path)
|
|
17
|
+
|
|
18
|
+
new(data: TomlRB.load_file(full_path), path: full_path)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.write(path, config)
|
|
22
|
+
File.write(path, TomlRB.dump(config.to_h))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.generate(dependencies: [], includes: [])
|
|
26
|
+
data = build_base_data(includes)
|
|
27
|
+
dependencies.each { |dep| add_dep_to_data(data, dep) }
|
|
28
|
+
new(data: data)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.validate(data)
|
|
32
|
+
validate_kobold_section(data)
|
|
33
|
+
validate_includes(data["kobold"])
|
|
34
|
+
validate_dependencies(data["dependencies"])
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(data:, path: nil)
|
|
39
|
+
self.class.validate(data)
|
|
40
|
+
@path = path
|
|
41
|
+
kobold = data["kobold"]
|
|
42
|
+
@format_version = kobold["format_version"]
|
|
43
|
+
@includes = kobold.fetch("includes", [])
|
|
44
|
+
@dependencies = data.fetch("dependencies", {})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
h = { "kobold" => { "format_version" => @format_version } }
|
|
49
|
+
h["kobold"]["includes"] = @includes unless @includes.empty?
|
|
50
|
+
h["dependencies"] = @dependencies unless @dependencies.empty?
|
|
51
|
+
h
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def each_dependency(&block)
|
|
55
|
+
@dependencies.each(&block)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def add_dependency(name, dep)
|
|
59
|
+
@dependencies[name] = dep.each_with_object({}) { |(k, v), h| h[k.to_s] = v unless v.nil? }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def remove_dependency(name)
|
|
63
|
+
@dependencies.delete(name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def dependency?(name)
|
|
67
|
+
@dependencies.key?(name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def dependency(name)
|
|
71
|
+
@dependencies[name]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def update_dependency(name, key, value)
|
|
75
|
+
raise Errors::DependencyNotFound, name unless @dependencies.key?(name)
|
|
76
|
+
|
|
77
|
+
value.nil? ? @dependencies[name].delete(key.to_s) : @dependencies[name][key.to_s] = value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.build_base_data(includes)
|
|
81
|
+
data = { "kobold" => { "format_version" => Kobold::FORMAT_VERSION }, "dependencies" => {} }
|
|
82
|
+
data["kobold"]["includes"] = includes unless includes.empty?
|
|
83
|
+
data
|
|
84
|
+
end
|
|
85
|
+
private_class_method :build_base_data
|
|
86
|
+
|
|
87
|
+
def self.add_dep_to_data(data, dep)
|
|
88
|
+
dep = dep.dup
|
|
89
|
+
name = dep.delete(:name) || dep.delete("name")
|
|
90
|
+
raise Errors::ConfigError, "Dependency must have a 'name' key" unless name
|
|
91
|
+
|
|
92
|
+
entry = {}
|
|
93
|
+
dep.each { |k, v| entry[k.to_s] = v }
|
|
94
|
+
data["dependencies"][name] = entry
|
|
95
|
+
end
|
|
96
|
+
private_class_method :add_dep_to_data
|
|
97
|
+
|
|
98
|
+
def self.validate_kobold_section(data)
|
|
99
|
+
kobold = data["kobold"]
|
|
100
|
+
raise Errors::ConfigError, "Missing [kobold] section" unless kobold.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
version = kobold["format_version"]
|
|
103
|
+
raise Errors::ConfigError, "Missing format_version in [kobold] section" unless version
|
|
104
|
+
raise Errors::InvalidFormat.new(version, Kobold::FORMAT_VERSION) unless version == Kobold::FORMAT_VERSION
|
|
105
|
+
end
|
|
106
|
+
private_class_method :validate_kobold_section
|
|
107
|
+
|
|
108
|
+
def self.validate_includes(kobold)
|
|
109
|
+
return unless kobold.key?("includes")
|
|
110
|
+
return if kobold["includes"].is_a?(Array) && kobold["includes"].all? { |i| i.is_a?(String) }
|
|
111
|
+
|
|
112
|
+
raise Errors::ConfigError, "'includes' must be an array of strings"
|
|
113
|
+
end
|
|
114
|
+
private_class_method :validate_includes
|
|
115
|
+
|
|
116
|
+
def self.validate_dependencies(deps)
|
|
117
|
+
return unless deps.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
deps.each { |name, dep| validate_single_dep(name, dep) }
|
|
120
|
+
end
|
|
121
|
+
private_class_method :validate_dependencies
|
|
122
|
+
|
|
123
|
+
def self.validate_single_dep(name, dep)
|
|
124
|
+
raise Errors::ConfigError, "Dependency '#{name}' must be a table" unless dep.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
REQUIRED_KEYS.each do |key|
|
|
127
|
+
raise Errors::ConfigError, "Dependency '#{name}' is missing required key '#{key}'" unless dep.key?(key)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
unknown = dep.keys - ALL_DEP_KEYS
|
|
131
|
+
raise Errors::ConfigError, "Dependency '#{name}' has unknown keys: #{unknown.join(", ")}" unless unknown.empty?
|
|
132
|
+
raise Errors::ConfigError, "Dependency '#{name}' has a label but no commit" if dep["label"] && !dep["commit"]
|
|
133
|
+
end
|
|
134
|
+
private_class_method :validate_single_dep
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "toml-rb"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Kobold
|
|
7
|
+
# Manages a single named database (cache directory).
|
|
8
|
+
#
|
|
9
|
+
# Each database lives at:
|
|
10
|
+
# <KOBOLD_DIR>/databases/<name>/
|
|
11
|
+
#
|
|
12
|
+
# Contains a registry.toml tracking registered repos and a repos/ directory
|
|
13
|
+
# holding cached bare clones and worktrees.
|
|
14
|
+
class Database
|
|
15
|
+
# List all databases that exist on disk.
|
|
16
|
+
#
|
|
17
|
+
# @return [Array<String>] database names
|
|
18
|
+
def self.list_databases
|
|
19
|
+
databases_dir = File.join(Kobold::KOBOLD_DIR, "databases")
|
|
20
|
+
return [] unless Dir.exist?(databases_dir)
|
|
21
|
+
|
|
22
|
+
Dir.children(databases_dir)
|
|
23
|
+
.select { |name| File.directory?(File.join(databases_dir, name)) }
|
|
24
|
+
.sort
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Check if a named database exists on disk.
|
|
28
|
+
#
|
|
29
|
+
# @param name [String] database name
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def self.exists?(name)
|
|
32
|
+
Dir.exist?(File.join(Kobold::KOBOLD_DIR, "databases", name))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
REGISTRY_FILE = "registry.toml"
|
|
36
|
+
|
|
37
|
+
attr_reader :name
|
|
38
|
+
|
|
39
|
+
# @param name [String] the database name (default: "default")
|
|
40
|
+
def initialize(name = "default")
|
|
41
|
+
@name = name
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Absolute path to this database directory.
|
|
45
|
+
#
|
|
46
|
+
# @return [String]
|
|
47
|
+
def path
|
|
48
|
+
File.join(Kobold::KOBOLD_DIR, "databases", @name)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Ensure the database directory and registry exist.
|
|
52
|
+
#
|
|
53
|
+
# @return [void]
|
|
54
|
+
def setup
|
|
55
|
+
FileUtils.mkdir_p(repos_dir)
|
|
56
|
+
write_registry({}) unless File.file?(registry_path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if this database exists on disk.
|
|
60
|
+
#
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def exists?
|
|
63
|
+
Dir.exist?(path)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Delete this entire database and all its contents.
|
|
67
|
+
#
|
|
68
|
+
# @return [void]
|
|
69
|
+
def delete
|
|
70
|
+
raise Errors::DatabaseNotFound, @name unless exists?
|
|
71
|
+
|
|
72
|
+
FileUtils.rm_rf(path)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Register a repo in this database.
|
|
76
|
+
#
|
|
77
|
+
# @param slug [String] the repo slug (e.g. "raysan5-raylib")
|
|
78
|
+
# @param source_url [String] the full clone URL
|
|
79
|
+
# @return [Hash] the registered entry
|
|
80
|
+
def register_repo(slug, source_url)
|
|
81
|
+
with_locked_registry do |reg|
|
|
82
|
+
reg[slug] = { "source_url" => source_url }
|
|
83
|
+
reg[slug]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Unregister a repo from this database.
|
|
88
|
+
#
|
|
89
|
+
# @param slug [String] the repo slug
|
|
90
|
+
# @return [void]
|
|
91
|
+
def unregister_repo(slug)
|
|
92
|
+
with_locked_registry do |reg|
|
|
93
|
+
raise Errors::RepoNotFound, slug unless reg.key?(slug)
|
|
94
|
+
|
|
95
|
+
reg.delete(slug)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# List all registered repos.
|
|
100
|
+
#
|
|
101
|
+
# @return [Hash] slug => { "source_url" => "..." }
|
|
102
|
+
def list_repos
|
|
103
|
+
registry
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get a Repo object for a registered slug.
|
|
107
|
+
#
|
|
108
|
+
# @param slug [String] the repo slug
|
|
109
|
+
# @return [Kobold::Repo]
|
|
110
|
+
def repo(slug)
|
|
111
|
+
reg = registry
|
|
112
|
+
raise Errors::RepoNotFound, slug unless reg.key?(slug)
|
|
113
|
+
|
|
114
|
+
Repo.new(self, slug, reg[slug]["source_url"])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Convert a "user/repo" string to a slug.
|
|
118
|
+
#
|
|
119
|
+
# @param repo_path [String] e.g. "raysan5/raylib"
|
|
120
|
+
# @return [String] e.g. "raysan5-raylib"
|
|
121
|
+
def self.slugify(repo_path)
|
|
122
|
+
repo_path.gsub("/", "-")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Path to the repos/ subdirectory.
|
|
126
|
+
#
|
|
127
|
+
# @return [String]
|
|
128
|
+
def repos_dir
|
|
129
|
+
File.join(path, "repos")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def registry_path
|
|
135
|
+
File.join(path, REGISTRY_FILE)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def lock_path
|
|
139
|
+
File.join(path, "#{REGISTRY_FILE}.lock")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def registry
|
|
143
|
+
return {} unless File.file?(registry_path)
|
|
144
|
+
|
|
145
|
+
data = TomlRB.load_file(registry_path)
|
|
146
|
+
data.fetch("repos", {})
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def write_registry(repos_hash)
|
|
150
|
+
FileUtils.mkdir_p(path)
|
|
151
|
+
data = { "repos" => repos_hash }
|
|
152
|
+
File.write(registry_path, TomlRB.dump(data))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Acquire an exclusive file lock, read the registry, yield the
|
|
156
|
+
# mutable hash, then write it back. Prevents TOCTOU races when
|
|
157
|
+
# multiple processes share a database.
|
|
158
|
+
def with_locked_registry
|
|
159
|
+
FileUtils.mkdir_p(path)
|
|
160
|
+
File.open(lock_path, File::CREAT | File::RDWR) do |lock|
|
|
161
|
+
lock.flock(File::LOCK_EX)
|
|
162
|
+
reg = registry
|
|
163
|
+
result = yield reg
|
|
164
|
+
write_registry(reg)
|
|
165
|
+
result
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobold
|
|
4
|
+
module Errors
|
|
5
|
+
class RepoNotFound < StandardError
|
|
6
|
+
def initialize(slug)
|
|
7
|
+
super("Repository not found: #{slug}")
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class WorktreeExists < StandardError
|
|
12
|
+
def initialize(path)
|
|
13
|
+
super("Worktree already exists at: #{path}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class ConfigError < StandardError; end
|
|
18
|
+
|
|
19
|
+
class DatabaseNotFound < StandardError
|
|
20
|
+
def initialize(name)
|
|
21
|
+
super("Database not found: #{name}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class InvalidFormat < StandardError
|
|
26
|
+
def initialize(version, expected)
|
|
27
|
+
super("Invalid format version '#{version}', expected '#{expected}'")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class CloneError < StandardError
|
|
32
|
+
def initialize(url, message)
|
|
33
|
+
super("Failed to clone #{url}: #{message}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class FetchError < StandardError
|
|
38
|
+
def initialize(slug, message)
|
|
39
|
+
super("Failed to fetch #{slug}: #{message}")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class GitError < StandardError; end
|
|
44
|
+
|
|
45
|
+
class DependencyNotFound < StandardError
|
|
46
|
+
def initialize(name)
|
|
47
|
+
super("Dependency not found: #{name}")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class DatabaseExists < StandardError
|
|
52
|
+
def initialize(name)
|
|
53
|
+
super("Database already exists: #{name}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class LinkError < StandardError; end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/Kobold/fetch.rb
CHANGED
|
@@ -1,62 +1,19 @@
|
|
|
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
|
-
|
|
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
|
-
next
|
|
39
|
-
# TODO this may need to be reworked, might not fetch new branches that are required
|
|
40
|
-
# if they havent been invoked previously.
|
|
41
|
-
# works good enough for now :3
|
|
42
|
-
else
|
|
43
|
-
source_repo = Git.open("#{repo_dir}/source")
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
progress_bar = TTY::ProgressBar.new("[:bar] Fetching: #{value["source"]}/#{value['repo']}.git ", bar_format: :blade, total: nil, width: 45)
|
|
47
|
-
|
|
48
|
-
thread = Thread.new(abort_on_exception: true) do
|
|
49
|
-
source_repo.fetch(all: true)
|
|
50
|
-
end
|
|
51
|
-
progress_bar.start
|
|
52
|
-
while thread.status
|
|
53
|
-
progress_bar.advance
|
|
54
|
-
sleep 0.016
|
|
55
|
-
end
|
|
56
|
-
puts
|
|
57
|
-
#return thread.value
|
|
58
|
-
end
|
|
59
|
-
end
|
|
5
|
+
# Fetch updates for all repos referenced in a .kobold config.
|
|
6
|
+
#
|
|
7
|
+
# This is a convenience wrapper around Kobold::Manager#fetch_all.
|
|
8
|
+
#
|
|
9
|
+
# @param config_path [String] path to the .kobold file (default: current dir)
|
|
10
|
+
# @param database_name [String] which database to use (default: "default")
|
|
11
|
+
# @return [Array<Hash>] results for each dependency fetched
|
|
12
|
+
def fetch(config_path: Dir.pwd, database_name: "default")
|
|
13
|
+
manager = Manager.new(database: database_name)
|
|
14
|
+
results = manager.fetch_all(config_path: config_path)
|
|
15
|
+
puts "Fetch complete."
|
|
16
|
+
results
|
|
60
17
|
end
|
|
61
18
|
end
|
|
62
19
|
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "git"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module Kobold
|
|
8
|
+
# Low-level Git operations used internally by Repo and other classes.
|
|
9
|
+
# All methods are module-level (stateless).
|
|
10
|
+
module GitOps
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Clone a repository as a bare clone.
|
|
14
|
+
#
|
|
15
|
+
# @param url [String] the Git remote URL
|
|
16
|
+
# @param path [String] the local destination path
|
|
17
|
+
# @return [Git::Base] the opened bare repository
|
|
18
|
+
def clone(url, path)
|
|
19
|
+
puts "Cloning: #{url}"
|
|
20
|
+
_out, err, status = Open3.capture3("git", "clone", "--bare", url, path)
|
|
21
|
+
raise Errors::CloneError.new(url, err) unless status.success?
|
|
22
|
+
|
|
23
|
+
puts "Clone complete."
|
|
24
|
+
Git.bare(path)
|
|
25
|
+
rescue Errors::CloneError
|
|
26
|
+
raise
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
raise Errors::CloneError.new(url, e.message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Open an existing Git repository (bare or non-bare).
|
|
32
|
+
#
|
|
33
|
+
# @param path [String] path to the repository
|
|
34
|
+
# @return [Git::Base]
|
|
35
|
+
def open(path)
|
|
36
|
+
if bare_repo?(path)
|
|
37
|
+
Git.bare(path)
|
|
38
|
+
else
|
|
39
|
+
Git.open(path)
|
|
40
|
+
end
|
|
41
|
+
rescue ArgumentError, Git::Error => e
|
|
42
|
+
raise Errors::GitError, "Failed to open repository at #{path}: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check whether a path is a bare Git repository.
|
|
46
|
+
#
|
|
47
|
+
# @param path [String]
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def bare_repo?(path)
|
|
50
|
+
File.exist?(File.join(path, "HEAD")) && File.directory?(File.join(path, "objects"))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Resolve the git dir for a repo object (bare or non-bare).
|
|
54
|
+
#
|
|
55
|
+
# For bare repos: returns the repo path directly.
|
|
56
|
+
# For non-bare repos: returns the .git directory path.
|
|
57
|
+
#
|
|
58
|
+
# @param repo [Git::Base]
|
|
59
|
+
# @return [String]
|
|
60
|
+
def git_dir(repo)
|
|
61
|
+
repo.repo.path.chomp("/")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Create a Git worktree.
|
|
65
|
+
#
|
|
66
|
+
# Uses --detach to avoid conflicts when a branch is already checked out
|
|
67
|
+
# in the source clone or another worktree.
|
|
68
|
+
#
|
|
69
|
+
# @param repo [Git::Base] the source repository
|
|
70
|
+
# @param path [String] destination path for the worktree
|
|
71
|
+
# @param ref [String] branch name or commit SHA to check out
|
|
72
|
+
# @return [void]
|
|
73
|
+
def create_worktree(repo, path, ref)
|
|
74
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
75
|
+
sha = resolve_ref(repo, ref)
|
|
76
|
+
repo_dir = git_dir(repo)
|
|
77
|
+
_out, err, status = Open3.capture3("git", "--git-dir", repo_dir, "worktree", "add", "--detach", path, sha)
|
|
78
|
+
return if status.success?
|
|
79
|
+
|
|
80
|
+
raise Errors::GitError, "Failed to create worktree at #{path}: #{err}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Remove a Git worktree.
|
|
84
|
+
#
|
|
85
|
+
# @param repo [Git::Base] the source repository
|
|
86
|
+
# @param path [String] path of the worktree to remove
|
|
87
|
+
# @return [void]
|
|
88
|
+
def remove_worktree(repo, path)
|
|
89
|
+
repo_dir = git_dir(repo)
|
|
90
|
+
_out, err, status = Open3.capture3("git", "--git-dir", repo_dir, "worktree", "remove", "--force", path)
|
|
91
|
+
return if status.success?
|
|
92
|
+
|
|
93
|
+
raise Errors::GitError, "Failed to remove worktree at #{path}: #{err}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Fetch all remotes.
|
|
97
|
+
#
|
|
98
|
+
# @param repo [Git::Base] the repository to fetch
|
|
99
|
+
# @param label [String] display label for progress output
|
|
100
|
+
# @return [void]
|
|
101
|
+
def fetch(repo, label: "repository")
|
|
102
|
+
puts "Fetching: #{label}"
|
|
103
|
+
repo_dir = git_dir(repo)
|
|
104
|
+
_out, err, status = Open3.capture3("git", "--git-dir", repo_dir, "fetch", "--all")
|
|
105
|
+
raise Errors::FetchError.new(label, err) unless status.success?
|
|
106
|
+
|
|
107
|
+
puts "Fetch complete."
|
|
108
|
+
rescue Errors::FetchError
|
|
109
|
+
raise
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
raise Errors::FetchError.new(label, e.message)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Resolve a commit reference (short SHA, branch, tag) to a full SHA.
|
|
115
|
+
#
|
|
116
|
+
# @param repo [Git::Base] the repository
|
|
117
|
+
# @param ref [String] the reference to resolve
|
|
118
|
+
# @return [String] full SHA
|
|
119
|
+
def resolve_ref(repo, ref)
|
|
120
|
+
cleaned = ref.to_s.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'")
|
|
121
|
+
repo.object(cleaned).sha
|
|
122
|
+
rescue Git::Error => e
|
|
123
|
+
raise Errors::GitError, "Failed to resolve ref '#{ref}': #{e.message}. Try running fetch first."
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Resolve a branch name to the full SHA of its HEAD.
|
|
127
|
+
# Checks both local and remote branches.
|
|
128
|
+
#
|
|
129
|
+
# @param repo [Git::Base] the repository
|
|
130
|
+
# @param branch [String] the branch name
|
|
131
|
+
# @return [String] full SHA of the branch HEAD
|
|
132
|
+
def resolve_branch_head(repo, branch)
|
|
133
|
+
try_remote_branch(repo, branch) || resolve_local_branch(repo, branch)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Read the default branch from HEAD in a bare repository.
|
|
137
|
+
#
|
|
138
|
+
# @param repo [Git::Base]
|
|
139
|
+
# @return [String] the default branch name (e.g. "main")
|
|
140
|
+
def default_branch(repo)
|
|
141
|
+
repo_dir = git_dir(repo)
|
|
142
|
+
out, _err, status = Open3.capture3("git", "--git-dir", repo_dir, "symbolic-ref", "--short", "HEAD")
|
|
143
|
+
return out.strip if status.success?
|
|
144
|
+
|
|
145
|
+
"main"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @api private
|
|
149
|
+
def try_remote_branch(repo, branch)
|
|
150
|
+
repo.object("origin/#{branch}").sha
|
|
151
|
+
rescue Git::Error
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @api private
|
|
156
|
+
def resolve_local_branch(repo, branch)
|
|
157
|
+
repo.object(branch).sha
|
|
158
|
+
rescue Git::Error => e
|
|
159
|
+
raise Errors::GitError, "Failed to resolve branch '#{branch}': #{e.message}. Try running fetch first."
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
data/lib/Kobold/init.rb
CHANGED
|
@@ -1,36 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "tty-config"
|
|
4
|
-
require 'fileutils'
|
|
5
|
-
require 'git'
|
|
6
|
-
|
|
7
3
|
module Kobold
|
|
8
4
|
class << self
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
[
|
|
14
|
-
|
|
5
|
+
# Create a new .kobold TOML file in the current directory.
|
|
6
|
+
#
|
|
7
|
+
# This is a convenience wrapper around Kobold::Manager#init.
|
|
8
|
+
#
|
|
9
|
+
# @param dir [String] directory to create the file in (default: current dir)
|
|
10
|
+
# @return [Hash] { path:, created: }
|
|
11
|
+
def init(dir: Dir.pwd)
|
|
12
|
+
manager = Manager.new
|
|
13
|
+
result = manager.init(dir: dir)
|
|
14
|
+
|
|
15
|
+
if result[:created]
|
|
16
|
+
puts "Created .kobold at #{result[:path]}"
|
|
17
|
+
else
|
|
18
|
+
puts "WARNING: .kobold already exists at #{dir}"
|
|
19
|
+
end
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
;[raylib-linux]
|
|
18
|
-
;
|
|
19
|
-
; required
|
|
20
|
-
;repo = raysan5/raylib
|
|
21
|
-
;source = https://github.com
|
|
22
|
-
;
|
|
23
|
-
; optional, remove slash at the end to rename the dir the repo is in
|
|
24
|
-
;dir = external/linux-x64/
|
|
25
|
-
;
|
|
26
|
-
; one of these 2 is required
|
|
27
|
-
;branch = something
|
|
28
|
-
;commit = 'b8cd102'
|
|
29
|
-
;
|
|
30
|
-
; optional, makes unique trunk
|
|
31
|
-
;label = linux-x64
|
|
32
|
-
EOS
|
|
33
|
-
File.write('.kobold', kobold_default)
|
|
21
|
+
result
|
|
34
22
|
end
|
|
35
23
|
end
|
|
36
24
|
end
|