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 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.3
1
+ Metadata-Version: 2.4
2
2
  Name: git-env
3
- Version: 0.2.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
- Requires-Dist: arguably>=1.2.2
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
- Environment file syncronisation to your linked git worktrees
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/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/alxwrd/git-env/test.yml?branch=main&label=main"></a>
19
- <a href="https://pypi.python.org/pypi/git-env"><img src="https://img.shields.io/pypi/v/git-env.svg"></a>
20
- <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>
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
- copied .env
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
- 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,15 +1,15 @@
1
1
  [project]
2
2
  name = "git-env"
3
- version = "0.2.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, *, source: str) -> bool:
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, *, source: str) -> int:
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 _resolve_bool(
120
- config_key: str, envsync: dict[str, list[str]], envsync_key: str, default: bool, cwd: Path
121
- ) -> bool:
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 _parse_bool(value, source=config_key)
130
+ return parse(value, config_key) if parse else value
125
131
  if envsync_key in envsync:
126
- return _parse_bool(envsync[envsync_key][-1], source=f".envsync:{envsync_key}")
127
- return default
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 = _resolve_bool(
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 = _resolve_int(
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 = _resolve_str(
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 = _resolve_bool(
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 not ignore.empty and ignore.matches(rel_child_posix, is_dir=True):
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 not ignore.empty and ignore.matches(rel_posix, is_dir=False):
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) -> 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]:
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
- 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)
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 bare_ok and bare_out == "true":
71
+ if bare.ok and bare.out == "true":
79
72
  raise RepoError("bare repositories are not supported")
80
73
 
81
- if not inside_ok or inside_out != "true":
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 = _xdg_data_home() / "bash-completion" / "completions" / "git-env"
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
- 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