git-env 0.3.0__tar.gz → 0.5.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 → git_env-0.5.0}/PKG-INFO +7 -12
- {git_env-0.3.0 → git_env-0.5.0}/README.md +6 -10
- {git_env-0.3.0 → git_env-0.5.0}/pyproject.toml +2 -4
- git_env-0.5.0/src/git_env/_argparser.py +84 -0
- git_env-0.5.0/src/git_env/cli.py +120 -0
- {git_env-0.3.0 → git_env-0.5.0}/src/git_env/discovery.py +2 -6
- {git_env-0.3.0 → git_env-0.5.0}/src/git_env/repo.py +7 -9
- {git_env-0.3.0 → git_env-0.5.0}/src/git_env/sync.py +22 -7
- git_env-0.3.0/src/git_env/cli.py +0 -183
- {git_env-0.3.0 → git_env-0.5.0}/LICENCE +0 -0
- {git_env-0.3.0 → git_env-0.5.0}/src/git_env/__init__.py +0 -0
- {git_env-0.3.0 → git_env-0.5.0}/src/git_env/completions/_git-env +0 -0
- {git_env-0.3.0 → git_env-0.5.0}/src/git_env/completions/git-env.bash +0 -0
- {git_env-0.3.0 → git_env-0.5.0}/src/git_env/completions/git-env.fish +0 -0
- {git_env-0.3.0 → git_env-0.5.0}/src/git_env/config.py +0 -0
- {git_env-0.3.0 → git_env-0.5.0}/src/git_env/output.py +0 -0
- {git_env-0.3.0 → git_env-0.5.0}/src/git_env/shell_completions.py +0 -0
|
@@ -1,28 +1,25 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-env
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
8
|
License-File: LICENCE
|
|
9
|
-
Requires-Dist: arguably>=1.2.2
|
|
10
9
|
Requires-Python: >=3.10
|
|
11
10
|
Description-Content-Type: text/markdown
|
|
12
11
|
|
|
13
12
|
<div align="center">
|
|
14
13
|
<h1><code>git-env</code></h1>
|
|
15
14
|
<p align="center"><i>
|
|
16
|
-
|
|
15
|
+
Copies untracked env files to your worktrees
|
|
17
16
|
</i></p>
|
|
18
17
|
<img width="256px" src="https://github.com/alxwrd/git-env/raw/main/.github/assets/man-reading-the-mail-768.png">
|
|
19
18
|
<div align="center">
|
|
20
|
-
<a href="https://github.com/alxwrd/git-env/actions/workflows/
|
|
21
|
-
<a href="https://pypi.python.org/pypi/git-env"><img src="https://img.shields.io/pypi/v/git-env
|
|
22
|
-
<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>
|
|
23
22
|
</div>
|
|
24
|
-
|
|
25
|
-
Copies untracked env files to your worktrees.
|
|
26
23
|
</div>
|
|
27
24
|
|
|
28
25
|
|
|
@@ -37,10 +34,8 @@ git env sync # copies .env from the primary
|
|
|
37
34
|
|
|
38
35
|
```plain
|
|
39
36
|
$ git env sync
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
1 file synced.
|
|
37
|
+
synced .env
|
|
38
|
+
2 files synced, 0 unchanged, 0 conflicts skipped
|
|
44
39
|
```
|
|
45
40
|
|
|
46
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,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "git-env"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Sync environment files across linked git worktrees"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -9,9 +9,7 @@ authors = [
|
|
|
9
9
|
license = "MIT"
|
|
10
10
|
license-files = ["LICENCE"]
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
|
-
dependencies = [
|
|
13
|
-
"arguably>=1.2.2",
|
|
14
|
-
]
|
|
12
|
+
dependencies = []
|
|
15
13
|
|
|
16
14
|
[project.scripts]
|
|
17
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
|
|
@@ -102,10 +102,6 @@ class _IgnoreMatcher:
|
|
|
102
102
|
ignored = not negate
|
|
103
103
|
return ignored
|
|
104
104
|
|
|
105
|
-
@property
|
|
106
|
-
def empty(self) -> bool:
|
|
107
|
-
return not self._rules
|
|
108
|
-
|
|
109
105
|
|
|
110
106
|
def _load_ignore_matcher(primary_root: Path) -> _IgnoreMatcher:
|
|
111
107
|
ignore_file = primary_root / ".envsyncignore"
|
|
@@ -148,7 +144,7 @@ def discover_env_files(
|
|
|
148
144
|
continue
|
|
149
145
|
if child.is_symlink() and not config.follow_symlinks:
|
|
150
146
|
continue
|
|
151
|
-
if
|
|
147
|
+
if ignore.matches(rel_child_posix, is_dir=True):
|
|
152
148
|
continue
|
|
153
149
|
kept_dirnames.append(dirname)
|
|
154
150
|
dirnames[:] = kept_dirnames
|
|
@@ -162,7 +158,7 @@ def discover_env_files(
|
|
|
162
158
|
continue
|
|
163
159
|
if _matches_any(filename, config.exclude):
|
|
164
160
|
continue
|
|
165
|
-
if
|
|
161
|
+
if ignore.matches(rel_posix, is_dir=False):
|
|
166
162
|
continue
|
|
167
163
|
|
|
168
164
|
is_symlink = abs_path.is_symlink()
|
|
@@ -91,7 +91,7 @@ def detect_repository(cwd: Path | None = None) -> Repository:
|
|
|
91
91
|
_git("rev-parse", "--path-format=absolute", "--show-toplevel", cwd=cwd).out
|
|
92
92
|
).resolve()
|
|
93
93
|
|
|
94
|
-
_check_pwd_within_worktree(
|
|
94
|
+
_check_pwd_within_worktree(cwd)
|
|
95
95
|
|
|
96
96
|
return Repository(
|
|
97
97
|
git_dir=git_dir.resolve(),
|
|
@@ -101,11 +101,11 @@ def detect_repository(cwd: Path | None = None) -> Repository:
|
|
|
101
101
|
)
|
|
102
102
|
|
|
103
103
|
|
|
104
|
-
def _check_pwd_within_worktree(
|
|
104
|
+
def _check_pwd_within_worktree(cwd: Path) -> None:
|
|
105
105
|
"""Paranoid check (spec: "Safety rails") that the shell-reported `$PWD`
|
|
106
|
-
is actually
|
|
107
|
-
invocations (e.g. a symlinked directory pointing somewhere else)
|
|
108
|
-
the shell's notion of cwd has diverged from the real one.
|
|
106
|
+
is actually the directory we resolved everything else from. Catches
|
|
107
|
+
weird invocations (e.g. a symlinked directory pointing somewhere else)
|
|
108
|
+
where the shell's notion of cwd has diverged from the real one.
|
|
109
109
|
|
|
110
110
|
Skipped if `$PWD` isn't set, since not every caller sets it.
|
|
111
111
|
"""
|
|
@@ -113,10 +113,8 @@ def _check_pwd_within_worktree(worktree_root: Path) -> None:
|
|
|
113
113
|
if not pwd:
|
|
114
114
|
return
|
|
115
115
|
resolved_pwd = Path(pwd).resolve()
|
|
116
|
-
if resolved_pwd !=
|
|
117
|
-
raise RepoError(
|
|
118
|
-
f"$PWD ({pwd}) is not within the current worktree at {worktree_root}"
|
|
119
|
-
)
|
|
116
|
+
if resolved_pwd != cwd:
|
|
117
|
+
raise RepoError(f"$PWD ({pwd}) is not within the current worktree at {cwd}")
|
|
120
118
|
|
|
121
119
|
|
|
122
120
|
def check_primary_clean(
|
|
@@ -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.
|
|
@@ -122,6 +116,22 @@ def _atomic_copy(src: Path, dest: Path) -> None:
|
|
|
122
116
|
raise SyncIOError(f"failed to copy {src} -> {dest}: {exc}") from exc
|
|
123
117
|
|
|
124
118
|
|
|
119
|
+
def _exclude_can_ever_match(config: SyncConfig) -> bool:
|
|
120
|
+
"""Whether any filename could possibly satisfy both `config.patterns` and
|
|
121
|
+
`config.exclude` (both are fnmatch globs).
|
|
122
|
+
|
|
123
|
+
Exclude entries are almost always literal filenames (e.g. ".env.example"),
|
|
124
|
+
so testing each one against every include pattern is a cheap, correct
|
|
125
|
+
way to rule out the common case (e.g. patterns=(".env",) can never match
|
|
126
|
+
".env.example") without walking the filesystem to find out.
|
|
127
|
+
"""
|
|
128
|
+
return any(
|
|
129
|
+
fnmatch.fnmatch(exclude, pattern)
|
|
130
|
+
for pattern in config.patterns
|
|
131
|
+
for exclude in config.exclude
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
125
135
|
def _cleanup_excluded_in_linked(
|
|
126
136
|
worktree_root: Path,
|
|
127
137
|
config: SyncConfig,
|
|
@@ -135,6 +145,9 @@ def _cleanup_excluded_in_linked(
|
|
|
135
145
|
Excluded patterns are for committed templates (e.g. .env.example) that git
|
|
136
146
|
checks out into linked worktrees but that sync should never treat as env files.
|
|
137
147
|
"""
|
|
148
|
+
if not _exclude_can_ever_match(config):
|
|
149
|
+
return 0
|
|
150
|
+
|
|
138
151
|
root = worktree_root
|
|
139
152
|
search_root = (root / path) if path else root
|
|
140
153
|
if not search_root.is_dir():
|
|
@@ -173,7 +186,9 @@ def run_sync(
|
|
|
173
186
|
) -> SyncResult:
|
|
174
187
|
"""Sync env files from `repo.primary_root` into `repo.worktree_root`."""
|
|
175
188
|
files, warnings = discover_env_files(repo.primary_root, config)
|
|
176
|
-
|
|
189
|
+
if path is not None:
|
|
190
|
+
prefix = Path(path)
|
|
191
|
+
files = [f for f in files if prefix in (f.relative_path, *f.relative_path.parents)]
|
|
177
192
|
|
|
178
193
|
for warning in warnings:
|
|
179
194
|
reporter.warn(f"{warning.relative_path}: {warning.message}")
|
git_env-0.3.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|