git-env 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.3.0/LICENCE +7 -0
- {git_env-0.2.0 → git_env-0.3.0}/PKG-INFO +4 -2
- {git_env-0.2.0 → git_env-0.3.0}/pyproject.toml +3 -1
- {git_env-0.2.0 → git_env-0.3.0}/src/git_env/config.py +25 -44
- {git_env-0.2.0 → git_env-0.3.0}/src/git_env/discovery.py +0 -6
- {git_env-0.2.0 → git_env-0.3.0}/src/git_env/repo.py +15 -22
- {git_env-0.2.0 → git_env-0.3.0}/src/git_env/shell_completions.py +1 -5
- {git_env-0.2.0 → git_env-0.3.0}/README.md +0 -0
- {git_env-0.2.0 → git_env-0.3.0}/src/git_env/__init__.py +0 -0
- {git_env-0.2.0 → git_env-0.3.0}/src/git_env/cli.py +0 -0
- {git_env-0.2.0 → git_env-0.3.0}/src/git_env/completions/_git-env +0 -0
- {git_env-0.2.0 → git_env-0.3.0}/src/git_env/completions/git-env.bash +0 -0
- {git_env-0.2.0 → git_env-0.3.0}/src/git_env/completions/git-env.fish +0 -0
- {git_env-0.2.0 → git_env-0.3.0}/src/git_env/output.py +0 -0
- {git_env-0.2.0 → git_env-0.3.0}/src/git_env/sync.py +0 -0
git_env-0.3.0/LICENCE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 Alex Ward
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: git-env
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Sync environment files across linked git worktrees
|
|
5
5
|
Author: Alex Ward
|
|
6
6
|
Author-email: Alex Ward <alxwrd@googlemail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENCE
|
|
7
9
|
Requires-Dist: arguably>=1.2.2
|
|
8
10
|
Requires-Python: >=3.10
|
|
9
11
|
Description-Content-Type: text/markdown
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "git-env"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Sync environment files across linked git worktrees"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
7
7
|
{ name = "Alex Ward", email = "alxwrd@googlemail.com" }
|
|
8
8
|
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENCE"]
|
|
9
11
|
requires-python = ">=3.10"
|
|
10
12
|
dependencies = [
|
|
11
13
|
"arguably>=1.2.2",
|
|
@@ -6,6 +6,7 @@ local -> worktree) handled natively by `git config --get[-all]`.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import subprocess
|
|
9
|
+
from collections.abc import Callable
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
@@ -89,7 +90,7 @@ def _parse_envsync_file(path: Path) -> dict[str, list[str]]:
|
|
|
89
90
|
return values
|
|
90
91
|
|
|
91
92
|
|
|
92
|
-
def _parse_bool(value: str,
|
|
93
|
+
def _parse_bool(value: str, source: str) -> bool:
|
|
93
94
|
lowered = value.strip().lower()
|
|
94
95
|
if lowered in _TRUE_VALUES:
|
|
95
96
|
return True
|
|
@@ -98,7 +99,7 @@ def _parse_bool(value: str, *, source: str) -> bool:
|
|
|
98
99
|
raise ConfigError(f"invalid boolean value for {source}: {value!r}")
|
|
99
100
|
|
|
100
101
|
|
|
101
|
-
def _parse_int(value: str,
|
|
102
|
+
def _parse_int(value: str, source: str) -> int:
|
|
102
103
|
try:
|
|
103
104
|
return int(value.strip())
|
|
104
105
|
except ValueError as exc:
|
|
@@ -116,36 +117,20 @@ def _resolve_multi(
|
|
|
116
117
|
return list(default)
|
|
117
118
|
|
|
118
119
|
|
|
119
|
-
def
|
|
120
|
-
config_key: str,
|
|
121
|
-
|
|
120
|
+
def _resolve(
|
|
121
|
+
config_key: str,
|
|
122
|
+
envsync: dict[str, list[str]],
|
|
123
|
+
envsync_key: str,
|
|
124
|
+
default: str | bool | int,
|
|
125
|
+
cwd: Path,
|
|
126
|
+
parse: Callable[[str, str], str | bool | int] | None = None,
|
|
127
|
+
) -> str | bool | int:
|
|
122
128
|
value = _git_config_get(config_key, cwd)
|
|
123
129
|
if value is not None:
|
|
124
|
-
return
|
|
130
|
+
return parse(value, config_key) if parse else value
|
|
125
131
|
if envsync_key in envsync:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def _resolve_int(
|
|
131
|
-
config_key: str, envsync: dict[str, list[str]], envsync_key: str, default: int, cwd: Path
|
|
132
|
-
) -> int:
|
|
133
|
-
value = _git_config_get(config_key, cwd)
|
|
134
|
-
if value is not None:
|
|
135
|
-
return _parse_int(value, source=config_key)
|
|
136
|
-
if envsync_key in envsync:
|
|
137
|
-
return _parse_int(envsync[envsync_key][-1], source=f".envsync:{envsync_key}")
|
|
138
|
-
return default
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _resolve_str(
|
|
142
|
-
config_key: str, envsync: dict[str, list[str]], envsync_key: str, default: str, cwd: Path
|
|
143
|
-
) -> str:
|
|
144
|
-
value = _git_config_get(config_key, cwd)
|
|
145
|
-
if value is not None:
|
|
146
|
-
return value
|
|
147
|
-
if envsync_key in envsync:
|
|
148
|
-
return envsync[envsync_key][-1]
|
|
132
|
+
raw = envsync[envsync_key][-1]
|
|
133
|
+
return parse(raw, f".envsync:{envsync_key}") if parse else raw
|
|
149
134
|
return default
|
|
150
135
|
|
|
151
136
|
|
|
@@ -164,17 +149,13 @@ def load_config(primary_root: Path) -> SyncConfig:
|
|
|
164
149
|
exclude = _resolve_multi(
|
|
165
150
|
"env.sync.exclude", envsync, "exclude", DEFAULT_EXCLUDE, primary_root
|
|
166
151
|
)
|
|
167
|
-
follow_symlinks =
|
|
168
|
-
"env.sync.followSymlinks",
|
|
169
|
-
envsync,
|
|
170
|
-
"followSymlinks",
|
|
171
|
-
DEFAULT_FOLLOW_SYMLINKS,
|
|
172
|
-
primary_root,
|
|
152
|
+
follow_symlinks = _resolve(
|
|
153
|
+
"env.sync.followSymlinks", envsync, "followSymlinks", DEFAULT_FOLLOW_SYMLINKS, primary_root, _parse_bool
|
|
173
154
|
)
|
|
174
|
-
max_file_size =
|
|
175
|
-
"env.sync.maxFileSize", envsync, "maxFileSize", DEFAULT_MAX_FILE_SIZE, primary_root
|
|
155
|
+
max_file_size = _resolve(
|
|
156
|
+
"env.sync.maxFileSize", envsync, "maxFileSize", DEFAULT_MAX_FILE_SIZE, primary_root, _parse_int
|
|
176
157
|
)
|
|
177
|
-
on_conflict =
|
|
158
|
+
on_conflict = _resolve(
|
|
178
159
|
"env.sync.onConflict", envsync, "onConflict", DEFAULT_ON_CONFLICT, primary_root
|
|
179
160
|
)
|
|
180
161
|
if on_conflict not in VALID_ON_CONFLICT:
|
|
@@ -182,15 +163,15 @@ def load_config(primary_root: Path) -> SyncConfig:
|
|
|
182
163
|
f"invalid env.sync.onConflict value: {on_conflict!r}"
|
|
183
164
|
f" (expected one of {sorted(VALID_ON_CONFLICT)})"
|
|
184
165
|
)
|
|
185
|
-
backup =
|
|
186
|
-
"env.sync.backup", envsync, "backup", DEFAULT_BACKUP, primary_root
|
|
166
|
+
backup = _resolve(
|
|
167
|
+
"env.sync.backup", envsync, "backup", DEFAULT_BACKUP, primary_root, _parse_bool
|
|
187
168
|
)
|
|
188
169
|
|
|
189
170
|
return SyncConfig(
|
|
190
171
|
patterns=tuple(patterns),
|
|
191
172
|
exclude=tuple(exclude),
|
|
192
|
-
follow_symlinks=follow_symlinks,
|
|
193
|
-
max_file_size=max_file_size,
|
|
194
|
-
on_conflict=on_conflict,
|
|
195
|
-
backup=backup,
|
|
173
|
+
follow_symlinks=bool(follow_symlinks),
|
|
174
|
+
max_file_size=int(max_file_size), # type: ignore[arg-type]
|
|
175
|
+
on_conflict=str(on_conflict),
|
|
176
|
+
backup=bool(backup),
|
|
196
177
|
)
|
|
@@ -32,10 +32,6 @@ class DiscoveredFile:
|
|
|
32
32
|
absolute_path: Path
|
|
33
33
|
"""Absolute path to the file (symlink target if followed)."""
|
|
34
34
|
|
|
35
|
-
size: int
|
|
36
|
-
|
|
37
|
-
is_symlink: bool
|
|
38
|
-
|
|
39
35
|
|
|
40
36
|
@dataclass(frozen=True)
|
|
41
37
|
class DiscoveryWarning:
|
|
@@ -195,8 +191,6 @@ def discover_env_files(
|
|
|
195
191
|
DiscoveredFile(
|
|
196
192
|
relative_path=rel_path,
|
|
197
193
|
absolute_path=abs_path,
|
|
198
|
-
size=stat_result.st_size,
|
|
199
|
-
is_symlink=is_symlink,
|
|
200
194
|
)
|
|
201
195
|
)
|
|
202
196
|
|
|
@@ -9,6 +9,12 @@ from dataclasses import dataclass
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ShellResult:
|
|
14
|
+
ok: bool
|
|
15
|
+
out: str
|
|
16
|
+
|
|
17
|
+
|
|
12
18
|
class RepoError(Exception):
|
|
13
19
|
"""Raised when the current location is not a usable linked worktree.
|
|
14
20
|
|
|
@@ -33,20 +39,7 @@ class Repository:
|
|
|
33
39
|
"""Absolute path to the current (linked) worktree's root directory."""
|
|
34
40
|
|
|
35
41
|
|
|
36
|
-
def _git(*args: str, cwd: Path | None = None) ->
|
|
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]:
|
|
42
|
+
def _git(*args: str, cwd: Path | None = None) -> ShellResult:
|
|
50
43
|
try:
|
|
51
44
|
result = subprocess.run(
|
|
52
45
|
["git", *args],
|
|
@@ -56,7 +49,7 @@ def _git_ok(*args: str, cwd: Path | None = None) -> tuple[bool, str]:
|
|
|
56
49
|
)
|
|
57
50
|
except FileNotFoundError as exc:
|
|
58
51
|
raise RepoError("git executable not found on PATH") from exc
|
|
59
|
-
return result.returncode == 0, result.stdout.strip()
|
|
52
|
+
return ShellResult(ok=result.returncode == 0, out=result.stdout.strip())
|
|
60
53
|
|
|
61
54
|
|
|
62
55
|
def detect_repository(cwd: Path | None = None) -> Repository:
|
|
@@ -69,21 +62,21 @@ def detect_repository(cwd: Path | None = None) -> Repository:
|
|
|
69
62
|
"""
|
|
70
63
|
cwd = (cwd or Path.cwd()).resolve()
|
|
71
64
|
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
inside = _git("rev-parse", "--is-inside-work-tree", cwd=cwd)
|
|
66
|
+
bare = _git("rev-parse", "--is-bare-repository", cwd=cwd)
|
|
74
67
|
|
|
75
68
|
# A bare repo has no work tree, so --is-inside-work-tree reports "false" (not
|
|
76
69
|
# an error) even when we *are* inside a git dir. Check bare-ness first so that
|
|
77
70
|
# case gets its own message instead of the generic "not inside a worktree" one.
|
|
78
|
-
if
|
|
71
|
+
if bare.ok and bare.out == "true":
|
|
79
72
|
raise RepoError("bare repositories are not supported")
|
|
80
73
|
|
|
81
|
-
if not
|
|
74
|
+
if not inside.ok or inside.out != "true":
|
|
82
75
|
raise RepoError("not inside a git worktree")
|
|
83
76
|
|
|
84
|
-
git_dir = Path(_git("rev-parse", "--path-format=absolute", "--git-dir", cwd=cwd))
|
|
77
|
+
git_dir = Path(_git("rev-parse", "--path-format=absolute", "--git-dir", cwd=cwd).out)
|
|
85
78
|
git_common_dir = Path(
|
|
86
|
-
_git("rev-parse", "--path-format=absolute", "--git-common-dir", cwd=cwd)
|
|
79
|
+
_git("rev-parse", "--path-format=absolute", "--git-common-dir", cwd=cwd).out
|
|
87
80
|
)
|
|
88
81
|
|
|
89
82
|
if git_dir.resolve() == git_common_dir.resolve():
|
|
@@ -95,7 +88,7 @@ def detect_repository(cwd: Path | None = None) -> Repository:
|
|
|
95
88
|
|
|
96
89
|
primary_root = git_common_dir.resolve().parent
|
|
97
90
|
worktree_root = Path(
|
|
98
|
-
_git("rev-parse", "--path-format=absolute", "--show-toplevel", cwd=cwd)
|
|
91
|
+
_git("rev-parse", "--path-format=absolute", "--show-toplevel", cwd=cwd).out
|
|
99
92
|
).resolve()
|
|
100
93
|
|
|
101
94
|
_check_pwd_within_worktree(worktree_root)
|
|
@@ -30,15 +30,11 @@ class CompletionTarget:
|
|
|
30
30
|
enable_snippet: str
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
def _xdg_data_home() -> Path:
|
|
34
|
-
return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
|
|
35
|
-
|
|
36
|
-
|
|
37
33
|
def completion_target(shell: str) -> CompletionTarget:
|
|
38
34
|
"""Resolve the standard user-level install path and rc snippet for `shell`."""
|
|
39
35
|
home = Path.home()
|
|
40
36
|
if shell == "bash":
|
|
41
|
-
path =
|
|
37
|
+
path = Path(os.environ.get("XDG_DATA_HOME") or home / ".local" / "share") / "bash-completion" / "completions" / "git-env"
|
|
42
38
|
snippet = f'source "{path}"'
|
|
43
39
|
elif shell == "zsh":
|
|
44
40
|
path = home / ".zsh" / "completions" / "_git-env"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|