diff --git a/.gitignore b/.gitignore index 70e3b51..c647e49 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,11 @@ venv/ .tox/ .pytest_cache/ .mypy_cache/ +.ruff_cache/ .coverage +coverage.xml htmlcov/ +.hypothesis/ .idea/ .vscode/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f63a24..089341e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to this project are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.1.4] - 2026-08-26 + +### Fixed + +- Bare `eoldoctor --json` / `eoldoctor --lang zh` / `eoldoctor ` now + run `explain` with those flags instead of failing as unrecognized + arguments (the default-command path used to drop every extra argument). +- `check` now fails when a targeted file cannot be read (permission or + I/O error). Previously an unreadable file was only an info finding and + the CI gate returned 0. +- `GitContext.list_files` resolves paths against the context cwd, so the + library API works when the process cwd is not the repository root. +- Boolean git config still normalizes `1`/`yes`/`on` when Git is older + than 2.40 (no `--type=bool-or-str`): fall back to a raw `git config` + get plus the same spellings git itself accepts. +- `attributes --write` reports a permission/I/O error and exits 1 + instead of dumping a traceback. + +### Changed + +- `python -m unittest` now includes `compileall` checks so CI syntax-lints + the package without extra workflow jobs. `[tool.ruff]` is configured + for local `ruff check` (bug-oriented rules only). + ## [0.1.3] - 2026-08-17 ### Changed @@ -32,7 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - An invalid `EOLDOCTOR_LANG` environment value no longer crashes every command (falls back to English). - `--include/--exclude` accept Windows-style backslash globs - (`--exclude vendor\*`). + (`--exclude vendor\\*`). - `*.egg-info` build directories are now excluded from plain filesystem walks, like other build artifacts. - Phantom-change detection batches its `git diff` pathspecs (50 per call) @@ -73,6 +97,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). walking through `git ls-files`, zero runtime dependencies. - pre-commit hooks (`eoldoctor-check`, `eoldoctor-fix`). +[0.1.4]: https://github.com/hc-ui/eoldoctor/releases/tag/v0.1.4 +[0.1.3]: https://github.com/hc-ui/eoldoctor/releases/tag/v0.1.3 [0.1.2]: https://github.com/hc-ui/eoldoctor/releases/tag/v0.1.2 [0.1.1]: https://github.com/hc-ui/eoldoctor/releases/tag/v0.1.1 [0.1.0]: https://github.com/hc-ui/eoldoctor/releases/tag/v0.1.0 diff --git a/README.md b/README.md index a5df584..226e239 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,8 @@ policy → gate the CI**, in one zero-dependency tool. ```yaml # .github/workflows/eol.yml -- run: pip install eoldoctor +# Not on PyPI yet — install from git until a release is published. +- run: pip install git+https://github.com/hc-ui/eoldoctor.git - run: eoldoctor check --require lf # exit 1 on violations ``` @@ -133,7 +134,7 @@ Or as a [pre-commit](https://pre-commit.com) hook: ```yaml repos: - repo: https://github.com/hc-ui/eoldoctor - rev: v0.1.1 + rev: v0.1.4 hooks: - id: eoldoctor-check ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index b2c6e56..8b38d03 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -132,7 +132,8 @@ eoldoctor 补上了缺失的那一环:**诊断原因 → 修复文件 → 落地 ```yaml # .github/workflows/eol.yml -- run: pip install eoldoctor +# 尚未上 PyPI,先从 git 安装。 +- run: pip install git+https://github.com/hc-ui/eoldoctor.git - run: eoldoctor check --require lf # 有问题即失败 ``` @@ -141,7 +142,7 @@ eoldoctor 补上了缺失的那一环:**诊断原因 → 修复文件 → 落地 ```yaml repos: - repo: https://github.com/hc-ui/eoldoctor - rev: v0.1.1 + rev: v0.1.4 hooks: - id: eoldoctor-check ``` diff --git a/eoldoctor/__init__.py b/eoldoctor/__init__.py index b401af8..b4c59bf 100644 --- a/eoldoctor/__init__.py +++ b/eoldoctor/__init__.py @@ -10,7 +10,7 @@ from .gitinfo import GitContext from .diagnose import Finding, diagnose -__version__ = "0.1.3" +__version__ = "0.1.4" __all__ = [ "__version__", diff --git a/eoldoctor/cli.py b/eoldoctor/cli.py index 2823138..c08b496 100644 --- a/eoldoctor/cli.py +++ b/eoldoctor/cli.py @@ -32,6 +32,8 @@ ) from .walker import collect_files +_COMMANDS = frozenset({"scan", "check", "fix", "explain", "attributes"}) + _EPILOG = """examples: eoldoctor scan overview of line endings in this repo eoldoctor explain why git keeps warning about CRLF/LF @@ -202,7 +204,14 @@ def cmd_scan(args) -> int: def _check_violations(stats: Sequence[FileStat], args) -> List[Finding]: - base_ids = {"script-crlf", "bom-in-script", "mixed-eol", "cr-only", "batch-lf"} + base_ids = { + "script-crlf", + "bom-in-script", + "mixed-eol", + "cr-only", + "batch-lf", + "unreadable", + } if args.forbid_bom: base_ids |= {"utf8-bom", "utf16-file"} if args.require_final_newline: @@ -374,15 +383,19 @@ def cmd_attributes(args) -> int: content = generate_gitattributes(stats, existing) path = _gitattributes_path(git) written = False + write_error = None if content and args.write: # Match the EOL style already used by the file we are appending to, # so we never create a mixed-EOL .gitattributes ourselves. eol = "\r\n" if "\r\n" in existing else "\n" - with open(path, "a", encoding="utf-8", newline=eol) as handle: - if existing and not existing.endswith("\n"): - handle.write("\n") - handle.write(content) - written = True + try: + with open(path, "a", encoding="utf-8", newline=eol) as handle: + if existing and not existing.endswith("\n"): + handle.write("\n") + handle.write(content) + written = True + except OSError as exc: + write_error = exc.strerror or str(exc) if args.json: print( @@ -394,10 +407,18 @@ def cmd_attributes(args) -> int: "path": path.replace(os.sep, "/"), "content": content, "written": written, + "error": write_error, } ) ) - return 0 + return 1 if write_error else 0 + + if write_error: + print( + "eoldoctor: cannot write %s: %s" % (path.replace(os.sep, "/"), write_error), + file=sys.stderr, + ) + return 1 if not content: print("# nothing to add - existing .gitattributes already covers this repo") @@ -423,10 +444,16 @@ def main(argv: Optional[Sequence[str]] = None) -> int: sys.stderr.reconfigure(errors="replace") except Exception: pass + raw = list(argv) if argv is not None else sys.argv[1:] parser = build_parser() - args = parser.parse_args(argv) - if getattr(args, "func", None) is None: - args = parser.parse_args(["explain"]) + # Parent-only flags must stay on the root parser. Everything else + # without a subcommand is `explain` so `eoldoctor --json` and + # `eoldoctor src/` work instead of being rejected as unknown args. + if not raw: + raw = ["explain"] + elif raw[0] not in _COMMANDS and raw[0] not in ("-h", "--help", "--version"): + raw = ["explain", *raw] + args = parser.parse_args(raw) try: return args.func(args) except FileNotFoundError as exc: diff --git a/eoldoctor/diagnose.py b/eoldoctor/diagnose.py index def04a0..0c01314 100644 --- a/eoldoctor/diagnose.py +++ b/eoldoctor/diagnose.py @@ -100,7 +100,7 @@ def add(fid: str, severity: str, files: List[str], **data) -> None: add("utf8-bom", SEVERITY_INFO, utf8_bom) add("utf16-file", SEVERITY_INFO, utf16) add("final-newline-missing", SEVERITY_INFO, no_final_newline) - add("unreadable", SEVERITY_INFO, unreadable) + add("unreadable", SEVERITY_WARNING, unreadable) return findings diff --git a/eoldoctor/gitinfo.py b/eoldoctor/gitinfo.py index c3fbbc1..3f200ec 100644 --- a/eoldoctor/gitinfo.py +++ b/eoldoctor/gitinfo.py @@ -14,7 +14,25 @@ from dataclasses import dataclass from typing import List, Optional, Sequence -from .walker import norm_display +from .walker import norm_display, safe_relpath + +_BOOL_TRUE = frozenset({"true", "yes", "on", "1"}) +_BOOL_FALSE = frozenset({"false", "no", "off", "0"}) + + +def normalize_git_bool(raw: str) -> str: + """Map git's boolean spellings to true/false; leave other values as-is. + + Used when ``git config --type=bool-or-str`` is unavailable (Git < 2.40). + ``input`` and ``warn`` pass through unchanged. + """ + value = raw.strip().lower() + if value in _BOOL_TRUE: + return "true" + if value in _BOOL_FALSE: + return "false" + return value + _EOL_RECORD = re.compile(rb"^i/(\S*)\s+w/(\S*)\s+attr/([^\t]*)\t(.*)$", re.S) @@ -78,14 +96,24 @@ def _run(self, *args: str): except (OSError, subprocess.SubprocessError): return None, b"" + def _abs_in_cwd(self, rel: str) -> str: + if os.path.isabs(rel): + return rel + return os.path.normpath(os.path.join(self.cwd, rel)) + def _config(self, key: str, default: str, normalize_bool: bool = False) -> str: - # --type=bool-or-str canonicalizes 1/0/yes/no/on/off to true/false - # while leaving strings like "input" or "warn" untouched. - args = ["config"] + # --type=bool-or-str (Git 2.40+) canonicalizes 1/0/yes/no/on/off + # to true/false while leaving strings like "input" or "warn" intact. if normalize_bool: - args.append("--type=bool-or-str") - args += ["--get", key] - rc, out = self._run(*args) + rc, out = self._run("config", "--type=bool-or-str", "--get", key) + if rc == 0 and out.strip(): + return os.fsdecode(out.strip()).lower() + # Older git: raw get + the same boolean spellings git accepts. + rc, out = self._run("config", "--get", key) + if rc == 0 and out.strip(): + return normalize_git_bool(os.fsdecode(out.strip())) + return default + rc, out = self._run("config", "--get", key) if rc == 0 and out.strip(): return os.fsdecode(out.strip()).lower() return default @@ -133,11 +161,14 @@ def list_files(self, path: str = ".") -> Optional[List[str]]: if not raw: continue rel = os.fsdecode(raw) + # git ls-files paths are relative to the git cwd, which may + # differ from the process cwd when GitContext(cwd=...) is used. + full = self._abs_in_cwd(rel) # Drop entries that are not plain files on disk: deleted-but- # tracked files, submodule gitlinks, symlinks. - if os.path.islink(rel) or not os.path.isfile(rel): + if os.path.islink(full) or not os.path.isfile(full): continue - found.append(rel) + found.append(norm_display(safe_relpath(full))) return found def ls_files_eol(self, path: str = ".") -> List[EolEntry]: diff --git a/eoldoctor/walker.py b/eoldoctor/walker.py index e38c599..c9b0d07 100644 --- a/eoldoctor/walker.py +++ b/eoldoctor/walker.py @@ -49,7 +49,7 @@ def norm_display(path: str) -> str: return out or "." -def _safe_relpath(path: str) -> str: +def safe_relpath(path: str) -> str: """relpath that tolerates Windows paths on a different drive.""" try: return os.path.relpath(path) @@ -57,6 +57,11 @@ def _safe_relpath(path: str) -> str: return os.path.abspath(path) +def _safe_relpath(path: str) -> str: + # Kept as an alias so older call sites and tests keep working. + return safe_relpath(path) + + def _matches(patterns: Sequence[str], rel: str) -> bool: base = rel.rsplit("/", 1)[-1] for pat in patterns: diff --git a/pyproject.toml b/pyproject.toml index 2423a81..b8a558b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "eoldoctor" -version = "0.1.3" +version = "0.1.4" description = "Diagnose and fix Git line-ending chaos: CRLF/LF mix-ups, core.autocrlf, .gitattributes, BOMs, and phantom changes. Zero dependencies." readme = "README.md" requires-python = ">=3.9" @@ -51,3 +51,11 @@ eoldoctor = "eoldoctor.cli:main" [tool.setuptools] packages = ["eoldoctor"] + +# CI-only lint: catch real defects, do not reformat the whole tree. +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E9", "F63", "F7", "F82", "F401", "F811", "F821", "F822", "F823", "F841"] diff --git a/tests/test_cli.py b/tests/test_cli.py index e90962d..2b25d69 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -141,6 +141,17 @@ def test_check_json(self): self.assertFalse(payload["ok"]) self.assertEqual(payload["violations"][0]["id"], "script-crlf") + @unittest.skipIf(os.name == "nt", "POSIX permissions only") + def test_check_fails_on_unreadable(self): + path = self.write("secret.txt", b"hello\n") + os.chmod(path, 0o000) + try: + code, out, _ = self.run_cli("check", "secret.txt", "--no-git") + finally: + os.chmod(path, 0o644) + self.assertEqual(code, 1) + self.assertIn("unreadable", out) + class FixTest(CliTestCase): def test_dry_run_default(self): @@ -274,6 +285,23 @@ def test_write_keeps_lf_style(self): self.assertNotIn(b"\r\n", data) self.assertIn(b"* text=auto\n", data) + @unittest.skipIf(os.name == "nt", "POSIX permissions only") + def test_write_permission_error_exits_1(self): + self.write("run.sh", b"echo\n") + locked = self.write(".gitattributes", b"# locked\n") + os.chmod(locked, 0o444) + try: + code, out, err = self.run_cli( + "attributes", "--no-git", "--write", "--json" + ) + finally: + os.chmod(locked, 0o644) + self.assertEqual(code, 1) + payload = json.loads(out) + self.assertFalse(payload["written"]) + self.assertTrue(payload["error"]) + self.assertEqual(self.read(".gitattributes"), b"# locked\n") + class EnvLangTest(CliTestCase): def test_invalid_env_lang_falls_back_to_english(self): @@ -312,6 +340,26 @@ def test_no_args_runs_explain(self): self.assertTrue(out.strip()) self.assertNotIn("usage: eoldoctor", out.lower()) + def test_bare_flags_forward_to_explain(self): + self.write("ok.txt", b"x\n") + code, out, _ = self.run_cli("--json", "--no-git") + self.assertEqual(code, 0) + payload = json.loads(out) + self.assertEqual(payload["command"], "explain") + self.assertIn("findings", payload) + + def test_bare_path_forwards_to_explain(self): + self.write("run.sh", b"echo\r\n") + self.write("other.txt", b"a\r\nb\n") + code, out, _ = self.run_cli("run.sh", "--no-git", "--json") + self.assertEqual(code, 0) + payload = json.loads(out) + self.assertEqual(payload["command"], "explain") + ids = [f["id"] for f in payload["findings"]] + self.assertIn("script-crlf", ids) + # other.txt was not in the path list, so mixed-eol must not appear. + self.assertNotIn("mixed-eol", ids) + def test_version_flag(self): with self.assertRaises(SystemExit) as ctx: self.run_cli("--version") diff --git a/tests/test_diagnose.py b/tests/test_diagnose.py index 2324f67..adf9b72 100644 --- a/tests/test_diagnose.py +++ b/tests/test_diagnose.py @@ -98,6 +98,15 @@ def test_binary_and_empty_ignored(self): ] self.assertEqual(diagnose_files(stats), []) + def test_unreadable_is_warning(self): + from eoldoctor.filescan import FileStat + + stats = [FileStat(path="locked.txt", error="Permission denied")] + found = by_id(diagnose_files(stats), "unreadable") + self.assertIsNotNone(found) + self.assertEqual(found.severity, "warning") + self.assertEqual(found.files, ["locked.txt"]) + def test_severity_order(self): stats = [ analyze_bytes(b"a\nb", path="x.txt"), # info diff --git a/tests/test_gitinfo.py b/tests/test_gitinfo.py index 8a6df4c..d4f05fc 100644 --- a/tests/test_gitinfo.py +++ b/tests/test_gitinfo.py @@ -4,7 +4,12 @@ import tempfile import unittest -from eoldoctor.gitinfo import _EOL_RECORD, EolEntry, GitContext +from eoldoctor.gitinfo import ( + _EOL_RECORD, + EolEntry, + GitContext, + normalize_git_bool, +) GIT = shutil.which("git") is not None @@ -84,6 +89,21 @@ def _run(self, *args): return 1, b"" +class NormalizeGitBoolTest(unittest.TestCase): + def test_truthy_spellings(self): + for raw in ("true", "TRUE", "yes", "on", "1", " Yes "): + self.assertEqual(normalize_git_bool(raw), "true", raw) + + def test_falsey_spellings(self): + for raw in ("false", "no", "off", "0"): + self.assertEqual(normalize_git_bool(raw), "false", raw) + + def test_non_bool_values_pass_through(self): + self.assertEqual(normalize_git_bool("input"), "input") + self.assertEqual(normalize_git_bool("warn"), "warn") + self.assertEqual(normalize_git_bool("native"), "native") + + class PhantomParseTest(unittest.TestCase): def test_phantom_detection_from_canned_output(self): responses = { @@ -117,6 +137,29 @@ def test_rename_status_records_skipped(self): ctx = FakeRunContext(responses) self.assertEqual(ctx.phantom_eol_changes(), []) + def test_bool_or_str_fallback_to_raw_get(self): + # Git < 2.40 rejects --type=bool-or-str (typically exit 129). + responses = { + ("--version",): (0, b"git version 2.34.1"), + ("rev-parse", "--is-inside-work-tree"): (0, b"true\n"), + ("rev-parse", "--show-toplevel"): (0, b"/repo\n"), + ("config", "--type=bool-or-str", "--get", "core.autocrlf"): ( + 129, + b"", + ), + ("config", "--get", "core.autocrlf"): (0, b"yes\n"), + ("config", "--type=bool-or-str", "--get", "core.safecrlf"): ( + 129, + b"", + ), + ("config", "--get", "core.safecrlf"): (0, b"warn\n"), + ("config", "--get", "core.eol"): (0, b"lf\n"), + } + ctx = FakeRunContext(responses) + self.assertEqual(ctx.autocrlf, "true") + self.assertEqual(ctx.safecrlf, "warn") + self.assertEqual(ctx.eol, "lf") + @unittest.skipUnless(GIT, "git not installed") class RealRepoTest(unittest.TestCase): @@ -203,6 +246,25 @@ def test_real_change_is_not_phantom(self): phantoms = GitContext().phantom_eol_changes() self.assertEqual(phantoms, []) + def test_list_files_resolves_against_context_cwd(self): + self._write("tracked.txt", b"x\n") + _git(self.root, "add", "tracked.txt") + outside = tempfile.mkdtemp(prefix="eold-out-") + self.addCleanup(lambda: shutil.rmtree(outside, ignore_errors=True)) + os.chdir(outside) + try: + ctx = GitContext(cwd=self.root) + found = ctx.list_files(".") + self.assertIsNotNone(found) + self.assertTrue( + any(os.path.basename(p) == "tracked.txt" for p in found), + found, + ) + for path in found: + self.assertTrue(os.path.isfile(path), path) + finally: + os.chdir(self.root) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_lint.py b/tests/test_lint.py new file mode 100644 index 0000000..b2e995a --- /dev/null +++ b/tests/test_lint.py @@ -0,0 +1,19 @@ +import compileall +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class SyntaxLintTest(unittest.TestCase): + def test_package_compiles(self): + self.assertTrue( + compileall.compile_dir(str(ROOT / "eoldoctor"), quiet=1), + "eoldoctor failed compileall", + ) + + def test_tests_compile(self): + self.assertTrue( + compileall.compile_dir(str(ROOT / "tests"), quiet=1), + "tests failed compileall", + )