Skip to content

Commit abc85a0

Browse files
committed
Fix gitignore pattern handling
1 parent b4f5c1e commit abc85a0

5 files changed

Lines changed: 287 additions & 75 deletions

File tree

pythongit/cli.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,12 +1506,8 @@ def cmd_clean(argv: list[str]) -> int:
15061506
if not (args.force or args.dry_run):
15071507
_err("fatal: clean.requireForce; use -f or -n")
15081508
return 1
1509-
from . import ignore as _ig
1510-
ig = None if args.x else _ig.load(repo.path)
1511-
s = workdir.status(repo)
1509+
s = workdir.status(repo, include_ignored=args.x)
15121510
for u in s["untracked"]:
1513-
if ig and ig.is_ignored(u):
1514-
continue
15151511
p = repo.path / u
15161512
if args.dry_run:
15171513
_print(f"Would remove {u}")
@@ -1884,7 +1880,9 @@ def cmd_check_ignore(argv: list[str]) -> int:
18841880
ig = _ig.load(repo.path)
18851881
rc = 1
18861882
for p in args.paths:
1887-
if ig.is_ignored(p):
1883+
match_path = p.replace(os.sep, "/")
1884+
is_dir = match_path.endswith("/") or (repo.path / p).is_dir()
1885+
if ig.is_ignored(match_path, is_dir=is_dir):
18881886
_print(p)
18891887
rc = 0
18901888
return rc

pythongit/ignore.py

Lines changed: 154 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,96 @@
1-
"""gitignore parsing.
2-
3-
Each directory may have its own .gitignore. Patterns:
4-
- lines starting with # are comments
5-
- leading ! negates
6-
- trailing / matches directories only
7-
- leading / anchors to the .gitignore's directory
8-
- ** matches any number of path components
9-
"""
1+
"""gitignore parsing."""
102
from __future__ import annotations
113

12-
import fnmatch
134
import os
145
import re
156
from pathlib import Path
16-
from typing import Optional
177

188

199
class IgnoreRule:
20-
__slots__ = ("pattern", "negate", "dir_only", "anchored", "base", "regex")
10+
__slots__ = ("pattern", "negate", "dir_only", "pathname", "base", "regex")
2111

2212
def __init__(self, raw: str, base: str):
2313
self.negate = False
2414
self.dir_only = False
25-
self.anchored = False
26-
self.base = base
15+
self.pathname = False
16+
self.base = base.strip("/")
2717
s = raw
2818
if s.startswith("!"):
2919
self.negate = True
3020
s = s[1:]
21+
elif s.startswith("\\!") or s.startswith("\\#"):
22+
s = s[1:]
3123
if s.endswith("/"):
3224
self.dir_only = True
3325
s = s[:-1]
3426
if s.startswith("/"):
35-
self.anchored = True
27+
self.pathname = True
3628
s = s[1:]
29+
if "/" in s:
30+
self.pathname = True
3731
self.pattern = s
38-
# Translate glob to regex anchored to base
39-
regex = ""
40-
i = 0
41-
while i < len(s):
42-
c = s[i]
43-
if c == "*" and i + 1 < len(s) and s[i + 1] == "*":
44-
# **
45-
regex += ".*"
46-
i += 2
47-
if i < len(s) and s[i] == "/":
48-
i += 1
49-
elif c == "*":
50-
regex += "[^/]*"
51-
i += 1
52-
elif c == "?":
53-
regex += "[^/]"
54-
i += 1
55-
elif c in ".(){}+|^$\\":
56-
regex += "\\" + c
57-
i += 1
58-
elif c == "[":
59-
end = s.find("]", i)
60-
if end == -1:
61-
regex += "\\["
62-
i += 1
63-
else:
64-
regex += s[i : end + 1]
65-
i = end + 1
66-
else:
67-
regex += c
68-
i += 1
69-
self.regex = re.compile("^" + regex + "$")
32+
self.regex = re.compile("^" + _wildmatch_to_regex(s) + "$")
7033

7134
def match(self, rel: str, is_dir: bool) -> bool:
72-
if self.dir_only and not is_dir:
35+
sub = self._relative_path(rel)
36+
if not sub:
7337
return False
74-
target = rel
75-
if self.anchored:
76-
if self.base and not target.startswith(self.base + "/") and target != self.base:
77-
return False
78-
sub = target[len(self.base) + 1 :] if self.base else target
79-
return self.regex.match(sub) is not None
80-
# match against any suffix
81-
if self.regex.match(target):
38+
if self._match_path(sub, is_dir):
8239
return True
83-
for i, ch in enumerate(target):
84-
if ch == "/" and self.regex.match(target[i + 1 :]):
40+
41+
# A pattern that matches a directory also applies to everything below
42+
# that directory. This is what makes "build/" ignore "build/out.o".
43+
parts = sub.split("/")
44+
for i in range(1, len(parts)):
45+
parent = "/".join(parts[:i])
46+
if self._match_path(parent, True):
8547
return True
86-
# also match against final component
87-
last = target.rsplit("/", 1)[-1]
88-
return self.regex.match(last) is not None
48+
return False
49+
50+
def _relative_path(self, rel: str) -> str | None:
51+
if self.base:
52+
if rel == self.base:
53+
return None
54+
prefix = self.base + "/"
55+
if not rel.startswith(prefix):
56+
return None
57+
return rel[len(prefix) :]
58+
return rel
59+
60+
def _match_path(self, sub: str, is_dir: bool) -> bool:
61+
if self.dir_only and not is_dir:
62+
return False
63+
if self.pathname:
64+
return self.regex.match(sub) is not None
65+
if "/" in sub:
66+
sub = sub.rsplit("/", 1)[-1]
67+
return self.regex.match(sub) is not None
8968

9069

9170
class IgnoreSet:
9271
def __init__(self) -> None:
9372
self.rules: list[IgnoreRule] = []
9473

9574
def add_file(self, gitignore_path: Path, base_rel: str) -> None:
96-
if not gitignore_path.exists():
75+
if not gitignore_path.exists() or gitignore_path.is_symlink():
9776
return
9877
for line in gitignore_path.read_text(encoding="utf-8", errors="replace").splitlines():
99-
line = line.rstrip()
78+
line = _trim_trailing_spaces(line)
10079
if not line or line.startswith("#"):
10180
continue
10281
self.rules.append(IgnoreRule(line, base_rel))
10382

10483
def is_ignored(self, rel_path: str, is_dir: bool = False) -> bool:
84+
rel_path, is_dir = _normalize_path(rel_path, is_dir)
85+
if not rel_path:
86+
return False
87+
parts = rel_path.split("/")
88+
for i in range(1, len(parts)):
89+
if self._last_match("/".join(parts[:i]), True):
90+
return True
91+
return self._last_match(rel_path, is_dir)
92+
93+
def _last_match(self, rel_path: str, is_dir: bool) -> bool:
10594
ignored = False
10695
for r in self.rules:
10796
if r.match(rel_path, is_dir):
@@ -111,10 +100,9 @@ def is_ignored(self, rel_path: str, is_dir: bool = False) -> bool:
111100

112101
def load(repo_path: Path) -> IgnoreSet:
113102
s = IgnoreSet()
114-
# Root .gitignore + .git/info/exclude
115-
s.add_file(repo_path / ".gitignore", "")
103+
# Lower-precedence excludes are loaded first; later matches override them.
116104
s.add_file(repo_path / ".git" / "info" / "exclude", "")
117-
# Walk for nested .gitignore files
105+
s.add_file(repo_path / ".gitignore", "")
118106
for root, dirs, files in os.walk(repo_path):
119107
dirs[:] = [d for d in dirs if d != ".git"]
120108
if ".gitignore" in files:
@@ -123,3 +111,103 @@ def load(repo_path: Path) -> IgnoreSet:
123111
continue
124112
s.add_file(Path(root) / ".gitignore", rel_root)
125113
return s
114+
115+
116+
def _normalize_path(path: str, is_dir: bool) -> tuple[str, bool]:
117+
path = path.replace(os.sep, "/")
118+
if path.endswith("/"):
119+
is_dir = True
120+
path = path.rstrip("/")
121+
parts = [p for p in path.split("/") if p and p != "."]
122+
return "/".join(parts), is_dir
123+
124+
125+
def _trim_trailing_spaces(line: str) -> str:
126+
last_space: int | None = None
127+
i = 0
128+
while i < len(line):
129+
c = line[i]
130+
if c == " ":
131+
if last_space is None:
132+
last_space = i
133+
elif c == "\\":
134+
i += 1
135+
if i >= len(line):
136+
return line
137+
last_space = None
138+
else:
139+
last_space = None
140+
i += 1
141+
if last_space is not None:
142+
return line[:last_space]
143+
return line
144+
145+
146+
def _wildmatch_to_regex(pattern: str) -> str:
147+
out: list[str] = []
148+
i = 0
149+
while i < len(pattern):
150+
c = pattern[i]
151+
if c == "*":
152+
start = i
153+
while i < len(pattern) and pattern[i] == "*":
154+
i += 1
155+
count = i - start
156+
prev_is_sep = start == 0 or pattern[start - 1] == "/"
157+
next_is_sep = i == len(pattern) or pattern[i] == "/"
158+
if count >= 2 and prev_is_sep and next_is_sep:
159+
if i < len(pattern) and pattern[i] == "/":
160+
out.append("(?:.*/)?")
161+
i += 1
162+
else:
163+
out.append(".*")
164+
else:
165+
out.append("[^/]*")
166+
continue
167+
if c == "?":
168+
out.append("[^/]")
169+
elif c == "[":
170+
token, end = _translate_char_class(pattern, i)
171+
out.append(token)
172+
i = end
173+
continue
174+
elif c == "\\":
175+
i += 1
176+
if i >= len(pattern):
177+
return r"(?!)"
178+
out.append(re.escape(pattern[i]))
179+
else:
180+
out.append(re.escape(c))
181+
i += 1
182+
return "".join(out)
183+
184+
185+
def _translate_char_class(pattern: str, start: int) -> tuple[str, int]:
186+
i = start + 1
187+
if i >= len(pattern):
188+
return r"\[", start + 1
189+
190+
negate = False
191+
if pattern[i] in ("!", "^"):
192+
negate = True
193+
i += 1
194+
if i < len(pattern) and pattern[i] == "]":
195+
i += 1
196+
197+
body = ""
198+
while i < len(pattern) and pattern[i] != "]":
199+
if pattern[i] == "\\" and i + 1 < len(pattern):
200+
i += 1
201+
body += re.escape(pattern[i])
202+
else:
203+
body += pattern[i]
204+
i += 1
205+
if i >= len(pattern):
206+
return r"\[", start + 1
207+
208+
body = body.replace("\\", r"\\")
209+
if negate:
210+
token = f"(?:(?!/)[^{body}])"
211+
else:
212+
token = f"(?:(?!/)[{body}])"
213+
return token, i + 1

pythongit/workdir.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pathlib import Path
88
from typing import Iterable, Iterator, Optional
99

10+
from . import ignore as ignore_mod
1011
from . import objects as objs
1112
from . import refs as refs_mod
1213
from .index import (
@@ -33,6 +34,13 @@ def _ignored(rel: str) -> bool:
3334
return ".git" in parts
3435

3536

37+
def _is_dir_no_follow(path: Path) -> bool:
38+
try:
39+
return st_mod.S_ISDIR(path.lstat().st_mode)
40+
except FileNotFoundError:
41+
return False
42+
43+
3644
def iter_worktree(repo: Repository) -> list[str]:
3745
out: list[str] = []
3846
base = repo.path
@@ -116,6 +124,8 @@ def tree_path_entry(repo: Repository, tree_sha: str, path: str) -> Optional[objs
116124

117125
def add_paths(repo: Repository, paths: Iterable[str]) -> None:
118126
idx = read_index(repo)
127+
tracked = set(idx.by_path())
128+
ignores = ignore_mod.load(repo.path)
119129
to_add: list[str] = []
120130
for p in paths:
121131
ap = (repo.path / p).resolve()
@@ -124,10 +134,12 @@ def add_paths(repo: Repository, paths: Iterable[str]) -> None:
124134
dirs[:] = [d for d in dirs if d != ".git"]
125135
for f in files:
126136
rel = _norm(os.path.relpath(os.path.join(root, f), repo.path))
127-
if not _ignored(rel):
137+
if not _ignored(rel) and (rel in tracked or not ignores.is_ignored(rel)):
128138
to_add.append(rel)
129139
else:
130-
to_add.append(_norm(os.path.relpath(ap, repo.path)))
140+
rel = _norm(os.path.relpath(ap, repo.path))
141+
if rel in tracked or not ignores.is_ignored(rel, is_dir=_is_dir_no_follow(ap)):
142+
to_add.append(rel)
131143
for rel in sorted(set(to_add)):
132144
full = repo.path / rel
133145
if not full.exists() and not full.is_symlink():
@@ -161,10 +173,11 @@ def rm_paths(repo: Repository, paths: Iterable[str], *, cached: bool = False) ->
161173
# status / diff target lists
162174

163175

164-
def status(repo: Repository) -> dict[str, list[str]]:
176+
def status(repo: Repository, *, include_ignored: bool = False) -> dict[str, list[str]]:
165177
"""Return groups: staged_new, staged_mod, staged_del, mod, untracked."""
166178
idx = read_index(repo)
167179
by_path = idx.by_path()
180+
ignores = None if include_ignored else ignore_mod.load(repo.path)
168181

169182
head_tree = _head_tree_map(repo)
170183

@@ -187,6 +200,8 @@ def status(repo: Repository) -> dict[str, list[str]]:
187200
sha, _ = objs.hash_bytes("blob", data, repo)
188201
if sha != by_path[rel].sha:
189202
modified.append(rel)
203+
elif ignores is not None and ignores.is_ignored(rel, is_dir=_is_dir_no_follow(full)):
204+
pass
190205
else:
191206
untracked.append(rel)
192207
seen.discard(rel)

0 commit comments

Comments
 (0)