Class: Agentilda::Worktree
- Inherits:
-
Object
- Object
- Agentilda::Worktree
- Defined in:
- lib/agentilda/worktree.rb
Overview
A git worktree per plan, so agents working on different plans cannot collide at all.
A lock coordinates a shared tree; a worktree removes the sharing. That matters more than it sounds for an agent loop: two agents editing one checkout produce no git conflict — same branch, same files — so the last writer simply wins and the loser's work vanishes with nothing anywhere to say it happened.
The branch carries the plan number (<user>/NNN.MM-slug), which is not
decoration: it is the first thing resync prs reads when deciding which
plan a pull request implements. Naming branches this way means the number
carries itself from worktree creation through to a merged pull request with
nobody having to remember it.
Defined Under Namespace
Classes: Checkout
Constant Summary collapse
- SEEDER =
bin/setup-worktree, which copies in the ignored-but-required files a new checkout does not get. Shelling out rather than reimplementing the rules here keeps one answer to "what does a worktree need", and keeps that answer usable from a shell on a machine that has never runbundle install. File.("../../bin/setup-worktree", __dir__)
Instance Attribute Summary collapse
-
#dir ⇒ String
readonly
Where worktrees are kept.
-
#root ⇒ String
readonly
The main repository.
Instance Method Summary collapse
-
#branch_for(feature) ⇒ String
E.g.
-
#canonical(path) ⇒ String
The symlink-resolved path, or the input when it is gone.
-
#checkout_for(feature) ⇒ Agentilda::Worktree::Checkout
Get, or create, the isolated checkout for one plan.
-
#initialize(root:, dir: nil, user: ENV["USER"] || "agent") ⇒ Worktree
constructor
A new instance of Worktree.
-
#list ⇒ Array<String>
Every worktree this class manages, as git sees them.
-
#prune ⇒ Array<String>
Remove worktrees the agents left untouched.
- #remove(path) ⇒ void
-
#repository? ⇒ Boolean
Whether root is a git repository at all.
-
#seed(path) ⇒ Boolean
Copy in the files git ignores and a new checkout therefore lacks:
.envand friends, and credential keys.
Constructor Details
Instance Attribute Details
#dir ⇒ String (readonly)
Returns where worktrees are kept.
59 60 61 |
# File 'lib/agentilda/worktree.rb', line 59 def dir @dir end |
#root ⇒ String (readonly)
Returns the main repository.
56 57 58 |
# File 'lib/agentilda/worktree.rb', line 56 def root @root end |
Instance Method Details
#branch_for(feature) ⇒ String
Returns e.g. "kig/002.00-tenancy-households".
138 |
# File 'lib/agentilda/worktree.rb', line 138 def branch_for(feature) = "#{@user}/#{feature.ordinal}-#{feature.slug}" |
#canonical(path) ⇒ String
Returns the symlink-resolved path, or the input when it is gone.
157 158 159 160 161 |
# File 'lib/agentilda/worktree.rb', line 157 def canonical(path) File.realpath(path) rescue Errno::ENOENT path end |
#checkout_for(feature) ⇒ Agentilda::Worktree::Checkout
Get, or create, the isolated checkout for one plan.
The checkout is seeded before it is handed back, on both paths. git worktree add brings every TRACKED file and nothing else, so an agent
given a bare one cannot run the suite it was told to keep green: a Rails
project dies on MissingKeyError because config/credentials/*.key is
gitignored. The agent then reports a broken checkout as a broken plan,
which is the worst failure this loop has, because the diagnosis points
at the code rather than at the tree.
A reused worktree is seeded too. #seed only ever adds a missing file, so running it again costs one fast subprocess and repairs the worktrees that were created before any of this existed.
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/agentilda/worktree.rb', line 81 def checkout_for(feature) branch = branch_for(feature) path = File.join(dir, "#{feature.ordinal}-#{feature.slug}") if File.directory?(path) seed(path) return Checkout.new(ordinal: feature.ordinal, branch:, path:, created: false) end # git keeps a registration for a worktree whose directory was deleted by # hand, marks it `prunable`, and then refuses to create a new one at that # path. Anybody who has ever `rm -rf`d a worktree meets this, and the # error says only "already exists" about a directory that does not. forget_stale FileUtils.mkdir_p(dir) add(branch, path) seed(path) Checkout.new(ordinal: feature.ordinal, branch:, path:, created: true) end |
#list ⇒ Array<String>
Every worktree this class manages, as git sees them.
143 144 145 146 147 148 149 150 151 152 153 |
# File 'lib/agentilda/worktree.rb', line 143 def list # Compare canonical paths. git reports resolved ones, and on macOS the # temp and home trees run through symlinks (/var -> /private/var), so a # raw string compare matches nothing and prune silently does nothing. mine = canonical(dir) `git -C #{root.shellescape} worktree list --porcelain 2>/dev/null` .lines(chomp: true) .filter_map { |l| l.delete_prefix("worktree ") if l.start_with?("worktree ") } .select { |p| canonical(p).start_with?(mine) } end |
#prune ⇒ Array<String>
Remove worktrees the agents left untouched.
An agent that ran and changed nothing has produced a checkout that is pure cost: it looks like work in progress and is not. Dirty ones are always kept — that is the output.
170 171 172 |
# File 'lib/agentilda/worktree.rb', line 170 def prune list.select { |path| clean?(path) }.each { |path| remove(path) } end |
#remove(path) ⇒ void
This method returns an undefined value.
176 177 178 |
# File 'lib/agentilda/worktree.rb', line 176 def remove(path) system("git", "-C", root, "worktree", "remove", "--force", path, out: File::NULL, err: File::NULL) end |
#repository? ⇒ Boolean
Returns whether root is a git repository at all.
62 |
# File 'lib/agentilda/worktree.rb', line 62 def repository? = system("git", "-C", root, "rev-parse", "--git-dir", out: File::NULL, err: File::NULL) |
#seed(path) ⇒ Boolean
Copy in the files git ignores and a new checkout therefore lacks: .env
and friends, and credential keys.
Never fatal. A repository with no ignored files is perfectly normal, and
a plan is not worth abandoning over a seeding step, so this reports and
carries on. It runs --quiet, which prints nothing on success and leaves
a real failure on STDERR where the loop's other progress goes.
$stderr rather than Kernel#warn, matching UI, which
writes its progress to $stderr too. Style/StderrPuts prefers warn so the
output can be silenced, but warn reaches file descriptor 2 through
Warning.warn and ignores a reassigned $stderr entirely:
$stderr = StringIO.new; warn "x"; $stderr.string # => ""
So anything capturing this loop's output, the suite included, would never see a seeding failure. Being silenceable is worth less than being seen. rubocop: disable Style/StderrPuts
122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/agentilda/worktree.rb', line 122 def seed(path) unless File.executable?(SEEDER) $stderr.puts "worktree: #{SEEDER} is missing, so #{path} has no .env or credential keys" return false end return true if system(SEEDER, "--quiet", path, out: File::NULL) $stderr.puts "worktree: could not seed #{path}; its suite may fail on a missing key" false end |