Coverage for smith / infrastructure / gitignore.py: 100%
0 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-01 18:48 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-01 18:48 +0000
1"""Gitignore adapter — manage the smith-managed section in .gitignore."""
3from pathlib import Path
5from smith.domain.value_objects import GitignoreSection
7START_MARKER = "# smith managed"
8END_MARKER = "# end smith managed"
11class GitignoreManager:
12 """Read and mutate the smith-managed section of a project's .gitignore."""
14 def __init__(self, project_dir: Path) -> None:
15 """Initialise with the project root directory."""
16 self._gitignore = project_dir / ".gitignore"
18 def add_section(self, patterns: list[str]) -> None:
19 """Add or replace the smith-managed section in .gitignore."""
20 section = GitignoreSection(patterns=patterns)
21 lines = self._read_lines()
22 if self._find_section_bounds(lines) is not None:
23 self._replace_section(lines, section)
24 else:
25 self._append_section(lines, section)
27 def has_section(self) -> bool:
28 """Return whether a smith-managed section exists in .gitignore."""
29 lines = self._read_lines()
30 return self._find_section_bounds(lines) is not None
32 def get_patterns(self) -> list[str]:
33 """Return the patterns listed inside the smith-managed section."""
34 lines = self._read_lines()
35 bounds = self._find_section_bounds(lines)
36 if bounds is None:
37 return []
38 start, end = bounds
39 patterns = []
40 for line in lines[start + 1 : end]:
41 stripped = line.strip()
42 if stripped and not stripped.startswith("#"):
43 patterns.append(stripped)
44 return patterns
46 def _read_lines(self) -> list[str]:
47 if not self._gitignore.exists():
48 return []
49 return self._gitignore.read_text().splitlines(keepends=True)
51 def _write_lines(self, lines: list[str]) -> None:
52 content = "".join(lines)
53 if content and not content.endswith("\n"):
54 content += "\n"
55 self._gitignore.write_text(content)
57 def _find_section_bounds(self, lines: list[str]) -> tuple[int, int] | None:
58 start = None
59 for i, line in enumerate(lines):
60 if line.strip().startswith(START_MARKER):
61 start = i
62 break
63 if start is None:
64 return None
65 for i in range(start + 1, len(lines)):
66 if lines[i].strip() == END_MARKER:
67 return (start, i)
68 return None
70 def _replace_section(self, lines: list[str], section: GitignoreSection) -> None:
71 bounds = self._find_section_bounds(lines)
72 if bounds is None:
73 self._append_section(lines, section)
74 return
75 start, end = bounds
76 header = lines[start].rstrip("\n")
77 source_match = [p for p in header.split() if p.startswith("source:")]
78 source_part = f" {source_match[0]}" if source_match else ""
79 new_section = [f"{START_MARKER}{source_part}\n"]
80 for p in section.patterns:
81 new_section.append(f"{p}\n")
82 new_section.append(f"{END_MARKER}\n")
83 lines[start : end + 1] = new_section
84 self._write_lines(lines)
86 def _append_section(self, lines: list[str], section: GitignoreSection) -> None:
87 new_lines = [f"{START_MARKER}\n"]
88 for p in section.patterns:
89 new_lines.append(f"{p}\n")
90 new_lines.append(f"{END_MARKER}\n")
91 if lines and lines[-1].strip():
92 new_lines.insert(0, "\n")
93 lines.extend(new_lines)
94 self._write_lines(lines)