From 2fe15e23282f85de4cff9dd8a173df9f01af420b Mon Sep 17 00:00:00 2001 From: beads/crew/lizzy Date: Tue, 20 Jan 2026 19:12:51 -0800 Subject: [PATCH] feat(hooks): add jujutsu (jj) version control support Add detection and hook support for jujutsu repositories: - IsJujutsuRepo(): detects .jj directory - IsColocatedJJGit(): detects colocated jj+git repos - GetJujutsuRoot(): finds jj repo root For colocated repos (jj git init --colocate): - Install simplified hooks without staging (jj auto-commits working copy) - Worktree handling preserved for git worktrees in colocated repos For pure jj repos (no git): - Print alias instructions since jj doesn't have native hooks yet Closes: hq-ew1mbr.12 Co-Authored-By: Claude Opus 4.5 --- cmd/bd/init.go | 56 +++++++--- cmd/bd/init_git_hooks.go | 193 +++++++++++++++++++++++++++++++++++ internal/git/gitdir.go | 43 ++++++++ internal/git/jujutsu_test.go | 187 +++++++++++++++++++++++++++++++++ 4 files changed, 462 insertions(+), 17 deletions(-) create mode 100644 internal/git/jujutsu_test.go diff --git a/cmd/bd/init.go b/cmd/bd/init.go index 66ded46a..d9db869f 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -535,31 +535,53 @@ With --stealth: configures per-repository git settings for invisible beads usage // Check if we're in a git repo and hooks aren't installed // Install by default unless --skip-hooks is passed // For Dolt backend, install hooks to .beads/hooks/ (uses git config core.hooksPath) - if !skipHooks && isGitRepo() && !hooksInstalled() { - if backend == configfile.BackendDolt { - // Dolt backend: install hooks to .beads/hooks/ - embeddedHooks, err := getEmbeddedHooks() - if err == nil { - if err := installHooksWithOptions(embeddedHooks, false, false, false, true); err != nil && !quiet { - fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks to .beads/hooks/: %v\n", ui.RenderWarn("⚠"), err) - fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", ui.RenderAccent("bd hooks install --beads")) - } else if !quiet { - fmt.Printf(" Hooks installed to: .beads/hooks/\n") - } - } else if !quiet { - fmt.Fprintf(os.Stderr, "\n%s Failed to load embedded hooks: %v\n", ui.RenderWarn("⚠"), err) + // For jujutsu colocated repos, use simplified hooks (no staging needed) + if !skipHooks && !hooksInstalled() { + isJJ := git.IsJujutsuRepo() + isColocated := git.IsColocatedJJGit() + + if isJJ && !isColocated { + // Pure jujutsu repo (no git) - print alias instructions + if !quiet { + printJJAliasInstructions() } - } else { - // SQLite backend: use traditional hook installation - if err := installGitHooks(); err != nil && !quiet { - fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks: %v\n", ui.RenderWarn("⚠"), err) + } else if isColocated { + // Colocated jj+git repo - use simplified hooks + if err := installJJHooks(); err != nil && !quiet { + fmt.Fprintf(os.Stderr, "\n%s Failed to install jj hooks: %v\n", ui.RenderWarn("⚠"), err) fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", ui.RenderAccent("bd doctor --fix")) + } else if !quiet { + fmt.Printf(" Hooks installed (jujutsu mode - no staging)\n") + } + } else if isGitRepo() { + // Regular git repo + if backend == configfile.BackendDolt { + // Dolt backend: install hooks to .beads/hooks/ + embeddedHooks, err := getEmbeddedHooks() + if err == nil { + if err := installHooksWithOptions(embeddedHooks, false, false, false, true); err != nil && !quiet { + fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks to .beads/hooks/: %v\n", ui.RenderWarn("⚠"), err) + fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", ui.RenderAccent("bd hooks install --beads")) + } else if !quiet { + fmt.Printf(" Hooks installed to: .beads/hooks/\n") + } + } else if !quiet { + fmt.Fprintf(os.Stderr, "\n%s Failed to load embedded hooks: %v\n", ui.RenderWarn("⚠"), err) + } + } else { + // SQLite backend: use traditional hook installation + if err := installGitHooks(); err != nil && !quiet { + fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks: %v\n", ui.RenderWarn("⚠"), err) + fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", ui.RenderAccent("bd doctor --fix")) + } } } } // Check if we're in a git repo and merge driver isn't configured // Install by default unless --skip-merge-driver is passed + // For colocated jj+git repos, merge driver is still useful + // For pure jj repos, skip merge driver (no git) if !skipMergeDriver && isGitRepo() && !mergeDriverInstalled() { if err := installMergeDriver(); err != nil && !quiet { fmt.Fprintf(os.Stderr, "\n%s Failed to install merge driver: %v\n", ui.RenderWarn("⚠"), err) diff --git a/cmd/bd/init_git_hooks.go b/cmd/bd/init_git_hooks.go index 2b32bd0f..e7d5eb1d 100644 --- a/cmd/bd/init_git_hooks.go +++ b/cmd/bd/init_git_hooks.go @@ -440,6 +440,199 @@ func mergeDriverInstalled() bool { return hasCanonical || hasLegacy } +// installJJHooks installs simplified git hooks for colocated jujutsu+git repos. +// jj's model is simpler: the working copy IS always a commit, so no staging needed. +// Changes flow into the current change automatically. +func installJJHooks() error { + hooksDir, err := git.GetGitHooksDir() + if err != nil { + return err + } + + // Ensure hooks directory exists + if err := os.MkdirAll(hooksDir, 0750); err != nil { + return fmt.Errorf("failed to create hooks directory: %w", err) + } + + // Detect existing hooks + existingHooks := detectExistingHooks() + + // Check if any non-bd hooks exist + hasExistingHooks := false + for _, hook := range existingHooks { + if hook.exists && !hook.isBdHook { + hasExistingHooks = true + break + } + } + + // Determine installation mode + chainHooks := false + if hasExistingHooks { + choice := promptHookAction(existingHooks) + switch choice { + case "1", "": + chainHooks = true + // Chain mode - rename existing hooks to .old so they can be called + for _, hook := range existingHooks { + if hook.exists && !hook.isBdHook { + oldPath := hook.path + ".old" + if err := os.Rename(hook.path, oldPath); err != nil { + return fmt.Errorf("failed to rename %s to .old: %w", hook.name, err) + } + fmt.Printf(" Renamed %s to %s\n", hook.name, filepath.Base(oldPath)) + } + } + case "2": + // Overwrite mode - backup existing hooks + for _, hook := range existingHooks { + if hook.exists && !hook.isBdHook { + timestamp := time.Now().Format("20060102-150405") + backup := hook.path + ".backup-" + timestamp + if err := os.Rename(hook.path, backup); err != nil { + return fmt.Errorf("failed to backup %s: %w", hook.name, err) + } + fmt.Printf(" Backed up %s to %s\n", hook.name, filepath.Base(backup)) + } + } + case "3": + fmt.Printf("Skipping git hooks installation.\n") + return nil + default: + return fmt.Errorf("invalid choice: %s", choice) + } + } + + // pre-commit hook (simplified for jj - no staging) + preCommitPath := filepath.Join(hooksDir, "pre-commit") + preCommitContent := buildJJPreCommitHook(chainHooks, existingHooks) + + // post-merge hook (same as git) + postMergePath := filepath.Join(hooksDir, "post-merge") + postMergeContent := buildPostMergeHook(chainHooks, existingHooks) + + // Write pre-commit hook + // #nosec G306 - git hooks must be executable + if err := os.WriteFile(preCommitPath, []byte(preCommitContent), 0700); err != nil { + return fmt.Errorf("failed to write pre-commit hook: %w", err) + } + + // Write post-merge hook + // #nosec G306 - git hooks must be executable + if err := os.WriteFile(postMergePath, []byte(postMergeContent), 0700); err != nil { + return fmt.Errorf("failed to write post-merge hook: %w", err) + } + + if chainHooks { + fmt.Printf("%s Chained bd hooks with existing hooks (jj mode)\n", ui.RenderPass("✓")) + } + + return nil +} + +// buildJJPreCommitHook generates the pre-commit hook content for jujutsu repos. +// jj's model is simpler: no staging needed, changes flow into the working copy automatically. +func buildJJPreCommitHook(chainHooks bool, existingHooks []hookInfo) string { + if chainHooks { + // Find existing pre-commit hook (already renamed to .old by caller) + var existingPreCommit string + for _, hook := range existingHooks { + if hook.name == "pre-commit" && hook.exists && !hook.isBdHook { + existingPreCommit = hook.path + ".old" + break + } + } + + return `#!/bin/sh +# +# bd (beads) pre-commit hook (chained, jujutsu mode) +# +# This hook chains bd functionality with your existing pre-commit hook. +# Simplified for jujutsu: no staging needed, jj auto-commits working copy. + +# Run existing hook first +if [ -x "` + existingPreCommit + `" ]; then + "` + existingPreCommit + `" "$@" + EXIT_CODE=$? + if [ $EXIT_CODE -ne 0 ]; then + exit $EXIT_CODE + fi +fi + +` + jjPreCommitHookBody() + } + + return `#!/bin/sh +# +# bd (beads) pre-commit hook (jujutsu mode) +# +# This hook ensures that any pending bd issue changes are flushed to +# .beads/issues.jsonl before the commit. +# +# Simplified for jujutsu: no staging needed, jj auto-commits working copy changes. + +` + jjPreCommitHookBody() +} + +// jjPreCommitHookBody returns the pre-commit hook logic for jujutsu repos. +// Key difference from git: no git add needed, jj handles working copy automatically. +// Still needs worktree handling since colocated jj+git repos can use git worktrees. +func jjPreCommitHookBody() string { + return `# Check if bd is available +if ! command -v bd >/dev/null 2>&1; then + echo "Warning: bd command not found, skipping pre-commit flush" >&2 + exit 0 +fi + +# Check if we're in a bd workspace +# For worktrees, .beads is in the main repository root, not the worktree +BEADS_DIR="" +if git rev-parse --git-dir >/dev/null 2>&1; then + # Check if we're in a worktree + if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then + # Worktree: .beads is in main repo root + MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)" + MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")" + if [ -d "$MAIN_REPO_ROOT/.beads" ]; then + BEADS_DIR="$MAIN_REPO_ROOT/.beads" + fi + else + # Regular repo: check current directory + if [ -d .beads ]; then + BEADS_DIR=".beads" + fi + fi +fi + +if [ -z "$BEADS_DIR" ]; then + exit 0 +fi + +# Flush pending changes to JSONL +# In jujutsu, changes automatically become part of the working copy commit +if ! bd sync --flush-only >/dev/null 2>&1; then + echo "Error: Failed to flush bd changes to JSONL" >&2 + echo "Run 'bd sync --flush-only' manually to diagnose" >&2 + exit 1 +fi + +# No git add needed - jujutsu automatically includes working copy changes +exit 0 +` +} + +// printJJAliasInstructions prints setup instructions for pure jujutsu repos. +// Since jj doesn't have native hooks yet, users need to set up aliases. +func printJJAliasInstructions() { + fmt.Printf("\n%s Jujutsu repository detected (not colocated with git)\n\n", ui.RenderWarn("⚠")) + fmt.Printf("Jujutsu doesn't support hooks yet. To auto-export beads on push,\n") + fmt.Printf("add this alias to your jj config (~/.config/jj/config.toml):\n\n") + fmt.Printf(" %s\n", ui.RenderAccent("[aliases]")) + fmt.Printf(" %s\n", ui.RenderAccent(`push = ["util", "exec", "--", "sh", "-c", "bd sync --flush-only && jj git push \"$@\"", ""]`)) + fmt.Printf("\nThen use %s instead of %s\n\n", ui.RenderAccent("jj push"), ui.RenderAccent("jj git push")) + fmt.Printf("For more details, see: https://github.com/steveyegge/beads/blob/main/docs/JUJUTSU.md\n\n") +} + // installMergeDriver configures git to use bd merge for JSONL files // Note: This runs during bd init BEFORE .beads exists, so it runs git in CWD. func installMergeDriver() error { diff --git a/internal/git/gitdir.go b/internal/git/gitdir.go index c1db6296..7cfdf0d9 100644 --- a/internal/git/gitdir.go +++ b/internal/git/gitdir.go @@ -2,6 +2,7 @@ package git import ( "fmt" + "os" "os/exec" "path/filepath" "runtime" @@ -271,3 +272,45 @@ func ResetCaches() { gitCtxOnce = sync.Once{} gitCtx = gitContext{} } + +// IsJujutsuRepo returns true if the current directory is in a jujutsu (jj) repository. +// Jujutsu stores its data in a .jj directory at the repository root. +func IsJujutsuRepo() bool { + _, err := GetJujutsuRoot() + return err == nil +} + +// IsColocatedJJGit returns true if this is a colocated jujutsu+git repository. +// Colocated repos have both .jj and .git directories, created via `jj git init --colocate`. +// In colocated repos, git hooks work normally since jj manages the git repo. +func IsColocatedJJGit() bool { + if !IsJujutsuRepo() { + return false + } + // If we're also in a git repo, it's colocated + _, err := getGitContext() + return err == nil +} + +// GetJujutsuRoot returns the root directory of the jujutsu repository. +// Returns empty string and error if not in a jujutsu repository. +func GetJujutsuRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + + dir := cwd + for { + jjPath := filepath.Join(dir, ".jj") + if info, err := os.Stat(jjPath); err == nil && info.IsDir() { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("not a jujutsu repository (no .jj directory found)") + } + dir = parent + } +} diff --git a/internal/git/jujutsu_test.go b/internal/git/jujutsu_test.go new file mode 100644 index 00000000..adc3263e --- /dev/null +++ b/internal/git/jujutsu_test.go @@ -0,0 +1,187 @@ +package git + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIsJujutsuRepo(t *testing.T) { + // Save original directory + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(origDir) + ResetCaches() + }() + + t.Run("not a jj repo", func(t *testing.T) { + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + ResetCaches() + + if IsJujutsuRepo() { + t.Error("Expected IsJujutsuRepo() to return false for non-jj directory") + } + }) + + t.Run("jj repo root", func(t *testing.T) { + tmpDir := t.TempDir() + jjDir := filepath.Join(tmpDir, ".jj") + if err := os.Mkdir(jjDir, 0750); err != nil { + t.Fatalf("Failed to create .jj directory: %v", err) + } + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + ResetCaches() + + if !IsJujutsuRepo() { + t.Error("Expected IsJujutsuRepo() to return true for jj repo root") + } + }) + + t.Run("jj repo subdirectory", func(t *testing.T) { + tmpDir := t.TempDir() + jjDir := filepath.Join(tmpDir, ".jj") + if err := os.Mkdir(jjDir, 0750); err != nil { + t.Fatalf("Failed to create .jj directory: %v", err) + } + subDir := filepath.Join(tmpDir, "src", "lib") + if err := os.MkdirAll(subDir, 0750); err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + if err := os.Chdir(subDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + ResetCaches() + + if !IsJujutsuRepo() { + t.Error("Expected IsJujutsuRepo() to return true for jj repo subdirectory") + } + }) +} + +func TestIsColocatedJJGit(t *testing.T) { + // Save original directory + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(origDir) + ResetCaches() + }() + + t.Run("jj only (not colocated)", func(t *testing.T) { + tmpDir := t.TempDir() + jjDir := filepath.Join(tmpDir, ".jj") + if err := os.Mkdir(jjDir, 0750); err != nil { + t.Fatalf("Failed to create .jj directory: %v", err) + } + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + ResetCaches() + + if IsColocatedJJGit() { + t.Error("Expected IsColocatedJJGit() to return false for jj-only repo") + } + }) + + t.Run("not a repo", func(t *testing.T) { + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + ResetCaches() + + if IsColocatedJJGit() { + t.Error("Expected IsColocatedJJGit() to return false for non-repo") + } + }) +} + +func TestGetJujutsuRoot(t *testing.T) { + // Save original directory + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(origDir) + ResetCaches() + }() + + t.Run("not a jj repo", func(t *testing.T) { + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + ResetCaches() + + _, err := GetJujutsuRoot() + if err == nil { + t.Error("Expected GetJujutsuRoot() to return error for non-jj directory") + } + }) + + t.Run("jj repo root", func(t *testing.T) { + tmpDir := t.TempDir() + // Resolve symlinks for comparison (macOS /var -> /private/var) + tmpDir, _ = filepath.EvalSymlinks(tmpDir) + + jjDir := filepath.Join(tmpDir, ".jj") + if err := os.Mkdir(jjDir, 0750); err != nil { + t.Fatalf("Failed to create .jj directory: %v", err) + } + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + ResetCaches() + + root, err := GetJujutsuRoot() + if err != nil { + t.Fatalf("GetJujutsuRoot() returned error: %v", err) + } + if root != tmpDir { + t.Errorf("Expected root %q, got %q", tmpDir, root) + } + }) + + t.Run("jj repo subdirectory", func(t *testing.T) { + tmpDir := t.TempDir() + // Resolve symlinks for comparison (macOS /var -> /private/var) + tmpDir, _ = filepath.EvalSymlinks(tmpDir) + + jjDir := filepath.Join(tmpDir, ".jj") + if err := os.Mkdir(jjDir, 0750); err != nil { + t.Fatalf("Failed to create .jj directory: %v", err) + } + subDir := filepath.Join(tmpDir, "src", "lib") + if err := os.MkdirAll(subDir, 0750); err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + if err := os.Chdir(subDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + ResetCaches() + + root, err := GetJujutsuRoot() + if err != nil { + t.Fatalf("GetJujutsuRoot() returned error: %v", err) + } + if root != tmpDir { + t.Errorf("Expected root %q, got %q", tmpDir, root) + } + }) +}