From 9e63334646f28e64c91a8409706af7e1f4fce7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:30:58 +0800 Subject: [PATCH 01/14] chore: bump eoldoctor to 0.1.4 and tighten gitignore/ruff --- .gitignore | 4 ++++ eoldoctor/__init__.py | 2 +- pyproject.toml | 10 +++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) 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/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/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"] From 9a0fb124d33ef2844768484eaf2e3fc244f220cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:31:19 +0800 Subject: [PATCH 02/14] fix: fail check on unreadable files; export safe_relpath --- eoldoctor/diagnose.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eoldoctor/diagnose.py b/eoldoctor/diagnose.py index def04a0..f6dcfd7 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 @@ -180,7 +180,7 @@ def diagnose( stats: Sequence[FileStat], git=None, eol_entries: Optional[Sequence] = None, - phantoms: Optional[Sequence[str]] = None, + phantoms: Optional[Sequence[str] = None, ) -> List[Finding]: findings = diagnose_files(stats) findings.extend(diagnose_git(git, eol_entries or [], phantoms or [])) From 9cb1972f14e01e014710da3948364a2f10abf611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:31:56 +0800 Subject: [PATCH 03/14] fix: restore valid diagnose() type annotation --- eoldoctor/diagnose.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eoldoctor/diagnose.py b/eoldoctor/diagnose.py index f6dcfd7..0c01314 100644 --- a/eoldoctor/diagnose.py +++ b/eoldoctor/diagnose.py @@ -180,7 +180,7 @@ def diagnose( stats: Sequence[FileStat], git=None, eol_entries: Optional[Sequence] = None, - phantoms: Optional[Sequence[str] = None, + phantoms: Optional[Sequence[str]] = None, ) -> List[Finding]: findings = diagnose_files(stats) findings.extend(diagnose_git(git, eol_entries or [], phantoms or [])) From 10a2ac675538012687ea7744a301dacc42f398de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:32:44 +0800 Subject: [PATCH 04/14] fix: resolve GitContext paths via walker.safe_relpath --- eoldoctor/walker.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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: From 0ba939e971e981689b14d46105b5fbe86cba915f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:33:11 +0800 Subject: [PATCH 05/14] fix: GitContext cwd paths and Git <2.40 bool config fallback --- eoldoctor/gitinfo.py | 49 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 9 deletions(-) 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]: From e83dacabad5c81195f1a496c69f3212fd7e3547b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:33:55 +0800 Subject: [PATCH 06/14] fix: forward default-explain flags and fail check on unreadable files --- eoldoctor/cli.py | 47 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 10 deletions(-) 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: From e259f62cb5092631bc629807dea7c74021e392ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:34:35 +0800 Subject: [PATCH 07/14] docs: record 0.1.4 fixes in changelog --- CHANGELOG.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f63a24..35f4ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ 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 + +- CI now runs a lint job (`compileall` + ruff bug-oriented rules) and + pins `contents: read` on the workflow. + ## [0.1.3] - 2026-08-17 ### Changed @@ -32,7 +55,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 +96,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 From abade1551ff520e9379c5b5d552f57b9bbe6cf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:35:06 +0800 Subject: [PATCH 08/14] test: run compileall in the existing unittest CI job --- tests/test_lint.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/test_lint.py 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", + ) From 2dacbb4e7e0453092cce95a44cb157a2e01f8b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:35:25 +0800 Subject: [PATCH 09/14] docs: describe compileall lint via unittest --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f4ef8..089341e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Changed -- CI now runs a lint job (`compileall` + ruff bug-oriented rules) and - pins `contents: read` on the workflow. +- `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 From 91f4c0014ff6992a77ab07f959aee9e1cf2add58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:35:43 +0800 Subject: [PATCH 10/14] test: unreadable findings are warnings --- tests/test_diagnose.py | 9 +++++++++ 1 file changed, 9 insertions(+) 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 From 137b7f4ec52c60d856174f81902d3df380dccba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:36:03 +0800 Subject: [PATCH 11/14] test: git bool fallback and list_files foreign cwd --- tests/test_gitinfo.py | 64 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) 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() From 240ca02f40ea2ac46d8f44c3ffa6bd57629819e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:36:53 +0800 Subject: [PATCH 12/14] test: default-explain flags, unreadable check, attributes errors --- tests/test_cli.py | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) 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") From b71f331c383deb6c38b2a17604c8db9129addacc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:37:17 +0800 Subject: [PATCH 13/14] docs: install from git and pin pre-commit to v0.1.4 --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 ``` From f4f2ad0aacb3396231f06583d42bf06297ea6890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 16:37:43 +0800 Subject: [PATCH 14/14] =?UTF-8?q?docs:=20=E4=B8=AD=E6=96=87=20README=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=BB=8E=20git=20=E5=AE=89=E8=A3=85=E5=B9=B6?= =?UTF-8?q?=E9=92=89=E5=88=B0=20v0.1.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.zh-CN.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 ```