Skip to content

Commit 685829a

Browse files
committed
fix: anchor context dir walk to git root to prevent cross-project resolution
When a non-ctx-initialized repo lives inside a ctx-initialized parent workspace, walkForContextDir walked upward and found the parent's .context, then the boundary check rejected it. Now the walk validates candidates against the git root: if a .context is found outside the git root, it belongs to a different project and is discarded. Git is a hint, not a requirement — when no .git exists, the walk falls back to CWD. Changes: - Add findGitRoot helper to walk.go - Update walkForContextDir to validate candidates against git root - Add DotDir constant to config/git, consolidate Diff into subcommands block - Add walk_test.go with 9 edge-case tests (nested repos, worktrees, no git) - Update existing UpwardWalkFromSubdir test to include .git directory Spec: specs/walk-boundary-git-anchor.md Signed-off-by: Jose Alekhinne <jose@ctx.ist>
1 parent 78fbdf7 commit 685829a

5 files changed

Lines changed: 522 additions & 24 deletions

File tree

internal/config/git/git.go

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,35 @@ package git
99
// Binary is the git executable name.
1010
const Binary = "git"
1111

12-
// Git subcommands.
12+
// DotDir is the name of the git metadata directory (or file in worktrees).
13+
const DotDir = ".git"
14+
15+
// Subcommand names passed as the first argument to git.
1316
const (
1417
Branch = "branch"
18+
Diff = "diff"
1519
DiffTree = "diff-tree"
1620
Log = "log"
1721
Remote = "remote"
1822
RevParse = "rev-parse"
1923
)
2024

21-
// Git hook names.
25+
// Hook names used in .git/hooks/.
2226
const (
2327
HookPrepareCommitMsg = "prepare-commit-msg"
2428
HookPostCommit = "post-commit"
2529
HooksDir = "hooks"
2630
)
2731

28-
// Git subcommands (additional).
29-
const (
30-
Diff = "diff"
31-
)
32-
33-
// Git rev-parse flags.
32+
// Rev-parse flags.
3433
const (
3534
FlagShort = "--short"
3635
FlagShowCurrent = "--show-current"
3736
FlagShowToplevel = "--show-toplevel"
3837
FlagGitDir = "--git-dir"
3938
)
4039

41-
// Git flags.
40+
// Common flags and format strings for git commands.
4241
const (
4342
FlagCached = "--cached"
4443
FlagChangeDir = "-C"
@@ -63,13 +62,13 @@ const (
6362
FlagLastN = "-%d"
6463
)
6564

66-
// Git ref constants.
65+
// Ref constants for addressing commits and branches.
6766
const (
6867
// RefHead is the symbolic reference for the current commit.
6968
RefHead = "HEAD"
7069
)
7170

72-
// Git remote subcommands and arguments.
71+
// Remote subcommands and arguments.
7372
const (
7473
RemoteGetURL = "get-url"
7574
RemoteOrigin = "origin"
@@ -78,7 +77,7 @@ const (
7877
// PathSeparator is the separator git uses in file paths (always forward slash).
7978
const PathSeparator = "/"
8079

81-
// Git commit trailers.
80+
// Commit trailer keys for structured metadata in commit messages.
8281
const (
8382
// TrailerSpec is the commit trailer for spec references.
8483
TrailerSpec = "Spec: specs/"

internal/rc/rc_test.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -881,17 +881,18 @@ func TestContextDir_UpwardWalkFromSubdir(t *testing.T) {
881881
tempDir := t.TempDir()
882882

883883
// Project root layout:
884+
// <tempDir>/project/.git/
884885
// <tempDir>/project/.context/
885886
// <tempDir>/project/deep/nested/
886887
projectRoot := filepath.Join(tempDir, "project")
888+
gitPath := filepath.Join(projectRoot, ".git")
887889
contextPath := filepath.Join(projectRoot, dir.Context)
888890
deepSubdir := filepath.Join(projectRoot, "deep", "nested")
889891

890-
if mkErr := os.MkdirAll(contextPath, 0700); mkErr != nil {
891-
t.Fatalf("mkdir context: %v", mkErr)
892-
}
893-
if mkErr := os.MkdirAll(deepSubdir, 0700); mkErr != nil {
894-
t.Fatalf("mkdir deep: %v", mkErr)
892+
for _, d := range []string{gitPath, contextPath, deepSubdir} {
893+
if mkErr := os.MkdirAll(d, 0700); mkErr != nil {
894+
t.Fatalf("mkdir %s: %v", d, mkErr)
895+
}
895896
}
896897

897898
origDir, _ := os.Getwd()

internal/rc/walk.go

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,24 @@ package rc
99
import (
1010
"os"
1111
"path/filepath"
12+
"strings"
13+
14+
cfgGit "github.com/ActiveMemory/ctx/internal/config/git"
1215
)
1316

1417
// walkForContextDir walks upward from the current working directory
1518
// looking for an existing directory whose basename matches name.
1619
//
20+
// When a candidate is found above CWD, it is validated against the
21+
// git root (if any). If the candidate falls outside the git root,
22+
// it belongs to a different project and is discarded — the git root
23+
// is used as the anchor instead.
24+
//
1725
// Absolute configured names skip the walk entirely. When no matching
18-
// directory is found upward, returns filepath.Join(cwd, name) as an
19-
// absolute path so that ctx init can create a fresh context directory at
20-
// the current location.
26+
// directory is found upward, returns the context directory anchored
27+
// to the git root (if found) or filepath.Join(cwd, name) as an
28+
// absolute path so that ctx init can create a fresh context directory
29+
// at the current location.
2130
//
2231
// Parameters:
2332
// - name: Configured context directory name (may be relative or absolute)
@@ -34,11 +43,14 @@ func walkForContextDir(name string) string {
3443
return name
3544
}
3645

46+
// Walk upward looking for an existing context directory.
47+
var candidate string
3748
cur := cwd
3849
for {
39-
candidate := filepath.Join(cur, name)
40-
if info, statErr := os.Stat(candidate); statErr == nil && info.IsDir() {
41-
return candidate
50+
path := filepath.Join(cur, name)
51+
if info, statErr := os.Stat(path); statErr == nil && info.IsDir() {
52+
candidate = path
53+
break
4254
}
4355
parent := filepath.Dir(cur)
4456
if parent == cur {
@@ -47,5 +59,60 @@ func walkForContextDir(name string) string {
4759
cur = parent
4860
}
4961

50-
return filepath.Join(cwd, name)
62+
gitRoot := findGitRoot(cwd)
63+
64+
// No candidate found — anchor to git root or CWD.
65+
if candidate == "" {
66+
if gitRoot != "" {
67+
return filepath.Join(gitRoot, name)
68+
}
69+
return filepath.Join(cwd, name)
70+
}
71+
72+
// Candidate found in CWD itself — always valid.
73+
candidateParent := filepath.Dir(candidate)
74+
if candidateParent == cwd {
75+
return candidate
76+
}
77+
78+
// Candidate found above CWD — validate against git root.
79+
if gitRoot == "" {
80+
// No git root to confirm ownership; don't trust the ancestor.
81+
return filepath.Join(cwd, name)
82+
}
83+
84+
// Check whether the candidate is within the git root.
85+
// Append separator to avoid "/foo/bar" matching "/foo/b".
86+
root := gitRoot + string(os.PathSeparator)
87+
if candidateParent == gitRoot || strings.HasPrefix(candidateParent, root) {
88+
return candidate
89+
}
90+
91+
// Candidate is outside the git root — belongs to a different project.
92+
// Anchor to the git root instead.
93+
return filepath.Join(gitRoot, name)
94+
}
95+
96+
// findGitRoot walks upward from start looking for a .git entry
97+
// (directory or file, to support worktrees). Returns the parent
98+
// directory of the .git entry, or "" if none is found.
99+
//
100+
// Parameters:
101+
// - start: Directory to start searching from
102+
//
103+
// Returns:
104+
// - string: Absolute path to the git root, or "" if not found
105+
func findGitRoot(start string) string {
106+
cur := start
107+
for {
108+
gitPath := filepath.Join(cur, cfgGit.DotDir)
109+
if _, statErr := os.Stat(gitPath); statErr == nil {
110+
return cur
111+
}
112+
parent := filepath.Dir(cur)
113+
if parent == cur {
114+
return ""
115+
}
116+
cur = parent
117+
}
51118
}

0 commit comments

Comments
 (0)