# GitIgnoreMatcher — Full Requirements

---

## 1. Constructor: `GitIgnoreMatcher(root_folder: Path)`

- `root_folder` is the root of the repository — the directory that contains `.git/`
- The class operates **only within** `root_folder` — it never reads ignore files from parent directories
- `root_folder` must be an absolute path, or resolved to absolute internally
- If `root_folder` does not exist or is not a directory, raise a clear error
- If `root_folder` does not contain a `.git/` entry, the class should still work — it just won't have a `.git/info/exclude` source. This supports the use case of running against a non-git directory
- At construction time, the class performs **only the following eager work**:
  - Resolves `root_folder` to absolute
  - Reads git config files in cascade order to determine the global excludes file path (see section 3)
  - Loads the global excludes file and `.git/info/exclude` — these are **single files with global scope** so there is no benefit to lazy-loading them
  - Detects top-level nested repo boundaries (immediate subdirectories containing `.git/`) — optionally this can also be lazy (see section 7)
- Per-directory `.gitignore` files are **not** loaded at construction time — they are discovered and cached lazily on first access (see section 6)

---

## 2. Method: `is_git_ignored(path: Path) -> bool`

- Returns `True` if the path would be ignored by git, `False` otherwise
- `path` may be absolute or relative — if relative, it is interpreted as relative to `root_folder`
- If `path` is outside `root_folder`, raise a clear error
- The method must correctly handle both files and directories — directory-specific patterns (trailing `/`) should only match directories
- The method must handle paths that don't exist on disk — matching is purely pattern-based, no filesystem access required for the match itself
- The `.git/` directory itself always returns `True` (hardcoded, not pattern-based)
- Paths inside a **nested repo** (a subdirectory containing its own `.git/`) return `False` — the class does not apply parent repo rules inside nested repos (see section 7)
- On each call, the method ensures all `.gitignore` files along the path from `root_folder` down to the queried path are loaded and cached — any not yet loaded are read on demand (see section 6)

---

## 3. Ignore sources, in increasing order of precedence

Rules are applied in this order (later sources override earlier ones):

1. **Global excludes file** — a single file, located by reading git config files in cascade order (system then user) and extracting `core.excludesFile`. Because user config overrides system config, exactly one path results. Config files are read directly, without calling git:
   - System config: `/etc/gitconfig` (Linux/macOS) or `C:\ProgramData\Git\config` (Windows)
   - User config: `~/.gitconfig` and/or `$XDG_CONFIG_HOME/git/config` (falling back to `~/.config/git/config`); if both define `core.excludesFile`, the `~/.gitconfig` value wins
   - If `core.excludesFile` is not set in any config, fall back to the default path: `$XDG_CONFIG_HOME/git/ignore`, or `~/.config/git/ignore` if `$XDG_CONFIG_HOME` is unset or empty
   - Optional, silently skipped if not found or not readable
2. **`.git/info/exclude`** — repo-local excludes at `root_folder/.git/info/exclude`. Optional, silently skipped if not found
3. **Per-directory `.gitignore` files** — loaded lazily from `root_folder` downward as paths are queried. Each file's rules are scoped to its own directory and below. More deeply nested files have higher precedence than shallower ones

Sources 1–2 are loaded eagerly at construction time since they have global scope and fixed locations. Source 3 is loaded lazily (see section 6).

### 3.1 Git config parsing
- Config files are parsed directly using a minimal ini-style parser — Python's `configparser` is sufficient since git config format is a compatible ini dialect
- Only `core.excludesFile` needs to be extracted
- `~` in the extracted path must be expanded via `os.path.expanduser`
- Environment variable expansion should also be applied (`$HOME`, `$XDG_CONFIG_HOME` etc.)
- Config files are read in cascade order (system then user); if `core.excludesFile` appears in multiple config files, the user-level value takes precedence. If no config file defines it, the default path described above is used instead

---

## 4. Pattern syntax (full gitignore spec)

### 4.1 Ignored lines
- Blank lines are ignored
- Lines starting with `#` are comments and ignored; put a `\` in front of the first `#` for patterns that begin with a literal `#`
- Trailing spaces are ignored unless escaped with `\`; **leading spaces are preserved** — a pattern ` foo` (leading space) matches a file literally named ` foo`
- A pattern whose only content is a backslash, or that ends with an unescaped backslash, is invalid and silently discarded
- Invalid glob syntax (e.g. unclosed `[`) causes that individual pattern to be silently discarded; the rest of the file continues to apply

### 4.2 Glob syntax
- `*` — matches any sequence of characters except `/`
- `?` — matches any single character except `/`
- `[abc]`, `[a-z]`, `[!abc]` — character classes, same as shell glob; note that `[/]` inside a character class **does** match `/` when matching against full paths (git's wildmatch pathmatch mode does not apply the POSIX `FNM_PATHNAME` restriction to bracket expressions)
- `**` — matches zero or more directories:
  - Leading `**/` — matches in any directory (`**/foo` matches `foo` anywhere)
  - Trailing `/**` — matches everything inside (`foo/**` matches all contents of `foo/`)
  - Middle `/**/` — matches zero or more path segments between two path components (`foo/**/bar`)
- Any other use of `**` is treated as a regular `*` (matches anything except `/`)

### 4.3 Anchoring rules
- A pattern **without** any `/` (other than optional trailing `/`) matches the **filename only**, at any depth
- A pattern **with** a `/` anywhere other than trailing is **anchored** to the directory of the `.gitignore` file that contains it
- A leading `/` explicitly anchors to the `.gitignore` file's own directory; when the pattern already has a `/` in the middle, a leading `/` is **redundant** — `doc/frotz` and `/doc/frotz` are identical in behavior
- A trailing `/` means the pattern matches **directories only** — never files, and never symlinks to directories
- **Exception for the global excludes file**: when patterns in `core.excludesFile` are anchored (contain `/`), they match relative to the **repository root** — not relative to the excludes file's own filesystem location

### 4.4 Negation
- A leading `!` negates the pattern — a previously matched path is un-matched
- `!` can be escaped as `\!` to match a literal `!` at the start
- **Critical rule**: a path inside an already-ignored directory **cannot** be re-included via negation — if a directory is ignored, all its contents are ignored unconditionally regardless of any `!` rules, at any precedence level
- Within a single file, later patterns override earlier ones — negations can override positive matches and vice versa

---

## 5. Precedence rules in detail

### 5.1 Across sources
Higher precedence sources (listed in section 3) override lower ones on conflict. A deeper `.gitignore` file can negate a match from a shallower one.

### 5.2 Within a single file
Later lines take precedence over earlier lines. A `!foo` after `foo` un-ignores it. A `foo` after `!foo` re-ignores it.

### 5.3 Across nesting levels
A `.gitignore` at depth N+1 takes precedence over one at depth N. So `src/.gitignore` overrides `root/.gitignore` for paths under `src/`.

### 5.4 The ignored directory hard rule
Once a directory is matched by a positive (non-negated) rule, everything inside it is ignored — no rule at any precedence level can un-ignore individual files inside it. The only way to un-ignore contents is to first un-ignore the directory itself (e.g. `!dir` or `!dir/`), then selectively re-ignore what should remain ignored.

A related workaround: using `dir/*` (ignoring the **contents** of a directory rather than the directory itself) leaves the directory traversable. Git will still descend into `dir/`, read inner `.gitignore` files inside it, and evaluate negation rules. This is the canonical technique for selectively re-including files inside a broadly-ignored subtree.

---

## 6. Lazy loading of per-directory `.gitignore` files

- The class maintains an internal cache mapping directory paths to their parsed rules
- On each call to `is_git_ignored(path)`, the class walks the ancestor chain from `root_folder` down to `path.parent`, and for each ancestor directory not yet in the cache:
  - Checks whether a `.gitignore` file exists in that directory
  - If it exists, parses and stores it in the cache keyed by that directory
  - If it does not exist, stores an empty entry in the cache to avoid re-checking
- **Ignored directories are not descended into**: if a directory is itself ignored (determined while walking the ancestor chain), its `.gitignore` file is not loaded and the walk stops — consistent with git's behavior of not listing contents of excluded directories
- This means each directory is read from disk **at most once** for the lifetime of the instance
- The cache is per-instance — no global or class-level shared state
- Thread safety is not required unless explicitly specified

---

## 7. Nested repository handling

- A nested repo is any subdirectory of `root_folder` (at any depth) that contains its own `.git/` entry
- Any path falling under a nested repo boundary returns `False` from `is_git_ignored` — the parent repo's rules do not apply inside nested repos
- Nested repo boundaries can be detected lazily during the ancestor walk in `is_git_ignored` — when descending into a directory, if it contains a `.git/` entry and is not `root_folder` itself, mark it as a nested repo boundary and return `False` for any path beneath it
- Once a nested repo boundary is detected it is cached — subsequent queries under the same nested repo do not re-check the filesystem
- `.gitignore` files inside nested repos are never loaded
- A pattern in the parent repo that matches the nested repo directory itself (e.g. `vendor/mylib/`) is still valid — it applies to the directory as a whole, not to its contents
- **Caller responsibility**: because paths inside nested repos always return `False`, callers that want to exclude all content unreachable to git (e.g. a gitignore-aware glob) must not rely solely on `is_git_ignored(path)`. They must also check `is_git_ignored` on each ancestor directory up to `root_folder`. If any ancestor is ignored, the path is unreachable to git — git would have pruned traversal at that ancestor before ever reaching the nested repo. Example: `out/` is gitignored and `out/build/app/.git` exists; `is_git_ignored("out/build/app/main.py")` returns `False` (nested repo), but a gitignore-aware glob must still exclude it because `is_git_ignored("out/")` returns `True`.

---

## 8. Encoding and file reading

- `.gitignore` files must be read as **UTF-8**, with BOM stripped if present
- If a file has encoding errors, fall back to **latin-1** — git treats pattern files as byte strings, and latin-1 is a safe fallback that maps all byte values
- Lines must be split on `\n`, `\r\n`, and `\r`
- A `.gitignore` file that cannot be opened due to permission errors must be silently skipped — an unreadable ignore file is treated as if it does not exist

---

## 9. Path normalisation

- All paths are normalised to use **forward slashes** internally for pattern matching, regardless of OS
- Symlinks: the class matches against the **symlink path itself**, not the resolved target — consistent with git's behavior
- Case sensitivity: driven by `core.ignorecase` in git config if present; otherwise detected from the filesystem by probing; defaults to case-sensitive if detection is inconclusive

---

## 10. Hardcoded rules

- `root_folder/.git/` is always treated as ignored — hardcoded, not expressed in any pattern file
- This applies only to the top-level `.git/` of `root_folder`, not to `.git/` directories inside nested repos (those are handled by section 7)

---

## 11. What the class does NOT do

- Does not call git or any external process — all git config reading is done by parsing config files directly
- Does not consider whether a file is tracked — this is a pure pattern matcher operating on paths
- Does not modify any files or git state
- Does not recurse into or load rules from directories above `root_folder`
- Does not implement sparse checkout logic
- Does not handle `.gitmodules` — nested repo detection is based solely on `.git/` directory presence
