gitbroker 0.6.6 → 0.7.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/lib/gitbroker/agent_projects.rb +35 -0
- data/lib/gitbroker/cli.rb +4 -4
- data/lib/gitbroker/config.rb +34 -7
- data/lib/gitbroker/daemon.rb +27 -2
- data/lib/gitbroker/repo_scanner.rb +37 -5
- data/lib/gitbroker/version.rb +1 -1
- data/lib/gitbroker.rb +1 -0
- data/plugin/.claude-plugin/plugin.json +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cdb076d007739b3e1eb411ac473ffe84dc28423a2ad78065a4aa33d14f11bbd9
|
|
4
|
+
data.tar.gz: 93e99568aba514f872f45e8f445ca9f1c40fdc8d0ffccfdbd1b454feb3008a54
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b87041c66a6a11b01ab1003ee3912e7bddc8cb2ccdd7d8b6c6162bc4da04320530c621d941024b059dbe13080088b8d21112b7f47a24481d76369930093eea9a
|
|
7
|
+
data.tar.gz: 57b801d31d18aae68cb9bf52e9e58a890a9fa9b41cddbc7c43eb3cc9aed227fca3064bdca72b58b9523630855aa3c657c899915da79d45d8f3ecb55199e37a81
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Gitbroker
|
|
6
|
+
# The directories the user's own agents already know: Claude Code lists every project it ran in (~/.claude.json),
|
|
7
|
+
# Codex every project it trusts (~/.codex/config.toml). They tell where the user keeps code without asking and without
|
|
8
|
+
# walking the disk. Only the paths are read, and they never leave the machine: the server gets what RepoScanner
|
|
9
|
+
# finds in them (a repository name and a path), as for any other checkout.
|
|
10
|
+
module AgentProjects
|
|
11
|
+
CODEX_PROJECT = /\A\[projects\.(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\]\s*\z/
|
|
12
|
+
|
|
13
|
+
def self.paths(home: Dir.home)
|
|
14
|
+
(claude(File.join(home, ".claude.json")) + codex(File.join(home, ".codex", "config.toml"))).uniq
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.claude(file)
|
|
18
|
+
projects = File.file?(file) ? JSON.parse(File.read(file))["projects"] : nil
|
|
19
|
+
projects.is_a?(Hash) ? projects.keys.grep(String) : []
|
|
20
|
+
rescue JSON::ParserError, SystemCallError
|
|
21
|
+
[]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.codex(file)
|
|
25
|
+
return [] unless File.file?(file)
|
|
26
|
+
|
|
27
|
+
File.foreach(file).filter_map { |line|
|
|
28
|
+
match = CODEX_PROJECT.match(line.strip)
|
|
29
|
+
match && (match[1] ? match[1].gsub(/\\(.)/, '\\1') : match[2])
|
|
30
|
+
}
|
|
31
|
+
rescue SystemCallError
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/gitbroker/cli.rb
CHANGED
|
@@ -81,7 +81,7 @@ module Gitbroker
|
|
|
81
81
|
|
|
82
82
|
private
|
|
83
83
|
|
|
84
|
-
def config = @config ||= Config.load(dir: Config.default_dir(@env), env: @env)
|
|
84
|
+
def config = @config ||= Config.load(dir: Config.default_dir(@env), env: @env, home: @home)
|
|
85
85
|
|
|
86
86
|
def login
|
|
87
87
|
options = {}
|
|
@@ -107,7 +107,7 @@ module Gitbroker
|
|
|
107
107
|
if options[:server]
|
|
108
108
|
# Setting a machine up remembers its server (the background service must use it too). A daemon run by hand only
|
|
109
109
|
# uses it for this run: it must not rewrite the settings the service reads.
|
|
110
|
-
@config = foreground ? Config.load(dir: Config.default_dir(@env), env: @env.merge("GITBROKER_SERVER" => options[:server])) : config.save!("server" => options[:server])
|
|
110
|
+
@config = foreground ? Config.load(dir: Config.default_dir(@env), env: @env.merge("GITBROKER_SERVER" => options[:server]), home: @home) : config.save!("server" => options[:server])
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
return run_daemon if foreground
|
|
@@ -250,10 +250,10 @@ module Gitbroker
|
|
|
250
250
|
@out.puts "Agent: #{config.agent}"
|
|
251
251
|
@out.puts "Terminal: #{config.terminal}"
|
|
252
252
|
@out.puts "Hooks: #{hooks_installer.installed? ? "installed" : "not installed (gitbroker install-hooks)"}"
|
|
253
|
-
@out.puts "Roots: #{config.roots.join(", ")}"
|
|
253
|
+
@out.puts "Roots: #{config.roots.join(", ")} (plus the projects Claude Code and Codex know)"
|
|
254
254
|
@out.puts "Worktrees: #{config.worktree_root}"
|
|
255
255
|
@out.puts "Task worktrees: #{Worktree.new(root: config.worktree_root).list.size} (running or kept; `gitbroker clean` removes finished ones)"
|
|
256
|
-
repositories =
|
|
256
|
+
repositories = config.repo_scanner.scan
|
|
257
257
|
@out.puts "Repositories found: #{repositories.size}"
|
|
258
258
|
repositories.each { @out.puts " #{_1["full_name"]} #{_1["path"]}" }
|
|
259
259
|
end
|
data/lib/gitbroker/config.rb
CHANGED
|
@@ -13,22 +13,46 @@ module Gitbroker
|
|
|
13
13
|
DEFAULT_CLAUDE_ARGS = %w[--permission-mode auto --permission-prompts none].freeze
|
|
14
14
|
DEFAULT_CODEX_ARGS = %w[--sandbox workspace-write -c sandbox_workspace_write.network_access=true].freeze
|
|
15
15
|
# Git Broker sets these itself for every run; user args may not override them.
|
|
16
|
+
# Where people usually keep code; the ones that exist are always scanned (with `roots` and the agents' project lists).
|
|
17
|
+
COMMON_ROOTS = %w[~/Code ~/code ~/Projects ~/projects ~/Developer ~/dev ~/src ~/repos ~/workspace ~/work ~/git ~/github ~/Sites].freeze
|
|
16
18
|
RESERVED_ARGS = %w[-p --print --output-format --mcp-config --strict-mcp-config --plugin-dir --json].freeze
|
|
17
19
|
|
|
18
|
-
attr_reader :dir, :data
|
|
20
|
+
attr_reader :dir, :data, :home
|
|
19
21
|
|
|
20
22
|
def self.default_dir(env = ENV)
|
|
21
23
|
env["GITBROKER_CONFIG_DIR"] || File.join(env["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config"), "gitbroker")
|
|
22
24
|
end
|
|
23
25
|
|
|
24
|
-
def self.
|
|
26
|
+
def self.common_roots(home = Dir.home)
|
|
27
|
+
COMMON_ROOTS.map { File.expand_path(_1.sub("~", home)) }.select { File.directory?(_1) }.uniq { _1.downcase }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
MAX_DEVICE_ROOTS = 20
|
|
31
|
+
|
|
32
|
+
# The folders the user added in Git Broker (a "roots" message from the server). The server is not trusted with more
|
|
33
|
+
# than a list of existing-looking absolute folders: no home folder, no "/", a bounded count.
|
|
34
|
+
def self.clean_device_roots(list, home: Dir.home)
|
|
35
|
+
home = File.expand_path(home)
|
|
36
|
+
Array(list).first(MAX_DEVICE_ROOTS * 2).filter_map { |entry|
|
|
37
|
+
next unless entry.is_a?(String) && entry.length <= 500 && !entry.include?("\0")
|
|
38
|
+
|
|
39
|
+
entry = entry.strip
|
|
40
|
+
next unless entry.start_with?("/", "~/")
|
|
41
|
+
|
|
42
|
+
path = File.expand_path(entry.sub(/\A~/, home))
|
|
43
|
+
path unless path == home || path == "/"
|
|
44
|
+
}.uniq.first(MAX_DEVICE_ROOTS)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.load(dir: default_dir, env: ENV, platform: Gitbroker.platform, home: Dir.home)
|
|
25
48
|
path = File.join(dir, "config.yml")
|
|
26
49
|
data = File.exist?(path) ? YAML.safe_load_file(path) || {} : {}
|
|
27
|
-
new(dir:, data:, env:, platform:)
|
|
50
|
+
new(dir:, data:, env:, platform:, home:)
|
|
28
51
|
end
|
|
29
52
|
|
|
30
|
-
def initialize(dir:, data: {}, env: ENV, platform: Gitbroker.platform)
|
|
53
|
+
def initialize(dir:, data: {}, env: ENV, platform: Gitbroker.platform, home: Dir.home)
|
|
31
54
|
@dir = dir
|
|
55
|
+
@home = home
|
|
32
56
|
@data = data.to_h.transform_keys(&:to_s)
|
|
33
57
|
@env = env
|
|
34
58
|
@platform = platform
|
|
@@ -37,10 +61,13 @@ module Gitbroker
|
|
|
37
61
|
|
|
38
62
|
def server = (@env["GITBROKER_SERVER"] || data["server"] || DEFAULT_SERVER).to_s.chomp("/")
|
|
39
63
|
def cable_url = "#{server.sub(/\Ahttp/, "ws")}/cable"
|
|
40
|
-
|
|
64
|
+
# `roots` in config.yml and the folders added in Git Broker (`device_roots`) add to the usual folders; the checkouts Claude Code and Codex know come in through `repo_scanner`.
|
|
65
|
+
def device_roots = self.class.clean_device_roots(data["device_roots"], home:)
|
|
66
|
+
def roots = (self.class.common_roots(home) + Array(data["roots"]).map { File.expand_path(_1.to_s.sub(/\A~/, home)) } + device_roots).uniq
|
|
67
|
+
def repo_scanner = RepoScanner.new(roots:, depth: scan_depth, exclude: [ worktree_root ], hints: AgentProjects.paths(home:), home:)
|
|
41
68
|
def scan_depth = Integer(data["scan_depth"] || 3)
|
|
42
69
|
def terminal = (data["terminal"] || (@platform == "macos" ? "terminal" : "tmux")).to_s
|
|
43
|
-
def worktree_root = File.expand_path(data["worktree_root"] || "
|
|
70
|
+
def worktree_root = File.expand_path(data["worktree_root"] || File.join(home, ".local/share/gitbroker/worktrees"))
|
|
44
71
|
def state_dir = File.expand_path(data["state_dir"] || "~/.local/state/gitbroker")
|
|
45
72
|
def agent = (data["agent"] || "claude").to_s
|
|
46
73
|
def claude_command = (data["claude_command"] || "claude").to_s
|
|
@@ -57,7 +84,7 @@ module Gitbroker
|
|
|
57
84
|
merged = data.merge(updates.transform_keys(&:to_s)).compact
|
|
58
85
|
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
59
86
|
File.write(File.join(dir, "config.yml"), merged.to_yaml)
|
|
60
|
-
self.class.new(dir:, data: merged, env: @env, platform: @platform)
|
|
87
|
+
self.class.new(dir:, data: merged, env: @env, platform: @platform, home:)
|
|
61
88
|
end
|
|
62
89
|
|
|
63
90
|
def save_credentials!(device_token:, device_id:)
|
data/lib/gitbroker/daemon.rb
CHANGED
|
@@ -23,12 +23,22 @@ module Gitbroker
|
|
|
23
23
|
process_scanner: build_process_scanner(config))
|
|
24
24
|
raise Error, "This machine is not paired. Run `gitbroker start` in a terminal to pair it." unless config.paired?
|
|
25
25
|
|
|
26
|
-
new(scanner:
|
|
26
|
+
new(scanner: config.repo_scanner, roots_changed: roots_changed(config), device_roots: config.device_roots,
|
|
27
27
|
client_factory: -> { CableClient.new(url: config.cable_url, token: config.device_token, origin: config.server) },
|
|
28
28
|
task_runner_factory: ->(report) { TaskRunner.new(config:, report:, out:) }, out:, relay:, process_scanner:,
|
|
29
29
|
lifecycle: Lifecycle.new(dir: config.dir, server: config.server))
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# The server's "roots" message: remember the folders in config.yml (so they survive a restart without the server) and
|
|
33
|
+
# scan with them from now on. Returns the new scanner.
|
|
34
|
+
def self.roots_changed(config)
|
|
35
|
+
current = config
|
|
36
|
+
lambda do |roots|
|
|
37
|
+
current = current.save!("device_roots" => roots.empty? ? nil : roots)
|
|
38
|
+
current.repo_scanner
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
# Claude sessions with hooks installed report real working/waiting status, so the scanner only looks for `claude`
|
|
33
43
|
# without hooks (checked on every scan, so installing hooks takes effect without a restart). Git Broker's own
|
|
34
44
|
# worktrees are skipped: those agents are tasks, reported by the task.
|
|
@@ -44,8 +54,10 @@ module Gitbroker
|
|
|
44
54
|
|
|
45
55
|
def initialize(scanner:, client_factory:, task_runner_factory:, out: $stdout, sleeper: ->(seconds) { sleep(seconds) },
|
|
46
56
|
clock: -> { Time.now }, spawner: ->(&block) { Thread.new(&block) }, relay: nil, process_scanner: nil, lifecycle: nil,
|
|
47
|
-
maintenance: Maintenance.new)
|
|
57
|
+
maintenance: Maintenance.new, roots_changed: nil, device_roots: [])
|
|
48
58
|
@scanner = scanner
|
|
59
|
+
@device_roots = device_roots
|
|
60
|
+
@roots_changed = roots_changed
|
|
49
61
|
@client_factory = client_factory
|
|
50
62
|
@task_runner = task_runner_factory.call(method(:report))
|
|
51
63
|
@out = out
|
|
@@ -209,6 +221,7 @@ module Gitbroker
|
|
|
209
221
|
|
|
210
222
|
def handle(client, message)
|
|
211
223
|
return control(message["action"]) if message["type"] == "control"
|
|
224
|
+
return apply_roots(client, message["roots"]) if message["type"] == "roots"
|
|
212
225
|
|
|
213
226
|
id = message["task_id"]
|
|
214
227
|
return unless id.is_a?(String)
|
|
@@ -220,6 +233,18 @@ module Gitbroker
|
|
|
220
233
|
end
|
|
221
234
|
end
|
|
222
235
|
|
|
236
|
+
# Sent on every connection and whenever the user changes the list. Nothing happens when it is what we already scan.
|
|
237
|
+
def apply_roots(client, roots)
|
|
238
|
+
return unless @roots_changed
|
|
239
|
+
|
|
240
|
+
clean = Config.clean_device_roots(roots, home: Dir.home)
|
|
241
|
+
return if clean == @device_roots
|
|
242
|
+
|
|
243
|
+
@device_roots = clean
|
|
244
|
+
@scanner = @roots_changed.call(clean)
|
|
245
|
+
rescan(client)
|
|
246
|
+
end
|
|
247
|
+
|
|
223
248
|
def start_task(id, message)
|
|
224
249
|
return unless @running_lock.synchronize { !@upgrading && @running.add?(id) } # the upgrade's restart would kill it
|
|
225
250
|
|
|
@@ -4,7 +4,7 @@ module Gitbroker
|
|
|
4
4
|
# Finds GitHub checkouts under the configured roots and reports them (DeviceRepository), so a card only offers
|
|
5
5
|
# agents where a checkout exists. Reads .git/config as a file; never runs git in scanned directories.
|
|
6
6
|
class RepoScanner
|
|
7
|
-
GITHUB_URL = %r{\A(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)([\w.-]+)/([\w.-]+?)(?:\.git)?/?\z}
|
|
7
|
+
GITHUB_URL = %r{\A(?:https://(?:[^@/\s]+@)?github\.com/|git@github\.com:|ssh://git@github\.com/)([\w.-]+)/([\w.-]+?)(?:\.git)?/?\z}
|
|
8
8
|
SKIP = %w[node_modules vendor tmp log].freeze
|
|
9
9
|
|
|
10
10
|
def self.full_name_from(url)
|
|
@@ -12,8 +12,17 @@ module Gitbroker
|
|
|
12
12
|
match && "#{match[1]}/#{match[2]}".downcase
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# `hints` are directories known to hold work (the agents' project lists): each counts as a checkout when it, or a
|
|
16
|
+
# parent up to HINT_LEVELS above it, is one, however deep it is. Its parent folder is scanned too (HINT_PARENT_DEPTH
|
|
17
|
+
# levels), because the user keeps the other repositories next to it, where no agent has run. A parent that is the home
|
|
18
|
+
# folder (or above it) is never scanned: that is the whole account, not a place for code.
|
|
19
|
+
HINT_LEVELS = 4
|
|
20
|
+
HINT_PARENT_DEPTH = 2
|
|
21
|
+
|
|
22
|
+
def initialize(roots:, depth: 3, exclude: [], hints: [], home: Dir.home)
|
|
16
23
|
@roots = roots
|
|
24
|
+
@hints = hints
|
|
25
|
+
@home = File.expand_path(home)
|
|
17
26
|
@depth = depth
|
|
18
27
|
@exclude = exclude.map { File.expand_path(_1) }
|
|
19
28
|
end
|
|
@@ -21,25 +30,48 @@ module Gitbroker
|
|
|
21
30
|
def scan
|
|
22
31
|
found = {}
|
|
23
32
|
@roots.each { |root| walk(File.expand_path(root), 0, found) if File.directory?(root) }
|
|
33
|
+
parents = @hints.filter_map { |hint| add_hint(File.expand_path(hint), found) }.map { File.dirname(_1) }.uniq
|
|
34
|
+
parents.each { |parent| walk(parent, 0, found, HINT_PARENT_DEPTH) if scannable_parent?(parent) }
|
|
24
35
|
found.sort.map { |full_name, path| { "full_name" => full_name, "path" => path } }
|
|
25
36
|
end
|
|
26
37
|
|
|
27
38
|
private
|
|
28
39
|
|
|
29
|
-
|
|
40
|
+
# Records the checkout a hint is in and returns its directory (nil when it is not in one).
|
|
41
|
+
def add_hint(dir, found)
|
|
42
|
+
HINT_LEVELS.times do
|
|
43
|
+
return nil if @exclude.any? { |excluded| dir == excluded || dir.start_with?("#{excluded}/") }
|
|
44
|
+
return nil unless File.directory?(dir)
|
|
45
|
+
|
|
46
|
+
if File.exist?(File.join(dir, ".git"))
|
|
47
|
+
remote_names(dir).each { |name| found[name] = dir if found[name].nil? || dir.length < found[name].length }
|
|
48
|
+
return dir
|
|
49
|
+
end
|
|
50
|
+
dir = File.dirname(dir)
|
|
51
|
+
end
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scannable_parent?(dir)
|
|
56
|
+
return true if dir.start_with?("#{@home}/")
|
|
57
|
+
|
|
58
|
+
!(dir == @home || dir == "/" || @home.start_with?("#{dir}/"))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def walk(dir, level, found, limit = @depth)
|
|
30
62
|
return if @exclude.any? { |excluded| dir == excluded || dir.start_with?("#{excluded}/") }
|
|
31
63
|
|
|
32
64
|
if File.exist?(File.join(dir, ".git"))
|
|
33
65
|
remote_names(dir).each { |name| found[name] = dir if found[name].nil? || dir.length < found[name].length }
|
|
34
66
|
return
|
|
35
67
|
end
|
|
36
|
-
return if level >=
|
|
68
|
+
return if level >= limit
|
|
37
69
|
|
|
38
70
|
Dir.children(dir).sort.each do |child|
|
|
39
71
|
next if child.start_with?(".") || SKIP.include?(child)
|
|
40
72
|
|
|
41
73
|
path = File.join(dir, child)
|
|
42
|
-
walk(path, level + 1, found) if File.directory?(path) && !File.symlink?(path)
|
|
74
|
+
walk(path, level + 1, found, limit) if File.directory?(path) && !File.symlink?(path)
|
|
43
75
|
end
|
|
44
76
|
rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR
|
|
45
77
|
nil
|
data/lib/gitbroker/version.rb
CHANGED
data/lib/gitbroker.rb
CHANGED
|
@@ -13,6 +13,7 @@ module Gitbroker
|
|
|
13
13
|
# Loads every autoloaded constant. Called before `gem cleanup` removes the files of the running version.
|
|
14
14
|
def self.eager_load! = constants.each { const_get(_1) }
|
|
15
15
|
|
|
16
|
+
autoload :AgentProjects, File.expand_path("gitbroker/agent_projects", __dir__)
|
|
16
17
|
autoload :Agents, File.expand_path("gitbroker/agents", __dir__)
|
|
17
18
|
autoload :Api, File.expand_path("gitbroker/api", __dir__)
|
|
18
19
|
autoload :BranchPublisher, File.expand_path("gitbroker/branch_publisher", __dir__)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitbroker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yuri Sidorov (@newstler)
|
|
@@ -34,6 +34,7 @@ files:
|
|
|
34
34
|
- LICENSE.txt
|
|
35
35
|
- exe/gitbroker
|
|
36
36
|
- lib/gitbroker.rb
|
|
37
|
+
- lib/gitbroker/agent_projects.rb
|
|
37
38
|
- lib/gitbroker/agents.rb
|
|
38
39
|
- lib/gitbroker/agents/base.rb
|
|
39
40
|
- lib/gitbroker/agents/claude.rb
|