package git import ( "fmt" "os/exec" "path/filepath" "runtime" "strings" "sync" ) // gitContext holds cached git repository information. // All fields are populated with a single git call for efficiency. type gitContext struct { gitDir string // Result of --git-dir commonDir string // Result of --git-common-dir (absolute) repoRoot string // Result of --show-toplevel (normalized, symlinks resolved) isWorktree bool // Derived: gitDir != commonDir err error // Any error during initialization } var ( gitCtxOnce sync.Once gitCtx gitContext ) // initGitContext populates the gitContext with a single git call. // This is called once per process via sync.Once. func initGitContext() { // Get all three values with a single git call cmd := exec.Command("git", "rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel") output, err := cmd.Output() if err != nil { gitCtx.err = fmt.Errorf("not a git repository: %w", err) return } lines := strings.Split(strings.TrimSpace(string(output)), "\n") if len(lines) < 3 { gitCtx.err = fmt.Errorf("unexpected git rev-parse output: got %d lines, expected 3", len(lines)) return } gitCtx.gitDir = strings.TrimSpace(lines[0]) commonDirRaw := strings.TrimSpace(lines[1]) repoRootRaw := strings.TrimSpace(lines[2]) // Convert commonDir to absolute for reliable comparison absCommon, err := filepath.Abs(commonDirRaw) if err != nil { gitCtx.err = fmt.Errorf("failed to resolve common dir path: %w", err) return } gitCtx.commonDir = absCommon // Convert gitDir to absolute for worktree comparison absGitDir, err := filepath.Abs(gitCtx.gitDir) if err != nil { gitCtx.err = fmt.Errorf("failed to resolve git dir path: %w", err) return } // Derive isWorktree from comparing absolute paths gitCtx.isWorktree = absGitDir != absCommon // Process repoRoot: normalize Windows paths, resolve symlinks, // and canonicalize case on case-insensitive filesystems (GH#880). // This is critical for git worktree operations which string-compare paths. repoRoot := NormalizePath(repoRootRaw) if resolved, err := filepath.EvalSymlinks(repoRoot); err == nil { repoRoot = resolved } // Canonicalize case on macOS/Windows (GH#880) if canonicalized := canonicalizeCase(repoRoot); canonicalized != "" { repoRoot = canonicalized } gitCtx.repoRoot = repoRoot } // getGitContext returns the cached git context, initializing it if needed. func getGitContext() (*gitContext, error) { gitCtxOnce.Do(initGitContext) if gitCtx.err != nil { return nil, gitCtx.err } return &gitCtx, nil } // GetGitDir returns the actual .git directory path for the current repository. // In a normal repo, this is ".git". In a worktree, .git is a file // containing "gitdir: /path/to/actual/git/dir", so we use git rev-parse. // // This function uses Git's native worktree-aware APIs and should be used // instead of direct filepath.Join(path, ".git") throughout the codebase. func GetGitDir() (string, error) { ctx, err := getGitContext() if err != nil { return "", err } return ctx.gitDir, nil } // GetGitCommonDir returns the common git directory shared across all worktrees. // For regular repos, this equals GetGitDir(). For worktrees, this returns // the main repository's .git directory where shared data (like worktree // registrations, hooks, and objects) lives. // // Use this instead of GetGitDir() when you need to create new worktrees or // access shared git data that should not be scoped to a single worktree. // GH#639: This is critical for bare repo setups where GetGitDir() returns // a worktree-specific path that cannot host new worktrees. func GetGitCommonDir() (string, error) { ctx, err := getGitContext() if err != nil { return "", err } return ctx.commonDir, nil } // GetGitHooksDir returns the path to the Git hooks directory. // This function is worktree-aware: hooks are shared across all worktrees // and live in the common git directory (e.g., /repo/.git/hooks), not in // the worktree-specific directory (e.g., /repo/.git/worktrees/feature/hooks). func GetGitHooksDir() (string, error) { ctx, err := getGitContext() if err != nil { return "", err } // Respect core.hooksPath if configured. // This is used by beads' Dolt backend (hooks installed to .beads/hooks/). cmd := exec.Command("git", "config", "--get", "core.hooksPath") cmd.Dir = ctx.repoRoot if out, err := cmd.Output(); err == nil { hooksPath := strings.TrimSpace(string(out)) if hooksPath != "" { if filepath.IsAbs(hooksPath) { return hooksPath, nil } // Git treats relative core.hooksPath as relative to the repo root in common usage. // (e.g., ".beads/hooks", ".githooks"). p := filepath.Join(ctx.repoRoot, hooksPath) if abs, err := filepath.Abs(p); err == nil { return abs, nil } return p, nil } } // Default: hooks are stored in the common git directory. return filepath.Join(ctx.commonDir, "hooks"), nil } // GetGitRefsDir returns the path to the Git refs directory. // This function is worktree-aware and handles both regular repos and worktrees. func GetGitRefsDir() (string, error) { gitDir, err := GetGitDir() if err != nil { return "", err } return filepath.Join(gitDir, "refs"), nil } // GetGitHeadPath returns the path to the Git HEAD file. // This function is worktree-aware and handles both regular repos and worktrees. func GetGitHeadPath() (string, error) { gitDir, err := GetGitDir() if err != nil { return "", err } return filepath.Join(gitDir, "HEAD"), nil } // IsWorktree returns true if the current directory is in a Git worktree. // This is determined by comparing --git-dir and --git-common-dir. // The result is cached after the first call since worktree status doesn't // change during a single command execution. func IsWorktree() bool { ctx, err := getGitContext() if err != nil { return false } return ctx.isWorktree } // GetMainRepoRoot returns the main repository root directory. // When in a worktree, this returns the main repository root. // Otherwise, it returns the regular repository root. // // For nested worktrees (worktrees located under the main repo, e.g., // /project/.worktrees/feature/), this correctly returns the main repo // root (/project/) by using git rev-parse --git-common-dir which always // points to the main repo's .git directory. (GH#509) // The result is cached after the first call. func GetMainRepoRoot() (string, error) { ctx, err := getGitContext() if err != nil { return "", err } if ctx.isWorktree { // For worktrees, the main repo root is the parent of the shared .git directory. return filepath.Dir(ctx.commonDir), nil } // For regular repos (including submodules), repoRoot is the correct root. return ctx.repoRoot, nil } // GetRepoRoot returns the root directory of the current git repository. // Returns empty string if not in a git repository. // // This function is worktree-aware and handles Windows path normalization // (Git on Windows may return paths like /c/Users/... or C:/Users/...). // It also resolves symlinks to get the canonical path. // The result is cached after the first call. func GetRepoRoot() string { ctx, err := getGitContext() if err != nil { return "" } return ctx.repoRoot } // canonicalizeCase resolves a path to its true filesystem case on // case-insensitive filesystems (macOS/Windows). This is needed because // git operations string-compare paths exactly - a path with wrong case // will fail even though it points to the same location. (GH#880) // // On macOS, uses realpath(1) which returns the canonical case. // Returns empty string if resolution fails or isn't needed. func canonicalizeCase(path string) string { if runtime.GOOS == "darwin" { // Use realpath to get canonical path with correct case cmd := exec.Command("realpath", path) output, err := cmd.Output() if err == nil { return strings.TrimSpace(string(output)) } } // Windows: filepath.EvalSymlinks already handles case // Linux: case-sensitive, no canonicalization needed return "" } // NormalizePath converts Git's Windows path formats to native format. // Git on Windows may return paths like /c/Users/... or C:/Users/... // This function converts them to native Windows format (C:\Users\...). // On non-Windows systems, this is a no-op. func NormalizePath(path string) string { // Only apply Windows normalization on Windows if filepath.Separator != '\\' { return path } // Convert /c/Users/... to C:\Users\... if len(path) >= 3 && path[0] == '/' && path[2] == '/' { return strings.ToUpper(string(path[1])) + ":" + filepath.FromSlash(path[2:]) } // Convert C:/Users/... to C:\Users\... return filepath.FromSlash(path) } // ResetCaches resets all cached git information. This is intended for use // by tests that need to change directory between subtests. // In production, these caches are safe because the working directory // doesn't change during a single command execution. // // WARNING: Not thread-safe. Only call from single-threaded test contexts. func ResetCaches() { gitCtxOnce = sync.Once{} gitCtx = gitContext{} }