@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.
- package/PLASTIC-reference.md +2 -0
- package/PLASTIC.md +132 -31
- package/agents/plastic-intent-curator.md +10 -2
- package/package.json +1 -1
- package/scripts/doctor.rb +117 -15
- package/scripts/end-intent +32 -7
- package/scripts/lib/agent_models.rb +25 -0
- package/scripts/lib/bridge.rb +3 -4
- package/scripts/lib/hook_registry.rb +12 -0
- package/scripts/lib/installer_core.rb +18 -6
- package/scripts/lib/maintenance_git.rb +94 -0
- package/scripts/lib/revisions_writer.rb +69 -0
- package/scripts/lib/worktree.rb +14 -32
- package/scripts/lib/worktree_sweep.rb +129 -0
- package/scripts/maintenance-run +236 -0
- package/scripts/project-links +127 -24
- package/scripts/rebuild-graph +37 -3
- package/scripts/restore-intent-v1 +37 -3
- package/scripts/sweep-store-worktrees +53 -0
- package/skills/auto/references/end-tail.md +8 -6
- package/skills/doctor/SKILL.md +6 -0
- package/skills/releasing/SKILL.md +3 -3
- package/skills/store-curating/SKILL.md +9 -0
- package/skills/store-curating/evals/evals.json +16 -0
|
@@ -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,
|
|
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
|
|
640
|
-
#
|
|
641
|
-
#
|
|
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
|
-
|
|
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
|
package/scripts/lib/worktree.rb
CHANGED
|
@@ -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
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|