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
2 changes: 2 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,14 @@ Complete end-to-end workflow from issue to PR:
- Use speculative/unproven patterns
- Commit without tests passing
- Batch unrelated changes in one PR
- Create a PR without using the `/pr` skill (even if user says "create pr")

### Always:
- Write tests before implementation
- Use existing patterns from `tests/**` and `src/**`
- Minimal code in GREEN phase
- Keep tests passing during REFACTOR
- Update CHANGELOG.md before creating a PR (for user-facing changes)
- Run quality checks before committing
- Update CHANGELOG.md for user-visible changes
- Maintain Bash 3.0+ compatibility
Expand Down
4 changes: 4 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"includeCoAuthoredBy": false
}
111 changes: 111 additions & 0 deletions .claude/skills/pr/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
---
name: pr
description: Push branch and create a GitHub PR with concise, issue-linked description
user-invocable: true
argument-hint: "[#issue]"
disable-model-invocation: true
allowed-tools: Bash, Read, Grep, Glob
---

# Create Pull Request

Push branch and create a PR with a concise, issue-linked description.

> **IMPORTANT:** This skill MUST be used for ALL pull request creation — even when the user says "create pr" without `/pr`. Never create a PR without following these steps.

## Current Branch Context
- Branch: !`git branch --show-current`
- Commits: !`git log main..HEAD --oneline 2>/dev/null`
- Changed files: !`git diff main..HEAD --stat 2>/dev/null`

## Arguments
- `$ARGUMENTS` - Issue reference (optional, e.g., `#42` or `42`). If provided, the PR will be linked to this issue.

## Instructions

1. **Review the branch context above** — the commits and changed files are already loaded.

2. **MANDATORY: Update CHANGELOG.md** — Read `CHANGELOG.md` and check the `## Unreleased` section. If the changes from this branch are NOT already listed there, you MUST update it before proceeding. **Do NOT skip this step. Do NOT proceed to push without verifying.**
- Add entries under the appropriate subsection (`### Added`, `### Changed`, `### Fixed`, etc.)
- Reference issue numbers where applicable (e.g., `(Issue #123)`)
- Commit the update:
```bash
git add CHANGELOG.md && git commit -m "docs: update changelog"
```

3. **Push branch**:
```bash
git push -u origin HEAD
```
- The `pre-push` git hook automatically runs the full test suite (BE & FE in parallel).
- If the hook fails, read the output, fix the issue, commit the fix, and retry the push. Do NOT use `--no-verify` to bypass.

4. **Generate PR title**:
- If `$ARGUMENTS` contains an issue number, fetch the issue title:
```bash
gh issue view <number> --json title -q '.title'
```
- PR title format: `<type>(<scope>): <short description>` (conventional commit style, under 70 chars)
- Derive the type from the branch prefix (`feat/` → feat, `fix/` → fix, `docs/` → docs)

5. **Create PR** using the template from `.github/PULL_REQUEST_TEMPLATE.md`:
```bash
gh pr create --title "<title>" --assignee @me --label "<label>" --body "$(cat <<'EOF'
## 🤔 Background

Related #<issue-number>

<1-2 sentences: motivation and context for the changes>

## 💡 Changes

- <bullet 1: what changed and why>
- <bullet 2>
- <bullet 3> (optional)
- <bullet 4> (optional)
EOF
)"
```

**MANDATORY:** Always follow the PR template structure (`## 🤔 Background` + `## 💡 Changes`). Never use a different format.

**Assignee:** Always assign to `@me` (the PR creator).

**Labels:** Add the single most relevant label based on the branch prefix and change context:
- `bug` — branch starts with `fix/` and addresses a defect
- `enhancement` — branch starts with `feat/` or adds new functionality
- `documentation` — branch starts with `docs/` or only changes docs
- `refactor` — code restructuring with no behavior change
- `ui` — visual/frontend-only changes
- `investigation` — spikes, research, or exploratory work

**Body guidelines:**
- **Background**: Link the issue with `Related #<number>`, then 1-2 sentences of context. **NEVER use `Closes` or `Fixes`.**
- **Changes**: 2-4 short bullet points. Focus on *what* and *why*, not implementation details.
- **No file lists, no class names, no code snippets** in the body.
- Keep the entire body under 15 lines.

6. **Move issue to "In Review"** in GitHub Project (if issue number provided):
```bash
# Read project config from .claude/github-project.json
ITEM_ID=$(gh project item-list PROJECT_NUMBER --owner OWNER --format json \
| jq -r '.items[] | select(.content.number == ISSUE_NUMBER) | .id')

gh project item-edit \
--id "$ITEM_ID" \
--project-id "PROJECT_ID" \
--field-id "STATUS_FIELD_ID" \
--single-select-option-id "IN_REVIEW_OPTION_ID"
```

**Note:** Requires `project` scope. Run `gh auth refresh -s project` if needed.

7. **Report the PR URL** to the user.

## Example Usage

```
/pr
/pr #42
/pr 15
```
20 changes: 17 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,26 @@ jobs:
run: make test

windows:
name: "On windows (${{ matrix.test_chunk }})"
name: "On windows (${{ matrix.name }})"
timeout-minutes: 10
runs-on: windows-latest
strategy:
matrix:
test_chunk: [acceptance, functional, unit]
include:
- name: "acceptance a-l"
test_path: "tests/acceptance/bashunit_[a-l]*_test.sh"
- name: "acceptance m-z"
test_path: "tests/acceptance/bashunit_[m-z]*_test.sh tests/acceptance/[i-p]*_test.sh"
- name: functional
test_path: "tests/functional/*_test.sh"
- name: "unit a-b"
test_path: "tests/unit/[a-b]*_test.sh"
- name: "unit c"
test_path: "tests/unit/c*_test.sh"
- name: "unit d-p"
test_path: "tests/unit/[d-p]*_test.sh"
- name: "unit r-z"
test_path: "tests/unit/[r-z]*_test.sh"
fail-fast: false
steps:
- name: Checkout code
Expand All @@ -50,7 +64,7 @@ jobs:
- name: Run tests
shell: bash
run: |
./bashunit --parallel tests/${{ matrix.test_chunk }}/*_test.sh
./bashunit --parallel ${{ matrix.test_path }}

alpine:
name: "On alpine-latest"
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Added
- Add `assert_have_been_called_nth_with` assertion for verifying arguments on the Nth invocation of a spy (Issue #172)

### Changed
- Split Windows CI test jobs into parallel chunks to avoid timeouts

## [0.33.0](https://github.com/TypedDevs/bashunit/compare/0.32.0...0.33.0) - 2026-02-15

### Changed
Expand Down
43 changes: 43 additions & 0 deletions src/test_doubles.sh
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,49 @@ function assert_have_been_called_times() {
bashunit::state::add_assertions_passed
}

function assert_have_been_called_nth_with() {
local nth=$1
local command=$2
shift 2
local expected="$*"

local variable
variable="$(bashunit::helper::normalize_variable_name "$command")"
local times_file_var="${variable}_times_file"
local file_var="${variable}_params_file"
local label
label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")"

local times=0
if [ -f "${!times_file_var-}" ]; then
times=$(cat "${!times_file_var}" 2>/dev/null || echo 0)
fi

if [ "$nth" -gt "$times" ]; then
bashunit::state::add_assertions_failed
bashunit::console_results::print_failed_test "${label}" \
"expected call" "at index ${nth} but" "only called ${times} times"
return
fi

local line=""
if [ -f "${!file_var-}" ]; then
line=$(sed -n "${nth}p" "${!file_var}" 2>/dev/null || true)
fi

local raw
IFS=$'\x1e' read -r raw _ <<<"$line" || true

if [ "$expected" != "$raw" ]; then
bashunit::state::add_assertions_failed
bashunit::console_results::print_failed_test "${label}" \
"$expected" "but got " "$raw"
return
fi

bashunit::state::add_assertions_passed
}

function assert_not_called() {
local command=$1
local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}"
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/test_doubles_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,40 @@ function test_spy_with_pipe_in_arguments() {

assert_have_been_called_with grep '-E foo|bar'
}

function test_successful_spy_nth_called_with() {
bashunit::spy ps

ps first_a first_b
ps second
ps third

assert_have_been_called_nth_with 1 ps "first_a first_b"
assert_have_been_called_nth_with 2 ps "second"
assert_have_been_called_nth_with 3 ps "third"
}

function test_unsuccessful_spy_nth_called_with() {
bashunit::spy ps

ps first
ps second

assert_same \
"$(bashunit::console_results::print_failed_test \
"Unsuccessful spy nth called with" \
"wrong" "but got " "first")" \
"$(assert_have_been_called_nth_with 1 ps "wrong")"
}

function test_unsuccessful_spy_nth_called_with_invalid_index() {
bashunit::spy ps

ps first

assert_same \
"$(bashunit::console_results::print_failed_test \
"Unsuccessful spy nth called with invalid index" \
"expected call" "at index 5 but" "only called 1 times")" \
"$(assert_have_been_called_nth_with 5 ps "first")"
}
Loading