diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index 04d3c2d1..97647258 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -2947,6 +2947,24 @@ func TestGitBranchForPromptResolvesRelativeWorktreeGitdir(t *testing.T) { } } +func TestGitBranchForPromptInSubdirectory(t *testing.T) { + root := t.TempDir() + gitdir := filepath.Join(root, ".git") + if err := os.MkdirAll(gitdir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitdir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil { + t.Fatal(err) + } + subdir := filepath.Join(root, "sub", "dir") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + if got := gitBranchForPrompt(subdir); got != "main" { + t.Fatalf("gitBranchForPrompt in subdirectory = %q, want main", got) + } +} + func TestBuildSystemPromptInjectsWorkspaceContext(t *testing.T) { cwd := t.TempDir() if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Always run `make lint` before committing."), 0o644); err != nil { diff --git a/internal/agent/system_prompt.go b/internal/agent/system_prompt.go index 1d7118ed..d193fd12 100644 --- a/internal/agent/system_prompt.go +++ b/internal/agent/system_prompt.go @@ -295,7 +295,7 @@ func workspaceContext(cwd string) string { } b.WriteString("") - b.WriteString(projectGuidelines(cwd, findProjectGitRoot(cwd))) + b.WriteString(projectGuidelines(cwd, FindProjectGitRoot(cwd))) if repoMap := repoMapContext(cwd); repoMap != "" { b.WriteString("\n\n## Repo map\n\n" + repoMap) } @@ -542,17 +542,17 @@ func resolveDirCaseInsensitive(target, anchor string) (string, bool) { return cur, true } -// findProjectGitRoot returns the nearest ancestor of cwd that contains a +// FindProjectGitRoot returns the nearest ancestor of cwd that contains a // .git entry (file or directory). Returns "" when no git root is found, so // the caller can fall back to cwd-only lookup. -func findProjectGitRoot(cwd string) string { +func FindProjectGitRoot(cwd string) string { cwd = strings.TrimSpace(cwd) if cwd == "" { return "" } cur := cwd for { - if hasGitMetadata(cur) { + if HasGitMetadata(cur) { return cur } parent := filepath.Dir(cur) @@ -563,7 +563,7 @@ func findProjectGitRoot(cwd string) string { } } -func hasGitMetadata(dir string) bool { +func HasGitMetadata(dir string) bool { gitPath := filepath.Join(dir, ".git") info, err := os.Stat(gitPath) if err != nil { @@ -619,7 +619,11 @@ func repoMapContext(cwd string) string { // cwd, handling both a regular checkout (.git dir) and a worktree (.git file). // Returns "" on any problem — the prompt simply omits the branch segment. func gitBranchForPrompt(cwd string) string { - gitPath := filepath.Join(cwd, ".git") + gitRoot := FindProjectGitRoot(cwd) + if gitRoot == "" { + return "" + } + gitPath := filepath.Join(gitRoot, ".git") info, err := os.Stat(gitPath) if err != nil { return "" @@ -635,10 +639,10 @@ func gitBranchForPrompt(cwd string) string { return "" } // In worktree mode the gitdir is often RELATIVE (e.g. - // "gitdir: ../.git/worktrees/") — resolve it against cwd, not the + // "gitdir: ../.git/worktrees/") — resolve it against the worktree root (gitRoot), not the // process working directory, or HEAD lookup fails and we drop the branch. if !filepath.IsAbs(dir) { - dir = filepath.Join(cwd, dir) + dir = filepath.Join(gitRoot, dir) } headPath = filepath.Join(dir, "HEAD") } diff --git a/internal/agent/system_prompt_test.go b/internal/agent/system_prompt_test.go index 87c1f1d6..fc07ba7b 100644 --- a/internal/agent/system_prompt_test.go +++ b/internal/agent/system_prompt_test.go @@ -424,7 +424,7 @@ func TestFindProjectGitRootIgnoresEmptyGitDirectory(t *testing.T) { if err := os.MkdirAll(leaf, 0o755); err != nil { t.Fatal(err) } - if got := findProjectGitRoot(leaf); got != "" { - t.Fatalf("findProjectGitRoot = %q, want empty for invalid .git directory", got) + if got := FindProjectGitRoot(leaf); got != "" { + t.Fatalf("FindProjectGitRoot = %q, want empty for invalid .git directory", got) } } diff --git a/internal/tui/tui_fixes_test.go b/internal/tui/tui_fixes_test.go index eb9043ce..77e3f36f 100644 --- a/internal/tui/tui_fixes_test.go +++ b/internal/tui/tui_fixes_test.go @@ -160,3 +160,21 @@ func suggestionNames2(s []commandSuggestion) []string { } return out } + +func TestGitBranchInSubdirectory(t *testing.T) { + root := t.TempDir() + gitdir := filepath.Join(root, ".git") + if err := os.MkdirAll(gitdir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitdir, "HEAD"), []byte("ref: refs/heads/feature-tui-branch\n"), 0o644); err != nil { + t.Fatal(err) + } + subdir := filepath.Join(root, "nested", "sub", "dir") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + if got := gitBranch(subdir); got != "feature-tui-branch" { + t.Fatalf("gitBranch in subdirectory = %q, want feature-tui-branch", got) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 49710766..e0a68246 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -462,10 +462,15 @@ func shortenPath(path string) string { // both regular checkouts (.git dir) and worktrees (.git file). Returns "" on any // problem — the header simply omits the segment. func gitBranch(cwd string) string { - if strings.TrimSpace(cwd) == "" { + cwd = strings.TrimSpace(cwd) + if cwd == "" { return "" } - gitPath := filepath.Join(cwd, ".git") + gitRoot := agent.FindProjectGitRoot(cwd) + if gitRoot == "" { + return "" + } + gitPath := filepath.Join(gitRoot, ".git") info, err := os.Stat(gitPath) if err != nil { return "" @@ -481,6 +486,12 @@ func gitBranch(cwd string) string { if dir == "" { return "" } + // In worktree mode the gitdir is often RELATIVE (e.g. + // "gitdir: ../.git/worktrees/") — resolve it against the worktree root (gitRoot), not the + // process working directory, or HEAD lookup fails and we drop the branch. + if !filepath.IsAbs(dir) { + dir = filepath.Join(gitRoot, dir) + } headPath = filepath.Join(dir, "HEAD") }