Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ Run <code><strong>src login <i>SOURCEGRAPH-URL</i></strong></code> to authentica
- `SRC_ENDPOINT`: the URL to your Sourcegraph instance (such as `https://sourcegraph.example.com`)
- `SRC_ACCESS_TOKEN`: your Sourcegraph access token (on your Sourcegraph instance, click your user menu in the top right, then select **Settings > Access tokens** to create one)

To force terminal-theme colours instead of 256-colour ANSI output, set `"force16Color": true` in `~/src-config.json`.

For convenience, you can add these environment variables persistently.

### Configuration: Mac OS / Linux
Expand Down
1 change: 1 addition & 0 deletions cmd/src/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func (c commander) run(flagSet *flag.FlagSet, cmdName, usageText string, args []
if err != nil {
log.Fatal("reading config: ", err)
}
applyColorMode(cfg.force16Color)

exitCode, err := runLegacy(cmd, flagSet)
if err != nil {
Expand Down
71 changes: 71 additions & 0 deletions cmd/src/cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package main

import (
"encoding/json"
"flag"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

// TestCommanderRunAppliesColorMode verifies the legacy commander.run path,
// which calls os.Exit after running a command, in a subprocess.
func TestCommanderRunAppliesColorMode(t *testing.T) {
snapshotAnsiColors(t)

// Sanity precondition: the default "logo" entry must be a 256-color
// SGR sequence, otherwise observing a remap is meaningless.
if before := ansiColors["logo"]; !strings.Contains(before, "38;5;") {
t.Fatalf(`precondition failed: ansiColors["logo"] = %q, expected a 256-color sequence`, before)
}

// 1. Write a config file enabling force16Color, point *configPath at it.
home := t.TempDir()
cfgFile := filepath.Join(home, "src-config.json")
data, err := json.Marshal(configFromFile{
Endpoint: "https://example.com",
AccessToken: "test-token",
Force16Color: true,
})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cfgFile, data, 0o600); err != nil {
t.Fatal(err)
}
oldConfigPath := *configPath
*configPath = cfgFile
t.Cleanup(func() { *configPath = oldConfigPath })

child := exec.Command(os.Args[0], "-test.run=^TestCommanderRunAppliesColorModeChild$")
child.Env = append(os.Environ(),
"SRC_CLI_TEST_COMMANDER_FORCE16=1",
"SRC_CLI_TEST_CONFIG="+cfgFile,
)
out, err := child.Output()
if err != nil {
t.Fatalf("child command failed: %v", err)
}
if got := string(out); strings.Contains(got, "38;5;") || strings.Contains(got, "48;5;") {
t.Errorf(`commander.run did not apply force16Color before dispatching the subcommand handler: ansiColors["logo"] = %q, still a 256-color SGR sequence`, got)
}
}

func TestCommanderRunAppliesColorModeChild(t *testing.T) {
if os.Getenv("SRC_CLI_TEST_COMMANDER_FORCE16") != "1" {
t.Skip("helper process")
}
*configPath = os.Getenv("SRC_CLI_TEST_CONFIG")

const subName = "__test_legacy_color"
c := commander{&command{
flagSet: flag.NewFlagSet(subName, flag.ContinueOnError),
handler: func(args []string) error {
_, _ = os.Stdout.WriteString(ansiColors["logo"])
return nil
},
}}
c.run(flag.NewFlagSet("src", flag.ContinueOnError), "src", "usage", []string{subName})
}
26 changes: 26 additions & 0 deletions cmd/src/colors.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"github.com/grafana/regexp"

"github.com/mattn/go-isatty"

"github.com/sourcegraph/sourcegraph/lib/output"
)

// Returns the string for a foreground ANSI 8 bit color code.
Expand Down Expand Up @@ -59,6 +61,30 @@ var ansiColors = map[string]string{
// MIT licensed, see https://github.com/acarl005/stripansi/blob/master/LICENSE
var ansiRegexp = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")

// applyColorMode optionally remaps ansiColors entries that use 256-color
// SGR sequences to their nearest basic 16-color SGR equivalents and flips
// lib/output's force16 state so package-level Style values render the same
// way.
//
// When force16 is false this is a no-op (the default lib/output state is
// already 256-color). Disabled color entries (empty strings, set by
// NO_COLOR / COLOR=false / non-tty) are left untouched so we never
// re-enable colored output the environment asked us to suppress.
//
// See sourcegraph/src-cli#1144.
func applyColorMode(force16 bool) {
if !force16 {
return
}
output.SetForce16Color(true)
for name, code := range ansiColors {
if code == "" {
continue
}
ansiColors[name] = output.Remap256To16SGR(code)
}
}

var isTest bool
var colorDisabled bool

Expand Down
172 changes: 172 additions & 0 deletions cmd/src/colors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package main

import (
"regexp"
"strings"
"testing"

"github.com/sourcegraph/sourcegraph/lib/output"
)

// snapshotAnsiColors copies ansiColors and restores it on test cleanup,
// and also resets lib/output's force16 state so mutations in colorMode
// tests do not leak into other tests in this package or in lib/output.
func snapshotAnsiColors(t *testing.T) {
t.Helper()
original := make(map[string]string, len(ansiColors))
for k, v := range ansiColors {
original[k] = v
}
t.Cleanup(func() {
for k := range ansiColors {
delete(ansiColors, k)
}
for k, v := range original {
ansiColors[k] = v
}
output.SetForce16Color(false)
})
}

// TestApplyColorMode16Remap is a regression test for sourcegraph/src-cli#1144.
// When 16-color mode is enabled via config, ansiColors entries that currently
// use 256-color SGR sequences (e.g. "logo") must be remapped to plain 16-color
// SGR sequences so the terminal does not receive any 38;5;N or 48;5;N codes.
func TestApplyColorMode16Remap(t *testing.T) {
snapshotAnsiColors(t)

before := ansiColors["logo"]
if before == "" {
t.Fatal(`precondition failed: ansiColors["logo"] is empty, cannot verify remap`)
}
if !strings.Contains(before, "38;5;") {
t.Fatalf(`precondition failed: ansiColors["logo"] = %q, expected a 256-color sequence containing "38;5;"`, before)
}

applyColorMode(true)

after := ansiColors["logo"]
if after == before {
t.Fatalf(`applyColorMode(true) did not remap ansiColors["logo"]: still %q`, after)
}
if strings.Contains(after, "38;5;") || strings.Contains(after, "48;5;") {
t.Fatalf(`applyColorMode(true) left a 256-color sequence in ansiColors["logo"]: %q`, after)
}

// Validate the remapped value is a concatenation of basic 16-color SGR
// codes: foreground 30-37 / 90-97 and optional background 40-47 / 100-107.
sgr16 := regexp.MustCompile(`^(\x1b\[(3[0-7]|9[0-7]|4[0-7]|10[0-7])m)+$`)
if !sgr16.MatchString(after) {
t.Fatalf(`ansiColors["logo"] after applyColorMode(true) = %q, want a 16-color ANSI SGR sequence`, after)
}
}

// TestApplyColorMode16PropagatesToLibOutput verifies that force16Color also
// affects lib/output package-level Style variables (e.g. StyleLogo,
// StyleSearchMatch). Those styles are emitted by output.Output consumers such
// as batch progress bars and search rendering.
//
// The test enables 16-color mode through the config-driven helper and
// asserts that lib/output's exported styles no longer contain 38;5;N or
// 48;5;N sequences.
func TestApplyColorMode16PropagatesToLibOutput(t *testing.T) {
snapshotAnsiColors(t)

if before := output.StyleLogo.String(); !strings.Contains(before, "38;5;") {
t.Fatalf(`precondition failed: output.StyleLogo = %q, expected a 256-color sequence`, before)
}

applyColorMode(true)

checks := map[string]output.Style{
"StyleLogo": output.StyleLogo,
"StyleWarning": output.StyleWarning,
"StyleSuccess": output.StyleSuccess,
"StyleFailure": output.StyleFailure,
"StyleSearchMatch": output.StyleSearchMatch,
}
for name, s := range checks {
got := s.String()
if strings.Contains(got, "38;5;") || strings.Contains(got, "48;5;") {
t.Errorf("after applyColorMode(true), output.%s still emits 256-color SGR: %q", name, got)
}
}
}

// TestApplyColorMode16PreservesDisabledColors is a regression test for
// the NO_COLOR / COLOR=false / non-tty edge of sourcegraph/src-cli#1144:
// when an ansiColors entry has been disabled (set to "") the 16-color
// remap must not promote it back to an active escape. The contract is
// "force16 downgrades 256-color to 16-color", not "force16 forces some
// color on", so disabled entries must round-trip through applyColorMode(true)
// untouched.
func TestApplyColorMode16PreservesDisabledColors(t *testing.T) {
snapshotAnsiColors(t)

// Disable a representative mix: a custom 256-color entry, a basic
// color, the reset code, and the already-empty alert title.
ansiColors["logo"] = ""
ansiColors["blue"] = ""
ansiColors["nc"] = ""
// "search-alert-proposed-title" is already "" in the default table;
// listed explicitly for documentation.
ansiColors["search-alert-proposed-title"] = ""

applyColorMode(true)

for _, name := range []string{
"logo",
"blue",
"nc",
"search-alert-proposed-title",
} {
if got := ansiColors[name]; got != "" {
t.Errorf(`applyColorMode(true) re-enabled disabled color %q: got %q, want ""`, name, got)
}
}
}

// TestApplyColorMode16WithFullyDisabledTable covers the NO_COLOR /
// COLOR=false path end-to-end for sourcegraph/src-cli#1144: when the
// environment has asked us to suppress all color (every ansiColors entry
// is ""), enabling force16 must not reintroduce any escape sequence
// anywhere in the table.
func TestApplyColorMode16WithFullyDisabledTable(t *testing.T) {
snapshotAnsiColors(t)

// Simulate the NO_COLOR / COLOR=false branch in colors.go's init():
// every entry blanked.
for k := range ansiColors {
ansiColors[k] = ""
}

applyColorMode(true)

for name, code := range ansiColors {
if code != "" {
t.Errorf(`applyColorMode(true) reintroduced an escape for disabled color %q: got %q, want ""`, name, code)
}
}
}

// TestApplyColorModeFalseIsNoop ensures that disabling the 16-color override
// leaves the original ansiColors table untouched.
func TestApplyColorModeFalseIsNoop(t *testing.T) {
snapshotAnsiColors(t)

before := make(map[string]string, len(ansiColors))
for k, v := range ansiColors {
before[k] = v
}

applyColorMode(false)

if len(ansiColors) != len(before) {
t.Fatalf("applyColorMode(false) changed ansiColors size: got %d, want %d", len(ansiColors), len(before))
}
for k, want := range before {
if got := ansiColors[k]; got != want {
t.Errorf("applyColorMode(false) mutated ansiColors[%q]: got %q, want %q", k, got, want)
}
}
}
5 changes: 5 additions & 0 deletions cmd/src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ type config struct {
endpointURL *url.URL // always non-nil; defaults to https://sourcegraph.com via readConfig
usingDefaultEndpoint bool
inCI bool
force16Color bool
}

// configFromFile holds the config as read from the config file,
Expand All @@ -160,6 +161,9 @@ type configFromFile struct {
AccessToken string `json:"accessToken"`
AdditionalHeaders map[string]string `json:"additionalHeaders"`
Proxy string `json:"proxy"`
// Force16Color restricts colored output to the basic 16-color SGR palette
// instead of 256-color sequences. See sourcegraph/src-cli#1144.
Force16Color bool `json:"force16Color"`
}

type AuthMode int
Expand Down Expand Up @@ -247,6 +251,7 @@ func readConfig() (*config, error) {
cfg.accessToken = cfgFromFile.AccessToken
cfg.additionalHeaders = cfgFromFile.AdditionalHeaders
proxyStr = cfgFromFile.Proxy
cfg.force16Color = cfgFromFile.Force16Color
}

envToken := os.Getenv("SRC_ACCESS_TOKEN")
Expand Down
36 changes: 36 additions & 0 deletions cmd/src/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,42 @@ func TestReadConfig(t *testing.T) {
inCI: true,
},
},
{
// Regression for sourcegraph/src-cli#1144: 16-color mode is a
// persistent config option, not a per-invocation flag. The JSON
// key "force16Color" must round-trip into config.force16Color.
name: "config file, force16Color enabled",
fileContents: &configFromFile{
Endpoint: "https://example.com/",
AccessToken: "deadbeef",
Force16Color: true,
},
want: &config{
endpointURL: &url.URL{
Scheme: "https",
Host: "example.com",
},
accessToken: "deadbeef",
additionalHeaders: map[string]string{},
force16Color: true,
},
},
{
name: "config file, force16Color omitted defaults to false",
fileContents: &configFromFile{
Endpoint: "https://example.com/",
AccessToken: "deadbeef",
},
want: &config{
endpointURL: &url.URL{
Scheme: "https",
Host: "example.com",
},
accessToken: "deadbeef",
additionalHeaders: map[string]string{},
force16Color: false,
},
},
}

for _, test := range tests {
Expand Down
1 change: 1 addition & 0 deletions cmd/src/run_migration_compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func maybeRunMigratedCommand() (isMigrated bool, exitCode int, err error) {
if err != nil {
log.Fatal("reading config: ", err)
}
applyColorMode(cfg.force16Color)

exitCode, err = runMigrated()
return
Expand Down
Loading