diff --git a/internal/knight/project_proposal.go b/internal/knight/project_proposal.go index 52114dfe0..50d468497 100644 --- a/internal/knight/project_proposal.go +++ b/internal/knight/project_proposal.go @@ -134,8 +134,18 @@ func gitStatusSnapshot(dir string) string { var b strings.Builder for _, line := range strings.Split(string(out), "\n") { if len(line) > 3 && strings.HasPrefix(line[3:], prefix) { + rest := strings.TrimPrefix(line[3:], prefix) + // #2755: a staged rename reads "R old -> new" and BOTH sides are + // root-relative. The old whole-line strip left the dst side + // prefixed ("R .ggcode/a.md -> mobile/.ggcode/b.md"), so the pure + // .ggcode rename exemption (which requires both sides in .ggcode) + // failed in monorepo subdirs and fired a false guardrail + // violation. Strip the dst side too. + if arrow := strings.Index(rest, " -> "); arrow >= 0 { + rest = rest[:arrow] + " -> " + strings.TrimPrefix(rest[arrow+4:], prefix) + } b.WriteString(line[:3]) - b.WriteString(line[3+len(prefix):]) + b.WriteString(rest) } else { b.WriteString(line) } diff --git a/internal/knight/zz_issue2755_test.go b/internal/knight/zz_issue2755_test.go new file mode 100644 index 000000000..d7e0da22b --- /dev/null +++ b/internal/knight/zz_issue2755_test.go @@ -0,0 +1,70 @@ +package knight + +// Issue #2755 probe: in a monorepo subdirectory (--show-prefix non-empty), +// gitStatusSnapshot stripped the show-prefix only from the start of the +// porcelain line - the rename DST side (after " -> ") kept the prefix, so a +// pure .ggcode rename read as "R .ggcode/a.md -> mobile/.ggcode/b.md", +// failed the BOTH-sides inGG() exemption check, and fired a false +// READ-ONLY GUARDRAIL VIOLATED. Real-git-repo probe: init a repo, commit a +// file under /.ggcode/, git mv it from inside , snapshot. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestIssue2755RenameBothSidesStrippedInSubdir(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + repo := t.TempDir() + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + run("init", "-q") + run("config", "user.email", "t@t") + run("config", "user.name", "t") + + sub := filepath.Join(repo, "mobile", ".ggcode") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + old := filepath.Join(sub, "a.md") + if err := os.WriteFile(old, []byte("x"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + run("add", "-A") + run("commit", "-qm", "init") + + // git mv from inside the subdir stages a rename (R line in porcelain). + mv := exec.Command("git", "mv", ".ggcode/a.md", ".ggcode/b.md") + mv.Dir = filepath.Join(repo, "mobile") + if out, err := mv.CombinedOutput(); err != nil { + t.Fatalf("git mv: %v\n%s", err, out) + } + + snap := gitStatusSnapshot(filepath.Join(repo, "mobile")) + var renameLine string + for _, line := range strings.Split(snap, "\n") { + if strings.HasPrefix(line, "R") && strings.Contains(line, "->") { + renameLine = line + } + } + if renameLine == "" { + t.Fatalf("no staged rename line in snapshot:\n%s", snap) + } + if strings.Contains(renameLine, "mobile/") { + t.Fatalf("rename line keeps show-prefix on a side: %q (want both sides dir-relative)", renameLine) + } + if renameLine != "R .ggcode/a.md -> .ggcode/b.md" { + t.Fatalf("unexpected normalized rename line: %q", renameLine) + } +}