Class: Agentilda::Worktree

Inherits:
Object
  • Object
show all
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 run bundle install.

File.expand_path("../../bin/setup-worktree", __dir__)

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root:, dir: nil, user: ENV["USER"] || "agent") ⇒ Worktree

Returns a new instance of Worktree.

Parameters:

  • root (String)

    the main repository

  • dir (String, nil) (defaults to: nil)

    where worktrees go; defaults to a sibling

  • user (String) (defaults to: ENV["USER"] || "agent")

    branch namespace



49
50
51
52
53
# File 'lib/agentilda/worktree.rb', line 49

def initialize(root:, dir: nil, user: ENV["USER"] || "agent")
  @root = File.expand_path(root)
  @user = user
  @dir = File.expand_path(dir || default_dir)
end

Instance Attribute Details

#dirString (readonly)

Returns where worktrees are kept.

Returns:

  • (String)

    where worktrees are kept



59
60
61
# File 'lib/agentilda/worktree.rb', line 59

def dir
  @dir
end

#rootString (readonly)

Returns the main repository.

Returns:

  • (String)

    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".

Parameters:

Returns:

  • (String)

    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.

Parameters:

  • path (String)

Returns:

  • (String)

    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.

Parameters:

Returns:

Raises:



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

#listArray<String>

Every worktree this class manages, as git sees them.

Returns:

  • (Array<String>)

    absolute paths



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

#pruneArray<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.

Returns:

  • (Array<String>)

    paths removed



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.

Parameters:

  • path (String)


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.

Returns:

  • (Boolean)

    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

Parameters:

  • path (String)

    the worktree to seed

Returns:

  • (Boolean)

    whether the seeder ran and succeeded



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