git-env 0.2.0__tar.gz → 0.4.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.4.0/LICENCE +7 -0
- {git_env-0.2.0 → git_env-0.4.0}/PKG-INFO +10 -13
- {git_env-0.2.0 → git_env-0.4.0}/README.md +6 -10
- {git_env-0.2.0 → git_env-0.4.0}/pyproject.toml +4 -4
- git_env-0.4.0/src/git_env/_argparser.py +84 -0
- git_env-0.4.0/src/git_env/cli.py +120 -0
- {git_env-0.2.0 → git_env-0.4.0}/src/git_env/config.py +25 -44
- {git_env-0.2.0 → git_env-0.4.0}/src/git_env/discovery.py +2 -12
- {git_env-0.2.0 → git_env-0.4.0}/src/git_env/repo.py +15 -22
- {git_env-0.2.0 → git_env-0.4.0}/src/git_env/shell_completions.py +1 -5
- {git_env-0.2.0 → git_env-0.4.0}/src/git_env/sync.py +3 -7
- git_env-0.2.0/src/git_env/cli.py +0 -183
- {git_env-0.2.0 → git_env-0.4.0}/src/git_env/__init__.py +0 -0
- {git_env-0.2.0 → git_env-0.4.0}/src/git_env/completions/_git-env +0 -0
- {git_env-0.2.0 → git_env-0.4.0}/src/git_env/completions/git-env.bash +0 -0
- {git_env-0.2.0 → git_env-0.4.0}/src/git_env/completions/git-env.fish +0 -0
- {git_env-0.2.0 → git_env-0.4.0}/src/git_env/output.py +0 -0
git_env-0.4.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,26 +1,25 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: git-env
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
-
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENCE
|
|
8
9
|
Requires-Python: >=3.10
|
|
9
10
|
Description-Content-Type: text/markdown
|
|
10
11
|
|
|
11
12
|
<div align="center">
|
|
12
13
|
<h1><code>git-env</code></h1>
|
|
13
14
|
<p align="center"><i>
|
|
14
|
-
|
|
15
|
+
Copies untracked env files to your worktrees
|
|
15
16
|
</i></p>
|
|
16
17
|
<img width="256px" src="https://github.com/alxwrd/git-env/raw/main/.github/assets/man-reading-the-mail-768.png">
|
|
17
18
|
<div align="center">
|
|
18
|
-
<a href="https://github.com/alxwrd/git-env/actions/workflows/
|
|
19
|
-
<a href="https://pypi.python.org/pypi/git-env"><img src="https://img.shields.io/pypi/v/git-env
|
|
20
|
-
<a href="https://github.com/alxwrd/git-env/blob/main/LICENCE"><img src="https://img.shields.io/pypi/l/git-env
|
|
19
|
+
<a href="https://github.com/alxwrd/git-env/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/alxwrd/git-env/ci.yml?branch=main&label=main"></a>
|
|
20
|
+
<a href="https://pypi.python.org/pypi/git-env"><img src="https://img.shields.io/pypi/v/git-env"></a>
|
|
21
|
+
<a href="https://github.com/alxwrd/git-env/blob/main/LICENCE"><img src="https://img.shields.io/pypi/l/git-env"></a>
|
|
21
22
|
</div>
|
|
22
|
-
|
|
23
|
-
Copies untracked env files to your worktrees.
|
|
24
23
|
</div>
|
|
25
24
|
|
|
26
25
|
|
|
@@ -35,10 +34,8 @@ git env sync # copies .env from the primary
|
|
|
35
34
|
|
|
36
35
|
```plain
|
|
37
36
|
$ git env sync
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
1 file synced.
|
|
37
|
+
synced .env
|
|
38
|
+
2 files synced, 0 unchanged, 0 conflicts skipped
|
|
42
39
|
```
|
|
43
40
|
|
|
44
41
|
Run it again later to pick up any changes made in the primary. By default,
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
<h1><code>git-env</code></h1>
|
|
3
3
|
<p align="center"><i>
|
|
4
|
-
|
|
4
|
+
Copies untracked env files to your worktrees
|
|
5
5
|
</i></p>
|
|
6
6
|
<img width="256px" src="https://github.com/alxwrd/git-env/raw/main/.github/assets/man-reading-the-mail-768.png">
|
|
7
7
|
<div align="center">
|
|
8
|
-
<a href="https://github.com/alxwrd/git-env/actions/workflows/
|
|
9
|
-
<a href="https://pypi.python.org/pypi/git-env"><img src="https://img.shields.io/pypi/v/git-env
|
|
10
|
-
<a href="https://github.com/alxwrd/git-env/blob/main/LICENCE"><img src="https://img.shields.io/pypi/l/git-env
|
|
8
|
+
<a href="https://github.com/alxwrd/git-env/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/alxwrd/git-env/ci.yml?branch=main&label=main"></a>
|
|
9
|
+
<a href="https://pypi.python.org/pypi/git-env"><img src="https://img.shields.io/pypi/v/git-env"></a>
|
|
10
|
+
<a href="https://github.com/alxwrd/git-env/blob/main/LICENCE"><img src="https://img.shields.io/pypi/l/git-env"></a>
|
|
11
11
|
</div>
|
|
12
|
-
|
|
13
|
-
Copies untracked env files to your worktrees.
|
|
14
12
|
</div>
|
|
15
13
|
|
|
16
14
|
|
|
@@ -25,10 +23,8 @@ git env sync # copies .env from the primary
|
|
|
25
23
|
|
|
26
24
|
```plain
|
|
27
25
|
$ git env sync
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
1 file synced.
|
|
26
|
+
synced .env
|
|
27
|
+
2 files synced, 0 unchanged, 0 conflicts skipped
|
|
32
28
|
```
|
|
33
29
|
|
|
34
30
|
Run it again later to pick up any changes made in the primary. By default,
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "git-env"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.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
|
-
dependencies = [
|
|
11
|
-
"arguably>=1.2.2",
|
|
12
|
-
]
|
|
12
|
+
dependencies = []
|
|
13
13
|
|
|
14
14
|
[project.scripts]
|
|
15
15
|
git-env = "git_env:main"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
|
|
6
|
+
from .shell_completions import SUPPORTED_SHELLS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitEnvExit(Exception):
|
|
10
|
+
"""Raised by command bodies to request a specific process exit code."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, code: int) -> None:
|
|
13
|
+
super().__init__(code)
|
|
14
|
+
self.code = code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def rewrite_help_subcommand(argv: list[str]) -> list[str]:
|
|
18
|
+
"""Translate `git env help [subcommand]` into `git env [subcommand] --help`."""
|
|
19
|
+
if argv and argv[0] == "help":
|
|
20
|
+
return [*argv[1:], "--help"]
|
|
21
|
+
return argv
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="git env",
|
|
27
|
+
description="sync environment files across linked git worktrees",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--version", action="version", version=f"%(prog)s {version('git-env')}"
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--porcelain",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="reserved for a future machine-readable output mode (not yet supported)",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--install-completions",
|
|
40
|
+
choices=SUPPORTED_SHELLS,
|
|
41
|
+
metavar="{" + "|".join(SUPPORTED_SHELLS) + "}",
|
|
42
|
+
help="print a completion snippet for the given shell; combine with --write to install",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--write",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="used with --install-completions: write the file instead of printing",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"-v",
|
|
51
|
+
"--verbose",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="print every file considered, including skips",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"-q", "--quiet", action="store_true", help="suppress non-error output"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
sub = parser.add_subparsers(dest="command")
|
|
60
|
+
|
|
61
|
+
p_sync = sub.add_parser(
|
|
62
|
+
"sync",
|
|
63
|
+
help="copy env files from primary worktree into current linked worktree",
|
|
64
|
+
)
|
|
65
|
+
p_sync.add_argument(
|
|
66
|
+
"-n", "--dry-run", action="store_true", help="print actions, change nothing"
|
|
67
|
+
)
|
|
68
|
+
p_sync.add_argument(
|
|
69
|
+
"-f",
|
|
70
|
+
"--force",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="overwrite local files even when they differ from primary",
|
|
73
|
+
)
|
|
74
|
+
p_sync.add_argument(
|
|
75
|
+
"--pattern",
|
|
76
|
+
action="append",
|
|
77
|
+
metavar="GLOB",
|
|
78
|
+
help="glob to sync, repeatable; overrides configured patterns",
|
|
79
|
+
)
|
|
80
|
+
p_sync.add_argument(
|
|
81
|
+
"--path", metavar="PATH", help="restrict to a subdirectory of the worktree"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return parser
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Top-level CLI: dispatches `git env <subcommand>` via argparse.
|
|
2
|
+
|
|
3
|
+
Exit codes are part of the spec's contract (see spec.md):
|
|
4
|
+
0 success, 1 sync conflicts skipped, 2 refused to run, 3 usage error, 4 I/O error.
|
|
5
|
+
argparse itself always raises `SystemExit(2)` for usage errors (bad flag, unknown
|
|
6
|
+
subcommand), which collides with our "refused to run" code. `GitEnvExit` lets
|
|
7
|
+
command bodies signal an intentional exit code; any other `SystemExit(2)` reaching
|
|
8
|
+
`main()` therefore came from argparse and is remapped to 3.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from ._argparser import GitEnvExit, build_parser, rewrite_help_subcommand
|
|
16
|
+
from .config import ConfigError, load_config
|
|
17
|
+
from .output import Reporter
|
|
18
|
+
from .repo import RepoError, check_primary_clean, detect_repository
|
|
19
|
+
from .shell_completions import install_completions as render_completions_install
|
|
20
|
+
from .sync import SyncIOError, run_sync
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sync(
|
|
24
|
+
*,
|
|
25
|
+
pattern: list[str] | None = None,
|
|
26
|
+
path: str | None = None,
|
|
27
|
+
dry_run: bool = False,
|
|
28
|
+
force: bool = False,
|
|
29
|
+
reporter: Reporter,
|
|
30
|
+
) -> None:
|
|
31
|
+
try:
|
|
32
|
+
repo = detect_repository()
|
|
33
|
+
except RepoError as exc:
|
|
34
|
+
reporter.error(str(exc))
|
|
35
|
+
raise GitEnvExit(2) from None
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
config = load_config(repo.primary_root)
|
|
39
|
+
except ConfigError as exc:
|
|
40
|
+
reporter.error(str(exc))
|
|
41
|
+
raise GitEnvExit(3) from None
|
|
42
|
+
|
|
43
|
+
if pattern:
|
|
44
|
+
config = type(config)(**{**config.__dict__, "patterns": tuple(pattern)})
|
|
45
|
+
|
|
46
|
+
if not force:
|
|
47
|
+
dirty = check_primary_clean(repo.primary_root, config.patterns, config.exclude)
|
|
48
|
+
if dirty:
|
|
49
|
+
reporter.error(
|
|
50
|
+
"primary worktree has uncommitted changes to tracked env files: "
|
|
51
|
+
f"{', '.join(dirty)} (use --force to override)"
|
|
52
|
+
)
|
|
53
|
+
raise GitEnvExit(2)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
result = run_sync(
|
|
57
|
+
repo,
|
|
58
|
+
config,
|
|
59
|
+
dry_run=dry_run,
|
|
60
|
+
force=force,
|
|
61
|
+
path=path,
|
|
62
|
+
reporter=reporter,
|
|
63
|
+
)
|
|
64
|
+
except SyncIOError as exc:
|
|
65
|
+
reporter.error(str(exc))
|
|
66
|
+
raise GitEnvExit(4) from None
|
|
67
|
+
|
|
68
|
+
if dry_run:
|
|
69
|
+
raise GitEnvExit(1 if result.would_change or result.conflicts else 0)
|
|
70
|
+
raise GitEnvExit(result.exit_code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main() -> None:
|
|
74
|
+
argv = rewrite_help_subcommand(sys.argv[1:])
|
|
75
|
+
try:
|
|
76
|
+
parser = build_parser()
|
|
77
|
+
args = parser.parse_args(argv)
|
|
78
|
+
|
|
79
|
+
if args.porcelain:
|
|
80
|
+
print(
|
|
81
|
+
"git env: --porcelain is reserved for a future version and is not yet"
|
|
82
|
+
" supported",
|
|
83
|
+
file=sys.stderr,
|
|
84
|
+
)
|
|
85
|
+
raise GitEnvExit(3)
|
|
86
|
+
if args.install_completions is not None:
|
|
87
|
+
print(
|
|
88
|
+
render_completions_install(args.install_completions, write=args.write)
|
|
89
|
+
)
|
|
90
|
+
raise GitEnvExit(0)
|
|
91
|
+
if args.write:
|
|
92
|
+
print("git env: --write requires --install-completions", file=sys.stderr)
|
|
93
|
+
raise GitEnvExit(3)
|
|
94
|
+
if args.verbose and args.quiet:
|
|
95
|
+
print(
|
|
96
|
+
"git env: --verbose and --quiet are mutually exclusive", file=sys.stderr
|
|
97
|
+
)
|
|
98
|
+
raise GitEnvExit(3)
|
|
99
|
+
|
|
100
|
+
reporter = Reporter(verbose=args.verbose, quiet=args.quiet)
|
|
101
|
+
|
|
102
|
+
match args.command:
|
|
103
|
+
case None:
|
|
104
|
+
parser.error("a subcommand is required, try 'git env --help'")
|
|
105
|
+
case "sync":
|
|
106
|
+
sync(
|
|
107
|
+
pattern=args.pattern,
|
|
108
|
+
path=args.path,
|
|
109
|
+
dry_run=args.dry_run,
|
|
110
|
+
force=args.force,
|
|
111
|
+
reporter=reporter,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
except GitEnvExit as exc:
|
|
115
|
+
sys.exit(exc.code)
|
|
116
|
+
except SystemExit as exc:
|
|
117
|
+
code = exc.code if isinstance(exc.code, int) else 1
|
|
118
|
+
if code == 2:
|
|
119
|
+
sys.exit(3)
|
|
120
|
+
raise
|
|
@@ -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:
|
|
@@ -106,10 +102,6 @@ class _IgnoreMatcher:
|
|
|
106
102
|
ignored = not negate
|
|
107
103
|
return ignored
|
|
108
104
|
|
|
109
|
-
@property
|
|
110
|
-
def empty(self) -> bool:
|
|
111
|
-
return not self._rules
|
|
112
|
-
|
|
113
105
|
|
|
114
106
|
def _load_ignore_matcher(primary_root: Path) -> _IgnoreMatcher:
|
|
115
107
|
ignore_file = primary_root / ".envsyncignore"
|
|
@@ -152,7 +144,7 @@ def discover_env_files(
|
|
|
152
144
|
continue
|
|
153
145
|
if child.is_symlink() and not config.follow_symlinks:
|
|
154
146
|
continue
|
|
155
|
-
if
|
|
147
|
+
if ignore.matches(rel_child_posix, is_dir=True):
|
|
156
148
|
continue
|
|
157
149
|
kept_dirnames.append(dirname)
|
|
158
150
|
dirnames[:] = kept_dirnames
|
|
@@ -166,7 +158,7 @@ def discover_env_files(
|
|
|
166
158
|
continue
|
|
167
159
|
if _matches_any(filename, config.exclude):
|
|
168
160
|
continue
|
|
169
|
-
if
|
|
161
|
+
if ignore.matches(rel_posix, is_dir=False):
|
|
170
162
|
continue
|
|
171
163
|
|
|
172
164
|
is_symlink = abs_path.is_symlink()
|
|
@@ -195,8 +187,6 @@ def discover_env_files(
|
|
|
195
187
|
DiscoveredFile(
|
|
196
188
|
relative_path=rel_path,
|
|
197
189
|
absolute_path=abs_path,
|
|
198
|
-
size=stat_result.st_size,
|
|
199
|
-
is_symlink=is_symlink,
|
|
200
190
|
)
|
|
201
191
|
)
|
|
202
192
|
|
|
@@ -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"
|
|
@@ -48,12 +48,6 @@ class SyncResult:
|
|
|
48
48
|
return 0
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
def _filter_path(files: list[DiscoveredFile], path: str | None) -> list[DiscoveredFile]:
|
|
52
|
-
if path is None:
|
|
53
|
-
return files
|
|
54
|
-
prefix = Path(path)
|
|
55
|
-
return [f for f in files if prefix in (f.relative_path, *f.relative_path.parents)]
|
|
56
|
-
|
|
57
51
|
|
|
58
52
|
def _resolve_dest(worktree_root: Path, relative_path: Path) -> Path:
|
|
59
53
|
"""Resolve `relative_path` against `worktree_root`, refusing escapes.
|
|
@@ -173,7 +167,9 @@ def run_sync(
|
|
|
173
167
|
) -> SyncResult:
|
|
174
168
|
"""Sync env files from `repo.primary_root` into `repo.worktree_root`."""
|
|
175
169
|
files, warnings = discover_env_files(repo.primary_root, config)
|
|
176
|
-
|
|
170
|
+
if path is not None:
|
|
171
|
+
prefix = Path(path)
|
|
172
|
+
files = [f for f in files if prefix in (f.relative_path, *f.relative_path.parents)]
|
|
177
173
|
|
|
178
174
|
for warning in warnings:
|
|
179
175
|
reporter.warn(f"{warning.relative_path}: {warning.message}")
|
git_env-0.2.0/src/git_env/cli.py
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
"""Top-level CLI: dispatches `git env <subcommand>` via arguably.
|
|
2
|
-
|
|
3
|
-
Exit codes are part of the spec's contract (see spec.md):
|
|
4
|
-
0 success, 1 sync conflicts skipped, 2 refused to run, 3 usage error, 4 I/O error.
|
|
5
|
-
argparse itself always raises `SystemExit(2)` for usage errors (bad flag, unknown
|
|
6
|
-
subcommand), which collides with our "refused to run" code. `GitEnvExit` lets
|
|
7
|
-
command bodies signal an intentional exit code; any other `SystemExit(2)` reaching
|
|
8
|
-
`main()` therefore came from argparse and is remapped to 3.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
import sys
|
|
14
|
-
|
|
15
|
-
import arguably
|
|
16
|
-
|
|
17
|
-
from .config import ConfigError, load_config
|
|
18
|
-
from .output import Reporter
|
|
19
|
-
from .repo import RepoError, check_primary_clean, detect_repository
|
|
20
|
-
from .shell_completions import SUPPORTED_SHELLS
|
|
21
|
-
from .shell_completions import install_completions as render_completions_install
|
|
22
|
-
from .sync import SyncIOError, run_sync
|
|
23
|
-
|
|
24
|
-
#: Names set aside for future official subcommands (see spec.md Extensibility).
|
|
25
|
-
RESERVED_SUBCOMMANDS = frozenset({"push", "diff", "status", "list", "edit", "check"})
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class GitEnvExit(Exception):
|
|
29
|
-
"""Raised by command bodies to request a specific process exit code."""
|
|
30
|
-
|
|
31
|
-
def __init__(self, code: int) -> None:
|
|
32
|
-
super().__init__(code)
|
|
33
|
-
self.code = code
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@arguably.command
|
|
37
|
-
def __root__(
|
|
38
|
-
*, porcelain: bool = False, install_completions: str | None = None, write: bool = False
|
|
39
|
-
) -> None:
|
|
40
|
-
"""
|
|
41
|
-
git env: sync environment files across linked git worktrees.
|
|
42
|
-
|
|
43
|
-
Args:
|
|
44
|
-
porcelain: reserved for a future machine-readable output mode (not yet supported)
|
|
45
|
-
install_completions: print a completion snippet for [bash|zsh|fish]; combine with
|
|
46
|
-
--write to install the completion file instead of printing it
|
|
47
|
-
write: used with --install-completions, write the completion file to its standard
|
|
48
|
-
location instead of printing a snippet
|
|
49
|
-
"""
|
|
50
|
-
if porcelain:
|
|
51
|
-
print(
|
|
52
|
-
"git env: --porcelain is reserved for a future version and is not yet"
|
|
53
|
-
" supported",
|
|
54
|
-
file=sys.stderr,
|
|
55
|
-
)
|
|
56
|
-
raise GitEnvExit(3)
|
|
57
|
-
if install_completions is not None:
|
|
58
|
-
if install_completions not in SUPPORTED_SHELLS:
|
|
59
|
-
print(
|
|
60
|
-
"git env: --install-completions expects one of "
|
|
61
|
-
f"{', '.join(SUPPORTED_SHELLS)}, got {install_completions!r}",
|
|
62
|
-
file=sys.stderr,
|
|
63
|
-
)
|
|
64
|
-
raise GitEnvExit(3)
|
|
65
|
-
print(render_completions_install(install_completions, write=write))
|
|
66
|
-
raise GitEnvExit(0)
|
|
67
|
-
if write:
|
|
68
|
-
print("git env: --write requires --install-completions", file=sys.stderr)
|
|
69
|
-
raise GitEnvExit(3)
|
|
70
|
-
if arguably.is_target():
|
|
71
|
-
arguably.error("a subcommand is required, try 'git env --help'")
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
@arguably.command
|
|
75
|
-
def sync(
|
|
76
|
-
*,
|
|
77
|
-
dry_run: bool = False,
|
|
78
|
-
force: bool = False,
|
|
79
|
-
verbose: bool = False,
|
|
80
|
-
quiet: bool = False,
|
|
81
|
-
pattern: list[str] | None = None,
|
|
82
|
-
path: str | None = None,
|
|
83
|
-
) -> None:
|
|
84
|
-
"""
|
|
85
|
-
Copy env files from the primary worktree into the current linked worktree.
|
|
86
|
-
|
|
87
|
-
Args:
|
|
88
|
-
dry_run: [-n] print actions, change nothing
|
|
89
|
-
force: [-f] overwrite local files even when they differ from the primary
|
|
90
|
-
verbose: [-v] print every file considered, including skips
|
|
91
|
-
quiet: [-q] suppress non-error output
|
|
92
|
-
pattern: glob to sync, repeatable; overrides configured patterns for this run
|
|
93
|
-
path: restrict to a subdirectory of the worktree
|
|
94
|
-
"""
|
|
95
|
-
if verbose and quiet:
|
|
96
|
-
print("git env sync: --verbose and --quiet are mutually exclusive", file=sys.stderr)
|
|
97
|
-
raise GitEnvExit(3)
|
|
98
|
-
|
|
99
|
-
reporter = Reporter(verbose=verbose, quiet=quiet)
|
|
100
|
-
|
|
101
|
-
try:
|
|
102
|
-
repo = detect_repository()
|
|
103
|
-
except RepoError as exc:
|
|
104
|
-
reporter.error(str(exc))
|
|
105
|
-
raise GitEnvExit(2) from None
|
|
106
|
-
|
|
107
|
-
try:
|
|
108
|
-
config = load_config(repo.primary_root)
|
|
109
|
-
except ConfigError as exc:
|
|
110
|
-
reporter.error(str(exc))
|
|
111
|
-
raise GitEnvExit(3) from None
|
|
112
|
-
|
|
113
|
-
if pattern:
|
|
114
|
-
config = type(config)(**{**config.__dict__, "patterns": tuple(pattern)})
|
|
115
|
-
|
|
116
|
-
if not force:
|
|
117
|
-
dirty = check_primary_clean(repo.primary_root, config.patterns, config.exclude)
|
|
118
|
-
if dirty:
|
|
119
|
-
reporter.error(
|
|
120
|
-
"primary worktree has uncommitted changes to tracked env files: "
|
|
121
|
-
f"{', '.join(dirty)} (use --force to override)"
|
|
122
|
-
)
|
|
123
|
-
raise GitEnvExit(2)
|
|
124
|
-
|
|
125
|
-
try:
|
|
126
|
-
result = run_sync(
|
|
127
|
-
repo,
|
|
128
|
-
config,
|
|
129
|
-
dry_run=dry_run,
|
|
130
|
-
force=force,
|
|
131
|
-
path=path,
|
|
132
|
-
reporter=reporter,
|
|
133
|
-
)
|
|
134
|
-
except SyncIOError as exc:
|
|
135
|
-
reporter.error(str(exc))
|
|
136
|
-
raise GitEnvExit(4) from None
|
|
137
|
-
|
|
138
|
-
if dry_run:
|
|
139
|
-
raise GitEnvExit(1 if result.would_change or result.conflicts else 0)
|
|
140
|
-
raise GitEnvExit(result.exit_code)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _rewrite_help_subcommand(argv: list[str]) -> list[str]:
|
|
144
|
-
"""Translate `git env help [subcommand]` into `git env [subcommand] --help`."""
|
|
145
|
-
if argv and argv[0] == "help":
|
|
146
|
-
rest = argv[1:]
|
|
147
|
-
return [*rest, "--help"]
|
|
148
|
-
return argv
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def _reject_reserved_subcommand(argv: list[str]) -> None:
|
|
152
|
-
"""Give a clearer error than argparse's "invalid choice" for reserved names."""
|
|
153
|
-
for token in argv:
|
|
154
|
-
if token == "--":
|
|
155
|
-
return
|
|
156
|
-
if token.startswith("-"):
|
|
157
|
-
continue
|
|
158
|
-
if token in RESERVED_SUBCOMMANDS:
|
|
159
|
-
print(
|
|
160
|
-
f"git env: '{token}' is reserved for future use and is not yet"
|
|
161
|
-
" implemented",
|
|
162
|
-
file=sys.stderr,
|
|
163
|
-
)
|
|
164
|
-
raise GitEnvExit(3)
|
|
165
|
-
return
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def main() -> None:
|
|
169
|
-
argv = _rewrite_help_subcommand(sys.argv[1:])
|
|
170
|
-
sys.argv = [sys.argv[0], *argv]
|
|
171
|
-
try:
|
|
172
|
-
_reject_reserved_subcommand(argv)
|
|
173
|
-
arguably.run(
|
|
174
|
-
name="git env",
|
|
175
|
-
version_flag=True,
|
|
176
|
-
)
|
|
177
|
-
except GitEnvExit as exc:
|
|
178
|
-
sys.exit(exc.code)
|
|
179
|
-
except SystemExit as exc:
|
|
180
|
-
code = exc.code if isinstance(exc.code, int) else 1
|
|
181
|
-
if code == 2:
|
|
182
|
-
sys.exit(3)
|
|
183
|
-
raise
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|