Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,16 +231,17 @@ override:
codemap -C /tmp/independent-clone --setup-root /path/to/original context
```

`-C`/`--project-root` selects the repository Codemap operates on.
`--setup-root` explicitly reuses `<repository>/.codemap` policy and runtime state
from another checkout. Both accept a repository or subdirectory; relative setup
paths resolve from the project root.
`-C`/`--project-root` operates on exactly the named directory, like `git -C`;
it may be a subdirectory of a repository, and storage and config discovery still
walk up to the git root. `--setup-root` explicitly reuses `<repository>/.codemap`
policy and runtime state from another checkout. Both accept a repository or
subdirectory; relative setup paths resolve from the project root.

| Flag | Description |

| Flag | Description |
|------|-------------|
| `-C, --project-root <repo>` | Operate on code in `<repo>` |
| `-C, --project-root <dir>` | Operate on exactly `<dir>` (may be a subdirectory of a repository; storage and config discovery still walk up to the git root) |
| `--setup-root <repo>` | Explicitly reuse policy and runtime state from `<repo>/.codemap` |
| `--depth, -d <n>` | Limit tree depth (0 = unlimited) |
| `--only <exts>` | Only include files with these extensions |
Expand All @@ -252,6 +253,7 @@ paths resolve from the project root.
| `--skyline` | City skyline visualization |
| `--animate` | Animate the skyline (with `--skyline`) |
| `--json` | Output JSON |
| `--color <mode>` | `auto` (default): ANSI colour only when stdout is a terminal and `NO_COLOR` is unset; `always` keeps colour when piping into `less -R`; `never` turns it off |

> Flags come before the path/URL: `codemap --json github.com/user/repo`

Expand Down
27 changes: 25 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
type GlobalRootOptions struct {
Directory string
SetupRoot string
// Color is the --color value (auto, always, never); empty means auto.
Color string
}

// Active reports whether this invocation overrides either root.
Expand Down Expand Up @@ -73,6 +75,18 @@ func ParseGlobalRootOptions(args []string) (GlobalRootOptions, []string, error)
if strings.TrimSpace(opts.SetupRoot) == "" {
return GlobalRootOptions{}, nil, fmt.Errorf("--setup-root requires a path")
}
case arg == "--color":
// Bare --color means always, like ls and grep; "--color <mode>" is also accepted.
opts.Color = "always"
if i+1 < len(args) && isColorMode(args[i+1]) {
i++
opts.Color = args[i]
}
case strings.HasPrefix(arg, "--color="):
opts.Color = strings.TrimPrefix(arg, "--color=")
if !isColorMode(opts.Color) {
return GlobalRootOptions{}, nil, fmt.Errorf("--color: unknown mode %q (want auto, always or never)", opts.Color)
}
default:
remaining = append(remaining, arg)
}
Expand All @@ -82,8 +96,17 @@ func ParseGlobalRootOptions(args []string) (GlobalRootOptions, []string, error)
}

func isGlobalRootOption(arg string) bool {
return arg == "-C" || arg == "--project-root" || arg == "--setup-root" ||
strings.HasPrefix(arg, "--project-root=") || strings.HasPrefix(arg, "--setup-root=")
return arg == "-C" || arg == "--project-root" || arg == "--setup-root" || arg == "--color" ||
strings.HasPrefix(arg, "--project-root=") || strings.HasPrefix(arg, "--setup-root=") ||
strings.HasPrefix(arg, "--color=")
}

func isColorMode(arg string) bool {
switch strings.ToLower(arg) {
case "auto", "always", "never":
return true
}
return false
}

// ResolveGlobalRoots resolves both inputs with nearest-repository recovery.
Expand Down
35 changes: 35 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,41 @@ func TestParseGlobalRootOptions(t *testing.T) {
}
})

t.Run("color accepts equals, separate value and bare forms", func(t *testing.T) {
cases := []struct {
args []string
wantColor string
wantArgs []string
}{
{[]string{"--color=never", "."}, "never", []string{"."}},
{[]string{"--color", "always", "."}, "always", []string{"."}},
{[]string{"--color", "."}, "always", []string{"."}},
{[]string{"hook", "session-start", "--color=auto"}, "auto", []string{"hook", "session-start"}},
{[]string{"."}, "", []string{"."}},
}
for _, tc := range cases {
opts, args, err := ParseGlobalRootOptions(tc.args)
if err != nil {
t.Fatalf("ParseGlobalRootOptions(%#v) error: %v", tc.args, err)
}
if opts.Color != tc.wantColor {
t.Fatalf("ParseGlobalRootOptions(%#v).Color = %q, want %q", tc.args, opts.Color, tc.wantColor)
}
if opts.Active() {
t.Fatalf("--color alone must not activate root options: %#v", opts)
}
if !reflect.DeepEqual(args, tc.wantArgs) {
t.Fatalf("args = %#v, want %#v", args, tc.wantArgs)
}
}
if _, _, err := ParseGlobalRootOptions([]string{"--color=sometimes"}); err == nil {
t.Fatal("ParseGlobalRootOptions(--color=sometimes) unexpectedly succeeded")
}
if _, _, err := ParseGlobalRootOptions([]string{"-C", "--color=never"}); err == nil {
t.Fatal("ParseGlobalRootOptions(-C --color=never) unexpectedly succeeded")
}
})

for _, args := range [][]string{{"-C"}, {"--project-root"}, {"--project-root="}, {"--setup-root"}, {"--setup-root="}} {
args := args
t.Run(strings.Join(args, "_"), func(t *testing.T) {
Expand Down
109 changes: 109 additions & 0 deletions color_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package main

import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

// runPipedCodemap runs the built binary with stdout captured in a pipe (never a
// terminal), the way agents, hooks and shells pipes see it.
func runPipedCodemap(t *testing.T, dir string, env []string, args ...string) (string, string) {
t.Helper()
command := exec.Command(codemapTestBinaryPath, args...)
command.Dir = dir
command.Env = append(os.Environ(), env...)
var stdout, stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
if err := command.Run(); err != nil {
t.Fatalf("codemap %s failed: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), err, stdout.String(), stderr.String())
}
return stdout.String(), stderr.String()
}

func TestColorEndToEnd(t *testing.T) {
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(repo, "src"), 0o755); err != nil {
t.Fatal(err)
}
for name, body := range map[string]string{
"src/main.go": "package main\n",
"src/util.go": "package main\n",
"README.md": "# demo\n",
"web/app.html": "<html></html>\n",
} {
if err := os.MkdirAll(filepath.Dir(filepath.Join(repo, name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}

t.Run("piped output carries no ANSI escapes", func(t *testing.T) {
out, _ := runPipedCodemap(t, repo, nil, ".")
if strings.Contains(out, "\x1b[") {
t.Fatalf("piped `codemap .` emitted ANSI escapes:\n%q", out)
}
for _, want := range []string{"src/", "main", "util", "app.html"} {
if !strings.Contains(out, want) {
t.Fatalf("piped output lost %q:\n%s", want, out)
}
}
})

t.Run("color=always restores ANSI escapes even with NO_COLOR", func(t *testing.T) {
out, _ := runPipedCodemap(t, repo, []string{"NO_COLOR=1"}, "--color=always", ".")
if !strings.Contains(out, "\x1b[1;34m src/\x1b[0m") {
t.Fatalf("`codemap --color=always .` lacks the blue directory escape:\n%q", out)
}
})

t.Run("color=never and NO_COLOR are quiet", func(t *testing.T) {
for _, tc := range []struct {
env []string
args []string
}{
{nil, []string{"--color=never", "."}},
{nil, []string{"--color", "never", "."}},
{[]string{"NO_COLOR=1"}, []string{"."}},
{[]string{"TERM=dumb"}, []string{"."}},
} {
out, _ := runPipedCodemap(t, repo, tc.env, tc.args...)
if strings.Contains(out, "\x1b[") {
t.Fatalf("codemap %v with env %v emitted ANSI escapes:\n%q", tc.args, tc.env, out)
}
}
})

t.Run("json is identical regardless of color mode", func(t *testing.T) {
auto, _ := runPipedCodemap(t, repo, nil, "--json", ".")
always, _ := runPipedCodemap(t, repo, nil, "--color=always", "--json", ".")
never, _ := runPipedCodemap(t, repo, nil, "--color=never", "--json", ".")
if auto != always || auto != never {
t.Fatalf("--json output differs across colour modes:\nauto:\n%s\nalways:\n%s\nnever:\n%s", auto, always, never)
}
if strings.Contains(auto, "\x1b[") {
t.Fatalf("--json output contains ANSI escapes:\n%q", auto)
}
})

t.Run("unknown color mode is rejected", func(t *testing.T) {
command := exec.Command(codemapTestBinaryPath, "--color=sometimes", ".")
command.Dir = repo
out, err := command.CombinedOutput()
if err == nil {
t.Fatalf("`codemap --color=sometimes` unexpectedly succeeded:\n%s", out)
}
if !strings.Contains(string(out), "--color: unknown mode") {
t.Fatalf("unexpected error text:\n%s", out)
}
})
}
8 changes: 8 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ func main() {
}
hookName := os.Args[2]
root, _ := os.Getwd()
// Hook stdout is an agent prompt, never a terminal: no colour, ever.
render.SetColorMode(render.ColorNever)
hookAgent := "claude"
hookIntegration := ""
for _, arg := range os.Args[3:] {
Expand Down Expand Up @@ -262,6 +264,7 @@ func main() {
fmt.Println(" --version Show build version")
fmt.Println(" -C, --project-root <repo> Operate on code in <repo>.")
fmt.Println(" --setup-root <repo> Reuse state from <repo>/.codemap.")
fmt.Println(" --color <mode> Colour output: auto (default: only on a terminal, off when NO_COLOR is set), always, never")
fmt.Println(" --skyline City skyline visualization")
fmt.Println(" --animate Animated skyline (use with --skyline)")
fmt.Println(" --deps Dependency flow map (functions & imports)")
Expand Down Expand Up @@ -545,6 +548,11 @@ func applyGlobalRootOptions(args []string) ([]string, bool, error) {
if err != nil {
return nil, false, err
}
colorMode, err := render.ParseColorMode(opts.Color)
if err != nil {
return nil, false, err
}
render.SetColorMode(colorMode)
if !opts.Active() {
launchDir, err := os.Getwd()
if err != nil {
Expand Down
124 changes: 124 additions & 0 deletions render/color_mode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package render

import (
"fmt"
"os"
"strings"

"golang.org/x/term"
)

// ColorMode selects whether renderers emit ANSI colour escapes.
type ColorMode int

const (
// ColorAuto emits colour only when stdout is a terminal, NO_COLOR is unset,
// and TERM is not "dumb". It is the default.
ColorAuto ColorMode = iota
// ColorAlways emits colour regardless of where stdout goes (for `less -R`).
ColorAlways
// ColorNever emits no colour escapes at all.
ColorNever
)

// String returns the flag spelling of the mode.
func (m ColorMode) String() string {
switch m {
case ColorAlways:
return "always"
case ColorNever:
return "never"
default:
return "auto"
}
}

// ParseColorMode parses a --color value. The empty string means auto.
func ParseColorMode(value string) (ColorMode, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "auto":
return ColorAuto, nil
case "always", "force", "yes", "on":
return ColorAlways, nil
case "never", "no", "off", "none":
return ColorNever, nil
}
return ColorAuto, fmt.Errorf("--color: unknown mode %q (want auto, always or never)", value)
}

// stdoutIsTerminal reports whether the process's stdout is a terminal. Tests
// override it to simulate piped output.
var stdoutIsTerminal = func() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}

// colorEnabled is the resolved state; read it through ColorEnabled.
var colorEnabled bool

func init() {
SetColorMode(ColorAuto)
}

// SetColorMode resolves mode and switches every colour variable in this
// package to its escape sequence (enabled) or the empty string (disabled).
// ColorAuto is resolved with autoColorEnabled against the real stdout and
// environment.
func SetColorMode(mode ColorMode) {
switch mode {
case ColorAlways:
applyColors(true)
case ColorNever:
applyColors(false)
default:
applyColors(autoColorEnabled(stdoutIsTerminal(), os.LookupEnv))
}
}

// ColorEnabled reports whether renderers currently emit colour escapes.
func ColorEnabled() bool {
return colorEnabled
}

// autoColorEnabled implements ColorAuto: colour needs a terminal on stdout, an
// unset NO_COLOR (https://no-color.org), and a TERM other than "dumb".
func autoColorEnabled(isTerminal bool, lookupEnv func(string) (string, bool)) bool {
if !isTerminal {
return false
}
if _, set := lookupEnv("NO_COLOR"); set {
return false
}
if value, _ := lookupEnv("TERM"); value == "dumb" {
return false
}
return true
}

func applyColors(enabled bool) {
colorEnabled = enabled
pick := func(code string) string {
if enabled {
return code
}
return ""
}
Reset = pick(ansiReset)
Bold = pick(ansiBold)
Dim = pick(ansiDim)
White = pick(ansiWhite)
Cyan = pick(ansiCyan)
Yellow = pick(ansiYellow)
Magenta = pick(ansiMagenta)
Green = pick(ansiGreen)
Red = pick(ansiRed)
Blue = pick(ansiBlue)
BoldWhite = pick(ansiBoldWhite)
BoldRed = pick(ansiBoldRed)
BoldBlue = pick(ansiBoldBlue)
DimWhite = pick(ansiDimWhite)
BoldGreen = pick(ansiBoldGreen)
buildingColors = buildingColors[:0]
for _, code := range buildingPalette {
buildingColors = append(buildingColors, pick(code))
}
}
Loading
Loading