Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Changed

- The `local-file` findings buffer moves to `findings-buffer/findings.jsonl` under the git common directory, so every worktree of a clone reads and writes one file; `read.sh` still returns rows from the old per-worktree `.work/buffer/findings.jsonl` (#12907).
4 changes: 2 additions & 2 deletions plugins/corrections/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ Run `scripts/prove-check.test.sh` to exercise it against a fixture repository.

## Configuration

`note` writes findings-buffer rows, under `.work/buffer/` at the repository root by default,
gitignored. No network.
`note` writes findings-buffer rows, by default to `findings-buffer/` in the git common directory:
one file per clone, shared by its worktrees, never committed. No network.

The two `findings_buffer_adapter_*` options bind one repository's own findings-buffer adapter, in
user scope, where a repository cannot write. `buffer.sh` reads them from your user settings and
Expand Down
3 changes: 2 additions & 1 deletion plugins/corrections/tools/findings-buffer/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,15 @@ adapter whose MAJOR differs from its own, and tolerates a higher MINOR. A missin

Rows derived from session transcripts never leave the machine. An adapter that stores rows off the
machine (a hosted sink) must not receive them; only the `local-file` adapter may hold such rows.
It keeps one file per clone, shared by the clone's worktrees and never committed or pushed.
`buffer.sh` does not route rows by origin yet: it hands every row to the bound adapter, so bind
only an adapter that keeps rows on the machine until routing to a hosted sink exists.

## Adapters

| Adapter | Storage |
|---|---|
| `local-file` | `.work/buffer/findings.jsonl` under the repository root, created on first append together with a `.work/buffer/.gitignore` holding `*`, so rows never reach git. Both verbs refuse (exit `2`, nothing stored) when `.work`, `.work/buffer`, the lock or `findings.jsonl` is a symlink, or the buffer resolves outside the repository. Appends take an exclusive lock, so concurrent calls never interleave or lose a row: `flock` on `.work/buffer/.lock`; where `flock` is absent (macOS), perl's `flock` on the same file when perl calls flock(2) itself (`$Config{d_flock}`); where neither is available, a `.work/buffer/.lockdir` directory holding the holder's pid. A flock(2) lock ends when every process holding it has exited, so an appender killed with SIGKILL holds up no later append. The holder removes the `.lockdir` directory on exit, and on HUP, INT or TERM, while its pid file still holds the holder's pid. That directory has no automatic takeover: on a host with neither, an appender killed with SIGKILL (or a power loss) leaves it behind, and every later append exits `1` naming it (at once when its pid is no longer running) until an operator removes it. Needs `jq`. Exits `3` outside a git repository or without `jq`. |
| `local-file` | `findings-buffer/findings.jsonl` under the git common directory (`git rev-parse --path-format=absolute --git-common-dir`), created on first append. Every worktree of one clone shares that directory, and git tracks nothing in it, so rows never reach a commit. `read.sh` also returns the rows of `.work/buffer/findings.jsonl` under the current worktree's root, where appends stored them before; `buffer.sh read` drops a repeat `obs_id`. Both verbs refuse (exit `2`, nothing stored) when `findings-buffer`, the lock or `findings.jsonl` is a symlink; `read.sh` skips the old file, with a note on stderr, when `.work`, `.work/buffer` or the old `findings.jsonl` is; `append.sh` also refuses when the buffer resolves outside the git common directory. Appends take an exclusive lock, held across worktrees, so concurrent calls never interleave or lose a row: `flock` on `findings-buffer/.lock`; where `flock` is absent (macOS), perl's `flock` on the same file when perl calls flock(2) itself (`$Config{d_flock}`); where neither is available, a `findings-buffer/.lockdir` directory holding the holder's pid. A flock(2) lock ends when every process holding it has exited, so an appender killed with SIGKILL holds up no later append. The holder removes the `.lockdir` directory on exit, and on HUP, INT or TERM, while its pid file still holds the holder's pid. That directory has no automatic takeover: on a host with neither, an appender killed with SIGKILL (or a power loss) leaves it behind, and every later append exits `1` naming it (at once when its pid is no longer running) until an operator removes it. Needs `jq`. Exits `3` outside a git repository or without `jq`. |

## Core

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# local-file findings buffer: append.sh < rows.jsonl
# Stores each row in .work/buffer/findings.jsonl under the repository root.
# Stores each row in findings-buffer/findings.jsonl under the git common directory,
# which every worktree of a clone shares.
# Exit: 0 every row durable, 1 storage failure, 2 usage, 3 not in a repository or jq missing.
set -uo pipefail

Expand All @@ -14,12 +15,12 @@ command -v jq >/dev/null 2>&1 || {
exit 3
}

ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
COMMON="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" && [[ -d "$COMMON" ]] || {
echo "append.sh: not inside a git repository" >&2
exit 3
}

DIR="$ROOT/.work/buffer"
DIR="$COMMON/findings-buffer"
FILE="$DIR/findings.jsonl"
LOCKDIR="$DIR/.lockdir"
HELD=0
Expand All @@ -43,25 +44,23 @@ fi

[[ -s "$TMP" ]] || exit 0

# refuse_links: the buffer stays a plain directory inside the repository, so a
# symlink can never redirect rows (or the lock file) elsewhere.
# refuse_links: the buffer stays a plain directory inside the git common directory,
# so a symlink can never redirect rows (or the lock file) elsewhere. Git tracks
# nothing there, so the rows never reach a commit.
refuse_links() {
local p
for p in "$ROOT/.work" "$DIR" "$DIR/.gitignore" "$DIR/.lock" "$LOCKDIR" "$FILE"; do
for p in "$DIR" "$DIR/.lock" "$LOCKDIR" "$FILE"; do
if [[ -L "$p" ]]; then
echo "append.sh: $p is a symlink; the buffer must be a plain directory in the repository" >&2
echo "append.sh: $p is a symlink; the buffer must be a plain directory in the git common directory" >&2
return 1
fi
done
}
refuse_links || exit 2
mkdir -p "$DIR" || exit 1
# Rows never leave the machine: keep the buffer out of git, keyed on the file so an
# interrupted or concurrent first append still ends with it in place.
[[ -f "$DIR/.gitignore" ]] || printf '*\n' >"$DIR/.gitignore" || exit 1
REAL_DIR="$(cd "$DIR" && pwd -P)" && REAL_ROOT="$(cd "$ROOT" && pwd -P)" || exit 1
if [[ "$REAL_DIR" != "$REAL_ROOT/.work/buffer" ]]; then
echo "append.sh: $DIR resolves outside the repository" >&2
REAL_DIR="$(cd "$DIR" && pwd -P)" && REAL_COMMON="$(cd "$COMMON" && pwd -P)" || exit 1
if [[ "$REAL_DIR" != "$REAL_COMMON/findings-buffer" ]]; then
echo "append.sh: $DIR resolves outside the git common directory" >&2
exit 2
fi

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,40 @@ if [[ -n "$SINCE" && ! "$SINCE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}
exit 2
fi

ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
COMMON="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" && [[ -d "$COMMON" ]] || {
echo "read.sh: not inside a git repository" >&2
exit 3
}

FILE="$ROOT/.work/buffer/findings.jsonl"
for p in "$ROOT/.work" "$ROOT/.work/buffer" "$FILE"; do
if [[ -L "$p" ]]; then
echo "read.sh: $p is a symlink; the buffer must be a plain directory in the repository" >&2
exit 2
fi
done
[[ -f "$FILE" ]] || exit 0
# The shared file, then the per-worktree file appends wrote before the buffer moved.
FILES=()
refuse_links() {
local p
for p in "$@"; do
if [[ -L "$p" ]]; then
echo "read.sh: $p is a symlink; the buffer must be a plain directory" >&2
exit 2
fi
done
}
refuse_links "$COMMON/findings-buffer" "$COMMON/findings-buffer/findings.jsonl"
[[ -f "$COMMON/findings-buffer/findings.jsonl" ]] && FILES+=("$COMMON/findings-buffer/findings.jsonl")
if TOP="$(git rev-parse --show-toplevel 2>/dev/null)" && [[ -n "$TOP" ]]; then
# A symlink on the legacy path skips only the legacy file, so the shared rows stay readable.
LEGACY_OK=1
for p in "$TOP/.work" "$TOP/.work/buffer" "$TOP/.work/buffer/findings.jsonl"; do
if [[ -L "$p" ]]; then
echo "read.sh: $p is a symlink; skipping the legacy buffer" >&2
LEGACY_OK=0
break
fi
done
[[ $LEGACY_OK -eq 1 && -f "$TOP/.work/buffer/findings.jsonl" ]] && FILES+=("$TOP/.work/buffer/findings.jsonl")
fi
[[ ${#FILES[@]} -gt 0 ]] || exit 0

if [[ -z "$SINCE" ]]; then
cat "$FILE" || exit 1
cat "${FILES[@]}" || exit 1
exit 0
fi

Expand All @@ -55,4 +73,4 @@ command -v jq >/dev/null 2>&1 || {
# a bare "Z", and the contract allows returning rows older than --since. A row with
# no string ts is kept.
jq -c --arg s "${SINCE:0:19}" \
'select((.ts | type) != "string" or .ts[0:19] >= $s)' "$FILE" || exit 1
'select((.ts | type) != "string" or .ts[0:19] >= $s)' "${FILES[@]}" || exit 1
2 changes: 1 addition & 1 deletion plugins/corrections/tools/findings-buffer/buffer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,5 @@ RC=$?
if [[ $RC -eq 0 || "$ADAPTER" == "$LOCAL" ]]; then
exit $RC
fi
echo "buffer.sh: adapter $ADAPTER failed to append (exit $RC); appending to local-file (.work/buffer/) instead" >&2
echo "buffer.sh: adapter $ADAPTER failed to append (exit $RC); appending to local-file instead" >&2
"$LOCAL/append.sh" <"$TMP/rows"
34 changes: 27 additions & 7 deletions plugins/corrections/tools/findings-buffer/buffer.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,11 @@ run() {
OUT="$(cat "$TEST_TMPDIR/out")"
ERR="$(cat "$TEST_TMPDIR/err")"
}
# local_file <repo>: the local-file adapter's file, under the repository's git common directory.
local_file() { printf '%s/findings-buffer/findings.jsonl' "$(git -C "$1" rev-parse --path-format=absolute --git-common-dir)"; }
# used_local <rc> <repo> <stderr>: the append succeeded into the local file and said so.
used_local() { [[ $1 -eq 0 && -s "$2/.work/buffer/findings.jsonl" ]] && grep -q "local-file" <<<"$3"; }
local_rows() { cat "$1/.work/buffer/findings.jsonl" 2>/dev/null; }
used_local() { [[ $1 -eq 0 && -s "$(local_file "$2")" ]] && grep -q "local-file" <<<"$3"; }
local_rows() { cat "$(local_file "$1")" 2>/dev/null; }

ROW='{"kind":"lint","session":"s1","msg":"unused variable"}'

Expand All @@ -101,7 +103,7 @@ run "$R" append < <(printf '%s\n' "$ROW")
DETAIL="exit $RC; stderr: $ERR"
check "no binding: append exits 0" test "$RC" -eq 0
DETAIL="local rows: $(local_rows "$R")"
GOT="$(jq -r 'select(.msg == "unused variable") | (.obs_id | test("^[0-9a-f]{16}$")) and (.ts | type == "string")' "$R/.work/buffer/findings.jsonl" 2>/dev/null)"
GOT="$(jq -r 'select(.msg == "unused variable") | (.obs_id | test("^[0-9a-f]{16}$")) and (.ts | type == "string")' "$R/.git/findings-buffer/findings.jsonl" 2>/dev/null)"
check "no binding: the row lands in the local file with obs_id and ts" test "$GOT" == true
DETAIL="stderr: $ERR"
check "no binding: stderr names local-file" grep -q "local-file" <<<"$ERR"
Expand All @@ -114,7 +116,7 @@ run "$R" append < <(printf '%s\n' "$ROW")
DETAIL="exit $RC; stderr: $ERR"
check "failing adapter: append still exits 0" test "$RC" -eq 0
DETAIL="local rows: $(local_rows "$R")"
check "failing adapter: the row is in the local file" grep -q '"unused variable"' "$R/.work/buffer/findings.jsonl"
check "failing adapter: the row is in the local file" grep -q '"unused variable"' "$R/.git/findings-buffer/findings.jsonl"
run "$R" read
DETAIL="exit $RC; out: $OUT"
check "failing adapter: read merges the adapter and fallback rows, once per obs_id" \
Expand Down Expand Up @@ -198,7 +200,7 @@ PATH="$SHIM:$PATH" run "$R" append < <(printf '%s\n' "$ROW")
DETAIL="exit $RC; stderr: $ERR"
check "redaction failure: append exits non-zero" test "$RC" -ne 0
check "redaction failure: nothing reaches the adapter or the local file" \
test ! -e "$ADAPTERS/ok/captured.jsonl" -a ! -e "$R/.work/buffer/findings.jsonl"
test ! -e "$ADAPTERS/ok/captured.jsonl" -a ! -e "$R/.git/findings-buffer/findings.jsonl"

# 7. A committed binding never runs repository code: only a plugin adapter name is accepted.
new_repo
Expand Down Expand Up @@ -243,7 +245,7 @@ run "$A" append < <(printf '%s\n' "$ROW")
DETAIL="exit $RC; stderr: $ERR"
check "user-scope binding: append exits 0" test "$RC" -eq 0
check "user-scope binding: the repository's adapter runs" grep -q '"unused variable"' "$A/tools/mine/captured.jsonl"
check "user-scope binding: nothing lands in the local file" test ! -e "$A/.work/buffer/findings.jsonl"
check "user-scope binding: nothing lands in the local file" test ! -e "$A/.git/findings-buffer/findings.jsonl"

new_repo
B="$R"
Expand Down Expand Up @@ -355,12 +357,30 @@ bind "$R" broken
run "$R" append < <(printf '%s\n' "$ROW")
old_row() { printf '{"ts":"2020-01-01T00:00:00Z","session":"s1","msg":"%s","obs_id":"%s"}\n' "$1" "$2"; }
old_row "old adapter row" aaaaaaaaaaaaaaaa >>"$ADAPTERS/broken/captured.jsonl"
old_row "old local row" bbbbbbbbbbbbbbbb >>"$R/.work/buffer/findings.jsonl"
old_row "old local row" bbbbbbbbbbbbbbbb >>"$R/.git/findings-buffer/findings.jsonl"
run "$R" read --since 2025-01-01T00:00:00Z
DETAIL="exit $RC; out: $OUT"
check "read --since: both reads leave out rows older than since and keep the new one" \
test "$RC" -eq 0 -a "$(grep -c 'old .* row' <<<"$OUT")" -eq 0 -a "$(grep -c '"unused variable"' <<<"$OUT")" -eq 1

# Worktrees of one clone share the local file, and a row also left in the old per-worktree file reads once.
new_repo
git -C "$R" -c user.name=fixture -c user.email=fixture@example.invalid -c commit.gpgsign=false commit -q --allow-empty -m fixture
WT="$TEST_TMPDIR/worktree-of-repo$N"
git -C "$R" worktree add -q -b shared "$WT"
run "$WT" append < <(printf '{"obs_id":"1111111111111111","msg":"noted in a worktree"}\n')
run "$R" read
DETAIL="exit $RC; out: $OUT"
check "worktrees: a row appended in a linked worktree reads back in the main worktree" \
test "$RC" -eq 0 -a "$(grep -c '"noted in a worktree"' <<<"$OUT")" -eq 1
mkdir -p "$R/.work/buffer"
printf '{"obs_id":"1111111111111111","ts":"2026-10-21T00:00:00Z","msg":"noted in a worktree"}\n' >"$R/.work/buffer/findings.jsonl"
printf '{"obs_id":"2222222222222222","ts":"2026-10-21T00:00:00Z","msg":"noted before the move"}\n' >>"$R/.work/buffer/findings.jsonl"
run "$R" read
DETAIL="exit $RC; out: $OUT"
check "worktrees: legacy rows read back, and a repeat obs_id reads once" \
test "$RC" -eq 0 -a "$(grep -c '"noted in a worktree"' <<<"$OUT")" -eq 1 -a "$(grep -c '"noted before the move"' <<<"$OUT")" -eq 1

printf '1..%d\n' "$CASE_NUM"
if [[ $FAILED -gt 0 ]]; then
echo "# $FAILED of $CASE_NUM failed"
Expand Down
Loading
Loading