diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dbed085f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Append-only board rows and notes. Every feature branch adds a row to the same table tail +# or the same prepend point, so two parallel branches collide on lines neither one got +# wrong. Union keeps both sides. Without this file the collision surfaced as a stash-pop +# conflict on _meta/BACKLOG.md that a session hand-resolved four times in one sitting. +# +# Caveat, deliberate: union never drops a line, so a row both sides EDITED (two branches +# flipping the same ID's Status) comes out TWICE rather than merged, and only a union re-merge +# runs `lib/board/backlog.sh dedupe-all` on its own. After a plain merge or rebase the next +# `board set ...` refuses with "matches N rows; dedupe first", which is the loud failure +# this trades the conflict for. Run `dedupe-all` then, and check which side's Status it kept. +# A table whose rows are REWRITTEN in place rather than appended does not belong on this list. +_meta/BACKLOG.md merge=union +_meta/backlog-staging.md merge=union +docs/implementation-notes/*.md merge=union diff --git a/_meta/BACKLOG.md b/_meta/BACKLOG.md index 65392176..c1d9b60e 100644 --- a/_meta/BACKLOG.md +++ b/_meta/BACKLOG.md @@ -35,6 +35,7 @@ Lane = the WORKFLOW.md risk tier (`tiny` / `normal` / `full`). | ID | Title | Source | Target artifact | Lane | Status | |----|-------|--------|-----------------|------|--------| +| ID-886 | declare the kit's append-only logs merge=union #wrap #git | the repo shipped every union mechanism and no .gitattributes of its own, so _meta/BACKLOG.md was never declared union here and parallel sessions hand-resolved the same stash-pop conflict four times; precedent: lib/wrap/wrap.sh pull_past_dirty path; lane=normal; source: wrap step 7b of the 2026-09-16 kit sweep session | shipped [https://github.com/dwarvesf/dwarves-kit/pull/663] | | ID-904 | spec-next mints against open PR heads, not only the local listing #spec #concurrency | Intent: three parallel workers on 2026-09-16 each got SPEC-289 from spec-next because it only scanned docs/specs/, local branches, and commit subjects, none of which show a number an open unmerged PR already holds, and none of them called reserve. Added a gh-backed scan of every open PR head's docs/specs/ listing (contents API, no clone), skippable via SPEC_NEXT_NO_PR_SCAN=1, degrading to the old local-only scan with a stderr note when gh is missing or unauthenticated. lane=normal. Source: wrap step 7b, 2026-09-16 kit sweep session. | shipped [PR #661] | | ID-903 | session observe tools: an --errors flag that groups a tool's error results by message prefix #observe #session #u-mid | Intent: session observe tools shows a tool's error COUNT (EnterWorktree 15 percent, ExitWorktree 17 percent this week) and nothing about WHY; a 30-line python over ~/.claude/projects grouping is_error tool_result content by its first 160 chars answered it in one run (17 of 19 EnterWorktree errors were subagents with a cwd override). Precedent: lib/session/observe/bin/session-observe owns the tools view; this is a flag on it, not a sibling script. lane=full. Goal draft: .claude/goals/observe-tool-errors.md. Source: wrap step 7b, 2026-09-16 kit sweep session. | queued | | ID-885 | dispatch Step 6 releases a settled task's attempt record, and the lane parsers read a markdown-bold `Lane:` header #kit #dispatch #gate | Source: this session's follow-ups from SPEC-290 and SPEC-289. SPEC-290 wired most attempt-state verbs into commands/dispatch.md but never `release`, so kit-attempts/ grew forever. A worker writing `**Lane**: full` had its push BLOCKED with "Spec has no 'Lane:' header"; three files parsed `^Lane:` with the same expression, so all three had to change together. | commands/dispatch.md, hooks/ship-gate.sh, commands/ship.md, commands/mega.md, commands/spec.md, tests/test-ship-gate-fail-closed.sh, tests/test-meta.sh | full | shipped feat/attempt-release-lane-header [SPEC-293, proof docs/verification/attempt-release-lane-header.md] | diff --git a/commands/wrap.md b/commands/wrap.md index b594bf55..47fa9149 100644 --- a/commands/wrap.md +++ b/commands/wrap.md @@ -99,7 +99,7 @@ Re-run the step 0 check first. Then, in this order: remove the session's own wor - Pass `--worktrees` on every call, dry run and apply alike, unless `wrap.tidy_worktrees` is false. It is the flag the operator asks for by saying "clean up worktrees", and `apply` refuses a dirty, detached, checked-out or unproven worktree on its own, so the flag is not the safety. Omitting it also strands every branch a worktree holds: `apply` skips those with `held by a worktree` and the branch survives with it. With the knob false, drop the flag, leave every worktree, list them under `Left alone`, and name the knob in `FYI`; the session's own worktree bullet below is unaffected, because that removal is proven per worktree rather than swept. - `bin/wrap apply` without `--apply` changes nothing; always read its dry-run SKIP lines before adding `--apply`. - Pull on the default branch is `--ff-only`; off the default branch, `apply` fetches the default branch into itself instead and reports a refusal as `FAILED`, never forced. That fetch refuses outright when the default branch is checked out in another worktree, which is the second way a repo stays behind; the main-checkout rule above is what avoids both. -- `wrap.pull_past_dirty` (default `false`) governs the third way a repo stays behind: a sibling session's dirty TRACKED file that git refuses to overwrite. `false` reports `FAILED` and leaves the checkout behind. `true` stashes only the blocking files under a run-named stash, pulls, and pops that stash by ref; a pop conflict prints `PULLED, POP CONFLICT: , stash kept` and exits 2, leaving the markers and the stash for the operator. Untracked files, a dirty index, and pre-existing stashes are out of its reach at either setting. Name the knob in `FYI` whenever it changed what the pull did. If a run is interrupted between the stash and the pop, the blocking files are still in the stash: `git -C stash list | grep wrap-pull-past-dirty` names it and `git stash pop ` finishes the restore. +- `wrap.pull_past_dirty` (default `false`) governs the third way a repo stays behind: a sibling session's dirty TRACKED file that git refuses to overwrite. `false` reports `FAILED` and leaves the checkout behind. `true` stashes only the blocking files under a run-named stash, pulls, and pops that stash by ref; a pop conflict prints `PULLED, POP CONFLICT: , stash kept` and exits 2, leaving the markers and the stash for the operator. Whether a dirty file reaches that conflict at all is the repo's own call: a file `.gitattributes` declares `merge=union` never blocks the pull in the first place (its local lines are carried across, below the `---` anchor, or by git's union driver when the file has no anchor), and one that rides in the stash resolves during the pop. This repo declares its board and its implementation notes union for exactly that reason. Untracked files, a dirty index, and pre-existing stashes are out of its reach at either setting. Name the knob in `FYI` whenever it changed what the pull did. If a run is interrupted between the stash and the pop, the blocking files are still in the stash: `git -C stash list | grep wrap-pull-past-dirty` names it and `git stash pop ` finishes the restore. - The session's own `EnterWorktree` worktree goes FIRST, before `wrap apply` runs, so the harness records the removal instead of finding the directory gone. Once `wrap scan` proves its branch squash-merged (tip matches the PR head) and the worktree is clean, remove it: `ExitWorktree remove` with `discard_changes: true` (the squashed commits are not ancestors of the default branch, so the tool asks; the proof is the confirmation), then `git branch -D `. The operator gave that confirmation once, as a standing rule, and a worktree whose PR merged this session is finished work, never something to keep. `ExitWorktree keep` only when the worktree is dirty, the branch is not proven merged, or the proof is unavailable (no `gh`); say which in `Left alone`. A plain secondary worktree still routes through `wrap apply --worktrees`, which skips it when dirty, detached, held by the checked-out branch, or on a branch no merge proof covers, and otherwise removes it and deletes that branch. A LOCK is not a skip reason there: the Agent tool locks every worktree it creates, so `apply` overrides the lock once the merge proof holds, then confirms the path is gone and reports `FAILED` when it is not. - Never remove a dirty or foreign worktree, under `--worktrees` or otherwise. - Never force-push and never rewrite history to make a delete or a pull succeed. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a02cc327..66891b9e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -20,7 +20,7 @@ GENERATED , do not hand-edit. Regenerate: `bash lib/registry/feature-registry.sh | `/kit:design` | `[H/I]` | Opt-in interactive solution-design beat between /think and /spec. Explores 2-3 approaches one question at a time, holds for your approval p… | SPEC-003, SPEC-004, SPEC-005 +105 | test-command-emit-sweep.sh, test-command-triggers.sh, test-design-record.sh +21 | | `/kit:devs-team` | `[H/I]` | Parallel multi-lens critique of a solution design (the active spec if present, else the decision brief). Dispatches 5 engineering lenses, m… | SPEC-016, SPEC-018, SPEC-019 +11 | test-gate-vocab-recording.sh, test-meta.sh, test-outcome-emit-sweep.sh | | `/kit:dispatch` | `[H/I]` | Fire several disjoint VALIDATED specs concurrently, each in its own worktree, then converge. Cross-goal fan-out behind a disjointness gate … | SPEC-002, SPEC-016, SPEC-017 +80 | test-advisor-ledger-emit.sh, test-agent-effectiveness.sh, test-attempt-state.sh +31 | -| `/kit:docs` | `[H/I]` | Update all project documentation to match the current codebase. Cross-references the diff against every doc file and fixes drift. | SPEC-001, SPEC-002, SPEC-003 +194 | proof-loop-09-scenario-b.sh, run-all.sh, run-workflow.sh +70 | +| `/kit:docs` | `[H/I]` | Update all project documentation to match the current codebase. Cross-references the diff against every doc file and fixes drift. | SPEC-001, SPEC-002, SPEC-003 +194 | proof-loop-09-scenario-b.sh, run-all.sh, run-workflow.sh +71 | | `/kit:draft-agent` | `[H/I]` | Meta-agent agent-builder. From a one-line description, generates a new subagent definition OR a mega-goal sub-goal file and (by default) in… | SPEC-089, SPEC-108, SPEC-139 +1 | test-agent-effectiveness.sh, test-command-emit-sweep.sh, test-meta-agent.sh +1 | | `/kit:execute` | `[H/I]` | Autonomous spec execution with verification. Dispatches worker subagents per task, verifies each with task-verifier, retries fixable failur… | SPEC-001, SPEC-003, SPEC-004 +60 | test-break-it.sh, test-gate-vocab-recording.sh, test-hooks.sh +9 | | `/kit:explain` | `[H/I]` | Turn a merged change into a literate-diff explainer a human READS to understand: background -> goal + intuition -> a prose-ordered diff -> … | SPEC-050, SPEC-060, SPEC-094 +18 | proof-loop-09-scenario-b.sh, test-boundary-lint.sh, test-command-emit-sweep.sh +11 | @@ -50,7 +50,7 @@ GENERATED , do not hand-edit. Regenerate: `bash lib/registry/feature-registry.sh | `/kit:verify` | `[H/I]` | Re-run the test levels (task-verifier + integration-verifier + acceptance-verifier + system-verifier) on the current spec/branch read-only,… | SPEC-002, SPEC-003, SPEC-006 +37 | test-break-it.sh, test-codex-hooks.sh, test-command-emit-sweep.sh +8 | | `/kit:visual-team` | `[H/I]` | Parallel multi-lens critique of a visual/UI design. Dispatches 5 design lenses, merges findings, reports a verdict. Report-only, downstream… | SPEC-016, SPEC-018, SPEC-019 +10 | test-command-emit-sweep.sh, test-meta.sh | | `/kit:wayfind` | `[H]` | Plan a chunk of work too big for one agent session as a shared decision map: map.md + typed decision tickets in the mega-goal folder, resol… | SPEC-206, SPEC-207, SPEC-217 +2 | - | -| `/kit:wrap` | `[H/I]` | The session-scoped landing step after ship: flips board rows, merges the operator's own green PRs one at a time, checks deploys, tidies bra… | SPEC-020, SPEC-060, SPEC-072 +12 | test-bin-forwarders.sh, test-boundary-lint.sh, test-config-registry.sh +4 | +| `/kit:wrap` | `[H/I]` | The session-scoped landing step after ship: flips board rows, merges the operator's own green PRs one at a time, checks deploys, tidies bra… | SPEC-020, SPEC-060, SPEC-072 +12 | test-bin-forwarders.sh, test-boundary-lint.sh, test-config-registry.sh +5 | ## Agents diff --git a/docs/implementation-notes/gitattributes-union.md b/docs/implementation-notes/gitattributes-union.md new file mode 100644 index 00000000..10756b05 --- /dev/null +++ b/docs/implementation-notes/gitattributes-union.md @@ -0,0 +1,61 @@ +# gitattributes-union + +Delta from the brief. The brief asked for a resolver on `bin/wrap apply`'s POP CONFLICT path +that would resolve a `merge=union` file's conflict hunks by hand. + +## 2026-09-16 14:00 The premise was refuted, so the root cause shipped instead + +Context: the brief said `git stash pop` uses the ordinary 3-way merge and leaves conflict +markers on a union-declared file, and that a session hand-resolved that four times. + +Decision: no resolver. Declare the repo's append-only files `merge=union` in a +`.gitattributes` this repo never had. + +Why, in three parts. + +1. Measured. A fresh repo declaring one file `merge=union`, an upstream commit prepending a + row and a local uncommitted row in the same hunk: `git stash push`, fast-forward, + `git stash pop` exits 0, both rows land, no markers, the file stays unstaged. Union + resolves during the pop. Binary-declared and NUL-byte variants do fail the pop, and they + leave no markers, so a hunk resolver never sees them either. +2. Already asserted. `tests/test-wrap.sh` "knob on: a union-marked file blocked by the same + pull resolves during the pop" has pinned this since the pull-past-dirty work, and + `lib/wrap/wrap.sh` states the same contract in the `_unstash` comment. +3. The real cause. This repo carried no `.gitattributes` at all, so + `git check-attr merge -- _meta/BACKLOG.md` answered `unspecified`. The board was never + union HERE. The proposed `check-attr == union` gate would not have fired either. The + sibling repo ops-toolkit declares the same file union and never needed the hand-resolve. + +Alternatives: ship the resolver anyway as a safety net. Rejected, because no text-file path +reaches it, and unreachable code carries no proof. + +Impact: the four-step hand procedure is gone at the source, and parallel branches adding a +board row now merge. + +## 2026-09-16 14:20 The union declaration exposed a placement bug, so it is fixed here + +Context: declaring `_meta/BACKLOG.md` union makes `_pull_default`'s carry-across-pull path +reachable for it. That path inserts carried lines below the first `---` line. The board has no +such line, so `_log_anchor_head_lines` returns 0 and the carried row prepended to line 1, above +the document title and outside the table. + +Decision: `_union_carry_back` keeps the anchor rule for a file that has an anchor, and runs +`git merge-file --union` over pulled/base/local for a file that has none. `_pull_default` saves +the pre-pull content beside the local copy, because after the pull the base exists nowhere in +the worktree. + +Why: git's driver is the same one the declaration names, so a carried row lands where a merge +or a stash pop would put it, at the table tail. A first attempt placed the block after the line +it followed locally; review broke that in four ways (a blank neighbour fell back to line 1, a +repeated neighbour picked the wrong table, non-contiguous added lines moved as one block, an +empty first line matched any blank). The driver answers all four and is less code. + +The anchor branch survives because the driver orders the incoming entry above the local one. +For a newest-first `LAB_LOG` that is backwards, and `tests/test-wrap.sh` asserts the carried +line sits above the older entries. Narrowing the new path to anchorless files leaves every +asserted contract untouched rather than weakening one. ops-toolkit's board has a `---` at line +112 and so never hit the placement bug at all. + +Open questions: a file declared union that `git merge-file` refuses (a binary one) now restores +its pre-pull content and reports `FAILED carry` rather than merging. Declaring a binary file +union is a configuration error; this makes it a loud one. diff --git a/docs/verification/gitattributes-union.md b/docs/verification/gitattributes-union.md new file mode 100644 index 00000000..793b0610 --- /dev/null +++ b/docs/verification/gitattributes-union.md @@ -0,0 +1,62 @@ +# Proof of done: the repo declares its append-only files `merge=union` + +2026-09-16. Acceptance: `git check-attr merge` answers `union` for `_meta/BACKLOG.md`, +`_meta/backlog-staging.md` and `docs/implementation-notes/*.md` and `unspecified` for source +files; a sibling session's uncommitted board row survives `bin/wrap apply --apply` with +`wrap.pull_past_dirty` true, lands inside the table rather than above the title, stays +unstaged, and leaves no stash; two branches each adding a row merge with no conflict. Lane: +normal. Files: `.gitattributes`, `lib/wrap/wrap.sh`, `tests/test-gitattributes-union.sh`, +`commands/wrap.md`. + +## The failure this replaces + +The kit shipped every union mechanism and no `.gitattributes` of its own, so the board was +never declared union HERE while the sibling repo ops-toolkit declared the same file and never +collided. Two sessions adding a row hit a stash-pop conflict; one sitting resolved it four +times by hand, each time with a stash push, an ff-only merge, a conflicted pop, a throwaway +script over the hunks, and a reset to leave the row unstaged. + +The brief proposed a hunk resolver on wrap's POP CONFLICT path instead. A measured repro +refuted the premise: `git stash pop` resolves a union-declared text file with no markers. +Evidence and the rejected alternative: `docs/implementation-notes/gitattributes-union.md`. + +## Green run + +Command: `bash tests/test-gitattributes-union.sh` +Exit: 0 +Output: `27/27 passed` +Verdict: PASS. Four cases against real git repos built on disk, two of them the +no-declaration controls that reproduce the hand-resolved conflict and the refused merge. + +Command: `bash tests/test-wrap.sh` +Exit: 0 +Output: `test-wrap: all 508 passed` +Verdict: PASS. The carry-across-pull contract is unchanged for anchored files; the new +union-driver path is reached only by a file with no `---` anchor. + +## Negative control + +Command: `bash lib/gate/negctl.sh . "bash tests/test-gitattributes-union.sh" "git rm -q --cached .gitattributes >/dev/null 2>&1; rm -f .gitattributes"` +Exit: 0 +Output: + +``` +Exit: 0 (green before mutation) +Mutation: git rm -q --cached .gitattributes >/dev/null 2>&1; rm -f .gitattributes +Changed: .gitattributes +Exit: 1 (under mutation, RED expected) +Restore: git checkout HEAD -- .gitattributes +Exit: 0 (green after restore) +Verdict: PASS +``` + +Verdict: PASS. Deleting the declaration turns the suite red and restoring it turns it green, +so the suite measures the declaration and not the fixtures around it. + +## Reproduce + +``` +git -C checkout feat/wrap-pop-union +bash tests/test-gitattributes-union.sh +bash tests/run-all.sh +``` diff --git a/lib/wrap/wrap.sh b/lib/wrap/wrap.sh index fd5f5902..d9cd1dff 100644 --- a/lib/wrap/wrap.sh +++ b/lib/wrap/wrap.sh @@ -401,31 +401,51 @@ _union_marked() { return 1 } -# _union_carry_back -- put back every line the saved copy holds and the -# pulled file lacks. Prints the count. The insert point comes from _log_anchor_head_lines, so -# a carried line lands below the header exactly where `wrap log` puts a new entry. +# _union_carry_back -- put the local lines back into the +# pulled file. Prints how many lines the local copy holds and the pulled file lacks; returns 1 +# when the merge refused, having restored the local copy so the operator's work is never the +# thing that goes missing. +# +# A file with a `---` anchor keeps the anchor rule: a carried line lands directly below the +# header, as the newest entry, which is where `wrap log` writes one and what a newest-first log +# means. A file with NO anchor gets git's own union driver instead, over the three sides any +# merge of this file would use: the pulled content, the pre-pull content as base, and the local +# copy. The prepend has no right answer there. A board table has no anchor, so a carried ROW +# landed at line 1, above the title and outside the table it belongs to, and a file with two +# tables offers the anchor rule two equally wrong places. _union_carry_back() { - local saved="$1" target="$2" add tmp head_n mode n + local local_copy="$1" base="$2" target="$3" add tmp head_n mode n add="$(mktemp)" - grep -Fxv -f "$target" "$saved" > "$add" 2>/dev/null + grep -Fxv -f "$target" "$local_copy" > "$add" 2>/dev/null n="$(grep -c '' "$add" 2>/dev/null)"; n="${n:-0}" - if [ "$n" -gt 0 ] 2>/dev/null; then - head_n="$(_log_anchor_head_lines "$target")" - tmp="$(mktemp)" - if [ "$head_n" -gt 0 ] 2>/dev/null; then - sed -n "1,${head_n}p" "$target" > "$tmp" - cat "$add" >> "$tmp" - tail -n "+$((head_n + 1))" "$target" >> "$tmp" - else - cat "$add" "$target" > "$tmp" - fi - mode="$(_fmode "$target")" - case "$mode" in ''|*[!0-7]*) mode="" ;; esac - [ -n "$mode" ] && chmod "$mode" "$tmp" # mktemp opens 0600; carry the target's mode over + if [ "$n" -le 0 ] 2>/dev/null; then rm -f "$add"; printf '0'; return 0; fi + + tmp="$(mktemp)" + mode="$(_fmode "$target")" + case "$mode" in ''|*[!0-7]*) mode="" ;; esac + [ -n "$mode" ] && chmod "$mode" "$tmp" # mktemp opens 0600; carry the target's mode over + + head_n="$(_log_anchor_head_lines "$target")" + if [ "$head_n" -gt 0 ] 2>/dev/null; then + sed -n "1,${head_n}p" "$target" > "$tmp" + cat "$add" >> "$tmp" + tail -n "+$((head_n + 1))" "$target" >> "$tmp" + rm -f "$add" mv -f "$tmp" "$target" + printf '%s' "$n" + return 0 fi rm -f "$add" - printf '%s' "$n" + cp "$target" "$tmp" + if git merge-file --union -q "$tmp" "$base" "$local_copy" >/dev/null 2>&1; then + mv -f "$tmp" "$target" + printf '%s' "$n" + return 0 + fi + rm -f "$tmp" + cp "$local_copy" "$target" # the operator's lines are never the thing that goes missing + printf '0' + return 1 } # _pull_past_dirty_on -- 0 when the operator authorized stashing a sibling session's dirty @@ -558,6 +578,9 @@ _pull_default() { cp "$repo/$f" "${saved_dir}/${n}" printf '%s\0' "$f" >> "${saved_dir}/list" git -C "$repo" checkout -- "$f" + # The restored file IS the merge base the carry-back needs, captured here because + # after the pull the pre-pull content is no longer anywhere in the worktree. + cp "$repo/$f" "${saved_dir}/${n}.base" done < <(git -C "$repo" diff --name-only -z 2>/dev/null) echo " saved ${n} union-marked file(s) aside so the pull can fast-forward" fi @@ -602,8 +625,12 @@ _pull_default() { while IFS= read -r -d '' f; do n=$(( n + 1 )) if [ "$FAILURES" = "$before" ]; then - carried="$(_union_carry_back "${saved_dir}/${n}" "$repo/$f")" - echo " carried ${carried} local line(s) back into ${f}" + if carried="$(_union_carry_back "${saved_dir}/${n}" "${saved_dir}/${n}.base" "$repo/$f")"; then + echo " carried ${carried} local line(s) back into ${f}" + else + echo " FAILED carry: ${f} would not union-merge, so its pre-pull content is back" + FAILURES=1 + fi else # The operator's lines never stay only in a temp file, whatever failed the pull. cp "${saved_dir}/${n}" "$repo/$f" diff --git a/tests/test-gitattributes-union.sh b/tests/test-gitattributes-union.sh new file mode 100644 index 00000000..2dda7364 --- /dev/null +++ b/tests/test-gitattributes-union.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# test-gitattributes-union.sh -- the repo's own `.gitattributes` and what it buys. +# +# The kit shipped every union mechanism (wrap's carry-across-pull, the union re-merge, the +# board dedupe) and no `.gitattributes` of its own, so `_meta/BACKLOG.md` was never declared +# merge=union HERE. Two parallel sessions adding a board row therefore collided by hand: one +# sitting resolved the same stash-pop conflict four times. These cases pin the declaration and +# the two collisions it clears, each with the no-declaration control beside it. +# +# Run: bash tests/test-gitattributes-union.sh + +set -uo pipefail +KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WRAP="$KIT_DIR/bin/wrap" + +PASS=0; FAIL=0; TOTAL=0 +RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m' +chk() { + TOTAL=$((TOTAL+1)) + if [ "$2" -eq 0 ] 2>/dev/null; then echo -e " ${GREEN}PASS${NC} $1"; PASS=$((PASS+1)) + else echo -e " ${RED}FAIL${NC} $1"; FAIL=$((FAIL+1)); fi +} +chk_has() { chk "$1" "$({ trap '' PIPE; printf '%s' "$2" 2>/dev/null || :; } | grep -qF -- "$3"; echo $?)"; } +chk_no() { chk "$1" "$({ trap '' PIPE; printf '%s' "$2" 2>/dev/null || :; } | grep -qF -- "$3" && echo 1 || echo 0)"; } + +TMPD="$(mktemp -d "${TMPDIR:-/tmp}/dk-attr-test.XXXXXX")" +TMPD="$(cd "$TMPD" && pwd)" +trap 'rm -rf "$TMPD"' EXIT + +# The operator's real config must never reach a case that did not ask for it. +KIT_CONFIG_OPERATOR="$TMPD/no-operator-config"; export KIT_CONFIG_OPERATOR +KNOB_ON="$TMPD/knob-on"; mkdir -p "$KNOB_ON" +printf '[wrap]\npull_past_dirty = true\n' > "$KNOB_ON/kit.toml" + +gitc() { git -C "$1" config user.email t@t; git -C "$1" config user.name t; git -C "$1" config commit.gpgsign false; } + +# A board shaped like the kit's own: a prose header, no `---` separator anywhere, one table +# whose rows every session appends to. The missing separator is the point: the anchor rule +# alone would carry a row to line 1, above the title. +BOARD_HEAD=$'# Task Backlog\n\nOne table row per work item.\n\n| ID | Item | Notes & source | Status |\n|---|---|---|---|\n' +board() { printf '%s' "$BOARD_HEAD"; printf '| %s | %s | src | queued |\n' "$@"; } + +# build -- a bare origin plus a clone on main. +build() { + local name="$1" attrs="$2" work="$TMPD/work-$1" clone="$TMPD/clone-$1" + mkdir -p "$work/_meta" + git -C "$work" init -q; gitc "$work" + git -C "$work" symbolic-ref HEAD refs/heads/main + # The declaration under test is the repo's REAL file, never a paraphrase of it. + [ "$attrs" = 1 ] && cp "$KIT_DIR/.gitattributes" "$work/.gitattributes" + board ID-001 first > "$work/_meta/BACKLOG.md" + printf 'unrelated\n' > "$work/README.md" + git -C "$work" add -A; git -C "$work" commit -qm base + git clone -q --bare "$work" "$TMPD/bare-$name" + git clone -q "$TMPD/bare-$name" "$clone"; gitc "$clone" + git -C "$clone" remote set-head origin main >/dev/null 2>&1 +} + +# advance -- an upstream session appends ITS row and pushes. +advance() { + local push="$TMPD/push-$1" + git clone -q "$TMPD/bare-$1" "$push"; gitc "$push" + board ID-001 first ID-002 upstream > "$push/_meta/BACKLOG.md" + git -C "$push" commit -qam upstream + git -C "$push" push -q origin main +} + +# dirty -- a sibling session's uncommitted row, at the same table tail. +dirty() { board ID-001 first ID-003 sibling > "$1/_meta/BACKLOG.md"; } + +echo "=== the repo declares its append-only files merge=union ===" +chk "the .gitattributes file is tracked" \ + "$(git -C "$KIT_DIR" ls-files --error-unmatch .gitattributes >/dev/null 2>&1; echo $?)" +for f in _meta/BACKLOG.md _meta/backlog-staging.md docs/implementation-notes/x.md; do + chk "check-attr says union for ${f}" \ + "$([ "$(git -C "$KIT_DIR" check-attr merge -- "$f")" = "${f}: merge: union" ]; echo $?)" +done +chk "a source file is left alone" \ + "$([ "$(git -C "$KIT_DIR" check-attr merge -- lib/wrap/wrap.sh)" = "lib/wrap/wrap.sh: merge: unspecified" ]; echo $?)" + +echo "=== case 1: a dirty board row survives the wrap pull ===" +build carry 1; advance carry +C="$TMPD/clone-carry"; dirty "$C" +TIP="$(git -C "$TMPD/bare-carry" rev-parse main)" +out="$(KIT_CONFIG_OPERATOR="$KNOB_ON" "$WRAP" apply --apply "$C" 2>&1)"; rc=$? +chk "carry: apply exits 0" "$rc" +chk_no "carry: the pull did not fail" "$out" "FAILED pull --ff-only" +chk "carry: HEAD reached the upstream tip" \ + "$([ "$(git -C "$C" rev-parse HEAD)" = "$TIP" ]; echo $?)" +chk "carry: the upstream row landed" \ + "$(grep -qF '| ID-002 | upstream |' "$C/_meta/BACKLOG.md"; echo $?)" +chk "carry: the sibling's row survived" \ + "$(grep -qF '| ID-003 | sibling |' "$C/_meta/BACKLOG.md"; echo $?)" +chk "carry: line 1 is still the title, not a carried row" \ + "$([ "$(sed -n 1p "$C/_meta/BACKLOG.md")" = "# Task Backlog" ]; echo $?)" +# Placement, not just survival: the carried row lands at the table tail, where the union +# driver puts it and where `board capture` had it. Contiguity is what says it is still a table. +chk "carry: the carried row is last, as a union merge orders it" \ + "$([ "$(grep '^| ID-' "$C/_meta/BACKLOG.md" | tail -1)" = "| ID-003 | sibling | src | queued |" ]; echo $?)" +chk "carry: the three rows are contiguous" \ + "$([ "$(grep -n '^| ID-' "$C/_meta/BACKLOG.md" | cut -d: -f1 | tr '\n' ' ')" = "7 8 9 " ]; echo $?)" +chk_has "carry: the row is still uncommitted" "$(git -C "$C" diff --name-only)" "_meta/BACKLOG.md" +chk "carry: and never staged" "$([ -z "$(git -C "$C" diff --cached --name-only)" ]; echo $?)" +chk "carry: no stash was left behind" "$([ "$(git -C "$C" stash list | grep -c '')" = "0" ]; echo $?)" + +echo "--- negative control: the same repo with no union declaration" +build nocarry 0; advance nocarry +N="$TMPD/clone-nocarry"; dirty "$N" +N_HEAD="$(git -C "$N" rev-parse HEAD)" +out="$(KIT_CONFIG_OPERATOR="$KNOB_ON" "$WRAP" apply --apply "$N" 2>&1)"; rc=$? +chk "control: apply exits 2" "$([ "$rc" -eq 2 ]; echo $?)" +chk_has "control: the pop conflict is what the operator hand-resolved" "$out" "POP CONFLICT" +chk "control: the conflict markers are in the board" \ + "$(grep -q '^<<<<<<<' "$N/_meta/BACKLOG.md"; echo $?)" +chk "control: the run's stash is kept" \ + "$([ "$(git -C "$N" stash list | grep -c 'wrap-pull-past-dirty-')" = "1" ]; echo $?)" + +echo "=== case 2: two branches adding a row merge without a conflict ===" +build merge 1 +M="$TMPD/clone-merge" +git -C "$M" checkout -q -b feat/a +board ID-001 first ID-010 branch-a > "$M/_meta/BACKLOG.md" +git -C "$M" commit -qam "row a" +git -C "$M" checkout -q main +git -C "$M" checkout -q -b feat/b +board ID-001 first ID-011 branch-b > "$M/_meta/BACKLOG.md" +git -C "$M" commit -qam "row b" +git -C "$M" merge feat/a --no-edit -q >/dev/null 2>&1; rc=$? +chk "merge: the branch merge succeeded" "$rc" +chk "merge: no conflict markers" "$(grep -q '^<<<<<<<' "$M/_meta/BACKLOG.md" && echo 1 || echo 0)" +chk "merge: branch a's row is present" "$(grep -qF '| ID-010 | branch-a |' "$M/_meta/BACKLOG.md"; echo $?)" +chk "merge: branch b's row is present" "$(grep -qF '| ID-011 | branch-b |' "$M/_meta/BACKLOG.md"; echo $?)" +chk "merge: the base row is present exactly once" \ + "$([ "$(grep -cF '| ID-001 | first |' "$M/_meta/BACKLOG.md")" = "1" ]; echo $?)" + +echo "--- negative control: the same two branches with no union declaration" +build nomerge 0 +NM="$TMPD/clone-nomerge" +git -C "$NM" checkout -q -b feat/a +board ID-001 first ID-010 branch-a > "$NM/_meta/BACKLOG.md" +git -C "$NM" commit -qam "row a" +git -C "$NM" checkout -q main +git -C "$NM" checkout -q -b feat/b +board ID-001 first ID-011 branch-b > "$NM/_meta/BACKLOG.md" +git -C "$NM" commit -qam "row b" +git -C "$NM" merge feat/a --no-edit -q >/dev/null 2>&1; rc=$? +chk "merge control: the merge refuses" "$([ "$rc" -ne 0 ]; echo $?)" +chk "merge control: the board carries conflict markers" \ + "$(grep -q '^<<<<<<<' "$NM/_meta/BACKLOG.md"; echo $?)" + +echo +echo " $PASS/$TOTAL passed" +[ "$FAIL" -eq 0 ] || exit 1