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

1"""Gitignore adapter — manage the smith-managed section in .gitignore.""" 

2 

3from pathlib import Path 

4 

5from smith.domain.value_objects import GitignoreSection 

6 

7START_MARKER = "# smith managed" 

8END_MARKER = "# end smith managed" 

9 

10 

11class GitignoreManager: 

12 """Read and mutate the smith-managed section of a project's .gitignore.""" 

13 

14 def __init__(self, project_dir: Path) -> None: 

15 """Initialise with the project root directory.""" 

16 self._gitignore = project_dir / ".gitignore" 

17 

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) 

26 

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 

31 

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 

45 

46 def _read_lines(self) -> list[str]: 

47 if not self._gitignore.exists(): 

48 return [] 

49 return self._gitignore.read_text().splitlines(keepends=True) 

50 

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) 

56 

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 

69 

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) 

85 

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)