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 +16 -0
- git_env/cli.py +183 -0
- git_env/completions/_git-env +43 -0
- git_env/completions/git-env.bash +64 -0
- git_env/completions/git-env.fish +32 -0
- git_env/config.py +196 -0
- git_env/discovery.py +204 -0
- git_env/output.py +34 -0
- git_env/repo.py +156 -0
- git_env/shell_completions.py +91 -0
- git_env/sync.py +243 -0
- git_env-0.1.0.dist-info/METADATA +224 -0
- git_env-0.1.0.dist-info/RECORD +15 -0
- git_env-0.1.0.dist-info/WHEEL +4 -0
- git_env-0.1.0.dist-info/entry_points.txt +3 -0
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
|
+
)
|