SLOPSHOPPER

action-pin

After an edit that adds a GitHub Actions step pinned to a tag or a branch, names the commit SHA to write instead, from the GitHub API.

newguardcommandpromptprocessnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · action-pin
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /action-pin ⎿ action-pin: on · mode note · no workflow is open ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

action-pin

A GitHub Actions step written as actions/checkout@v4 runs whatever code that tag points at on the day the workflow runs, and a tag or a branch can be moved after you reviewed it. This mod watches the workflows the model edits: when an edit adds a step pinned to a moving ref, it tells the model the commit SHA to write instead. By default it stops nothing; in deny mode a commit, a push and a merge wait until every ref is pinned.

What it does

  1. It watches the Edit and Write tools. A call is checked when its path is .github/workflows/<name>.yml or .github/actions/<name>/action.yml (.yaml too).
  2. Only the lines the edit adds are read. A uses: value whose ref is a 40 or 64 character hex commit is already pinned and passes. A local action (./.github/actions/setup) and a container (docker://alpine:3.20) have no commit to pin, so they pass too. Every other ref, a tag (@v4) or a branch (@main), is reported, actions/* and github/* included.
  3. For each reported action it asks https://api.github.com/repos/<owner>/<repo>/commits/<ref> with the Accept: application/vnd.github.sha header, and GitHub answers the commit as plain text. No token is sent, so the anonymous rate limit applies (60 requests an hour per address). Each action and ref is asked once per session.
  4. Right after the tool's result, the model reads this note:

action-pin: this edit uses actions by a moving ref: actions/checkout@v4 → 08c6903cd8c0fde910a37f88322edcfb5dd907a8. A tag or a branch can be moved to other code after a review, so a workflow with write access runs whatever it points at then. Write each as the SHA with the tag as a comment, for example: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4

At most 10 actions are named and looked up; the rest are counted. When GitHub does not answer, the action is named without a SHA, the note still asks for the pin, and the error is logged once until a different one comes.

  1. At the same moment you get one line in the transcript, so you see what the model was told. It holds the workflow and its actions, without the instruction. The workflow is named because the model saw the edit and you did not:

action-pin: .github/workflows/ci.yml uses actions by a moving ref: actions/checkout@v4 → 08c6903cd8c0fde910a37f88322edcfb5dd907a8

The note and the line are separate channels: the model never reads the line, and you never read the note. The workflow is shown relative to the git repository the session started in, or to the session's directory outside a repository. That path also keys its sidebar entry, so each workflow keeps an entry of its own. The root is read once at the session's start, because a Bash cd moves the session's own directory.

  1. With the sidebar open, the finding goes into its stream instead and the transcript stays clean. The workflow comes first in red, then one line per action: the action in the default colour, the moving ref red, the commit it points at faint. Without the sidebar, the line lands in the transcript as above.
  2. A finding stays open until the workflow pins those actions. After each later Edit or Write the mod reads every open workflow again, and one whose refs are all pinned closes. A workflow that is no longer there closes too, because it uses no action any more. One that is there but cannot be read keeps its finding, because an unread file proves nothing:

action-pin: every action of .github/workflows/ci.yml is pinned to a commit now: actions/checkout@v4 action-pin: .github/workflows/ci.yml is no longer there: actions/checkout@v4

In the sidebar the red entry is removed and a green one takes its place; with the sidebar closed the same text is one transcript line. The model reads none of this, because it wrote the SHA itself.

  1. A finding the model did not close is measured again at the end of each main-loop turn, and what is left reaches the model as one note with your next prompt. The SHAs are already in memory, so this asks GitHub nothing:

action-pin: 1 action(s) are still used by a moving ref: actions/checkout@v4. Pin each to the commit SHA of that ref, or take the step out.

One note per turn, not one per prompt. Without it the finding would be said once, at the edit, and then stand in the pane while the model forgot it. You read nothing new, because the pane already shows the same finding.

  1. In deny mode the mod also stops git commit, git push and git merge while a workflow still uses an action by a moving ref. Before it stops one it reads each open workflow again, so a file the model pinned opens the gate by itself. A git commit answers for its own files alone: the mod reads the index (git diff --cached --name-only -z) and lets the commit run when it holds none of the open workflows, with one line to you saying how many still stand. A push and a merge have no index to read, so every finding stands there. There is no bypass; only you turn the gate off, with /action-pin mode note. note mode is the default and stops nothing.

Command

/action-pin on or off, the mode, and the workflows that still move /action-pin on | off on by default /action-pin mode note note only; the default /action-pin mode deny a commit, a push and a merge also stop while a ref moves

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install action-pin@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, command.run{command=action-pin}, turn.complete, prompt.submit, tool.call{tool=Bash}, tool.call{tool=Edit}, tool.call{tool=Write} ❯ ./register.ts calls: $.command.register, $.fs.exists (via isThere), $.fs.read (via stillMoving), $.http.fetch (via resolveSha), $.process.run (via shownRootOf, stagedPaths), $.session.cwd (via stagedPaths), $.sidebar.clear (via dropEntry), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand, setMode), $.ui.log (via gate, report, toPerson)

Reach L3: it reaches the network.

  1. Reads: the path and the new text of each Edit and Write; the Bash command text; each open workflow again while a finding stands, also at the turn's end
  2. Runs: git rev-parse --show-toplevel once at the session's start, to show workflows against the repository root; git rev-parse --show-toplevel and git diff --cached --name-only -z, at a commit in deny mode, to read which files the commit holds
  3. Sends: the public action name and its ref (for example actions/checkout and v4) to api.github.com, at most 10 per edit, once each per session; no token, no repository content, no file path
  4. Persists: in $.store, the on/off setting and the mode; the resolved SHAs live in memory for one session
  5. Hostile input: the answer is used only when it is 40 hex characters, and it is written into the note alone; the mod never edits a file

Limits

  • The check is lexical: a uses: line inside a block comment or a YAML string still counts.
  • A workflow already in the repository is not checked; only the lines an edit adds are.
  • An action that the anonymous rate limit or a private repository hides gets the note without a SHA.
  • A SHA resolved once is kept for the session, so a tag moved during the session keeps its first answer.
  • A finding closes only when the workflow no longer uses those actions by a ref. A file that cannot be read keeps it open.
  • The deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /action-pin mode note.
  • The gate reads the command text. A commit through a script or an alias that hides git commit is not stopped.
  • A git commit -a, a -am and a commit with a pathspec after -- are not narrowed to the index, because they commit files the index does not hold yet. Every open finding stands for those.
  • The index is read in the repository of the session's own directory. A finding of a workflow in another repository never matches it, so such a commit runs.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 289 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { commitUrl, denyText, doneLines, doneLog, doneTitle, isCommit, isGuarded, isNarrowable, isWorkflow, logText, MAX_NAMED, modeOf, noteText, openNote, openRefs, refOf, sectionKey, shownPath, sidebarLines, unpinnedUses, type Line, type Mode, type Unpinned } from './pin.ts'
3
4const ENABLED_KEY = 'enabled'
5const MODE_KEY = 'mode'
6
7const USAGE = 'expects nothing (the status), on, off or mode note | deny'
8
9/** The GitHub API answers the plain SHA of a ref with this Accept header. */
10const HEADERS = { Accept: 'application/vnd.github.sha', 'User-Agent': 'action-pin' }
11
12/**
13 * The on/off setting and the mode as the store held them at the last read, the SHAs already resolved in
14 * this session, so one workflow does not ask GitHub twice, the refs the model is owed a note for, and the
15 * last error, so the same one is logged once, and the root every workflow is shown against, read once at
16 * the session's start (`shownRootOf`).
17 */
18type State = { enabled: boolean; mode: Mode; shas: Map<string, string>; open: Map<string, string[]>; owed: string[]; lastError?: string; root?: string }
19
20/**
21 * Reads the on/off setting and the mode from the store, which every window shares, so a change made in
22 * another window applies here at the next hook that acts on it.
23 */
24async function readSettings($: EngineInterface, state: State): Promise<void> {
25  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
26  state.mode = (await $.store.get(MODE_KEY)) === 'deny' ? 'deny' : 'note'
27}
28
29/**
30 * The git repository the session started in, so a file in a sibling directory of a session opened in a
31 * subdirectory still reads short; the session's own directory where git does not answer.
32 */
33async function shownRootOf($: EngineInterface, cwd: string): Promise<string> {
34  try {
35    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
36    const root = top.stdout.trim()
37    return top.exitCode === 0 && root !== '' ? root : cwd
38  } catch {
39    // No git here, or the command did not run: paths are shown against the session's directory.
40    return cwd
41  }
42}
43
44function errorText(err: unknown): string {
45  return err instanceof Error ? err.message : String(err)
46}
47
48/** The commit a ref points at, or undefined when GitHub does not answer it. */
49async function resolveSha($: EngineInterface, state: State, use: Unpinned): Promise<string | undefined> {
50  const key = `${use.action}@${use.ref}`
51  const known = state.shas.get(key)
52  if (known !== undefined) return known
53  const r = await $.http.fetch(commitUrl(use.action, use.ref), { headers: HEADERS })
54  if (!r.ok) throw new Error(`api.github.com answered HTTP ${r.status} for ${key}`)
55  const sha = r.text.trim()
56  if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`api.github.com answered no commit for ${key}`)
57  state.shas.set(key, sha)
58  return sha
59}
60
61/** Logs an error once until a different one comes. */
62function report($: EngineInterface, state: State, err: unknown): void {
63  const text = errorText(err)
64  if (text !== state.lastError) $.ui.log(`a commit SHA was not resolved: ${text}`)
65  state.lastError = text
66}
67
68/** Each action with its commit; one that GitHub does not answer keeps its ref alone. */
69async function withShas($: EngineInterface, state: State, uses: readonly Unpinned[]): Promise<Unpinned[]> {
70  const out: Unpinned[] = []
71  for (const use of uses.slice(0, MAX_NAMED)) {
72    try {
73      out.push({ ...use, sha: await resolveSha($, state, use) })
74      state.lastError = undefined
75    } catch (err) {
76      report($, state, err)
77      out.push(use)
78    }
79  }
80  return [...out, ...uses.slice(MAX_NAMED)]
81}
82
83/**
84 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
85 * transcript line, as before. The model's note is another channel and does not change here.
86 */
87async function toPerson($: EngineInterface, path: string, title: string, lines: Line[], line: string): Promise<void> {
88  try {
89    const taken = await $.sidebar.set({ consumer: 'action-pin', key: sectionKey(path), title, lines, until: 'stream' })
90    if (taken) return
91  } catch {
92    // The sidebar mod is not installed.
93  }
94  $.ui.log(line)
95}
96
97/** Drops the sidebar entries of one finding, so a workflow that pins its actions leaves no warning behind. */
98async function dropEntry($: EngineInterface, path: string): Promise<void> {
99  try {
100    await $.sidebar.clear({ consumer: 'action-pin', key: sectionKey(path) })
101  } catch {
102    // The sidebar mod is not installed.
103  }
104}
105
106/**
107 * The refs of one open file that still move, or undefined when the file is there and cannot be read. A
108 * workflow that is gone uses no action any more, so it answers an empty list and its finding closes.
109 */
110async function isThere($: EngineInterface, path: string): Promise<boolean> {
111  try {
112    return await $.fs.exists(path)
113  } catch {
114    // The path was not measured: it counts as there, so no finding closes on it.
115    return true
116  }
117}
118
119/** One workflow whose finding still stands, and the refs of it that still move. */
120type Left = { path: string; refs: string[] }
121
122async function stillMoving($: EngineInterface, path: string, refs: readonly string[]): Promise<string[] | undefined> {
123  try {
124    if (!(await isThere($, path))) return []
125    const held = new Set(unpinnedUses('', await $.fs.read(path)).map(refOf))
126    return refs.filter(ref => held.has(ref))
127  } catch {
128    // The file is there and was not read: the finding stays as it was.
129    return undefined
130  }
131}
132
133/** Reads each open workflow again and closes the findings whose refs are pinned now. */
134async function closeResolved($: EngineInterface, state: State, skip?: string): Promise<Left[]> {
135  const left: Left[] = []
136  for (const [path, refs] of [...state.open]) {
137    const moving = path === skip ? refs : await stillMoving($, path, refs)
138    if (moving === undefined || moving.length > 0) {
139      left.push({ path, refs: [...(moving ?? refs)] })
140      continue
141    }
142    state.open.delete(path)
143    const shown = shownPath(path, state.root)
144    await dropEntry($, shown)
145    const gone = path !== skip && !(await isThere($, path))
146    await toPerson($, shown, doneTitle(gone), doneLines(shown, refs), doneLog(shown, refs, gone))
147  }
148  return left
149}
150
151/** Adds the note to an edit that pins an action to a moving ref, and closes what a later edit fixed. */
152async function afterEdit($: EngineInterface, state: State, path: string, before: string, after: string, r: ToolCallResult): Promise<ToolCallResult> {
153  if (r.deny !== undefined || r.isError === true) return r
154  await readSettings($, state)
155  if (!state.enabled) return r
156  const found = isWorkflow(path) ? unpinnedUses(before, after) : []
157  if (found.length === 0) {
158    await closeResolved($, state)
159    return r
160  }
161  const uses = await withShas($, state, found)
162  state.open.set(path, openRefs(state.open.get(path), uses))
163  // The note goes to the model, the finding to the person: neither reads the other's channel.
164  const shown = shownPath(path, state.root)
165  await toPerson($, shown, 'actions by a moving ref', sidebarLines(shown, uses), logText(shown, uses))
166  await closeResolved($, state, path)
167  return { ...r, context: [...(r.context ?? []), noteText(uses)] }
168}
169
170/**
171 * The files this commit holds, by absolute path, or undefined when git did not answer. Read before the
172 * command runs, so it is the index as the commit will take it.
173 */
174async function stagedPaths($: EngineInterface): Promise<Set<string> | undefined> {
175  try {
176    const cwd = await $.session.cwd()
177    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
178    const staged = await $.process.run(['git', 'diff', '--cached', '--name-only', '-z'], { cwd })
179    if (top.exitCode !== 0 || staged.exitCode !== 0) return undefined
180    const base = top.stdout.trim()
181    return new Set(staged.stdout.split('\0').filter(Boolean).map(p => `${base}/${p}`))
182  } catch {
183    // No git here, or the command did not run: the findings are not narrowed.
184    return undefined
185  }
186}
187
188/**
189 * The findings this command answers for. A `git commit` answers for its own files alone, so a finding of
190 * a workflow the commit does not hold lets it run. A `push` or a `merge` holds no index to read, so every
191 * finding stands there.
192 */
193async function scopeOf($: EngineInterface, left: readonly Left[], command: string): Promise<Left[]> {
194  if (!isCommit(command) || !isNarrowable(command)) return [...left]
195  const staged = await stagedPaths($)
196  return staged === undefined ? [...left] : left.filter(l => staged.has(l.path))
197}
198
199/**
200 * The gate of the `deny` mode: it reads each open workflow again, so a ref the model pinned without a
201 * new finding opens the gate too. A ref of a workflow this command holds that still moves stops it, and
202 * there is no bypass.
203 */
204async function gate($: EngineInterface, state: State, command: string): Promise<string | undefined> {
205  if (state.open.size === 0 || !isGuarded(command)) return undefined
206  await readSettings($, state)
207  if (!state.enabled || state.mode !== 'deny') return undefined
208  const left = await closeResolved($, state)
209  if (left.length === 0) return undefined
210  const scoped = await scopeOf($, left, command)
211  if (scoped.length === 0) {
212    $.ui.log(`${left.length} workflow(s) still use a moving ref, and this command holds none of them`)
213    return undefined
214  }
215  return denyText(scoped.flatMap(l => l.refs))
216}
217
218async function setMode($: EngineInterface, state: State, word: string): Promise<string> {
219  const mode = modeOf(word)
220  if (mode === undefined) return 'mode expects note or deny'
221  await $.store.set(MODE_KEY, mode)
222  state.mode = mode
223  return mode === 'deny' ? 'mode deny: git commit, push and merge stop while an action is used by a moving ref' : 'mode note: the actions are only reported'
224}
225
226function statusText(state: State): string {
227  const open = state.open.size === 0 ? 'no workflow is open' : `${state.open.size} workflow(s) still use a moving ref`
228  return `${state.enabled ? 'on' : 'off'} · mode ${state.mode} · ${open}`
229}
230
231async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
232  const word = args.trim()
233  if (word === 'on' || word === 'off') {
234    await $.store.set(ENABLED_KEY, word === 'on')
235    state.enabled = word === 'on'
236    return word === 'on' ? 'on: a workflow edit that uses an action by a tag or a branch gets a note' : 'off: workflow edits are not checked'
237  }
238  if (word.startsWith('mode')) return setMode($, state, word.slice(4).trim())
239  if (word !== '') return USAGE
240  await readSettings($, state)
241  return statusText(state)
242}
243
244export const register: Register = on => {
245  const state: State = { enabled: true, mode: 'note', shas: new Map(), open: new Map(), owed: [] }
246
247  on('session.start', async ($, e, next) => {
248    const r = await next(e)
249    await $.command.register({ name: 'action-pin', description: 'GitHub Actions steps an edit pins to a moving tag: status, on, off, mode (action-pin)', argumentHint: '[on | off | mode note | deny]' })
250    await readSettings($, state)
251    // Read from the event, not from `$.session.cwd()`, which follows a Bash `cd`.
252    state.root = await shownRootOf($, e.cwd)
253    return r
254  })
255
256  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
257  on('command.run', { command: 'action-pin' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
258
259  /*
260   * The turn's end reads every open workflow again and owes the model a note for the refs that still
261   * move, because a finding it did not close would otherwise stand in the pane and reach it never again.
262   * The commit SHAs are already in `state.shas`, so this asks GitHub nothing.
263   */
264  on('turn.complete', async ($, e, next) => {
265    const r = await next(e)
266    if (e.agentId !== undefined) return r
267    await readSettings($, state)
268    if (!state.enabled) return r
269    state.owed = (await closeResolved($, state)).flatMap(l => l.refs)
270    return r
271  })
272
273  // The note goes to the model alone; the person reads the pane, which carries the same finding.
274  on('prompt.submit', async (_, e, next) => {
275    if (state.owed.length === 0) return next(e)
276    const note = openNote(state.owed)
277    state.owed = []
278    return next({ ...e, context: [...(e.context ?? []), note] })
279  })
280
281  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
282    const stop = await gate($, state, e.command)
283    return stop === undefined ? next(e) : { deny: stop }
284  })
285
286  on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, state, e.file_path, e.old_string, e.new_string, await next(e)))
287  on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, state, e.file_path, '', e.content, await next(e)))
288}
289
hooks/pin.ts 204 lines
1/** Which `uses:` lines of a workflow name an action by a moving ref, and the note for them. */
2
3/** One `uses:` line that names an action by a tag or a branch. */
4export type Unpinned = {
5  /** The action without its ref, for example `actions/checkout`. */
6  action: string
7  /** The ref as written, for example `v4` or `main`. */
8  ref: string
9  /** The commit the ref pointed at, when it was resolved. */
10  sha?: string
11}
12
13/** At most this many actions are named in one note, and resolved over the network. */
14export const MAX_NAMED = 10
15
16const WORKFLOW = /\.github\/workflows\/[^/]+\.ya?ml$/
17const ACTION_FILE = /\.github\/actions\/.+\/action\.ya?ml$/
18
19/** Whether the path is a workflow or a composite action of this repository. */
20export function isWorkflow(path: string): boolean {
21  return WORKFLOW.test(path) || ACTION_FILE.test(path)
22}
23
24const USES = /^\s*(?:-\s*)?uses:\s*["']?([^"'\s#]+)["']?/
25const SHA = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/
26
27/** A local action of the same repository, or a container image: neither has a commit to pin. */
28function isExempt(value: string): boolean {
29  return value.startsWith('./') || value.startsWith('../') || value.startsWith('docker://')
30}
31
32/** The action and the ref of one `uses:` line, or undefined when the line pins a commit or is exempt. */
33export function unpinnedUse(line: string): Unpinned | undefined {
34  const value = USES.exec(line)?.[1]
35  if (value === undefined || isExempt(value)) return undefined
36  const cut = value.lastIndexOf('@')
37  if (cut <= 0) return undefined
38  const ref = value.slice(cut + 1)
39  return SHA.test(ref) ? undefined : { action: value.slice(0, cut), ref }
40}
41
42/** Every line of `after` the `before` text did not have. */
43function addedLines(before: string, after: string): string[] {
44  const had = new Set(before.split('\n').map(l => l.trim()))
45  return after.split('\n').filter(l => !had.has(l.trim()))
46}
47
48/** The actions this edit pins to a moving ref, each once, in the order they appear. */
49export function unpinnedUses(before: string, after: string): Unpinned[] {
50  const out: Unpinned[] = []
51  const seen = new Set<string>()
52  for (const line of addedLines(before, after)) {
53    const use = unpinnedUse(line)
54    const key = use === undefined ? '' : `${use.action}@${use.ref}`
55    if (use === undefined || seen.has(key)) continue
56    seen.add(key)
57    out.push(use)
58  }
59  return out
60}
61
62/** The GitHub API URL that answers the commit a ref points at. */
63export function commitUrl(action: string, ref: string): string {
64  // A path inside a repository (`owner/repo/sub/action`) belongs to the repository's two first parts.
65  const [owner = '', repo = ''] = action.split('/')
66  return `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`
67}
68
69function named(uses: readonly Unpinned[]): string {
70  const rows = uses.slice(0, MAX_NAMED).map(u => `${u.action}@${u.ref}${u.sha === undefined ? '' : ` → ${u.sha}`}`)
71  if (uses.length > MAX_NAMED) rows.push(`${uses.length - MAX_NAMED} more`)
72  return rows.join(' · ')
73}
74
75export function noteText(uses: readonly Unpinned[]): string {
76  const example = uses[0]
77  const how = example?.sha === undefined
78    ? 'Pin each to the commit SHA of that tag, and keep the tag as a trailing comment.'
79    : `Write each as the SHA with the tag as a comment, for example: uses: ${example.action}@${example.sha} # ${example.ref}`
80  return `action-pin: this edit uses actions by a moving ref: ${named(uses)}. A tag or a branch can be moved to other code after a review, so a workflow with write access runs whatever it points at then. ${how}`
81}
82
83/**
84 * The transcript line: the workflow and its actions, without the instruction the model reads. The engine
85 * adds the mod name. The workflow is named because the person, unlike the model, did not see the edit.
86 */
87export function logText(file: string, uses: readonly Unpinned[]): string {
88  return `${file} uses actions by a moving ref: ${named(uses)}`
89}
90
91/** How the sidebar colours a line or a part of one. */
92type Tone = 'ok' | 'warn' | 'error' | 'dim'
93export type Part = { text: string; kind?: Tone }
94/** A sidebar line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
95export type Line = { text: string; kind?: Tone; parts?: Part[] }
96
97const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
98
99/** A line made of parts, its `text` their texts joined. */
100const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
101
102/** One action's row: the action in the default colour, the moving ref red, the commit it points at faint. */
103function useLine(u: Unpinned): Line {
104  const sha = u.sha === undefined ? [] : [part(` → ${u.sha}`, 'dim')]
105  return partsLine([part(u.action, undefined), part(`@${u.ref}`, 'error'), ...sha])
106}
107
108/** The sidebar lines of a finding: the workflow, then one line per action, as the closing lines read. */
109export function sidebarLines(file: string, uses: readonly Unpinned[]): Line[] {
110  const rows = uses.slice(0, MAX_NAMED).map(useLine)
111  if (uses.length > MAX_NAMED) rows.push({ text: `${uses.length - MAX_NAMED} more`, kind: 'dim' })
112  return [{ text: file, kind: 'error' }, ...rows]
113}
114
115/**
116 * `path` shown relative to `root` (the git repository the session started in, else its directory) when it is inside it. It also keys the
117 * sidebar entry: an absolute path is cut at 64 characters there, so every workflow of a repository under
118 * a long directory would share one key and a closing would take the other workflows' entries down.
119 */
120export function shownPath(path: string, root: string | undefined): string {
121  if (root === undefined) return path
122  const base = `${root.replace(/\/+$/, '')}/`
123  return path.startsWith(base) ? path.slice(base.length) : path
124}
125
126/** The transcript line of a finding a later edit closed, by what closed it. */
127export function doneLog(file: string, refs: readonly string[], gone = false): string {
128  const what = gone ? `${file} is no longer there` : `every action of ${file} is pinned to a commit now`
129  return `${what}: ${refs.join(' · ')}`
130}
131
132/** The title of a closed finding, by what closed it. */
133export function doneTitle(gone: boolean): string {
134  return gone ? 'workflow gone' : 'actions pinned'
135}
136
137/** The sidebar lines of a closed finding: the file, then the refs that are gone. */
138export function doneLines(file: string, refs: readonly string[]): Line[] {
139  return [{ text: file, kind: 'ok' }, ...refs.map(text => ({ text, kind: 'ok' as const }))]
140}
141
142/** `action@ref`, the form the finding is held and named by. */
143export const refOf = (use: Unpinned): string => `${use.action}@${use.ref}`
144
145/** The refs a file's finding holds open after a new report: the earlier ones and the new ones, each once. */
146export function openRefs(before: readonly string[] | undefined, uses: readonly Unpinned[]): string[] {
147  return [...new Set([...(before ?? []), ...uses.map(refOf)])]
148}
149
150/** The global flags git takes before the subcommand, so `git -c user.name=x commit` is still a commit. */
151const GIT_FLAG = String.raw`(?:\s+-[cC]\s+\S+|\s+--(?:git-dir|work-tree|namespace)=\S+|\s+--(?:no-pager|no-replace-objects|bare|literal-pathspecs|paginate))`
152
153/** A `git commit`, `git push` or `git merge` the gate stops while a finding is open. */
154const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
155const ASKING = /\s(--dry-run|--help|-h)(\s|$)/
156
157export function isGuarded(command: string): boolean {
158  return GUARDED.test(command) && !ASKING.test(command)
159}
160
161/** Whether the command is a `git commit`, the one guarded command whose own files can be measured. */
162export function isCommit(command: string): boolean {
163  return GUARDED.exec(command)?.[2] === 'commit'
164}
165
166/**
167 * Whether the index alone says what this commit holds. A `-a` or `-am` commit stages the tracked files
168 * as it runs, and a pathspec after `--` commits paths the index does not hold, so neither is narrowed.
169 */
170export function isNarrowable(command: string): boolean {
171  const words = command.split(/\s+/)
172  return !words.includes('--') && !words.some(w => w === '--all' || /^-[A-Za-z]*a/.test(w))
173}
174
175/** The mode of the mod: a note only, or a note and a gate on git commit, push and merge. */
176export type Mode = 'note' | 'deny'
177
178/** The mode a `/action-pin mode <word>` argument names, or undefined when it is not one. */
179export function modeOf(arg: string): Mode | undefined {
180  return arg === 'note' || arg === 'deny' ? arg : undefined
181}
182
183/** The deny text both the model and the person read: which refs still move, and the one way out. */
184export function denyText(refs: readonly string[]): string {
185  const rows = refs.slice(0, MAX_NAMED)
186  if (refs.length > MAX_NAMED) rows.push(`${refs.length - MAX_NAMED} more`)
187  return `stopped: ${refs.length} action(s) are used by a moving ref: ${rows.join(' · ')}. Pin each to the commit SHA of that ref, with the ref as a trailing comment, then run the command again; there is no way around this gate.`
188}
189
190/**
191 * The note the model reads at the next prompt while a finding stands, so a finding it did not close
192 * reaches it again instead of standing in the pane alone. The person reads the pane and needs no line.
193 */
194export function openNote(refs: readonly string[]): string {
195  const rows = refs.slice(0, MAX_NAMED)
196  if (refs.length > MAX_NAMED) rows.push(`${refs.length - MAX_NAMED} more`)
197  return `action-pin: ${refs.length} action(s) are still used by a moving ref: ${rows.join(' · ')}. Pin each to the commit SHA of that ref, or take the step out.`
198}
199
200/** A sidebar section key: the subject cut to what the sidebar takes, so one file keeps one section. */
201export function sectionKey(text: string): string {
202  return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
203}
204