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.
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 -63
  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 +12 -55
  40. data/lib/Kobold/git_ops.rb +162 -0
  41. data/lib/Kobold/init.rb +16 -28
  42. data/lib/Kobold/invoke.rb +12 -204
  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 -8
  55. data/lib/Kobold.rb +14 -14
  56. data/prototyping/.kobold +19 -24
  57. data/sample-project-ideas/.kobold +19 -27
  58. data/sig/Kobold.rbs +217 -1
  59. metadata +59 -59
  60. data/lib/Kobold/first_time_setup.rb +0 -14
  61. 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
- def fetch
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
- 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
- # create empty file with current
10
- # file version in current dir
11
- def init
12
- kobold_default = <<-EOS
13
- [_kobold_config]
14
- format_version = #{Kobold::FORMAT_VERSION}
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
- ; must be unique, can be anything that doesnt start with underscore
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