gitbroker 0.6.6 → 0.7.1

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: d89c372f196a3cd8b81d057f63c8abcdafa765585fcde6abaa15dd8ec81c48bf
4
+ data.tar.gz: 473d304e0ed3fe9bafc232490e4be505b17b12b09c2cebf6b194d2547b108203
5
5
  SHA512:
6
- metadata.gz: a784b73eceee92147c09a4a28de801bc40b3b6f93f2d2323c12dcad7c2e7da04901372fd6319464f3a494f4e82c3a12adfc653dcae329e22af3b130be6b54916
7
- data.tar.gz: c9aace186cb3e2a3309182e791e53ad3c1553ee167e50951cdab83b35aad0eb19c2f0f1f1bc82d847e1936e11278708d27c6cda0017d7e9854b76630e19b7344
6
+ metadata.gz: c0d446a0a798acb225933b1cd4c1f31c3425112ac025a2bc684b468e47bc57869b56f892683a4c278cbbb788ac5cd7f5f56fa8286671c98d160d115d72313c4c
7
+ data.tar.gz: 3914718aa114dfe375b4783dfe9c6d5e0408d43d319c013e400f4628912e15984f937417043409b6dfd9b3766cb2e6c3ed7f2a74ae678cc52813a35f57560f81
@@ -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,18 @@ 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 ignored_roots = self.class.clean_device_roots(data["ignored_roots"], home:)
67
+ def roots = (self.class.common_roots(home) + Array(data["roots"]).map { File.expand_path(_1.to_s.sub(/\A~/, home)) } + device_roots).uniq
68
+ # Folders the user removed in Git Broker (`ignored_roots`) are skipped everywhere except inside a folder they added.
69
+ def repo_scanner
70
+ RepoScanner.new(roots: roots - device_roots, explicit_roots: device_roots, depth: scan_depth, exclude: [ worktree_root ],
71
+ ignored: ignored_roots, hints: AgentProjects.paths(home:), home:)
72
+ end
41
73
  def scan_depth = Integer(data["scan_depth"] || 3)
42
74
  def terminal = (data["terminal"] || (@platform == "macos" ? "terminal" : "tmux")).to_s
43
- def worktree_root = File.expand_path(data["worktree_root"] || "~/.local/share/gitbroker/worktrees")
75
+ def worktree_root = File.expand_path(data["worktree_root"] || File.join(home, ".local/share/gitbroker/worktrees"))
44
76
  def state_dir = File.expand_path(data["state_dir"] || "~/.local/state/gitbroker")
45
77
  def agent = (data["agent"] || "claude").to_s
46
78
  def claude_command = (data["claude_command"] || "claude").to_s
@@ -57,7 +89,7 @@ module Gitbroker
57
89
  merged = data.merge(updates.transform_keys(&:to_s)).compact
58
90
  FileUtils.mkdir_p(dir, mode: 0o700)
59
91
  File.write(File.join(dir, "config.yml"), merged.to_yaml)
60
- self.class.new(dir:, data: merged, env: @env, platform: @platform)
92
+ self.class.new(dir:, data: merged, env: @env, platform: @platform, home:)
61
93
  end
62
94
 
63
95
  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, ignored_roots: config.ignored_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, ignored|
37
+ current = current.save!("device_roots" => roots.empty? ? nil : roots, "ignored_roots" => ignored.empty? ? nil : ignored)
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,11 @@ 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: [], ignored_roots: [])
48
58
  @scanner = scanner
59
+ @device_roots = device_roots
60
+ @ignored_roots = ignored_roots
61
+ @roots_changed = roots_changed
49
62
  @client_factory = client_factory
50
63
  @task_runner = task_runner_factory.call(method(:report))
51
64
  @out = out
@@ -209,6 +222,7 @@ module Gitbroker
209
222
 
210
223
  def handle(client, message)
211
224
  return control(message["action"]) if message["type"] == "control"
225
+ return apply_roots(client, message["roots"], message["ignored"]) if message["type"] == "roots"
212
226
 
213
227
  id = message["task_id"]
214
228
  return unless id.is_a?(String)
@@ -220,6 +234,20 @@ module Gitbroker
220
234
  end
221
235
  end
222
236
 
237
+ # Sent on every connection and whenever the user changes the list. Nothing happens when it is what we already scan.
238
+ def apply_roots(client, roots, ignored)
239
+ return unless @roots_changed
240
+
241
+ clean = Config.clean_device_roots(roots, home: Dir.home)
242
+ clean_ignored = Config.clean_device_roots(ignored, home: Dir.home)
243
+ return if clean == @device_roots && clean_ignored == @ignored_roots
244
+
245
+ @device_roots = clean
246
+ @ignored_roots = clean_ignored
247
+ @scanner = @roots_changed.call(clean, clean_ignored)
248
+ rescan(client)
249
+ end
250
+
223
251
  def start_task(id, message)
224
252
  return unless @running_lock.synchronize { !@upgrading && @running.add?(id) } # the upgrade's restart would kill it
225
253
 
@@ -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,34 +12,72 @@ 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
+ # `exclude` (the task worktrees) is skipped everywhere. `ignored` (folders the user removed) is skipped by everything
23
+ # found by itself; `explicit_roots` (folders the user added) are scanned even inside an ignored folder.
24
+ def initialize(roots:, depth: 3, exclude: [], hints: [], home: Dir.home, explicit_roots: [], ignored: [])
16
25
  @roots = roots
26
+ @explicit_roots = explicit_roots
27
+ @ignored = ignored.map { File.expand_path(_1) }
28
+ @hints = hints
29
+ @home = File.expand_path(home)
17
30
  @depth = depth
18
31
  @exclude = exclude.map { File.expand_path(_1) }
32
+ @skip = @exclude + @ignored
19
33
  end
20
34
 
21
35
  def scan
22
36
  found = {}
23
- @roots.each { |root| walk(File.expand_path(root), 0, found) if File.directory?(root) }
37
+ @roots.each { |root| walk(File.expand_path(root), 0, found, @depth, @skip) if File.directory?(root) }
38
+ @explicit_roots.each { |root| walk(File.expand_path(root), 0, found, @depth, @exclude) if File.directory?(root) }
39
+ parents = @hints.filter_map { |hint| add_hint(File.expand_path(hint), found) }.map { File.dirname(_1) }.uniq
40
+ parents.each { |parent| walk(parent, 0, found, HINT_PARENT_DEPTH, @skip) if scannable_parent?(parent) }
24
41
  found.sort.map { |full_name, path| { "full_name" => full_name, "path" => path } }
25
42
  end
26
43
 
27
44
  private
28
45
 
29
- def walk(dir, level, found)
30
- return if @exclude.any? { |excluded| dir == excluded || dir.start_with?("#{excluded}/") }
46
+ # Records the checkout a hint is in and returns its directory (nil when it is not in one).
47
+ def add_hint(dir, found)
48
+ HINT_LEVELS.times do
49
+ return nil if @skip.any? { |skipped| dir == skipped || dir.start_with?("#{skipped}/") }
50
+ return nil unless File.directory?(dir)
51
+
52
+ if File.exist?(File.join(dir, ".git"))
53
+ remote_names(dir).each { |name| found[name] = dir if found[name].nil? || dir.length < found[name].length }
54
+ return dir
55
+ end
56
+ dir = File.dirname(dir)
57
+ end
58
+ nil
59
+ end
60
+
61
+ def scannable_parent?(dir)
62
+ return true if dir.start_with?("#{@home}/")
63
+
64
+ !(dir == @home || dir == "/" || @home.start_with?("#{dir}/"))
65
+ end
66
+
67
+ def walk(dir, level, found, limit, skip)
68
+ return if skip.any? { |skipped| dir == skipped || dir.start_with?("#{skipped}/") }
31
69
 
32
70
  if File.exist?(File.join(dir, ".git"))
33
71
  remote_names(dir).each { |name| found[name] = dir if found[name].nil? || dir.length < found[name].length }
34
72
  return
35
73
  end
36
- return if level >= @depth
74
+ return if level >= limit
37
75
 
38
76
  Dir.children(dir).sort.each do |child|
39
77
  next if child.start_with?(".") || SKIP.include?(child)
40
78
 
41
79
  path = File.join(dir, child)
42
- walk(path, level + 1, found) if File.directory?(path) && !File.symlink?(path)
80
+ walk(path, level + 1, found, limit, skip) if File.directory?(path) && !File.symlink?(path)
43
81
  end
44
82
  rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR
45
83
  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.1"
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.1",
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.1
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