@zalom/plastic 1.4.1 → 1.6.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.
@@ -330,6 +330,11 @@ class InstallerCore
330
330
  "scripts/rebuild-graph" => "scripts/rebuild-graph",
331
331
  "scripts/lib/restore_intent_v1.rb" => "scripts/lib/restore_intent_v1.rb",
332
332
  "scripts/restore-intent-v1" => "scripts/restore-intent-v1",
333
+ "scripts/lib/revisions_writer.rb" => "scripts/lib/revisions_writer.rb",
334
+ "scripts/maintenance-run" => "scripts/maintenance-run",
335
+ "scripts/lib/maintenance_git.rb" => "scripts/lib/maintenance_git.rb",
336
+ "scripts/lib/worktree_sweep.rb" => "scripts/lib/worktree_sweep.rb",
337
+ "scripts/sweep-store-worktrees" => "scripts/sweep-store-worktrees",
333
338
  "scripts/validate-intent" => "scripts/validate-intent",
334
339
  "scripts/new-intent" => "scripts/new-intent",
335
340
  "scripts/end-intent" => "scripts/end-intent",
@@ -608,8 +613,8 @@ class InstallerCore
608
613
  end
609
614
 
610
615
  # Render one repo agents/*.md into a deterministic Codex agent TOML document. Fixed field
611
- # order (name, description, one model field, developer_instructions) so regenerate is
612
- # byte-identical (idempotency).
616
+ # order (name, description, the model field(s) from codex_model_fields, developer_instructions)
617
+ # so regenerate is byte-identical (idempotency).
613
618
  def render_codex_agent_toml(source_path, override)
614
619
  front, body = split_frontmatter(File.read(source_path))
615
620
  name = (front["name"] || File.basename(source_path, ".md")).to_s
@@ -636,14 +641,21 @@ class InstallerCore
636
641
  end
637
642
  end
638
643
 
639
- # The single model-selection line. A known tier alias (opus/sonnet/haiku) emits
640
- # model_reasoning_effort only; any other non-empty value is a literal Codex model id
641
- # emitted verbatim as `model`. Empty -> no line (the agent inherits the session default).
644
+ # The model-selection line(s). A known tier alias (opus/sonnet/haiku) emits BOTH a `model` line
645
+ # (from AgentModels.codex_model_for, the intent-186 per-role Codex identity) and a
646
+ # model_reasoning_effort line, model first for deterministic byte-identical regenerate. Any other
647
+ # non-empty value is a literal Codex model id emitted verbatim as `model` only. Empty -> no line
648
+ # (the agent inherits the session default). If an alias somehow lacks a mapped model, the effort
649
+ # line still emits alone (backward-safe).
642
650
  def codex_model_fields(effective)
643
651
  return "" if effective.nil? || effective.to_s.empty?
644
652
  effort = AgentModels.effort_for(effective)
645
653
  if effort
646
- %(model_reasoning_effort = "#{effort}")
654
+ lines = []
655
+ model = AgentModels.codex_model_for(effective)
656
+ lines << %(model = "#{toml_inline_escape(model)}") if model && !model.to_s.empty?
657
+ lines << %(model_reasoning_effort = "#{effort}")
658
+ lines.join("\n")
647
659
  else
648
660
  %(model = "#{toml_inline_escape(effective.to_s)}")
649
661
  end
@@ -0,0 +1,94 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "open3"
5
+
6
+ # MaintenanceGit - git isolation for a maintenance action (intent 197, D12/D13): a fresh
7
+ # branch off the CURRENT tip of `base`, the caller's block runs, only the paths the block
8
+ # ACTUALLY changed are staged (never `git add -A`), committed, and the branch is merged back
9
+ # to `base` as part of the SAME closed operation before this method returns. Nothing strands
10
+ # on an unmerged branch; nothing outside the block's own change is ever touched.
11
+ #
12
+ # Requires a CLEAN working tree before starting (see module doc above for why); refuses
13
+ # loudly rather than attempt to distinguish pre-existing dirt from the block's own changes.
14
+ # On any error inside the block, the working tree is hard-reset and returned to `base` before
15
+ # re-raising, which is SAFE only because the precondition already proved nothing else was
16
+ # dirty when the branch was created.
17
+ module MaintenanceGit
18
+ module_function
19
+
20
+ class NotAGitRepo < StandardError; end
21
+ class DirtyWorkingTree < StandardError; end
22
+
23
+ def git_toplevel(dir)
24
+ out, _err, status = Open3.capture3("git", "-C", dir, "rev-parse", "--show-toplevel")
25
+ return nil unless status.success?
26
+
27
+ top = out.strip
28
+ top.empty? ? nil : top
29
+ end
30
+
31
+ # Bare paths (status prefix stripped), relative to `root`. Empty array on a clean tree.
32
+ def porcelain_paths(root)
33
+ out, _err, status = Open3.capture3("git", "-C", root, "status", "--porcelain")
34
+ return [] unless status.success?
35
+
36
+ out.lines.map { |l| l[3..].to_s.strip }.reject(&:empty?)
37
+ end
38
+
39
+ # Runs `block` inside a fresh branch off `base`'s current tip. Returns
40
+ # { changed: [...], committed: bool, merged: bool, branch: name }.
41
+ def run_scoped(repo_dir:, branch_name:, commit_message:, base: "main")
42
+ root = git_toplevel(repo_dir)
43
+ raise NotAGitRepo, "#{repo_dir} is not inside a git repository" unless root
44
+
45
+ dirty = porcelain_paths(root)
46
+ unless dirty.empty?
47
+ raise DirtyWorkingTree,
48
+ "#{root} has #{dirty.size} uncommitted path(s) before maintenance started; " \
49
+ "commit or stash them first (never swept via git add -A): #{dirty.join(", ")}"
50
+ end
51
+
52
+ checkout!(root, base)
53
+ run_git!(root, "checkout", "--quiet", "-b", branch_name)
54
+
55
+ begin
56
+ yield
57
+ rescue StandardError
58
+ run_git!(root, "reset", "--hard", "--quiet")
59
+ run_git!(root, "clean", "-fd", "--quiet")
60
+ checkout!(root, base)
61
+ delete_branch(root, branch_name)
62
+ raise
63
+ end
64
+
65
+ changed = porcelain_paths(root)
66
+ if changed.empty?
67
+ checkout!(root, base)
68
+ delete_branch(root, branch_name)
69
+ return { changed: [], committed: false, merged: false, branch: branch_name }
70
+ end
71
+
72
+ run_git!(root, "add", "--", *changed)
73
+ run_git!(root, "-c", "user.name=Plastic", "-c", "user.email=plastic@localhost",
74
+ "commit", "--quiet", "-m", commit_message)
75
+ checkout!(root, base)
76
+ run_git!(root, "merge", "--quiet", "--no-ff", "-m", "Merge #{branch_name} into #{base}", branch_name)
77
+ delete_branch(root, branch_name)
78
+
79
+ { changed: changed, committed: true, merged: true, branch: branch_name }
80
+ end
81
+
82
+ def checkout!(root, ref)
83
+ run_git!(root, "checkout", "--quiet", ref)
84
+ end
85
+
86
+ def delete_branch(root, name)
87
+ Open3.capture3("git", "-C", root, "branch", "--quiet", "-D", name)
88
+ end
89
+
90
+ def run_git!(root, *args)
91
+ _out, err, status = Open3.capture3("git", "-C", root, *args)
92
+ raise "git #{args.join(" ")} failed in #{root}: #{err}" unless status.success?
93
+ end
94
+ end
@@ -0,0 +1,69 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # RevisionsWriter - the shared append-only revisions.md writer (intent 107's convention,
5
+ # generalized from restore_intent_v1.rb's proven pattern, intent 197). Every tool that
6
+ # performs structural maintenance on an intent (project-links, rebuild-graph,
7
+ # restore-intent-v1) must record it here: PLASTIC.md's `revisions.md` contract is that a
8
+ # structural change and its receipt are never separated. This module owns rendering ONE
9
+ # entry's text and appending it correctly; it does no git operations (that is
10
+ # lib/maintenance_git.rb's job) and never overwrites a prior entry.
11
+ #
12
+ # Pure where it can be (render_entry has no IO); the IO half (append!) is a thin,
13
+ # dependency-free file read/write, matching every other tool in scripts/lib.
14
+ module RevisionsWriter
15
+ module_function
16
+
17
+ # PURE. Renders one `## Revision vN - TIMESTAMP` entry in the documented shape
18
+ # (PLASTIC-reference.md > Structural maintenance and revisions.md; templates/revisions.md).
19
+ # `n` is the next revision number (caller resolves it via next_revision_number, or a caller
20
+ # that already knows it, e.g. a batch writer amortizing one file read across many entries).
21
+ # `why` is the one-sentence reason ending in "[rule: <tag>]" (tag is appended here if the
22
+ # caller passes a bare sentence plus `rule:`, so every caller cannot forget the tag).
23
+ # `prior_location` and `change` are free text (the "Change:" line for a metadata edit, or a
24
+ # multi-line indented "Content held:" block for a relocated section/file - callers building
25
+ # a Content-held entry should pass an already-indented `change` block).
26
+ def render_entry(n, why:, rule:, prior_location:, change:, timestamp: Time.now.utc)
27
+ ts = timestamp.strftime("%Y-%m-%d-%H:%M")
28
+ lines = []
29
+ lines << "## Revision v#{n} - #{ts}"
30
+ lines << "- Why: #{why.to_s.strip} [rule: #{rule}]"
31
+ lines << "- Prior location: #{prior_location}"
32
+ lines << "- Change: #{change}"
33
+ "#{lines.join("\n")}\n"
34
+ end
35
+
36
+ # PURE. Every existing "## Revision vN" number found in `existing_text` (empty array when
37
+ # none, i.e. the file does not exist yet or carries no entries). Mirrors
38
+ # scripts/restore-intent-v1's own `nums = existing.scan(/^## Revision v(\d+)/)` exactly, so
39
+ # the two writers can never disagree about numbering.
40
+ def revision_numbers(existing_text)
41
+ existing_text.to_s.scan(/^## Revision v(\d+)/).flatten.map(&:to_i)
42
+ end
43
+
44
+ def next_revision_number(existing_text)
45
+ (revision_numbers(existing_text).max || 0) + 1
46
+ end
47
+
48
+ # IO. Appends one entry to `<intent_dir>/revisions.md`, creating the file with its
49
+ # documented header (matching templates/revisions.md's "# revisions.md" title line) if it
50
+ # does not exist yet. NEVER overwrites or reorders a prior entry (append-only, intent 124's
51
+ # own v3-corrects-v2-by-appending precedent). Returns the revision number written.
52
+ #
53
+ # Raises RevisionsWriter::WriteFailed on any IO error (permission, disk full, read-only
54
+ # filesystem) so a caller can roll back a paired structural change rather than leave it
55
+ # unrecorded (D14's "or refuse"). Never swallows an error silently.
56
+ def append!(intent_dir, why:, rule:, prior_location:, change:, timestamp: Time.now.utc)
57
+ path = File.join(intent_dir, "revisions.md")
58
+ existing = File.exist?(path) ? File.read(path) : "# revisions.md\n\n"
59
+ n = next_revision_number(existing)
60
+ entry = render_entry(n, why: why, rule: rule, prior_location: prior_location,
61
+ change: change, timestamp: timestamp)
62
+ File.write(path, "#{existing.chomp}\n\n#{entry}")
63
+ n
64
+ rescue StandardError => e
65
+ raise WriteFailed, "could not append revisions.md at #{path}: #{e.message}"
66
+ end
67
+
68
+ class WriteFailed < StandardError; end
69
+ end
@@ -18,9 +18,11 @@ require_relative "lock"
18
18
  # projects.yml and runs `git -C <repo> worktree add`, so the cwd-not-root bug
19
19
  # dies by construction (decision D6).
20
20
  #
21
- # Two worktrees per project intent, both named `{id}--{slug}` (decision D2):
22
- # code worktree <repo>/.claude/worktrees/{id}--{slug} branch plastic/{id}--{slug}
23
- # store worktree <plastic_home>/.worktrees/{id}--{slug} branch plastic-store/{id}--{slug}
21
+ # One worktree per project intent (decision D2, retired to a single worktree by
22
+ # intent 178): the code worktree, <repo>/.claude/worktrees/{id}--{slug}, branch
23
+ # plastic/{id}--{slug}. Store-write safety for lifecycle-doc writes now comes
24
+ # from intent 197's branch-from-main plus scoped-commit mechanism instead of a
25
+ # second, dedicated worktree (see PLASTIC.md's worktree doctrine).
24
26
  #
25
27
  # The durable delivery.lock file in the intent dir is the single-owner
26
28
  # delivery lock (intent 108): session-keyed, lease-based, explicit takeover.
@@ -60,22 +62,21 @@ module Worktree
60
62
  "#{intent_id}--#{intent_slug}"
61
63
  end
62
64
 
63
- # Pure, deterministic. Returns the four paths/branches. No git calls.
65
+ # Pure, deterministic. Returns the code worktree's path/branch. No git calls.
64
66
  # `repo_path` is resolved from projects.yml when nil; when it cannot be
65
67
  # resolved the code worktree path/branch are nil (a global-store-only intent).
68
+ # Store-worktree provisioning was retired by intent 178: this used to also
69
+ # return a `store`/`store_branch` pair for a second worktree under
70
+ # `<plastic_home>/.worktrees/{id}--{slug}`; nothing provisions that anymore.
66
71
  def paths(slug:, intent_id:, intent_slug:, home: Dir.home, repo_path: nil)
67
72
  name = dir_name(intent_id, intent_slug)
68
73
  repo = repo_path || repo_for(slug, home: home)
69
- plastic_home = File.expand_path(File.join(home, ".plastic"))
70
74
 
71
75
  code_path = repo ? File.join(File.expand_path(repo), ".claude", "worktrees", name) : nil
72
- store_path = File.join(plastic_home, ".worktrees", name)
73
76
 
74
77
  {
75
78
  "code" => code_path,
76
79
  "code_branch" => code_path ? "plastic/#{name}" : nil,
77
- "store" => store_path,
78
- "store_branch" => "plastic-store/#{name}",
79
80
  }
80
81
  end
81
82
 
@@ -133,28 +134,16 @@ module Worktree
133
134
  block = {
134
135
  "code" => nil,
135
136
  "code_branch" => nil,
136
- "store" => p["store"],
137
- "store_branch" => p["store_branch"],
138
137
  "provisioned" => false,
139
138
  }
140
139
 
141
140
  plastic_home = File.expand_path(File.join(home, ".plastic"))
142
141
 
143
- # Gitignore safety (intent 73c3): the store worktrees live under the store git
144
- # repo, so without ignoring `.worktrees/` a `git add -A` sweeps their gitlinks
145
- # into the store commit. Ensure both ignore entries before any worktree add.
146
- ensure_gitignored(plastic_home, ".worktrees/", runner: runner)
147
-
148
142
  # The durable lock files live inside intent dirs under the store git repo
149
- # (intent 108, D2): transient state, never committed.
143
+ # (intent 108, D2): transient state, never committed. Unrelated to store
144
+ # worktrees (retired by intent 178); this stays regardless.
150
145
  ensure_gitignored(plastic_home, "*.lock", runner: runner)
151
146
 
152
- # Store worktree: created against the plastic home git repo. Fail-open if the
153
- # store repo is not a git repo (a fresh global store may be ungit'd).
154
- store_ok = add_worktree(runner, repo: plastic_home,
155
- worktree: p["store"], branch: p["store_branch"],
156
- label: "store")
157
-
158
147
  # Code worktree: MANDATORY for project intents. Fail-open when the repo is
159
148
  # unresolvable or non-git -- that is the global-store-only / non-git case.
160
149
  repo = repo_for(slug, home: home)
@@ -173,9 +162,6 @@ module Worktree
173
162
  "is unresolvable or not a git repo; code worktree skipped"
174
163
  end
175
164
 
176
- block["store"] = store_ok ? p["store"] : nil
177
- block["store_branch"] = store_ok ? p["store_branch"] : nil
178
-
179
165
  # provisioned is true only when the MANDATORY code worktree exists. The gate
180
166
  # fails open on provisioned: false (non-git / global-only).
181
167
  block["provisioned"] = code_ok
@@ -184,26 +170,22 @@ module Worktree
184
170
  bridge_data
185
171
  end
186
172
 
187
- # Remove both worktrees (then `git worktree prune`), clear the worktree block.
173
+ # Remove the worktree (then `git worktree prune`), clear the worktree block.
188
174
  # No-op when nothing was provisioned. CLEANUP (73c3) layers the merge-vs-remove
189
175
  # policy on top via `finish`; this is the plain remove. Pass `remove: false` to
190
176
  # clear the block WITHOUT touching git (so `finish` can merge first, then call
191
- # release to drop the worktrees once the code branch is integrated).
177
+ # release to drop the worktree once the code branch is integrated).
192
178
  def release(bridge_data, home: Dir.home, runner: ShellRunner.new, remove: true)
193
179
  return bridge_data unless bridge_data.is_a?(Hash)
194
180
  block = bridge_data["worktree"]
195
181
  return bridge_data unless block.is_a?(Hash)
196
182
 
197
183
  if remove
198
- plastic_home = File.expand_path(File.join(home, ".plastic"))
199
184
  slug = slug_for_store(bridge_data.dig("intent", "store").to_s, home: home)
200
185
  repo = repo_for(slug, home: home)
201
186
 
202
187
  remove_worktree(runner, repo: repo, worktree: block["code"]) if repo && block["code"]
203
- remove_worktree(runner, repo: plastic_home, worktree: block["store"]) if block["store"]
204
-
205
188
  prune(runner, repo: repo) if repo
206
- prune(runner, repo: plastic_home)
207
189
  end
208
190
 
209
191
  bridge_data.delete("worktree")
@@ -212,7 +194,7 @@ module Worktree
212
194
 
213
195
  # --- cleanup policy (merge-vs-remove) -------------------------------------
214
196
 
215
- # Finish an intent's delivery by tearing down its worktrees, optionally merging
197
+ # Finish an intent's delivery by tearing down its worktree, optionally merging
216
198
  # the code branch back first (intent 73c3). The merge-vs-remove decision is the
217
199
  # one piece of policy on top of the plain `release`:
218
200
  #
@@ -0,0 +1,129 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "worktree"
5
+ require_relative "bridge"
6
+
7
+ # WorktreeSweep -- the one-time orphan sweep for store worktrees retired by
8
+ # intent 178 (D2). Pure candidate classification plus a thin apply step, both
9
+ # dependency-injected (a ShellRunner, an explicit plastic_home) so the whole
10
+ # module is hermetically testable: no call here ever falls back to the real
11
+ # `Dir.home` (the intent 169 hermeticity lesson applies here too).
12
+ #
13
+ # A candidate is REMOVE iff its intent is terminal (Completed or Abandoned in
14
+ # SOME store's INDEX.md) AND its branch carries no commits ahead of that
15
+ # store's main. Any ambiguity -- no matching intent directory found in any
16
+ # store, an unreadable INDEX, or a branch that cannot be resolved -- SPARES
17
+ # the candidate rather than removing it. This module never shells out for
18
+ # `worktree remove`; that only happens in `apply!`, and only for candidates
19
+ # already marked REMOVE.
20
+ module WorktreeSweep
21
+ module_function
22
+
23
+ Candidate = Struct.new(:name, :dir, :branch, :intent_dir, :status, :ahead_count,
24
+ :decision, :reason, keyword_init: true)
25
+
26
+ TERMINAL_STATUSES = %w[Completed Abandoned].freeze
27
+
28
+ # All worktree dirs under `<plastic_home>/.worktrees/`, classified. `runner`
29
+ # is injected (default a real ShellRunner) so tests never shell out.
30
+ def candidates(plastic_home:, runner: Worktree::ShellRunner.new)
31
+ Dir.glob(File.join(plastic_home, ".worktrees", "*"))
32
+ .select { |d| File.directory?(d) }
33
+ .sort
34
+ .map { |dir| classify(dir, plastic_home: plastic_home, runner: runner) }
35
+ end
36
+
37
+ def classify(dir, plastic_home:, runner:)
38
+ name = File.basename(dir)
39
+ branch = "plastic-store/#{name}"
40
+ intent_dir = resolve_intent_dir(plastic_home, name)
41
+ status = intent_dir ? index_status(intent_dir, name) : nil
42
+ ahead = intent_dir ? branch_ahead_count(runner, plastic_home, branch) : nil
43
+
44
+ terminal = TERMINAL_STATUSES.include?(status)
45
+ ahead_or_unknown = ahead.nil? || ahead > 0
46
+ decision = (terminal && !ahead_or_unknown) ? :remove : :spare
47
+
48
+ Candidate.new(name: name, dir: dir, branch: branch, intent_dir: intent_dir,
49
+ status: status, ahead_count: ahead, decision: decision,
50
+ reason: reason_for(intent_dir, status, ahead, terminal))
51
+ end
52
+
53
+ # The FIRST real intent directory literally named `{id}--{slug}` (the exact
54
+ # worktree dir name) found under the global store or any project store.
55
+ # Matching on the full name, not just the numeric id, sidesteps id
56
+ # collisions across projects (ids are only unique WITHIN one store).
57
+ def resolve_intent_dir(plastic_home, name)
58
+ candidates = [File.join(plastic_home, "store", name)] +
59
+ Dir.glob(File.join(plastic_home, "projects", "*", "store", name))
60
+ candidates.find { |d| Dir.exist?(d) }
61
+ end
62
+
63
+ # The INDEX.md section heading (e.g. "Active", "Completed") that lists this
64
+ # intent dir, or nil if no INDEX.md entry links to it. Reuses
65
+ # Bridge.index_entry_match so this never drifts from the shared parser.
66
+ def index_status(intent_dir, name)
67
+ store = File.dirname(intent_dir)
68
+ index = File.join(File.dirname(store), "INDEX.md")
69
+ return nil unless File.exist?(index)
70
+
71
+ section = nil
72
+ File.foreach(index) do |line|
73
+ stripped = line.chomp
74
+ if stripped.start_with?("## ")
75
+ section = stripped.sub(/\A##\s*/, "").strip
76
+ next
77
+ end
78
+ m = Bridge.index_entry_match(stripped)
79
+ next unless m
80
+ return section if m[3].to_s.include?(name)
81
+ end
82
+ nil
83
+ end
84
+
85
+ # Commits reachable from `branch` but not from the repo's own current
86
+ # (default) branch, or nil if either cannot be resolved. nil is treated as
87
+ # "ahead or unknown" by `classify` (fail SPARE, never fail REMOVE).
88
+ def branch_ahead_count(runner, plastic_home, branch)
89
+ target = Worktree.current_branch(runner, repo: plastic_home)
90
+ return nil if target.nil? || target == branch
91
+ res = runner.run("-C", plastic_home, "rev-list", "--count", "#{target}..#{branch}")
92
+ return nil unless res.success?
93
+ res.stdout.to_s.strip.to_i
94
+ end
95
+
96
+ def reason_for(intent_dir, status, ahead, terminal)
97
+ return "no matching intent directory found in any store (orphaned/ambiguous reference)" unless intent_dir
98
+ return "ahead of main by #{ahead} commit(s); sparing to avoid stranding unmerged work" if ahead.nil? || ahead > 0
99
+ return "intent status is #{status.inspect}, not terminal; sparing" unless terminal
100
+ "terminal (#{status}) and not ahead of main"
101
+ end
102
+
103
+ # A stable, human-reviewable dry-run report. Every candidate is listed,
104
+ # remove and spare alike, with its full reasoning -- this is the artifact
105
+ # the owner reviews BEFORE anything is deleted (D2, AC5).
106
+ def dry_run_report(candidates, now: Time.now)
107
+ lines = ["Store-worktree sweep dry run -- #{now.utc.iso8601}", ""]
108
+ candidates.each do |c|
109
+ tag = c.decision == :remove ? "REMOVE" : "SPARE "
110
+ lines << "#{tag} #{c.name} status=#{c.status.inspect} ahead=#{c.ahead_count.inspect} #{c.reason}"
111
+ end
112
+ remove_n = candidates.count { |c| c.decision == :remove }
113
+ lines << ""
114
+ lines << "#{remove_n} of #{candidates.length} candidate(s) eligible for removal. " \
115
+ "Nothing has been removed yet; re-run with --apply after review."
116
+ lines.join("\n")
117
+ end
118
+
119
+ # Removes only :remove-decision candidates, via `git worktree remove` (falls
120
+ # back to --force only inside Worktree.remove_worktree's own existing
121
+ # retry), then `git worktree prune` once at the end. Returns the list of
122
+ # candidates actually removed. Never called for a :spare candidate.
123
+ def apply!(candidates, plastic_home:, runner: Worktree::ShellRunner.new)
124
+ removed = candidates.select { |c| c.decision == :remove }
125
+ removed.each { |c| Worktree.remove_worktree(runner, repo: plastic_home, worktree: c.dir) }
126
+ Worktree.prune(runner, repo: plastic_home) unless removed.empty?
127
+ removed
128
+ end
129
+ end