git-env 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
git_env/discovery.py ADDED
@@ -0,0 +1,204 @@
1
+ """File discovery: walk the primary worktree and find env files to sync.
2
+
3
+ Implements the "File discovery" rules from spec.md: match configured
4
+ patterns against the primary worktree, ignore `.gitignore` entirely, honor
5
+ an opt-out `.envsyncignore` (gitignore syntax), skip symlinks unless
6
+ configured otherwise, skip oversized files with a warning, and never
7
+ traverse into submodules.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import os
14
+ import re
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from .config import SyncConfig
19
+
20
+ #: Directory name that always marks a repository root; never descend into a
21
+ #: nested one (submodule) other than the primary worktree's own root.
22
+ _GIT_ENTRY = ".git"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class DiscoveredFile:
27
+ """A file in the primary worktree that matches the sync patterns."""
28
+
29
+ relative_path: Path
30
+ """Path relative to the primary worktree root."""
31
+
32
+ absolute_path: Path
33
+ """Absolute path to the file (symlink target if followed)."""
34
+
35
+ size: int
36
+
37
+ is_symlink: bool
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class DiscoveryWarning:
42
+ """A non-fatal issue encountered while discovering files."""
43
+
44
+ relative_path: Path
45
+ message: str
46
+
47
+
48
+ class _IgnoreMatcher:
49
+ """Minimal gitignore-syntax matcher for `.envsyncignore`."""
50
+
51
+ def __init__(self, lines: list[str]) -> None:
52
+ self._rules: list[tuple[re.Pattern[str], bool, bool]] = []
53
+ for raw in lines:
54
+ line = raw.rstrip("\n")
55
+ if not line.strip() or line.startswith("#"):
56
+ continue
57
+ negate = line.startswith("!")
58
+ if negate:
59
+ line = line[1:]
60
+ line = line.rstrip()
61
+ if not line:
62
+ continue
63
+ dir_only = line.endswith("/")
64
+ if dir_only:
65
+ line = line[:-1]
66
+ anchored = line.startswith("/")
67
+ if anchored:
68
+ line = line[1:]
69
+ pattern = re.compile(self._translate(line, anchored))
70
+ self._rules.append((pattern, negate, dir_only))
71
+
72
+ @staticmethod
73
+ def _translate(pattern: str, anchored: bool) -> str:
74
+ i = 0
75
+ n = len(pattern)
76
+ out: list[str] = ["^" if anchored else "^(?:.*/)?"]
77
+ while i < n:
78
+ ch = pattern[i]
79
+ if ch == "*":
80
+ if pattern[i : i + 2] == "**":
81
+ if pattern[i : i + 3] == "**/":
82
+ out.append("(?:.*/)?")
83
+ i += 3
84
+ continue
85
+ out.append(".*")
86
+ i += 2
87
+ continue
88
+ out.append("[^/]*")
89
+ i += 1
90
+ continue
91
+ if ch == "?":
92
+ out.append("[^/]")
93
+ i += 1
94
+ continue
95
+ out.append(re.escape(ch))
96
+ i += 1
97
+ out.append("$")
98
+ return "".join(out)
99
+
100
+ def matches(self, relative_posix: str, *, is_dir: bool) -> bool:
101
+ ignored = False
102
+ for pattern, negate, dir_only in self._rules:
103
+ if dir_only and not is_dir:
104
+ continue
105
+ if pattern.match(relative_posix):
106
+ ignored = not negate
107
+ return ignored
108
+
109
+ @property
110
+ def empty(self) -> bool:
111
+ return not self._rules
112
+
113
+
114
+ def _load_ignore_matcher(primary_root: Path) -> _IgnoreMatcher:
115
+ ignore_file = primary_root / ".envsyncignore"
116
+ if not ignore_file.is_file():
117
+ return _IgnoreMatcher([])
118
+ return _IgnoreMatcher(ignore_file.read_text().splitlines())
119
+
120
+
121
+ def _matches_any(name: str, patterns: tuple[str, ...]) -> bool:
122
+ return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
123
+
124
+
125
+ def discover_env_files(
126
+ primary_root: Path, config: SyncConfig
127
+ ) -> tuple[list[DiscoveredFile], list[DiscoveryWarning]]:
128
+ """Walk `primary_root` and return matching files plus any warnings."""
129
+ primary_root = primary_root.resolve()
130
+ ignore = _load_ignore_matcher(primary_root)
131
+
132
+ files: list[DiscoveredFile] = []
133
+ warnings: list[DiscoveryWarning] = []
134
+
135
+ for dirpath, dirnames, filenames in os.walk(
136
+ primary_root, followlinks=config.follow_symlinks
137
+ ):
138
+ dir_path = Path(dirpath)
139
+ rel_dir = dir_path.relative_to(primary_root)
140
+
141
+ kept_dirnames = []
142
+ for dirname in dirnames:
143
+ child = dir_path / dirname
144
+ rel_child = (rel_dir / dirname) if str(rel_dir) != "." else Path(dirname)
145
+ rel_child_posix = rel_child.as_posix()
146
+
147
+ if dirname == _GIT_ENTRY:
148
+ continue
149
+ # Nested repo (submodule): a directory with its own .git entry,
150
+ # other than the primary root itself.
151
+ if (child / _GIT_ENTRY).exists():
152
+ continue
153
+ if child.is_symlink() and not config.follow_symlinks:
154
+ continue
155
+ if not ignore.empty and ignore.matches(rel_child_posix, is_dir=True):
156
+ continue
157
+ kept_dirnames.append(dirname)
158
+ dirnames[:] = kept_dirnames
159
+
160
+ for filename in filenames:
161
+ abs_path = dir_path / filename
162
+ rel_path = (rel_dir / filename) if str(rel_dir) != "." else Path(filename)
163
+ rel_posix = rel_path.as_posix()
164
+
165
+ if not _matches_any(filename, config.patterns):
166
+ continue
167
+ if _matches_any(filename, config.exclude):
168
+ continue
169
+ if not ignore.empty and ignore.matches(rel_posix, is_dir=False):
170
+ continue
171
+
172
+ is_symlink = abs_path.is_symlink()
173
+ if is_symlink and not config.follow_symlinks:
174
+ continue
175
+
176
+ try:
177
+ stat_result = abs_path.stat()
178
+ except OSError as exc:
179
+ warnings.append(
180
+ DiscoveryWarning(rel_path, f"could not stat file: {exc}")
181
+ )
182
+ continue
183
+
184
+ if stat_result.st_size > config.max_file_size:
185
+ warnings.append(
186
+ DiscoveryWarning(
187
+ rel_path,
188
+ f"skipped: {stat_result.st_size} bytes exceeds "
189
+ f"env.sync.maxFileSize ({config.max_file_size})",
190
+ )
191
+ )
192
+ continue
193
+
194
+ files.append(
195
+ DiscoveredFile(
196
+ relative_path=rel_path,
197
+ absolute_path=abs_path,
198
+ size=stat_result.st_size,
199
+ is_symlink=is_symlink,
200
+ )
201
+ )
202
+
203
+ files.sort(key=lambda f: f.relative_path.as_posix())
204
+ return files, warnings
git_env/output.py ADDED
@@ -0,0 +1,34 @@
1
+ """Minimal stdout/stderr printer used by sync until colorized output lands
2
+ (see "Implement output formatting and color" todo).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+
9
+
10
+ class Reporter:
11
+ """Routes sync messages to stdout/stderr according to -v/-q."""
12
+
13
+ def __init__(self, *, verbose: bool = False, quiet: bool = False) -> None:
14
+ self.verbose = verbose
15
+ self.quiet = quiet
16
+
17
+ def info(self, message: str) -> None:
18
+ """Always-shown progress, e.g. "synced .env". Suppressed by --quiet."""
19
+ if not self.quiet:
20
+ print(message)
21
+
22
+ def detail(self, message: str) -> None:
23
+ """Verbose-only progress, e.g. skipped/unchanged files."""
24
+ if self.verbose and not self.quiet:
25
+ print(message)
26
+
27
+ def warn(self, message: str) -> None:
28
+ """Non-fatal warnings (conflicts, oversized files): always shown, on stderr."""
29
+ if not self.quiet:
30
+ print(f"warning: {message}", file=sys.stderr)
31
+
32
+ def error(self, message: str) -> None:
33
+ """Fatal errors: always shown, even under --quiet."""
34
+ print(f"error: {message}", file=sys.stderr)
git_env/repo.py ADDED
@@ -0,0 +1,156 @@
1
+ """Repository detection: locate the primary worktree and reject unsupported layouts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import os
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+
12
+ class RepoError(Exception):
13
+ """Raised when the current location is not a usable linked worktree.
14
+
15
+ Always corresponds to exit code 2 per the spec's repository detection rules.
16
+ """
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Repository:
21
+ """The repository context for the current invocation."""
22
+
23
+ git_dir: Path
24
+ """Absolute path to the current worktree's git dir."""
25
+
26
+ git_common_dir: Path
27
+ """Absolute path to the shared git dir (inside the primary worktree's .git)."""
28
+
29
+ primary_root: Path
30
+ """Absolute path to the primary worktree's root directory."""
31
+
32
+ worktree_root: Path
33
+ """Absolute path to the current (linked) worktree's root directory."""
34
+
35
+
36
+ def _git(*args: str, cwd: Path | None = None) -> str:
37
+ try:
38
+ result = subprocess.run(
39
+ ["git", *args],
40
+ cwd=cwd,
41
+ capture_output=True,
42
+ text=True,
43
+ )
44
+ except FileNotFoundError as exc:
45
+ raise RepoError("git executable not found on PATH") from exc
46
+ return result.stdout.strip()
47
+
48
+
49
+ def _git_ok(*args: str, cwd: Path | None = None) -> tuple[bool, str]:
50
+ try:
51
+ result = subprocess.run(
52
+ ["git", *args],
53
+ cwd=cwd,
54
+ capture_output=True,
55
+ text=True,
56
+ )
57
+ except FileNotFoundError as exc:
58
+ raise RepoError("git executable not found on PATH") from exc
59
+ return result.returncode == 0, result.stdout.strip()
60
+
61
+
62
+ def detect_repository(cwd: Path | None = None) -> Repository:
63
+ """Detect the repository context for `cwd` (defaults to the process cwd).
64
+
65
+ Raises RepoError (exit code 2) for any of:
66
+ - not inside a git worktree
67
+ - inside a bare repository
68
+ - inside the primary worktree (sync only runs from a linked worktree)
69
+ """
70
+ cwd = (cwd or Path.cwd()).resolve()
71
+
72
+ inside_ok, inside_out = _git_ok("rev-parse", "--is-inside-work-tree", cwd=cwd)
73
+ bare_ok, bare_out = _git_ok("rev-parse", "--is-bare-repository", cwd=cwd)
74
+
75
+ # A bare repo has no work tree, so --is-inside-work-tree reports "false" (not
76
+ # an error) even when we *are* inside a git dir. Check bare-ness first so that
77
+ # case gets its own message instead of the generic "not inside a worktree" one.
78
+ if bare_ok and bare_out == "true":
79
+ raise RepoError("bare repositories are not supported")
80
+
81
+ if not inside_ok or inside_out != "true":
82
+ raise RepoError("not inside a git worktree")
83
+
84
+ git_dir = Path(_git("rev-parse", "--path-format=absolute", "--git-dir", cwd=cwd))
85
+ git_common_dir = Path(
86
+ _git("rev-parse", "--path-format=absolute", "--git-common-dir", cwd=cwd)
87
+ )
88
+
89
+ if git_dir.resolve() == git_common_dir.resolve():
90
+ primary_root = git_common_dir.resolve().parent
91
+ raise RepoError(
92
+ f"git env sync runs from a linked worktree; you appear to be in the "
93
+ f"primary at {primary_root}"
94
+ )
95
+
96
+ primary_root = git_common_dir.resolve().parent
97
+ worktree_root = Path(
98
+ _git("rev-parse", "--path-format=absolute", "--show-toplevel", cwd=cwd)
99
+ ).resolve()
100
+
101
+ _check_pwd_within_worktree(worktree_root)
102
+
103
+ return Repository(
104
+ git_dir=git_dir.resolve(),
105
+ git_common_dir=git_common_dir.resolve(),
106
+ primary_root=primary_root,
107
+ worktree_root=worktree_root,
108
+ )
109
+
110
+
111
+ def _check_pwd_within_worktree(worktree_root: Path) -> None:
112
+ """Paranoid check (spec: "Safety rails") that the shell-reported `$PWD`
113
+ is actually inside the worktree we just resolved. Catches weird
114
+ invocations (e.g. a symlinked directory pointing somewhere else) where
115
+ the shell's notion of cwd has diverged from the real one.
116
+
117
+ Skipped if `$PWD` isn't set, since not every caller sets it.
118
+ """
119
+ pwd = os.environ.get("PWD")
120
+ if not pwd:
121
+ return
122
+ resolved_pwd = Path(pwd).resolve()
123
+ if resolved_pwd != worktree_root and worktree_root not in resolved_pwd.parents:
124
+ raise RepoError(
125
+ f"$PWD ({pwd}) is not within the current worktree at {worktree_root}"
126
+ )
127
+
128
+
129
+ def check_primary_clean(
130
+ primary_root: Path, patterns: tuple[str, ...], exclude: tuple[str, ...]
131
+ ) -> list[str]:
132
+ """Return relative paths of tracked env files with uncommitted changes
133
+ in `primary_root` (spec: "Safety rails" — don't propagate half-edits on
134
+ a tracked template). Empty list means the primary is clean.
135
+ """
136
+ result = subprocess.run(
137
+ ["git", "diff", "--name-only", "HEAD", "--no-renames"],
138
+ cwd=primary_root,
139
+ capture_output=True,
140
+ text=True,
141
+ )
142
+ if result.returncode != 0:
143
+ return []
144
+
145
+ dirty: list[str] = []
146
+ for line in result.stdout.splitlines():
147
+ path = line.strip()
148
+ if not path:
149
+ continue
150
+ name = Path(path).name
151
+ if not any(fnmatch.fnmatch(name, pattern) for pattern in patterns):
152
+ continue
153
+ if any(fnmatch.fnmatch(name, pattern) for pattern in exclude):
154
+ continue
155
+ dirty.append(path)
156
+ return dirty
@@ -0,0 +1,91 @@
1
+ """Shell completion scripts and the `git env --install-completions` helper.
2
+
3
+ The completion scripts themselves live in `git_env/completions/` (package
4
+ data, so they ship inside the installed wheel) and are shipped under their
5
+ target filenames (`git-env.bash`, `_git-env`, `git-env.fish`) per spec.md
6
+ "Tab completion".
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass
13
+ from importlib import resources
14
+ from pathlib import Path
15
+
16
+ SUPPORTED_SHELLS = ("bash", "zsh", "fish")
17
+
18
+ _PACKAGE_FILENAMES = {
19
+ "bash": "git-env.bash",
20
+ "zsh": "_git-env",
21
+ "fish": "git-env.fish",
22
+ }
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class CompletionTarget:
27
+ """Where a shell's completion file is installed, and how to enable it."""
28
+
29
+ install_path: Path
30
+ enable_snippet: str
31
+
32
+
33
+ def _xdg_data_home() -> Path:
34
+ return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
35
+
36
+
37
+ def completion_target(shell: str) -> CompletionTarget:
38
+ """Resolve the standard user-level install path and rc snippet for `shell`."""
39
+ home = Path.home()
40
+ if shell == "bash":
41
+ path = _xdg_data_home() / "bash-completion" / "completions" / "git-env"
42
+ snippet = f'source "{path}"'
43
+ elif shell == "zsh":
44
+ path = home / ".zsh" / "completions" / "_git-env"
45
+ snippet = f'fpath=("{path.parent}" $fpath)\nautoload -Uz compinit && compinit'
46
+ elif shell == "fish":
47
+ path = home / ".config" / "fish" / "completions" / "git-env.fish"
48
+ snippet = f"# fish loads completions from {path.parent} automatically, nothing else to do"
49
+ else:
50
+ raise ValueError(f"unsupported shell: {shell!r}")
51
+ return CompletionTarget(install_path=path, enable_snippet=snippet)
52
+
53
+
54
+ def completion_source(shell: str) -> str:
55
+ """Return the packaged completion script content for `shell`."""
56
+ filename = _PACKAGE_FILENAMES[shell]
57
+ return resources.files("git_env.completions").joinpath(filename).read_text()
58
+
59
+
60
+ def install_completions(shell: str, *, write: bool) -> str:
61
+ """
62
+ Print an rc snippet plus the completion script for `shell`, or write the
63
+ script to its standard location with `write=True`.
64
+
65
+ Returns the message to print to the user. Raises ValueError for an
66
+ unsupported shell.
67
+ """
68
+ if shell not in SUPPORTED_SHELLS:
69
+ raise ValueError(
70
+ f"unsupported shell {shell!r}, expected one of {', '.join(SUPPORTED_SHELLS)}"
71
+ )
72
+
73
+ source = completion_source(shell)
74
+ target = completion_target(shell)
75
+
76
+ if not write:
77
+ return (
78
+ f"{source}\n"
79
+ f"# add this to your shell rc file to enable completions:\n"
80
+ f"# {target.enable_snippet}"
81
+ )
82
+
83
+ target.install_path.parent.mkdir(parents=True, exist_ok=True)
84
+ target.install_path.write_text(source)
85
+ message = f"installed {shell} completions to {target.install_path}"
86
+ if shell != "fish":
87
+ message += (
88
+ "\nadd this to your shell rc file if not already present:\n"
89
+ f"{target.enable_snippet}"
90
+ )
91
+ return message