Files
git-repo/color.py
T
Brian Gan 35bbf701d0 color: Treat "true" and "yes" as "auto", not "always"
Per the git documentation for color.ui [1], setting color.ui to "true"
(or "yes") should behave identically to "auto", enabling color only
when output is written to a terminal or an active pager. Previously,
repo was equating "true" and "yes" with "always", which caused color
escape codes to be emitted unconditionally, even when output was piped
or redirected.

Replace the duplicated string-matching logic in SetDefaultColoring and
Coloring.__init__ with a single CONFIG_TO_COLOR_SETTING dict that maps
all git color config values to their behavior. This makes the mapping
easy to verify against the git docs and impossible to get out of sync
between the two call sites.

Added tests for SetDefaultColoring and Coloring.__init__ covering
all color mode values (auto, true, yes, always, never, no, false),
case insensitivity, TTY vs pipe behavior, active pager detection,
and unrecognised input.

[1] https://git-scm.com/docs/git-config#Documentation/git-config.txt-colorui

Bug: 295841573
Change-Id: I8a04b9c7e4154de37ed7518c010233039e0afdc9
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/602981
Tested-by: Brian Gan <brgan@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Brian Gan <brgan@google.com>
2026-07-01 15:59:45 -07:00

226 lines
5.1 KiB
Python

# Copyright (C) 2008 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import sys
from typing import Optional
import pager
COLORS = {
None: -1,
"normal": -1,
"black": 0,
"red": 1,
"green": 2,
"yellow": 3,
"blue": 4,
"magenta": 5,
"cyan": 6,
"white": 7,
}
ATTRS = {None: -1, "bold": 1, "dim": 2, "ul": 4, "blink": 5, "reverse": 7}
RESET = "\033[m"
def is_color(s):
return s in COLORS
def is_attr(s):
return s in ATTRS
def _Color(fg=None, bg=None, attr=None):
fg = COLORS[fg]
bg = COLORS[bg]
attr = ATTRS[attr]
if attr >= 0 or fg >= 0 or bg >= 0:
need_sep = False
code = "\033["
if attr >= 0:
code += chr(ord("0") + attr)
need_sep = True
if fg >= 0:
if need_sep:
code += ";"
need_sep = True
if fg < 8:
code += "3%c" % (ord("0") + fg)
else:
code += "38;5;%d" % fg
if bg >= 0:
if need_sep:
code += ";"
if bg < 8:
code += "4%c" % (ord("0") + bg)
else:
code += "48;5;%d" % bg
code += "m"
else:
code = ""
return code
DEFAULT = None
# Placholder value that indicates we need to check if the user is in an
# interactive terminal session to determine if we turn on color or not.
_CHECK_CONSOLE = object()
# https://git-scm.com/docs/git-config#Documentation/git-config.txt-colorui
_CONFIG_TO_COLOR_SETTING = {
"false": False,
"never": False,
"no": False,
"auto": _CHECK_CONSOLE,
"true": _CHECK_CONSOLE,
"yes": _CHECK_CONSOLE,
"always": True,
}
def SetDefaultColoring(state: Optional[str]) -> None:
"""Set coloring behavior to |state|.
This is useful for overriding config options via the command line.
"""
global DEFAULT
if isinstance(state, str):
state = state.lower()
if state in _CONFIG_TO_COLOR_SETTING:
DEFAULT = state
class Coloring:
def __init__(self, config, section_type):
self._section = f"color.{section_type}"
self._config = config
self._out = sys.stdout
on = DEFAULT
if on is None:
on = self._config.GetString(self._section)
if on is None:
on = self._config.GetString("color.ui")
if isinstance(on, str):
on = on.lower()
self._on = _CONFIG_TO_COLOR_SETTING.get(on, _CHECK_CONSOLE)
if self._on is _CHECK_CONSOLE:
self._on = pager.active or os.isatty(1)
def redirect(self, out):
self._out = out
@property
def is_on(self):
return self._on
def write(self, fmt, *args):
self._out.write(fmt % args)
def flush(self):
self._out.flush()
def nl(self):
self._out.write("\n")
def printer(self, opt=None, fg=None, bg=None, attr=None):
s = self
c = self.colorer(opt, fg, bg, attr)
def f(fmt, *args):
s._out.write(c(fmt, *args))
return f
def nofmt_printer(self, opt=None, fg=None, bg=None, attr=None):
s = self
c = self.nofmt_colorer(opt, fg, bg, attr)
def f(fmt):
s._out.write(c(fmt))
return f
def colorer(self, opt=None, fg=None, bg=None, attr=None):
if self._on:
c = self._parse(opt, fg, bg, attr)
def f(fmt, *args):
output = fmt % args
return "".join([c, output, RESET])
return f
else:
def f(fmt, *args):
return fmt % args
return f
def nofmt_colorer(self, opt=None, fg=None, bg=None, attr=None):
if self._on:
c = self._parse(opt, fg, bg, attr)
def f(fmt):
return "".join([c, fmt, RESET])
return f
else:
def f(fmt):
return fmt
return f
def _parse(self, opt, fg, bg, attr):
if not opt:
return _Color(fg, bg, attr)
v = self._config.GetString(f"{self._section}.{opt}")
if v is None:
return _Color(fg, bg, attr)
v = v.strip().lower()
if v == "reset":
return RESET
elif v == "":
return _Color(fg, bg, attr)
have_fg = False
for a in v.split(" "):
if is_color(a):
if have_fg:
bg = a
else:
have_fg = True
fg = a
elif is_attr(a):
attr = a
return _Color(fg, bg, attr)