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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ef35d4ad926d8851302c8ed0c6c5be6e0dd70f5207fac41d6fc4f77b02e46b0
4
- data.tar.gz: 1c6afa6a9db5082116ecaab590dff94ce6d3ca1527d0459fc9ddf468d77c7abb
3
+ metadata.gz: cdb076d007739b3e1eb411ac473ffe84dc28423a2ad78065a4aa33d14f11bbd9
4
+ data.tar.gz: 93e99568aba514f872f45e8f445ca9f1c40fdc8d0ffccfdbd1b454feb3008a54
5
5
  SHA512:
6
- metadata.gz: a784b73eceee92147c09a4a28de801bc40b3b6f93f2d2323c12dcad7c2e7da04901372fd6319464f3a494f4e82c3a12adfc653dcae329e22af3b130be6b54916
7
- data.tar.gz: c9aace186cb3e2a3309182e791e53ad3c1553ee167e50951cdab83b35aad0eb19c2f0f1f1bc82d847e1936e11278708d27c6cda0017d7e9854b76630e19b7344
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 = RepoScanner.new(roots: config.roots, depth: config.scan_depth, exclude: [ config.worktree_root ]).scan
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
@@ -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.load(dir: default_dir, env: ENV, platform: Gitbroker.platform)
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
- def roots = Array(data["roots"] || [ "~/Code" ]).map { File.expand_path(_1) }
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"] || "~/.local/share/gitbroker/worktrees")
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:)
@@ -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: RepoScanner.new(roots: config.roots, depth: config.scan_depth, exclude: [ config.worktree_root ]),
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
- def initialize(roots:, depth: 3, exclude: [])
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
- def walk(dir, level, found)
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 >= @depth
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gitbroker
4
- VERSION = "0.6.6"
4
+ VERSION = "0.7.0"
5
5
  end
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__)
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "gitbroker",
3
- "version": "0.6.6",
3
+ "version": "0.7.0",
4
4
  "description": "Skills for working on Git Broker agent tasks: the gitbroker MCP tools, write rules and report_task."
5
5
  }
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.6.6
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