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
25 changes: 16 additions & 9 deletions .github/workflows/backmerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
if: steps.check.outputs.needs_pr == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WORKFLOW_NAME: ${{ github.workflow }}
RUN_ID: ${{ github.run_id }}
run: |
MAIN_SHA="$(git rev-parse --short origin/main)"
MAIN_SUBJECT="$(git log -1 --format='%s' origin/main)"
Expand All @@ -44,16 +46,21 @@ jobs:
exit 0
fi

gh pr create \
--base develop \
--head main \
--title "chore: back-merge main into develop" \
--body "Automated back-merge of \`main\` into \`develop\`.
BODY_FILE="$(mktemp)"
cat > "$BODY_FILE" <<EOF
Automated back-merge of \`main\` into \`develop\`.

Latest commit on main: \`${MAIN_SHA}\` - ${MAIN_SUBJECT}

Latest commit on main: \`$MAIN_SHA\` - $MAIN_SUBJECT
Triggered by push to \`main\` in workflow \`${WORKFLOW_NAME}\` run \`${RUN_ID}\`.

Triggered by push to \`main\` in workflow \`${{ github.workflow }}\` run \`${{ github.run_id }}\`.
Merge this PR (or enable auto-merge on it) to keep \`develop\` in sync with the latest release commit on \`main\`. Resolve any conflicts manually before merging.

Merge this PR (or enable auto-merge on it) to keep \`develop\` in sync with the latest release commit on \`main\`. Resolve any conflicts manually before merging.
> Note on GPG: the merge commit GitHub creates when you click "Merge pull request" is signed by \`web-flow\`. The locksmith \`make verify\` GPG gate already ignores web-flow merge commits, so this back-merge will not break verification on \`develop\`.
EOF

> **Note on GPG**: the merge commit GitHub creates when you click \"Merge pull request\" is signed by \`web-flow\`. The locksmith \`make verify\` GPG gate already ignores web-flow merge commits, so this back-merge will not break verification on \`develop\`."
gh pr create \
--base develop \
--head main \
--title "chore: back-merge main into develop" \
--body-file "$BODY_FILE"
28 changes: 8 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,10 @@ jobs:
with:
go-version-file: go.mod
check-latest: false

- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum', 'go.work', 'go.work.sum') }}
restore-keys: |
${{ runner.os }}-go-
cache-dependency-path: |
**/go.sum
go.work
go.work.sum

- name: Initialize tools and protobuf
run: make init
Expand All @@ -53,16 +47,10 @@ jobs:
with:
go-version-file: go.mod
check-latest: false

- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum', 'go.work', 'go.work.sum') }}
restore-keys: |
${{ runner.os }}-go-
cache-dependency-path: |
**/go.sum
go.work
go.work.sum

- name: Initialize tools and protobuf
run: make init
Expand Down
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ Invoke these via the agent's skill mechanism. Each skill's
or `gh pr create` themselves - they print the commands for the
human maintainer to run.

## Superpowers + CodeGraph

When invoking any superpowers skill (brainstorming, writing-plans,
executing-plans, subagent-driven-development, debugging,
code-review, etc.), use `codegraph_*` tools for code navigation -
not grep or ad-hoc file reads. CodeGraph is faster and returns
structural information (callers, callees, blast radius) that grep
cannot. Dispatched sub-agents inherit this rule. See the CodeGraph
section below for the tool table.

<!-- CODEGRAPH_START -->
## CodeGraph

Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ merging them, and creating the release tag.
- Specs: save to `docs/superpowers/specs/YYYY-MM-DD-<feature>-design.md`
- Both directories are gitignored and MUST NOT be committed to git.
Never use `git add -f` on these files. They are local working artifacts only.
- **Use CodeGraph for code navigation during superpowers workflows.**
Brainstorming, writing-plans, executing-plans, debugging, and code-review
skills all benefit from `codegraph_*` tools (see the CodeGraph section
above). Prefer `codegraph_search` / `codegraph_context` /
`codegraph_impact` over grep when scoping work, locating call sites, or
estimating blast radius. Sub-agents dispatched from these skills inherit
the same rule.

## Final Verification (mandatory last step in every superpowers plan)

Expand Down
29 changes: 29 additions & 0 deletions internal/bundled/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,35 @@ func TestOpenFromBytes_NoManifest(t *testing.T) {
}
}

// TestOpenBundle_Wrapper checks that the OpenBundle wrapper either returns a
// usable bundle or the documented ErrEmptyBundle on builds without populated
// per-platform bytes. Any other error is a regression.
func TestOpenBundle_Wrapper(t *testing.T) {
b, err := OpenBundle()
if err != nil {
if !errors.Is(err, ErrEmptyBundle) {
t.Fatalf("OpenBundle() error = %v, want ErrEmptyBundle or nil", err)
}
return
}
if b == nil {
t.Fatal("OpenBundle() = nil, nil; want non-nil bundle")
}
}

// TestOpenFromBytes_BadManifestJSON exercises the JSON-decode error branch
// when manifest.json is present but contains invalid JSON.
func TestOpenFromBytes_BadManifestJSON(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, _ := zw.Create("manifest.json")
w.Write([]byte("{not json"))
zw.Close()
if _, err := openFromBytes(buf.Bytes()); err == nil {
t.Fatal("expected error for invalid manifest JSON")
}
}

func TestFindEntry(t *testing.T) {
mf := Manifest{Entries: []Entry{
{Name: "a", Kind: KindPlugin},
Expand Down
218 changes: 218 additions & 0 deletions internal/bundled/extract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)

Expand Down Expand Up @@ -214,6 +216,222 @@ func TestExtract_PinentryKind(t *testing.T) {
}
}

// TestExtract_OnExtracted_Called covers the OnExtracted callback branch.
func TestExtract_OnExtracted_Called(t *testing.T) {
dir := t.TempDir()
content := []byte("xyz")
mf := Manifest{Entries: []Entry{
{Name: "p", Kind: KindPlugin, SHA256: sha256Hex(content)},
}}
data := makeZip(t, mf, map[string][]byte{"p": content})
b, _ := openFromBytes(data)
var extracted []string
if err := Extract(b, ExtractOptions{
Names: []string{"p"}, PluginsDir: dir,
OnExtracted: func(name string) { extracted = append(extracted, name) },
}); err != nil {
t.Fatalf("Extract: %v", err)
}
if len(extracted) != 1 || extracted[0] != "p" {
t.Errorf("OnExtracted not called for p; got %v", extracted)
}
}

// TestExtract_OnKept_SilentSkip covers the OnKept(false) branch when an
// existing file already matches the bundle's SHA.
func TestExtract_OnKept_SilentSkip(t *testing.T) {
dir := t.TempDir()
content := []byte("same")
if err := os.WriteFile(filepath.Join(dir, "p"), content, 0o755); err != nil {
t.Fatal(err)
}
mf := Manifest{Entries: []Entry{
{Name: "p", Kind: KindPlugin, SHA256: sha256Hex(content)},
}}
data := makeZip(t, mf, map[string][]byte{"p": content})
b, _ := openFromBytes(data)
var keptName string
var keptWarn bool
if err := Extract(b, ExtractOptions{
Names: []string{"p"}, PluginsDir: dir,
OnKept: func(name string, withWarning bool) {
keptName = name
keptWarn = withWarning
},
}); err != nil {
t.Fatalf("Extract: %v", err)
}
if keptName != "p" || keptWarn {
t.Errorf("OnKept(name=%q, warn=%v), want (p, false)", keptName, keptWarn)
}
}

// TestFileSHA256_OpenFails exercises the open-error branch when the path is a
// directory (open succeeds on most systems but io.Copy of a directory fails).
// On macOS/Linux opening a directory returns no error but reads fail.
func TestFileSHA256_DirectoryPath(t *testing.T) {
dir := t.TempDir()
_, _, err := FileSHA256(dir)
if err == nil {
t.Skip("opening a directory does not error on this OS; cannot drive hashing error")
}
}

// TestShortSHA covers both branches: longer than ShortSHALen and shorter.
func TestShortSHA(t *testing.T) {
long := "abcdef0123456789"
if got := ShortSHA(long); got != "abcdef01" {
t.Errorf("ShortSHA(long) = %q, want abcdef01", got)
}
short := "ab"
if got := ShortSHA(short); got != "ab" {
t.Errorf("ShortSHA(short) = %q, want ab", got)
}
exact := "abcdef01"
if got := ShortSHA(exact); got != exact {
t.Errorf("ShortSHA(exact) = %q, want %q", got, exact)
}
}

// TestDestPathFor_UnknownKind covers the default branch of destPathFor via
// Extract: an entry with an unknown kind returns an error.
func TestExtract_UnknownKind(t *testing.T) {
dir := t.TempDir()
mf := Manifest{Entries: []Entry{
{Name: "weird", Kind: EntryKind("weird"), SHA256: sha256Hex([]byte("x"))},
}}
data := makeZip(t, mf, map[string][]byte{"weird": []byte("x")})
b, _ := openFromBytes(data)
err := Extract(b, ExtractOptions{Names: []string{"weird"}, PluginsDir: dir})
if err == nil {
t.Fatal("expected error for unknown kind")
}
if !strings.Contains(err.Error(), "unknown entry kind") {
t.Errorf("error = %v, want 'unknown entry kind'", err)
}
}

// TestExtract_EntryNotInBundle exercises the "bundle has no entry" branch.
func TestExtract_EntryNotInBundle(t *testing.T) {
dir := t.TempDir()
mf := Manifest{Entries: []Entry{
{Name: "real", Kind: KindPlugin, SHA256: sha256Hex([]byte("x"))},
}}
data := makeZip(t, mf, map[string][]byte{"real": []byte("x")})
b, _ := openFromBytes(data)
err := Extract(b, ExtractOptions{Names: []string{"absent"}, PluginsDir: dir})
if err == nil {
t.Fatal("expected error for missing entry name")
}
}

// TestExtract_FileSHA256_OpenError exercises FileSHA256's open-error branch via
// Extract: when the destination directory is unreadable, Extract surfaces the
// error. Using a path that contains a non-directory component triggers it.
func TestExtract_DestIsDirectory(t *testing.T) {
// Set PluginsDir to a path whose parent is an existing file (cannot mkdir).
tmp := t.TempDir()
notDir := filepath.Join(tmp, "blocker")
if err := os.WriteFile(notDir, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
mf := Manifest{Entries: []Entry{
{Name: "p", Kind: KindPlugin, SHA256: sha256Hex([]byte("xyz"))},
}}
data := makeZip(t, mf, map[string][]byte{"p": []byte("xyz")})
b, _ := openFromBytes(data)
err := Extract(b, ExtractOptions{
Names: []string{"p"},
PluginsDir: filepath.Join(notDir, "subdir"), // mkdir will fail: parent is a file
})
if err == nil {
t.Fatal("expected error when PluginsDir cannot be created")
}
}

// TestBundle_Open_Missing covers the "entry not found in bundle" branch of
// (*Bundle).Open.
func TestBundle_Open_Missing(t *testing.T) {
mf := Manifest{Entries: []Entry{{Name: "a", Kind: KindPlugin, SHA256: sha256Hex([]byte("x"))}}}
data := makeZip(t, mf, map[string][]byte{"a": []byte("x")})
b, _ := openFromBytes(data)
if _, err := b.Open("nonexistent"); err == nil {
t.Fatal("expected error for missing entry")
}
}

// TestOpenFromBytes_BadZip covers the zip.NewReader error branch.
func TestOpenFromBytes_BadZip(t *testing.T) {
if _, err := openFromBytes([]byte("not-a-zip")); err == nil {
t.Fatal("expected error for non-zip bytes")
}
}

// TestExtract_DestIsExistingDirectory exercises the writeEntry rename-failure
// branch by making the destination path an existing directory.
func TestExtract_DestIsExistingDirectory(t *testing.T) {
dir := t.TempDir()
// Pre-create a directory at the location where the plugin file would
// land. Rename of tmp to a directory location should fail.
if err := os.MkdirAll(filepath.Join(dir, "p"), 0o755); err != nil {
t.Fatal(err)
}
// Also create a child inside so the directory is non-empty (rename
// would otherwise succeed on some systems when target dir is empty).
if err := os.WriteFile(filepath.Join(dir, "p", "child"), []byte("c"), 0o600); err != nil {
t.Fatal(err)
}
content := []byte("xx")
mf := Manifest{Entries: []Entry{
{Name: "p", Kind: KindPlugin, SHA256: sha256Hex(content)},
}}
data := makeZip(t, mf, map[string][]byte{"p": content})
b, _ := openFromBytes(data)
err := Extract(b, ExtractOptions{
Names: []string{"p"},
PluginsDir: dir,
// File-exists branch with !ForceOverwrite triggers prompter; nil
// prompter defaults to Keep, but the file SHA differs (it's
// actually a directory entry SHA that won't match) so the path
// proceeds. Use ForceOverwrite to skip prompting.
ForceOverwrite: true,
})
if err == nil {
t.Skipf("OS allowed rename over non-empty directory (%s); test inconclusive", runtime.GOOS)
}
}

// TestExtract_DestParentReadOnly exercises the writeEntry OpenFile error
// branch: we make the plugin directory read-only so OpenFile of the tmp file
// fails with EACCES.
func TestExtract_DestParentReadOnly(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root bypasses directory permissions; skip on root")
}
dir := t.TempDir()
pluginsDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginsDir, 0o755); err != nil {
t.Fatal(err)
}
content := []byte("xx")
mf := Manifest{Entries: []Entry{
{Name: "p", Kind: KindPlugin, SHA256: sha256Hex(content)},
}}
data := makeZip(t, mf, map[string][]byte{"p": content})
b, _ := openFromBytes(data)
// Make the destination directory read-only. mkdirAll on the same
// directory in writeEntry will succeed (MkdirAll is a no-op for an
// existing dir), but OpenFile of the .tmp file will fail.
if err := os.Chmod(pluginsDir, 0o555); err != nil {
t.Fatal(err)
}
defer os.Chmod(pluginsDir, 0o755)
err := Extract(b, ExtractOptions{Names: []string{"p"}, PluginsDir: pluginsDir})
if err == nil {
t.Fatal("expected error writing into a read-only directory")
}
}

func TestExtract_BundleSHAFails(t *testing.T) {
// Force a sha256 mismatch between manifest and content.
dir := t.TempDir()
Expand Down
Loading
Loading