docs(07): create phase 7 import & CI/CD plans
This commit is contained in:
240
.planning/phases/07-import-cicd/07-05-PLAN.md
Normal file
240
.planning/phases/07-import-cicd/07-05-PLAN.md
Normal file
@@ -0,0 +1,240 @@
|
||||
---
|
||||
phase: 07-import-cicd
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["07-04"]
|
||||
files_modified:
|
||||
- cmd/hook.go
|
||||
- cmd/stubs.go
|
||||
- cmd/hook_script.sh
|
||||
- cmd/hook_test.go
|
||||
autonomous: true
|
||||
requirements: [CICD-01]
|
||||
must_haves:
|
||||
truths:
|
||||
- "keyhunter hook install writes an executable .git/hooks/pre-commit"
|
||||
- "The installed hook calls keyhunter scan on staged files and propagates the exit code"
|
||||
- "keyhunter hook uninstall removes a KeyHunter-owned hook, preserving non-KeyHunter content via backup"
|
||||
- "Both commands error cleanly when run outside a git repository"
|
||||
artifacts:
|
||||
- path: cmd/hook.go
|
||||
provides: "keyhunter hook install/uninstall implementation"
|
||||
contains: "var hookCmd"
|
||||
- path: cmd/hook_script.sh
|
||||
provides: "embedded pre-commit shell script"
|
||||
contains: "keyhunter scan"
|
||||
key_links:
|
||||
- from: cmd/hook.go
|
||||
to: cmd/hook_script.sh
|
||||
via: "go:embed compile-time bundling"
|
||||
pattern: "//go:embed hook_script.sh"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Replace the cmd/hook stub with working install/uninstall logic. The install subcommand writes a pre-commit script (embedded via go:embed) that invokes `keyhunter scan` on staged files and exits with scan's exit code.
|
||||
|
||||
Purpose: CICD-01 — git pre-commit integration prevents leaked keys from being committed. First line of defense for developer workflows.
|
||||
Output: Working `keyhunter hook install` / `keyhunter hook uninstall` subcommands, embedded script, tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/07-import-cicd/07-CONTEXT.md
|
||||
@cmd/stubs.go
|
||||
@cmd/root.go
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: cmd/hook.go with install/uninstall subcommands + embedded script</name>
|
||||
<files>cmd/hook.go, cmd/stubs.go, cmd/hook_script.sh, cmd/hook_test.go</files>
|
||||
<action>
|
||||
Remove the `hookCmd` stub block from cmd/stubs.go. Keep all other stubs.
|
||||
|
||||
Create cmd/hook_script.sh (exact contents below — trailing newline important):
|
||||
```sh
|
||||
#!/usr/bin/env bash
|
||||
# KEYHUNTER-HOOK v1 — managed by `keyhunter hook install`
|
||||
# Remove via `keyhunter hook uninstall`.
|
||||
set -e
|
||||
|
||||
files=$(git diff --cached --name-only --diff-filter=ACMR)
|
||||
if [ -z "$files" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run keyhunter against each staged file. Exit code 1 from keyhunter
|
||||
# means findings present; 2 means scan error. Either blocks the commit.
|
||||
echo "$files" | xargs -r keyhunter scan --exit-code
|
||||
status=$?
|
||||
if [ $status -ne 0 ]; then
|
||||
echo "keyhunter: pre-commit blocked (exit $status). Run 'git commit --no-verify' to bypass." >&2
|
||||
exit $status
|
||||
fi
|
||||
exit 0
|
||||
```
|
||||
|
||||
Create cmd/hook.go:
|
||||
```go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
//go:embed hook_script.sh
|
||||
var hookScript string
|
||||
|
||||
// hookMarker identifies a KeyHunter-managed hook. Uninstall refuses to
|
||||
// delete pre-commit files that don't contain this marker unless --force.
|
||||
const hookMarker = "KEYHUNTER-HOOK v1"
|
||||
|
||||
var (
|
||||
hookForce bool
|
||||
)
|
||||
|
||||
var hookCmd = &cobra.Command{
|
||||
Use: "hook",
|
||||
Short: "Install or manage git pre-commit hooks",
|
||||
}
|
||||
|
||||
var hookInstallCmd = &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install the keyhunter pre-commit hook into .git/hooks/",
|
||||
RunE: runHookInstall,
|
||||
}
|
||||
|
||||
var hookUninstallCmd = &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Remove the keyhunter pre-commit hook",
|
||||
RunE: runHookUninstall,
|
||||
}
|
||||
|
||||
func init() {
|
||||
hookInstallCmd.Flags().BoolVar(&hookForce, "force", false, "overwrite any existing pre-commit hook without prompt")
|
||||
hookUninstallCmd.Flags().BoolVar(&hookForce, "force", false, "delete pre-commit even if it is not KeyHunter-managed")
|
||||
hookCmd.AddCommand(hookInstallCmd)
|
||||
hookCmd.AddCommand(hookUninstallCmd)
|
||||
}
|
||||
|
||||
func hookPath() (string, error) {
|
||||
gitDir := ".git"
|
||||
info, err := os.Stat(gitDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return "", fmt.Errorf("not a git repository (no .git/ in current directory)")
|
||||
}
|
||||
return filepath.Join(gitDir, "hooks", "pre-commit"), nil
|
||||
}
|
||||
|
||||
func runHookInstall(cmd *cobra.Command, args []string) error {
|
||||
target, err := hookPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
existing, _ := os.ReadFile(target)
|
||||
if strings.Contains(string(existing), hookMarker) {
|
||||
// Already ours — overwrite silently to update script.
|
||||
} else if !hookForce {
|
||||
return fmt.Errorf("pre-commit hook already exists at %s (use --force to overwrite; a .bak backup will be kept)", target)
|
||||
} else {
|
||||
backup := target + ".bak." + time.Now().Format("20060102150405")
|
||||
if err := os.Rename(target, backup); err != nil {
|
||||
return fmt.Errorf("backing up existing hook: %w", err)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Backed up existing hook to %s\n", backup)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("creating hooks dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte(hookScript), 0o755); err != nil {
|
||||
return fmt.Errorf("writing hook: %w", err)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Installed pre-commit hook at %s\n", target)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runHookUninstall(cmd *cobra.Command, args []string) error {
|
||||
target, err := hookPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No pre-commit hook to remove.")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("reading hook: %w", err)
|
||||
}
|
||||
if !strings.Contains(string(data), hookMarker) && !hookForce {
|
||||
return fmt.Errorf("pre-commit at %s is not KeyHunter-managed (use --force to remove anyway)", target)
|
||||
}
|
||||
if err := os.Remove(target); err != nil {
|
||||
return fmt.Errorf("removing hook: %w", err)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Removed pre-commit hook at %s\n", target)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
hookCmd is already registered in cmd/root.go via `rootCmd.AddCommand(hookCmd)`. Removing the stub declaration lets this new `var hookCmd` take over the same identifier. Verify cmd/root.go still compiles.
|
||||
|
||||
Create cmd/hook_test.go:
|
||||
- Each test uses t.TempDir(), chdirs into it (t.Chdir(tmp) in Go 1.24+, else os.Chdir with cleanup), creates .git/ subdirectory.
|
||||
- TestHookInstall_FreshRepo: run install, assert .git/hooks/pre-commit exists, is 0o755 executable, and contains the hookMarker string.
|
||||
- TestHookInstall_NotAGitRepo: no .git/ dir → install returns error containing "not a git repository".
|
||||
- TestHookInstall_ExistingNonKeyhunterRefuses: pre-create pre-commit with "# my hook"; install without --force returns error; file unchanged.
|
||||
- TestHookInstall_ForceBackupsExisting: same as above with --force=true; assert original moved to *.bak.*, new hook installed.
|
||||
- TestHookInstall_ExistingKeyhunterOverwrites: pre-create pre-commit containing the marker; install succeeds without --force, file updated.
|
||||
- TestHookUninstall_RemovesKeyhunter: install then uninstall → file gone.
|
||||
- TestHookUninstall_RefusesForeign: pre-create foreign pre-commit; uninstall without --force errors; file unchanged.
|
||||
- TestHookUninstall_Force: same with --force → file removed.
|
||||
- TestHookUninstall_Missing: no pre-commit → succeeds with "No pre-commit hook to remove." output.
|
||||
- TestHookScript_ContainsRequired: the embedded hookScript variable contains "keyhunter scan" and "git diff --cached".
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go build ./... && go test ./cmd/... -run Hook -v</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- cmd/hook.go implements install/uninstall
|
||||
- hook_script.sh embedded via go:embed
|
||||
- All hook tests pass
|
||||
- Stub removed from cmd/stubs.go
|
||||
- go build succeeds
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
In a scratch git repo:
|
||||
```
|
||||
keyhunter hook install # creates .git/hooks/pre-commit
|
||||
cat .git/hooks/pre-commit # shows embedded script
|
||||
git add file-with-key.txt && git commit -m test # should block
|
||||
keyhunter hook uninstall # removes hook
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
CICD-01 delivered. Hook lifecycle (install → trigger on commit → uninstall) works on a real git repo; tests cover edge cases (non-repo, existing hook, force flag, missing marker).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07-import-cicd/07-05-SUMMARY.md`.
|
||||
</output>
|
||||
Reference in New Issue
Block a user