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: 1 addition & 1 deletion .agents/skills/ruff-sync-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ ruff-sync git@github.com:my-org/standards.git # SSH (shallow clone)

| Flag | Meaning |
|------|---------|
| `--output-format` | `text` (default), `json`, `github` (PR annotations) |
| `--output-format` | `text` (default), `json`, `github`, `gitlab`, `sarif` (auto-detected in CI) |
| `--semantic` | Ignore whitespace/comments in `check` |
| `--pre-commit` | Sync `.pre-commit-config.yaml` hook version |
| `--save` | Persist CLI args to `pyproject.toml` |
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ repos:
- id: no-commit-to-branch
args: [--branch, develop, --branch, main]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.15.8"
rev: "v0.15.9"
hooks:
- id: ruff-check
args: ["--fix"]
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]

Check failure on line 1 in pyproject.toml

View workflow job for this annotation

GitHub Actions / Test package installation

Ruff Sync Error

Key 'lint.mccabe' in pyproject.toml is out of sync! Run `ruff-sync` to update.

Check failure on line 1 in pyproject.toml

View workflow job for this annotation

GitHub Actions / Test package installation

Ruff Sync Error

Key 'lint.flake8-quotes' in pyproject.toml is out of sync! Run `ruff-sync` to update.

Check failure on line 1 in pyproject.toml

View workflow job for this annotation

GitHub Actions / Test package installation

Ruff Sync Error

Key 'lint.flake8-pytest-style' in pyproject.toml is out of sync! Run `ruff-sync` to update.

Check failure on line 1 in pyproject.toml

View workflow job for this annotation

GitHub Actions / Test package installation

Ruff Sync Error

Key 'lint.fixable' in pyproject.toml is out of sync! Run `ruff-sync` to update.

Check failure on line 1 in pyproject.toml

View workflow job for this annotation

GitHub Actions / Test package installation

Ruff Sync Error

Key 'lint.dummy-variable-rgx' in pyproject.toml is out of sync! Run `ruff-sync` to update.

Check failure on line 1 in pyproject.toml

View workflow job for this annotation

GitHub Actions / Test package installation

Ruff Sync Error

Key 'indent-width' in pyproject.toml is out of sync! Run `ruff-sync` to update.

Check failure on line 1 in pyproject.toml

View workflow job for this annotation

GitHub Actions / Test package installation

Ruff Sync Error

Key 'format.skip-magic-trailing-comma' in pyproject.toml is out of sync! Run `ruff-sync` to update.

Check failure on line 1 in pyproject.toml

View workflow job for this annotation

GitHub Actions / Test package installation

Ruff Sync Error

Key 'format.quote-style' in pyproject.toml is out of sync! Run `ruff-sync` to update.

Check failure on line 1 in pyproject.toml

View workflow job for this annotation

GitHub Actions / Test package installation

Ruff Sync Error

Key 'format.line-ending' in pyproject.toml is out of sync! Run `ruff-sync` to update.

Check failure on line 1 in pyproject.toml

View workflow job for this annotation

GitHub Actions / Test package installation

Ruff Sync Error

Key 'format.indent-style' in pyproject.toml is out of sync! Run `ruff-sync` to update.
name = "ruff-sync"
version = "0.1.4"
version = "0.1.5.dev1"
description = "Synchronize Ruff linter configuration across projects"
keywords = ["ruff", "linter", "config", "synchronize", "python", "linting", "automation", "tomlkit", "pre-commit"]
authors = [
Expand Down Expand Up @@ -206,6 +206,7 @@
"PLW1510", # subprocess check not needed in tests
"PT013", # pytest import style, prevents importing param and or types in from style
"TC002", # Move third-party import into type-checking block
"TID251", # allow cast in tests
]

[tool.ruff.lint.pydocstyle]
Expand All @@ -222,6 +223,7 @@

[tool.ruff.lint.flake8-tidy-imports.banned-api]
"unittest.mock".msg = "Prefer dedicated libraries to mock at external dependencies, such as `httpx` or `pyfakefs`."
"typing.cast".msg = "Please fix the underlying type issues instead of using typing.cast."

[tool.ruff.lint.flake8-import-conventions]
# Declare the banned `from` imports.
Expand Down
174 changes: 110 additions & 64 deletions src/ruff_sync/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,22 @@
from importlib import metadata
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Final,
Literal,
NamedTuple,
cast,
Protocol,
)

import tomlkit
from httpx import URL
from typing_extensions import deprecated
from typing_extensions import deprecated, override

from ruff_sync.constants import (
DEFAULT_BRANCH,
DEFAULT_EXCLUDE,
DEFAULT_PATH,
MISSING,
MissingType,
OutputFormat,
resolve_defaults,
)
from ruff_sync.core import (
Config,
Expand Down Expand Up @@ -85,7 +81,8 @@ def __init__(self, fmt: str = "%(message)s") -> None:
"""Initialize the formatter with a format string."""
super().__init__(fmt)

def format(self, record: logging.LogRecord) -> str: # type: ignore[explicit-override]
@override
def format(self, record: logging.LogRecord) -> str:
"""Format the log record with colors if the output is a TTY."""
if sys.stderr.isatty():
color = self.COLORS.get(record.levelno, self.RESET)
Expand All @@ -99,14 +96,14 @@ class Arguments(NamedTuple):
command: str
upstream: tuple[URL, ...]
to: pathlib.Path
exclude: Iterable[str] | MissingType
exclude: Iterable[str]
verbose: int
branch: str | MissingType = MISSING
path: str | MissingType = MISSING
branch: str = DEFAULT_BRANCH
path: str | None = None
semantic: bool = False
diff: bool = True
init: bool = False
pre_commit: bool | MissingType = MISSING
pre_commit: bool = False
save: bool | None = None
output_format: OutputFormat = OutputFormat.TEXT

Expand All @@ -128,9 +125,30 @@ class ResolvedArgs(NamedTuple):

upstream: tuple[URL, ...]
to: pathlib.Path
exclude: Iterable[str] | MissingType
branch: str | MissingType
path: str | MissingType
exclude: Iterable[str]
branch: str
path: str | None
output_format: OutputFormat


class CLIArguments(Protocol):
"""Protocol for parsed CLI arguments from ArgumentParser."""

command: str | None
upstream: list[URL]
to: str | None
source: str | None
exclude: list[str] | None
verbose: int
branch: str | None
path: str | None
pre_commit: bool | None
output_format: OutputFormat | None
# Subcommand specific
init: bool
save: bool | None
semantic: bool
diff: bool


@lru_cache(maxsize=1)
Expand All @@ -147,7 +165,7 @@ def get_config(
"""
local_toml = source / RuffConfigFileName.PYPROJECT_TOML
# TODO: use pydantic to validate the toml file
cfg_result: dict[str, Any] = {}
cfg_result: Config = {}
if local_toml.exists():
toml = tomlkit.parse(local_toml.read_text())
config = toml.get("tool", {}).get("ruff-sync")
Expand All @@ -166,13 +184,13 @@ def get_config(
"DeprecationWarning: [tool.ruff-sync] 'source' is deprecated. "
"Use 'to' instead."
)
cfg_result[arg_norm] = value
cfg_result[arg_norm] = value # type: ignore[literal-required]
else:
LOGGER.warning(f"Unknown ruff-sync configuration: {arg}")
# Ensure 'to' is populated if 'source' was used
if "source" in cfg_result and "to" not in cfg_result:
cfg_result["to"] = cfg_result["source"]
return cast("Config", cfg_result)
return cfg_result


def _get_cli_parser() -> ArgumentParser:
Expand Down Expand Up @@ -260,8 +278,8 @@ def _get_cli_parser() -> ArgumentParser:
"--output-format",
type=OutputFormat,
choices=list(OutputFormat),
default=OutputFormat.TEXT,
help="Format for output. Default: text.",
default=None,
help="Format for output. Default: text (auto-detected in CI).",
)

# Pull subcommand (the default action)
Expand Down Expand Up @@ -310,10 +328,10 @@ def _get_cli_parser() -> ArgumentParser:
PARSER: Final[ArgumentParser] = _get_cli_parser()


def _resolve_upstream(args: Any, config: Mapping[str, Any]) -> tuple[URL, ...]:
def _resolve_upstream(args: CLIArguments, config: Config) -> tuple[URL, ...]:
"""Resolve upstream URL(s) from CLI or config."""
if args.upstream:
upstreams = tuple(cast("Iterable[URL]", args.upstream))
upstreams = tuple(args.upstream)
# Log CLI upstreams for consistency with config sourcing
summary = (
f"{upstreams[0]}... ({len(upstreams)} total)"
Expand Down Expand Up @@ -351,7 +369,7 @@ def _resolve_upstream(args: Any, config: Mapping[str, Any]) -> tuple[URL, ...]:
)


def _resolve_exclude(args: Any, config: Mapping[str, Any]) -> Iterable[str] | MissingType:
def _resolve_exclude(args: CLIArguments, config: Config) -> Iterable[str]:
"""Resolve exclude patterns from CLI or config.

Returns the CLI value if provided, otherwise the value from
Expand All @@ -360,45 +378,69 @@ def _resolve_exclude(args: Any, config: Mapping[str, Any]) -> Iterable[str] | Mi
can apply the ``DEFAULT_EXCLUDE`` set downstream.
"""
if args.exclude is not None:
return cast("Iterable[str]", args.exclude)
return args.exclude
if "exclude" in config:
exclude = config["exclude"]
exclude: Iterable[str] = config["exclude"]
LOGGER.info(f"🚫 Using exclude from [tool.ruff-sync]: {list(exclude)}")
return cast("Iterable[str]", exclude)
return MISSING
return exclude
return DEFAULT_EXCLUDE


def _resolve_branch(args: Any, config: Mapping[str, Any]) -> str | MissingType:
def _resolve_branch(args: CLIArguments, config: Config) -> str:
"""Resolve branch name from CLI, config, or MISSING.

An empty string (``--branch ""``) is treated as falsy and falls back to
the config or default, preserving the original behaviour.
"""
if getattr(args, "branch", None):
return cast("str", args.branch)
if args.branch:
return args.branch
if "branch" in config:
branch = cast("str", config["branch"])
branch: str = config["branch"]
LOGGER.info(f"🌿 Using branch from [tool.ruff-sync]: {branch}")
return branch
return MISSING
return DEFAULT_BRANCH


def _resolve_path(args: Any, config: Mapping[str, Any]) -> str | MissingType:
def _resolve_path(args: CLIArguments, config: Config) -> str:
"""Resolve path prefix from CLI, config, or MISSING.

An empty string (``--path ""``) is treated as falsy and falls back to
the config or default, preserving the original behaviour.
"""
if getattr(args, "path", None):
return cast("str", args.path)
if args.path:
return args.path
if "path" in config:
path = cast("str", config["path"])
path: str = config["path"]
LOGGER.info(f"📄 Using path from [tool.ruff-sync]: {path}")
return path
return MISSING
return DEFAULT_PATH


def _resolve_output_format(args: CLIArguments, config: Config) -> OutputFormat:
"""Resolve output format from CLI, config, or environment auto-detection."""
if args.output_format:
return args.output_format

if "output_format" in config:
fmt_str = config["output_format"]
try:
fmt = OutputFormat(fmt_str)
except ValueError:
LOGGER.warning(f"Unknown output format in config: {fmt_str}")
else:
LOGGER.info(f"📊 Using output format from [tool.ruff-sync]: {fmt}")
return fmt

# Auto-detection
provider = _detect_ci_provider()
if provider:
LOGGER.info(f"🤖 Auto-detected CI environment: {provider}")
Comment on lines +435 to +437
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Auto-detected CI provider is returned as an OutputFormat, which is likely a type/enum mismatch.

Here provider is typed as CIProvider | None, but the function returns OutputFormat | MissingType. Returning provider directly means callers like resolve_defaults(..., output_format) and get_formatter(output_format) may receive a CIProvider where an OutputFormat is expected, which can break comparisons/formatter selection unless CIProvider is explicitly compatible with OutputFormat. Please either map CI providers to an OutputFormat here, or adjust the types so that relationship is explicit and enforced.

return provider

return OutputFormat.TEXT


def _resolve_to(args: Any, config: Mapping[str, Any], initial_to: pathlib.Path) -> pathlib.Path:
def _resolve_to(args: CLIArguments, config: Config, initial_to: pathlib.Path) -> pathlib.Path:
"""Resolve target path from CLI, config, or default."""
if args.source:
LOGGER.warning("DeprecationWarning: --source is deprecated. Use --to instead.")
Expand All @@ -415,14 +457,36 @@ def _resolve_to(args: Any, config: Mapping[str, Any], initial_to: pathlib.Path)
return initial_to


def _resolve_args(args: Any, config: Mapping[str, Any], initial_to: pathlib.Path) -> ResolvedArgs:
def _resolve_args(args: CLIArguments, config: Config, initial_to: pathlib.Path) -> ResolvedArgs:
"""Resolve upstream, to, exclude, branch, and path from CLI and config."""
upstream = _resolve_upstream(args, config)
to = _resolve_to(args, config, initial_to)
exclude = _resolve_exclude(args, config)
branch = _resolve_branch(args, config)
path = _resolve_path(args, config)
return ResolvedArgs(upstream, to, cast("Any", exclude), cast("Any", branch), cast("Any", path))
output_format = _resolve_output_format(args, config)

# Normalize path "" -> None to match core expectation
final_path: str | None = path or None

return ResolvedArgs(
upstream,
to,
exclude,
branch,
final_path,
output_format,
)


def _resolve_pre_commit(args: CLIArguments, config: Config) -> bool:
"""Resolve pre-commit sync setting from CLI or config."""
if args.pre_commit is not None:
return args.pre_commit
if "pre_commit_version_sync" in config:
val = config.get("pre_commit_version_sync")
return bool(val)
return False


def _detect_ci_provider() -> CIProvider | None:
Expand Down Expand Up @@ -489,24 +553,14 @@ def main() -> int:
initial_to = pathlib.Path(arg_to) if arg_to else pathlib.Path()
config: Config = get_config(initial_to)

upstream, to_val, exclude, branch, path = _resolve_args(args, config, initial_to)

pre_commit_arg = getattr(args, "pre_commit", None)
if pre_commit_arg is not None:
pre_commit_val: bool | MissingType = pre_commit_arg
elif "pre-commit-version-sync" in config:
pre_commit_val = cast("Any", config).get("pre-commit-version-sync")
elif "pre_commit_version_sync" in config:
pre_commit_val = config.get("pre_commit_version_sync", MISSING)
else:
pre_commit_val = MISSING

# Build Arguments first so _resolve_defaults can centralize MISSING → default
# resolution for branch and path (keeps cli.main and core._merge_multiple_upstreams
# in sync with a single source of truth).
upstream, to_val, exclude, branch, path, output_format = _resolve_args(args, config, initial_to)
pre_commit_val = _resolve_pre_commit(args, config)

resolved_upstream = tuple(resolve_raw_url(u, branch=branch, path=path) for u in upstream)

exec_args = Arguments(
command=args.command,
upstream=upstream,
upstream=resolved_upstream,
to=to_val,
exclude=exclude,
verbose=args.verbose,
Expand All @@ -517,16 +571,8 @@ def main() -> int:
init=getattr(args, "init", False),
pre_commit=pre_commit_val,
save=getattr(args, "save", None),
output_format=getattr(args, "output_format", OutputFormat.TEXT),
)

# Use the shared helper from constants so the MISSING→default logic for
# branch/path cannot diverge between cli.main and core._merge_multiple_upstreams.
res_branch, res_path, _exclude = resolve_defaults(branch, path, exclude)
resolved_upstream = tuple(
resolve_raw_url(u, branch=res_branch, path=res_path) for u in upstream
output_format=output_format,
)
exec_args = exec_args._replace(upstream=resolved_upstream)

# Warn if the specified output format doesn't match the current CI environment
_validate_ci_output_format(exec_args)
Expand Down
7 changes: 5 additions & 2 deletions src/ruff_sync/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ def resolve_defaults(
branch: str | MissingType,
path: str | MissingType,
exclude: Iterable[str] | MissingType,
) -> tuple[str, str | None, Iterable[str]]:
output_format: OutputFormat | MissingType = MISSING,
) -> tuple[str, str | None, Iterable[str], OutputFormat]:
"""Resolve MISSING sentinel values to their effective defaults.

This is the single source of truth for MISSING → default resolution across
Expand All @@ -76,6 +77,7 @@ def resolve_defaults(
branch: The resolved branch value or ``MISSING``.
path: The resolved path value or ``MISSING``.
exclude: The resolved exclude iterable or ``MISSING``.
output_format: The resolved output format or ``MISSING``.

Returns:
A ``(branch, path, exclude)`` tuple with defaults applied.
Expand All @@ -89,4 +91,5 @@ def resolve_defaults(
# but explicit None is the canonical "root directory" sentinel.
eff_path: str | None = raw_path or None
eff_exclude = exclude if exclude is not MISSING else DEFAULT_EXCLUDE
return eff_branch, eff_path, eff_exclude
eff_output_format = output_format if output_format is not MISSING else OutputFormat.TEXT
return eff_branch, eff_path, eff_exclude, eff_output_format
Loading
Loading