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/__init__.py +16 -0
- git_env/cli.py +183 -0
- git_env/completions/_git-env +43 -0
- git_env/completions/git-env.bash +64 -0
- git_env/completions/git-env.fish +32 -0
- git_env/config.py +196 -0
- git_env/discovery.py +204 -0
- git_env/output.py +34 -0
- git_env/repo.py +156 -0
- git_env/shell_completions.py +91 -0
- git_env/sync.py +243 -0
- git_env-0.1.0.dist-info/METADATA +224 -0
- git_env-0.1.0.dist-info/RECORD +15 -0
- git_env-0.1.0.dist-info/WHEEL +4 -0
- git_env-0.1.0.dist-info/entry_points.txt +3 -0
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
|