Source code for repomatic.gitignore
# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Generate `.gitignore` content from gitignore.io templates.
Backs the `sync-gitignore` command: fetches the base template categories plus
any `[tool.repomatic] gitignore.extra-categories` from gitignore.io, then
appends `gitignore.extra-content`.
"""
from __future__ import annotations
import logging
from urllib.request import Request, urlopen
from . import __version__
from .http import DEFAULT_TIMEOUT
TYPE_CHECKING = False
if TYPE_CHECKING:
from .config import Config
GITIGNORE_BASE_CATEGORIES: tuple[str, ...] = (
"certificates",
"emacs",
"git",
"gpg",
"linux",
"macos",
"node",
"nohup",
"python",
"rust",
"ssh",
"vim",
"virtualenv",
"visualstudiocode",
"windows",
)
"""Base gitignore.io template categories included in every generated `.gitignore`.
These cover common development environments, operating systems, and tools.
Downstream projects can add more via `gitignore.extra-categories` in
`[tool.repomatic]`.
"""
GITIGNORE_IO_URL = "https://www.toptal.com/developers/gitignore/api"
"""gitignore.io API endpoint for fetching `.gitignore` templates."""
[docs]
def build_gitignore(config: Config) -> str:
"""Fetch and assemble the `.gitignore` content for *config*.
Combines {data}`GITIGNORE_BASE_CATEGORIES` with the configured extra
categories (order-preserving, deduplicated), fetches the merged template
from gitignore.io, and appends the configured extra content.
:param config: The resolved `[tool.repomatic]` configuration.
:return: The full `.gitignore` text.
:raises urllib.error.URLError: When the gitignore.io fetch fails.
"""
all_categories = list(
dict.fromkeys((*GITIGNORE_BASE_CATEGORIES, *config.gitignore.extra_categories))
)
url = f"{GITIGNORE_IO_URL}/{','.join(all_categories)}"
logging.info(f"Fetching {url}")
request = Request(url, headers={"User-Agent": f"repomatic/{__version__}"})
with urlopen(request, timeout=DEFAULT_TIMEOUT) as response:
content: str = response.read().decode("UTF-8")
if config.gitignore.extra_content:
content += "\n" + config.gitignore.extra_content + "\n"
return content
[docs]
def parse_rules(content: str) -> list[str]:
"""Extract the ignore rules from `.gitignore` *content*.
Blank lines and comments are dropped, leaving only the lines git actually
matches paths against. Order is preserved and duplicates are collapsed, so
the result compares two files by what they ignore rather than by how they
are laid out.
Only a leading `#` opens a comment: git treats one anywhere else in the
line as part of the pattern, so no inline-comment stripping happens here.
:param content: Full text of a `.gitignore` file.
:return: The rules, in first-seen order.
"""
rules = (line.strip() for line in content.splitlines())
return list(
dict.fromkeys(rule for rule in rules if rule and not rule.startswith("#"))
)
[docs]
def orphaned_rules(existing: str, generated: str) -> list[str]:
"""Return the rules *generated* would drop from *existing*.
`sync-gitignore` rebuilds the file from gitignore.io plus
`[tool.repomatic.gitignore] extra-content` and never reads what is already
on disk, so a rule added by hand survives exactly one edit: the next sync
writes over it. Comparing the two rule sets before the write is what turns
that silent loss into something the caller can refuse.
:param existing: Current content of the `.gitignore` on disk.
:param generated: Content {func}`build_gitignore` just produced.
:return: Rules present in *existing* and absent from *generated*, in
first-seen order. Empty when the sync drops nothing.
"""
kept = set(parse_rules(generated))
return [rule for rule in parse_rules(existing) if rule not in kept]