git-env 0.3.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.
@@ -1,28 +1,25 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-env
3
- Version: 0.3.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
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
- Environment file syncronisation to your linked git worktrees
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/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/alxwrd/git-env/test.yml?branch=main&label=main"></a>
21
- <a href="https://pypi.python.org/pypi/git-env"><img src="https://img.shields.io/pypi/v/git-env.svg"></a>
22
- <a href="https://github.com/alxwrd/git-env/blob/main/LICENCE"><img src="https://img.shields.io/pypi/l/git-env.svg?"></a>
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
- copied .env
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
- Environment file syncronisation to your linked git worktrees
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/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/alxwrd/git-env/test.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.svg"></a>
10
- <a href="https://github.com/alxwrd/git-env/blob/main/LICENCE"><img src="https://img.shields.io/pypi/l/git-env.svg?"></a>
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
- copied .env
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.0"
3
+ version = "0.4.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 not ignore.empty and ignore.matches(rel_child_posix, is_dir=True):
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 not ignore.empty and ignore.matches(rel_posix, is_dir=False):
161
+ if ignore.matches(rel_posix, is_dir=False):
166
162
  continue
167
163
 
168
164
  is_symlink = abs_path.is_symlink()
@@ -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
- files = _filter_path(files, path)
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}")
@@ -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