Add BEADS_NO_AUTO_STAGE opt-out for pre-commit auto-staging (GH#826)
Users with conflicting git hooks (e.g., hooks that read the staging area like GGA) can now set BEADS_NO_AUTO_STAGE=1 to disable auto-staging and get check-and-block behavior instead. Default behavior unchanged - auto-staging still works for most users. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
0a96b10bba
commit
54d15252fa
@@ -464,9 +464,7 @@ func runChainedHook(hookName string, args []string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runPreCommitHook flushes pending changes to JSONL before commit.
|
// runPreCommitHook flushes pending changes to JSONL before commit.
|
||||||
// Returns 0 on success (or if not applicable), non-zero on error.
|
// Returns 0 on success (or if not applicable), 1 if unstaged beads changes detected.
|
||||||
//
|
|
||||||
//nolint:unparam // Always returns 0 by design - warnings don't block commits
|
|
||||||
func runPreCommitHook() int {
|
func runPreCommitHook() int {
|
||||||
// Run chained hook first (if exists)
|
// Run chained hook first (if exists)
|
||||||
if exitCode := runChainedHook("pre-commit", nil); exitCode != 0 {
|
if exitCode := runChainedHook("pre-commit", nil); exitCode != 0 {
|
||||||
@@ -492,14 +490,42 @@ func runPreCommitHook() int {
|
|||||||
// Don't block the commit - user may have removed beads or have other issues
|
// Don't block the commit - user may have removed beads or have other issues
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage all tracked JSONL files
|
// Stage JSONL files for commit
|
||||||
for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} {
|
// By default, we auto-stage for convenience. Users with conflicting git hooks
|
||||||
|
// (e.g., hooks that read the staging area) can set BEADS_NO_AUTO_STAGE=1 to
|
||||||
|
// disable this and stage manually. See: https://github.com/steveyegge/beads/issues/826
|
||||||
|
jsonlFiles := []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"}
|
||||||
|
|
||||||
|
if os.Getenv("BEADS_NO_AUTO_STAGE") != "" {
|
||||||
|
// Safe mode: check for unstaged changes and block if found
|
||||||
|
var unstaged []string
|
||||||
|
for _, f := range jsonlFiles {
|
||||||
|
if _, err := os.Stat(f); err == nil {
|
||||||
|
if hasUnstagedChanges(f) {
|
||||||
|
unstaged = append(unstaged, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unstaged) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "❌ Unstaged beads changes detected:")
|
||||||
|
for _, f := range unstaged {
|
||||||
|
fmt.Fprintf(os.Stderr, " %s\n", f)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
fmt.Fprintln(os.Stderr, "Run: git add .beads/")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: auto-stage JSONL files
|
||||||
|
for _, f := range jsonlFiles {
|
||||||
if _, err := os.Stat(f); err == nil {
|
if _, err := os.Stat(f); err == nil {
|
||||||
// #nosec G204 - f is from hardcoded list above, not user input
|
// #nosec G204 - f is from hardcoded list above, not user input
|
||||||
gitAdd := exec.Command("git", "add", f)
|
gitAdd := exec.Command("git", "add", f)
|
||||||
_ = gitAdd.Run() // Ignore errors - file may not exist
|
_ = gitAdd.Run() // Ignore errors - file may not exist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -1003,6 +1029,44 @@ func hasBeadsJSONL() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasUnstagedChanges checks if a file has uncommitted changes (modified or untracked).
|
||||||
|
// Returns true if the file needs to be staged before commit.
|
||||||
|
func hasUnstagedChanges(path string) bool {
|
||||||
|
// Check git status for this specific file
|
||||||
|
// #nosec G204 - path is from hardcoded list in caller
|
||||||
|
cmd := exec.Command("git", "status", "--porcelain", "--", path)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false // If git fails, assume no changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse porcelain output: XY filename
|
||||||
|
// X = staged status, Y = unstaged status
|
||||||
|
// We care about Y (unstaged) being non-space, OR the file being untracked (??)
|
||||||
|
status := strings.TrimSpace(string(output))
|
||||||
|
if status == "" {
|
||||||
|
return false // No changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each line (usually just one for a single file)
|
||||||
|
for _, line := range strings.Split(status, "\n") {
|
||||||
|
if len(line) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x, y := line[0], line[1]
|
||||||
|
// Untracked file
|
||||||
|
if x == '?' && y == '?' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Modified but not staged (Y is M, D, etc.)
|
||||||
|
if y != ' ' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var hooksRunCmd = &cobra.Command{
|
var hooksRunCmd = &cobra.Command{
|
||||||
Use: "run <hook-name> [args...]",
|
Use: "run <hook-name> [args...]",
|
||||||
Short: "Execute a git hook (called by thin shims)",
|
Short: "Execute a git hook (called by thin shims)",
|
||||||
|
|||||||
Reference in New Issue
Block a user