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 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
e00f013bda
commit
2fe15e2328
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
187
internal/git/jujutsu_test.go
Normal file
187
internal/git/jujutsu_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user