Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ venv/
.tox/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/
.hypothesis/
.idea/
.vscode/
.DS_Store
28 changes: 27 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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
```
Expand Down
5 changes: 3 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 有问题即失败
```

Expand All @@ -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
```
Expand Down
2 changes: 1 addition & 1 deletion eoldoctor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .gitinfo import GitContext
from .diagnose import Finding, diagnose

__version__ = "0.1.3"
__version__ = "0.1.4"

__all__ = [
"__version__",
Expand Down
47 changes: 37 additions & 10 deletions eoldoctor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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")
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion eoldoctor/diagnose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
49 changes: 40 additions & 9 deletions eoldoctor/gitinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
7 changes: 6 additions & 1 deletion eoldoctor/walker.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,19 @@ 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)
except ValueError:
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:
Expand Down
10 changes: 9 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"]
48 changes: 48 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading