git-env 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ import importlib.metadata
2
+
3
+ try:
4
+ __version__ = importlib.metadata.version("git-env")
5
+ except importlib.metadata.PackageNotFoundError:
6
+ __version__ = "0.0.0+dev"
7
+
8
+
9
+ def main() -> None:
10
+ import __main__
11
+
12
+ __main__.__version__ = __version__
13
+
14
+ from .cli import main as cli_main
15
+
16
+ cli_main()
git_env/cli.py ADDED
@@ -0,0 +1,183 @@
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
@@ -0,0 +1,43 @@
1
+ #compdef git-env
2
+ # Zsh completion for `git env` / `git-env`, following the `_git` plugin
3
+ # convention: a function named `_git-env` is dispatched automatically by
4
+ # zsh's bundled `_git` completion when it sees `git env <TAB>`. Placing this
5
+ # file on $fpath also makes `git-env <TAB>` work when invoked directly.
6
+
7
+ _git-env() {
8
+ local curcontext="$curcontext" state line
9
+ local -a subcommands
10
+ subcommands=(
11
+ 'sync:copy env files from the primary worktree into the current linked worktree'
12
+ )
13
+
14
+ _arguments -C \
15
+ '(- *)'{-h,--help}'[show help]' \
16
+ '(- *)--version[print the version]' \
17
+ '--install-completions[print a completion snippet for a shell]:shell:(bash zsh fish)' \
18
+ '--write[write the completion file instead of printing a snippet]' \
19
+ '1: :->subcommand' \
20
+ '*::arg:->args'
21
+
22
+ case $state in
23
+ subcommand)
24
+ _describe -t commands 'git env subcommand' subcommands
25
+ ;;
26
+ args)
27
+ case ${words[1]} in
28
+ sync)
29
+ _arguments \
30
+ '(-n --dry-run)'{-n,--dry-run}'[print actions, change nothing]' \
31
+ '(-f --force)'{-f,--force}'[overwrite local files even when they differ from the primary]' \
32
+ '(-v --verbose)'{-v,--verbose}'[print every file considered, including skips]' \
33
+ '(-q --quiet)'{-q,--quiet}'[suppress non-error output]' \
34
+ '*--pattern[glob to sync, repeatable]:glob' \
35
+ '--path[restrict to a subdirectory of the worktree]:subdir:_files -/' \
36
+ '(-h --help)'{-h,--help}'[show help]'
37
+ ;;
38
+ esac
39
+ ;;
40
+ esac
41
+ }
42
+
43
+ _git-env "$@"
@@ -0,0 +1,64 @@
1
+ # Bash completion for `git env` / `git-env`.
2
+ #
3
+ # Source this file directly, or install it where bash-completion looks for
4
+ # completions (see `git env --install-completions bash --write`).
5
+ #
6
+ # Defining `_git_env` lets git's own completion machinery dispatch
7
+ # `git env <TAB>` to this function automatically once git-completion.bash is
8
+ # loaded (it tries `_git_<subcommand>` for any subcommand it doesn't know
9
+ # about natively). The explicit `complete`/`__git_complete` call below also
10
+ # covers invoking the `git-env` binary directly.
11
+
12
+ _git_env_subcommands="sync"
13
+ _git_env_sync_opts="--dry-run -n --force -f --verbose -v --quiet -q --pattern --path --help -h"
14
+ _git_env_root_opts="--help -h --version --install-completions --write"
15
+
16
+ _git_env()
17
+ {
18
+ local cur prev words cword
19
+ if declare -F _get_comp_words_by_ref >/dev/null 2>&1; then
20
+ _get_comp_words_by_ref -n =: cur prev words cword
21
+ else
22
+ cur="${COMP_WORDS[COMP_CWORD]}"
23
+ prev="${COMP_WORDS[COMP_CWORD-1]:-}"
24
+ words=("${COMP_WORDS[@]}")
25
+ cword=$COMP_CWORD
26
+ fi
27
+
28
+ if [[ "$prev" == "--install-completions" ]]; then
29
+ COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur"))
30
+ return
31
+ fi
32
+
33
+ local subcommand="" i
34
+ for ((i = 1; i < cword; i++)); do
35
+ case "${words[i]}" in
36
+ -*) ;;
37
+ *)
38
+ subcommand="${words[i]}"
39
+ break
40
+ ;;
41
+ esac
42
+ done
43
+
44
+ if [[ -z "$subcommand" ]]; then
45
+ COMPREPLY=($(compgen -W "${_git_env_subcommands} ${_git_env_root_opts}" -- "$cur"))
46
+ return
47
+ fi
48
+
49
+ case "$subcommand" in
50
+ sync)
51
+ COMPREPLY=($(compgen -W "${_git_env_sync_opts}" -- "$cur"))
52
+ ;;
53
+ *)
54
+ COMPREPLY=()
55
+ ;;
56
+ esac
57
+ }
58
+
59
+ if declare -F __git_complete >/dev/null 2>&1; then
60
+ __git_complete git-env _git_env
61
+ else
62
+ complete -o bashdefault -o default -F _git_env git-env 2>/dev/null \
63
+ || complete -F _git_env git-env
64
+ fi
@@ -0,0 +1,32 @@
1
+ # Fish completions for `git-env` (and `git env`, which fish resolves to the
2
+ # same binary once `complete -c git-env` rules exist).
3
+
4
+ set -l subcommands sync
5
+
6
+ complete -c git-env -f
7
+
8
+ complete -c git-env -n "not __fish_seen_subcommand_from $subcommands" -a sync \
9
+ -d "Sync env files from the primary worktree into the current linked worktree"
10
+ complete -c git-env -n "not __fish_seen_subcommand_from $subcommands" -l version \
11
+ -d "Print the version"
12
+ complete -c git-env -n "not __fish_seen_subcommand_from $subcommands" -l install-completions \
13
+ -x -a "bash zsh fish" -d "Print a completion snippet for a shell"
14
+ complete -c git-env -n "not __fish_seen_subcommand_from $subcommands" -l write \
15
+ -d "Write the completion file instead of printing a snippet"
16
+ complete -c git-env -n "not __fish_seen_subcommand_from $subcommands" -s h -l help \
17
+ -d "Show help"
18
+
19
+ complete -c git-env -n "__fish_seen_subcommand_from sync" -s n -l dry-run \
20
+ -d "Print actions, change nothing"
21
+ complete -c git-env -n "__fish_seen_subcommand_from sync" -s f -l force \
22
+ -d "Overwrite local files even when they differ from the primary"
23
+ complete -c git-env -n "__fish_seen_subcommand_from sync" -s v -l verbose \
24
+ -d "Print every file considered, including skips"
25
+ complete -c git-env -n "__fish_seen_subcommand_from sync" -s q -l quiet \
26
+ -d "Suppress non-error output"
27
+ complete -c git-env -n "__fish_seen_subcommand_from sync" -l pattern -r \
28
+ -d "Glob to sync (repeatable)"
29
+ complete -c git-env -n "__fish_seen_subcommand_from sync" -l path -r -a "(__fish_complete_directories)" \
30
+ -d "Restrict to a subdirectory of the worktree"
31
+ complete -c git-env -n "__fish_seen_subcommand_from sync" -s h -l help \
32
+ -d "Show help"
git_env/config.py ADDED
@@ -0,0 +1,196 @@
1
+ """Configuration: read env.sync.* settings via `git config` and the optional
2
+ `.envsync` file, with standard git config precedence (system -> global ->
3
+ local -> worktree) handled natively by `git config --get[-all]`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ DEFAULT_PATTERNS = (".env", ".env.*")
13
+ DEFAULT_EXCLUDE = (".env.example", ".env.sample", ".env.template")
14
+ DEFAULT_FOLLOW_SYMLINKS = False
15
+ DEFAULT_MAX_FILE_SIZE = 1048576
16
+ DEFAULT_ON_CONFLICT = "skip"
17
+ DEFAULT_BACKUP = True
18
+
19
+ VALID_ON_CONFLICT = frozenset({"skip", "overwrite", "prompt"})
20
+
21
+ _TRUE_VALUES = frozenset({"true", "yes", "on", "1"})
22
+ _FALSE_VALUES = frozenset({"false", "no", "off", "0"})
23
+
24
+
25
+ class ConfigError(Exception):
26
+ """Raised when a configuration value is malformed or invalid."""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SyncConfig:
31
+ """Resolved `env.sync.*` configuration for a sync run."""
32
+
33
+ patterns: tuple[str, ...] = DEFAULT_PATTERNS
34
+ exclude: tuple[str, ...] = DEFAULT_EXCLUDE
35
+ follow_symlinks: bool = DEFAULT_FOLLOW_SYMLINKS
36
+ max_file_size: int = DEFAULT_MAX_FILE_SIZE
37
+ on_conflict: str = DEFAULT_ON_CONFLICT
38
+ backup: bool = DEFAULT_BACKUP
39
+
40
+
41
+ def _git_config_get_all(key: str, cwd: Path) -> list[str] | None:
42
+ """Return every value of a multi-value key, or None if unset.
43
+
44
+ `git config --get-all` itself walks system -> global -> local ->
45
+ worktree, so this already reflects standard precedence.
46
+ """
47
+ result = subprocess.run(
48
+ ["git", "config", "--get-all", key],
49
+ cwd=cwd,
50
+ capture_output=True,
51
+ text=True,
52
+ )
53
+ if result.returncode != 0:
54
+ return None
55
+ values = [line for line in result.stdout.split("\n") if line != ""]
56
+ return values or None
57
+
58
+
59
+ def _git_config_get(key: str, cwd: Path) -> str | None:
60
+ result = subprocess.run(
61
+ ["git", "config", "--get", key],
62
+ cwd=cwd,
63
+ capture_output=True,
64
+ text=True,
65
+ )
66
+ if result.returncode != 0:
67
+ return None
68
+ value = result.stdout.rstrip("\n")
69
+ return value or None
70
+
71
+
72
+ def _parse_envsync_file(path: Path) -> dict[str, list[str]]:
73
+ """Parse a `.envsync` file: gitignore-flavored `key=value` lines, no
74
+ `env.sync.` prefix. Repeated keys accumulate (for multi-value keys).
75
+ """
76
+ values: dict[str, list[str]] = {}
77
+ if not path.is_file():
78
+ return values
79
+ for raw_line in path.read_text().splitlines():
80
+ line = raw_line.strip()
81
+ if not line or line.startswith("#") or "=" not in line:
82
+ continue
83
+ key, _, value = line.partition("=")
84
+ key = key.strip()
85
+ value = value.strip()
86
+ if not key:
87
+ continue
88
+ values.setdefault(key, []).append(value)
89
+ return values
90
+
91
+
92
+ def _parse_bool(value: str, *, source: str) -> bool:
93
+ lowered = value.strip().lower()
94
+ if lowered in _TRUE_VALUES:
95
+ return True
96
+ if lowered in _FALSE_VALUES:
97
+ return False
98
+ raise ConfigError(f"invalid boolean value for {source}: {value!r}")
99
+
100
+
101
+ def _parse_int(value: str, *, source: str) -> int:
102
+ try:
103
+ return int(value.strip())
104
+ except ValueError as exc:
105
+ raise ConfigError(f"invalid integer value for {source}: {value!r}") from exc
106
+
107
+
108
+ def _resolve_multi(
109
+ config_key: str, envsync: dict[str, list[str]], envsync_key: str, default: tuple[str, ...], cwd: Path
110
+ ) -> list[str]:
111
+ values = _git_config_get_all(config_key, cwd)
112
+ if values is not None:
113
+ return values
114
+ if envsync_key in envsync:
115
+ return list(envsync[envsync_key])
116
+ return list(default)
117
+
118
+
119
+ def _resolve_bool(
120
+ config_key: str, envsync: dict[str, list[str]], envsync_key: str, default: bool, cwd: Path
121
+ ) -> bool:
122
+ value = _git_config_get(config_key, cwd)
123
+ if value is not None:
124
+ return _parse_bool(value, source=config_key)
125
+ 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]
149
+ return default
150
+
151
+
152
+ def load_config(primary_root: Path) -> SyncConfig:
153
+ """Resolve `env.sync.*` configuration for `primary_root`.
154
+
155
+ Precedence per key: git config (system -> global -> local -> worktree)
156
+ overrides the primary's `.envsync` file, which overrides built-in
157
+ defaults.
158
+ """
159
+ envsync = _parse_envsync_file(primary_root / ".envsync")
160
+
161
+ patterns = _resolve_multi(
162
+ "env.sync.patterns", envsync, "patterns", DEFAULT_PATTERNS, primary_root
163
+ )
164
+ exclude = _resolve_multi(
165
+ "env.sync.exclude", envsync, "exclude", DEFAULT_EXCLUDE, primary_root
166
+ )
167
+ follow_symlinks = _resolve_bool(
168
+ "env.sync.followSymlinks",
169
+ envsync,
170
+ "followSymlinks",
171
+ DEFAULT_FOLLOW_SYMLINKS,
172
+ primary_root,
173
+ )
174
+ max_file_size = _resolve_int(
175
+ "env.sync.maxFileSize", envsync, "maxFileSize", DEFAULT_MAX_FILE_SIZE, primary_root
176
+ )
177
+ on_conflict = _resolve_str(
178
+ "env.sync.onConflict", envsync, "onConflict", DEFAULT_ON_CONFLICT, primary_root
179
+ )
180
+ if on_conflict not in VALID_ON_CONFLICT:
181
+ raise ConfigError(
182
+ f"invalid env.sync.onConflict value: {on_conflict!r}"
183
+ f" (expected one of {sorted(VALID_ON_CONFLICT)})"
184
+ )
185
+ backup = _resolve_bool(
186
+ "env.sync.backup", envsync, "backup", DEFAULT_BACKUP, primary_root
187
+ )
188
+
189
+ return SyncConfig(
190
+ patterns=tuple(patterns),
191
+ exclude=tuple(exclude),
192
+ follow_symlinks=follow_symlinks,
193
+ max_file_size=max_file_size,
194
+ on_conflict=on_conflict,
195
+ backup=backup,
196
+ )