git-env 0.1.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.1.0/PKG-INFO +224 -0
- git_env-0.1.0/README.md +214 -0
- git_env-0.1.0/pyproject.toml +24 -0
- git_env-0.1.0/src/git_env/__init__.py +16 -0
- git_env-0.1.0/src/git_env/cli.py +183 -0
- git_env-0.1.0/src/git_env/completions/_git-env +43 -0
- git_env-0.1.0/src/git_env/completions/git-env.bash +64 -0
- git_env-0.1.0/src/git_env/completions/git-env.fish +32 -0
- git_env-0.1.0/src/git_env/config.py +196 -0
- git_env-0.1.0/src/git_env/discovery.py +204 -0
- git_env-0.1.0/src/git_env/output.py +34 -0
- git_env-0.1.0/src/git_env/repo.py +156 -0
- git_env-0.1.0/src/git_env/shell_completions.py +91 -0
- git_env-0.1.0/src/git_env/sync.py +243 -0
git_env-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: git-env
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sync environment files across linked git worktrees
|
|
5
|
+
Author: Alex Ward
|
|
6
|
+
Author-email: Alex Ward <alxwrd@googlemail.com>
|
|
7
|
+
Requires-Dist: arguably>=1.2.2
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
<div align="center">
|
|
12
|
+
<h1><code>git-env</code></h1>
|
|
13
|
+
<p align="center"><i>
|
|
14
|
+
Environment file syncronisation to your linked git worktrees
|
|
15
|
+
</i></p>
|
|
16
|
+
<img width="256px" src="https://github.com/alxwrd/git-env/raw/main/.github/assets/man-reading-the-mail-768.png">
|
|
17
|
+
<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>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
Copies untracked env files to your worktrees.
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## Example
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
cd ~/repos/myproject # primary worktree, has .env
|
|
31
|
+
git worktree add ~/worktrees/myproject-feature # create a linked worktree
|
|
32
|
+
cd ~/worktrees/myproject-feature
|
|
33
|
+
git env sync # copies .env from the primary
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```plain
|
|
37
|
+
$ git env sync
|
|
38
|
+
|
|
39
|
+
copied .env
|
|
40
|
+
copied .env.local
|
|
41
|
+
|
|
42
|
+
2 files synced.
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Run it again later to pick up any changes made in the primary. By default,
|
|
46
|
+
`sync` won't clobber a file that has diverged locally — pass `--force` if
|
|
47
|
+
you want the primary's copy to win.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```shell
|
|
53
|
+
uv tool install git-env
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or, with pipx:
|
|
57
|
+
|
|
58
|
+
```shell
|
|
59
|
+
pipx install git-env
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This installs a `git-env` executable on your `PATH`, which git picks up
|
|
63
|
+
automatically as the `env` subcommand:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
git env --version
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## Syncing
|
|
71
|
+
|
|
72
|
+
`git env sync` copies env files from the primary worktree into the linked
|
|
73
|
+
worktree you're standing in. The primary worktree is the original clone — the
|
|
74
|
+
one whose `.git` is a real directory, not a file. `sync` locates it via
|
|
75
|
+
`git rev-parse --git-common-dir`.
|
|
76
|
+
|
|
77
|
+
Files are matched by glob against the entire primary worktree tree.
|
|
78
|
+
`.gitignore` is **not** consulted (env files are normally gitignored, which is
|
|
79
|
+
the point), but a `.envsyncignore` file at the primary root is. A file that
|
|
80
|
+
doesn't exist at the destination is copied. A file that's byte-identical is
|
|
81
|
+
skipped silently. A file that differs is skipped with a warning and a one-line
|
|
82
|
+
diff summary, unless `--force` is given.
|
|
83
|
+
|
|
84
|
+
Writes are atomic: each file is written to `<dest>.envsync.tmp` then renamed
|
|
85
|
+
over the destination. Mode bits (including the executable bit) are preserved;
|
|
86
|
+
mtimes are not, so a synced file's timestamp tells you when it was synced.
|
|
87
|
+
|
|
88
|
+
`sync` must run from a **linked** worktree — it refuses to run from the
|
|
89
|
+
primary itself, and refuses on bare repositories.
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
git env sync [--dry-run] [--force] [--verbose | --quiet]
|
|
93
|
+
[--pattern <glob>]... [--path <subdir>]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| Flag | Short | Meaning |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `--dry-run` | `-n` | Print what would happen; change nothing. |
|
|
99
|
+
| `--force` | `-f` | Overwrite local files that differ from the primary, backing up the previous content first. |
|
|
100
|
+
| `--verbose` | `-v` | Print every file considered, including skips. |
|
|
101
|
+
| `--quiet` | `-q` | Suppress non-error output. |
|
|
102
|
+
| `--pattern <glob>` | | Override configured patterns for this run. Repeatable. |
|
|
103
|
+
| `--path <subdir>` | | Restrict the sync to a subdirectory of the worktree. |
|
|
104
|
+
|
|
105
|
+
`--verbose` and `--quiet` are mutually exclusive.
|
|
106
|
+
|
|
107
|
+
### Exit codes
|
|
108
|
+
|
|
109
|
+
| Code | Meaning |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `0` | Success, or dry-run with nothing to change. |
|
|
112
|
+
| `1` | Sync completed but one or more files were skipped due to conflicts. |
|
|
113
|
+
| `2` | Refused to run (not a git worktree, bare repo, invoked from the primary worktree, or the primary has uncommitted changes to tracked env files). |
|
|
114
|
+
| `3` | Usage error (bad flag, unknown or reserved subcommand). |
|
|
115
|
+
| `4` | I/O error while copying. |
|
|
116
|
+
|
|
117
|
+
These codes are part of the contract — scripts can rely on them.
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
## Configuration
|
|
121
|
+
|
|
122
|
+
All keys live under `env.sync.*` and are read with standard git config
|
|
123
|
+
precedence (system → global → local → worktree):
|
|
124
|
+
|
|
125
|
+
| Key | Type | Default | Meaning |
|
|
126
|
+
|---|---|---|---|
|
|
127
|
+
| `env.sync.patterns` | multi-value | `.env`, `.env.*` | Globs to sync. Multi-value, so additional `git config --add` calls append rather than replace. |
|
|
128
|
+
| `env.sync.exclude` | multi-value | `.env.example`, `.env.sample`, `.env.template` | Patterns never synced, even if they match `patterns` (typically committed templates). |
|
|
129
|
+
| `env.sync.followSymlinks` | bool | `false` | Follow symlinked env files instead of skipping them. |
|
|
130
|
+
| `env.sync.maxFileSize` | int (bytes) | `1048576` | Files larger than this are skipped with a warning. |
|
|
131
|
+
| `env.sync.onConflict` | enum | `skip` | `skip`, `overwrite`, or `prompt`. |
|
|
132
|
+
| `env.sync.backup` | bool | `true` | Whether `--force` writes a `<dest>.envsync.bak` backup before overwriting. |
|
|
133
|
+
|
|
134
|
+
Set per-repo in the primary worktree's `.git/config`, or per-worktree in
|
|
135
|
+
that worktree's own config:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
git config env.sync.onConflict overwrite
|
|
139
|
+
git config --add env.sync.patterns ".env.local"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `.envsync`
|
|
143
|
+
|
|
144
|
+
An optional `key=value` file at the primary worktree root, for pinning
|
|
145
|
+
patterns into version control so the whole team gets the same defaults
|
|
146
|
+
without everyone running `git config`. Same keys as above, minus the
|
|
147
|
+
`env.sync.` prefix:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
patterns=.env
|
|
151
|
+
patterns=.env.*
|
|
152
|
+
exclude=.env.example
|
|
153
|
+
followSymlinks=false
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Repeated keys accumulate (for multi-value settings). `git config` values,
|
|
157
|
+
if set, always take precedence over `.envsync`.
|
|
158
|
+
|
|
159
|
+
### `.envsyncignore`
|
|
160
|
+
|
|
161
|
+
A `gitignore`-syntax file at the primary worktree root. Paths it matches
|
|
162
|
+
are excluded from sync regardless of `env.sync.patterns` — use it to opt a
|
|
163
|
+
specific file or directory out without changing the glob patterns themselves.
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
## Shell completion
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
git env --install-completions bash # print a snippet for your rc file
|
|
170
|
+
git env --install-completions zsh --write # install the completion file directly
|
|
171
|
+
git env --install-completions fish
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Supported shells: `bash`, `zsh`, `fish`. Without `--write`, the command
|
|
175
|
+
prints what to add to your shell config; with `--write`, it installs the
|
|
176
|
+
completion file to a standard location for that shell.
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
## FAQ
|
|
180
|
+
|
|
181
|
+
**Why not just symlink the env files instead?**
|
|
182
|
+
A symlink means there's only ever one copy, so editing the file in a linked
|
|
183
|
+
worktree edits the primary too — that defeats the purpose of having isolated
|
|
184
|
+
worktrees in the first place (e.g. running two branches with different API
|
|
185
|
+
keys or feature flags side by side). `git env sync` gives each worktree its
|
|
186
|
+
own independent copy, seeded from the primary, that you can then diverge from
|
|
187
|
+
intentionally.
|
|
188
|
+
|
|
189
|
+
**Does this work with bare repositories?**
|
|
190
|
+
No. `git env sync` requires a primary worktree with a real working tree to
|
|
191
|
+
copy *from*. Bare repos are detected and rejected with exit code `2`.
|
|
192
|
+
|
|
193
|
+
**What about secrets in env files?**
|
|
194
|
+
`git env sync` only ever copies bytes between worktrees on your local
|
|
195
|
+
filesystem — it doesn't transmit, log, or store file contents anywhere else,
|
|
196
|
+
and it never touches git history (env files are normally gitignored and stay
|
|
197
|
+
that way). The usual rules still apply: don't commit secrets, and be mindful
|
|
198
|
+
that `--force` backups (`<dest>.envsync.bak`) leave a second copy of the
|
|
199
|
+
previous content on disk.
|
|
200
|
+
|
|
201
|
+
**Can I sync changes back from a worktree to the primary?**
|
|
202
|
+
Not yet. `sync` is one-way (primary → linked). A `git env push` for the
|
|
203
|
+
reverse direction is planned but not implemented in v1 — see Limitations.
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
## Limitations
|
|
207
|
+
|
|
208
|
+
- **One-way sync only.** `git env sync` copies primary → linked. There is no
|
|
209
|
+
bidirectional sync in v1; pushing changes from a linked worktree back to the
|
|
210
|
+
primary isn't supported yet.
|
|
211
|
+
- **Submodules aren't traversed.** Env files inside submodules are not
|
|
212
|
+
discovered or synced.
|
|
213
|
+
- **Single primary per invocation.** The tool assumes one primary worktree and
|
|
214
|
+
doesn't support syncing between two linked worktrees directly.
|
|
215
|
+
- **No format parsing.** Env files are treated as opaque bytes; `git env`
|
|
216
|
+
doesn't understand `KEY=VALUE` syntax, so it can't merge or diff values
|
|
217
|
+
semantically — only whole-file conflict detection.
|
|
218
|
+
- **`--porcelain` is reserved but not implemented.** Passing it is a usage
|
|
219
|
+
error in v1, by design, so scripts don't silently depend on output that may
|
|
220
|
+
change later.
|
|
221
|
+
|
|
222
|
+
`push`, `diff`, `status`, `list`, `edit`, and `check` are reserved for
|
|
223
|
+
future official subcommands and will error if invoked, so a third-party
|
|
224
|
+
`git-env-<name>` script doesn't collide with them later.
|
git_env-0.1.0/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1><code>git-env</code></h1>
|
|
3
|
+
<p align="center"><i>
|
|
4
|
+
Environment file syncronisation to your linked git worktrees
|
|
5
|
+
</i></p>
|
|
6
|
+
<img width="256px" src="https://github.com/alxwrd/git-env/raw/main/.github/assets/man-reading-the-mail-768.png">
|
|
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>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
Copies untracked env files to your worktrees.
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## Example
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
cd ~/repos/myproject # primary worktree, has .env
|
|
21
|
+
git worktree add ~/worktrees/myproject-feature # create a linked worktree
|
|
22
|
+
cd ~/worktrees/myproject-feature
|
|
23
|
+
git env sync # copies .env from the primary
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```plain
|
|
27
|
+
$ git env sync
|
|
28
|
+
|
|
29
|
+
copied .env
|
|
30
|
+
copied .env.local
|
|
31
|
+
|
|
32
|
+
2 files synced.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Run it again later to pick up any changes made in the primary. By default,
|
|
36
|
+
`sync` won't clobber a file that has diverged locally — pass `--force` if
|
|
37
|
+
you want the primary's copy to win.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```shell
|
|
43
|
+
uv tool install git-env
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or, with pipx:
|
|
47
|
+
|
|
48
|
+
```shell
|
|
49
|
+
pipx install git-env
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This installs a `git-env` executable on your `PATH`, which git picks up
|
|
53
|
+
automatically as the `env` subcommand:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
git env --version
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## Syncing
|
|
61
|
+
|
|
62
|
+
`git env sync` copies env files from the primary worktree into the linked
|
|
63
|
+
worktree you're standing in. The primary worktree is the original clone — the
|
|
64
|
+
one whose `.git` is a real directory, not a file. `sync` locates it via
|
|
65
|
+
`git rev-parse --git-common-dir`.
|
|
66
|
+
|
|
67
|
+
Files are matched by glob against the entire primary worktree tree.
|
|
68
|
+
`.gitignore` is **not** consulted (env files are normally gitignored, which is
|
|
69
|
+
the point), but a `.envsyncignore` file at the primary root is. A file that
|
|
70
|
+
doesn't exist at the destination is copied. A file that's byte-identical is
|
|
71
|
+
skipped silently. A file that differs is skipped with a warning and a one-line
|
|
72
|
+
diff summary, unless `--force` is given.
|
|
73
|
+
|
|
74
|
+
Writes are atomic: each file is written to `<dest>.envsync.tmp` then renamed
|
|
75
|
+
over the destination. Mode bits (including the executable bit) are preserved;
|
|
76
|
+
mtimes are not, so a synced file's timestamp tells you when it was synced.
|
|
77
|
+
|
|
78
|
+
`sync` must run from a **linked** worktree — it refuses to run from the
|
|
79
|
+
primary itself, and refuses on bare repositories.
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
git env sync [--dry-run] [--force] [--verbose | --quiet]
|
|
83
|
+
[--pattern <glob>]... [--path <subdir>]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
| Flag | Short | Meaning |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `--dry-run` | `-n` | Print what would happen; change nothing. |
|
|
89
|
+
| `--force` | `-f` | Overwrite local files that differ from the primary, backing up the previous content first. |
|
|
90
|
+
| `--verbose` | `-v` | Print every file considered, including skips. |
|
|
91
|
+
| `--quiet` | `-q` | Suppress non-error output. |
|
|
92
|
+
| `--pattern <glob>` | | Override configured patterns for this run. Repeatable. |
|
|
93
|
+
| `--path <subdir>` | | Restrict the sync to a subdirectory of the worktree. |
|
|
94
|
+
|
|
95
|
+
`--verbose` and `--quiet` are mutually exclusive.
|
|
96
|
+
|
|
97
|
+
### Exit codes
|
|
98
|
+
|
|
99
|
+
| Code | Meaning |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `0` | Success, or dry-run with nothing to change. |
|
|
102
|
+
| `1` | Sync completed but one or more files were skipped due to conflicts. |
|
|
103
|
+
| `2` | Refused to run (not a git worktree, bare repo, invoked from the primary worktree, or the primary has uncommitted changes to tracked env files). |
|
|
104
|
+
| `3` | Usage error (bad flag, unknown or reserved subcommand). |
|
|
105
|
+
| `4` | I/O error while copying. |
|
|
106
|
+
|
|
107
|
+
These codes are part of the contract — scripts can rely on them.
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
## Configuration
|
|
111
|
+
|
|
112
|
+
All keys live under `env.sync.*` and are read with standard git config
|
|
113
|
+
precedence (system → global → local → worktree):
|
|
114
|
+
|
|
115
|
+
| Key | Type | Default | Meaning |
|
|
116
|
+
|---|---|---|---|
|
|
117
|
+
| `env.sync.patterns` | multi-value | `.env`, `.env.*` | Globs to sync. Multi-value, so additional `git config --add` calls append rather than replace. |
|
|
118
|
+
| `env.sync.exclude` | multi-value | `.env.example`, `.env.sample`, `.env.template` | Patterns never synced, even if they match `patterns` (typically committed templates). |
|
|
119
|
+
| `env.sync.followSymlinks` | bool | `false` | Follow symlinked env files instead of skipping them. |
|
|
120
|
+
| `env.sync.maxFileSize` | int (bytes) | `1048576` | Files larger than this are skipped with a warning. |
|
|
121
|
+
| `env.sync.onConflict` | enum | `skip` | `skip`, `overwrite`, or `prompt`. |
|
|
122
|
+
| `env.sync.backup` | bool | `true` | Whether `--force` writes a `<dest>.envsync.bak` backup before overwriting. |
|
|
123
|
+
|
|
124
|
+
Set per-repo in the primary worktree's `.git/config`, or per-worktree in
|
|
125
|
+
that worktree's own config:
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
git config env.sync.onConflict overwrite
|
|
129
|
+
git config --add env.sync.patterns ".env.local"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### `.envsync`
|
|
133
|
+
|
|
134
|
+
An optional `key=value` file at the primary worktree root, for pinning
|
|
135
|
+
patterns into version control so the whole team gets the same defaults
|
|
136
|
+
without everyone running `git config`. Same keys as above, minus the
|
|
137
|
+
`env.sync.` prefix:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
patterns=.env
|
|
141
|
+
patterns=.env.*
|
|
142
|
+
exclude=.env.example
|
|
143
|
+
followSymlinks=false
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Repeated keys accumulate (for multi-value settings). `git config` values,
|
|
147
|
+
if set, always take precedence over `.envsync`.
|
|
148
|
+
|
|
149
|
+
### `.envsyncignore`
|
|
150
|
+
|
|
151
|
+
A `gitignore`-syntax file at the primary worktree root. Paths it matches
|
|
152
|
+
are excluded from sync regardless of `env.sync.patterns` — use it to opt a
|
|
153
|
+
specific file or directory out without changing the glob patterns themselves.
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
## Shell completion
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
git env --install-completions bash # print a snippet for your rc file
|
|
160
|
+
git env --install-completions zsh --write # install the completion file directly
|
|
161
|
+
git env --install-completions fish
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Supported shells: `bash`, `zsh`, `fish`. Without `--write`, the command
|
|
165
|
+
prints what to add to your shell config; with `--write`, it installs the
|
|
166
|
+
completion file to a standard location for that shell.
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
## FAQ
|
|
170
|
+
|
|
171
|
+
**Why not just symlink the env files instead?**
|
|
172
|
+
A symlink means there's only ever one copy, so editing the file in a linked
|
|
173
|
+
worktree edits the primary too — that defeats the purpose of having isolated
|
|
174
|
+
worktrees in the first place (e.g. running two branches with different API
|
|
175
|
+
keys or feature flags side by side). `git env sync` gives each worktree its
|
|
176
|
+
own independent copy, seeded from the primary, that you can then diverge from
|
|
177
|
+
intentionally.
|
|
178
|
+
|
|
179
|
+
**Does this work with bare repositories?**
|
|
180
|
+
No. `git env sync` requires a primary worktree with a real working tree to
|
|
181
|
+
copy *from*. Bare repos are detected and rejected with exit code `2`.
|
|
182
|
+
|
|
183
|
+
**What about secrets in env files?**
|
|
184
|
+
`git env sync` only ever copies bytes between worktrees on your local
|
|
185
|
+
filesystem — it doesn't transmit, log, or store file contents anywhere else,
|
|
186
|
+
and it never touches git history (env files are normally gitignored and stay
|
|
187
|
+
that way). The usual rules still apply: don't commit secrets, and be mindful
|
|
188
|
+
that `--force` backups (`<dest>.envsync.bak`) leave a second copy of the
|
|
189
|
+
previous content on disk.
|
|
190
|
+
|
|
191
|
+
**Can I sync changes back from a worktree to the primary?**
|
|
192
|
+
Not yet. `sync` is one-way (primary → linked). A `git env push` for the
|
|
193
|
+
reverse direction is planned but not implemented in v1 — see Limitations.
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
## Limitations
|
|
197
|
+
|
|
198
|
+
- **One-way sync only.** `git env sync` copies primary → linked. There is no
|
|
199
|
+
bidirectional sync in v1; pushing changes from a linked worktree back to the
|
|
200
|
+
primary isn't supported yet.
|
|
201
|
+
- **Submodules aren't traversed.** Env files inside submodules are not
|
|
202
|
+
discovered or synced.
|
|
203
|
+
- **Single primary per invocation.** The tool assumes one primary worktree and
|
|
204
|
+
doesn't support syncing between two linked worktrees directly.
|
|
205
|
+
- **No format parsing.** Env files are treated as opaque bytes; `git env`
|
|
206
|
+
doesn't understand `KEY=VALUE` syntax, so it can't merge or diff values
|
|
207
|
+
semantically — only whole-file conflict detection.
|
|
208
|
+
- **`--porcelain` is reserved but not implemented.** Passing it is a usage
|
|
209
|
+
error in v1, by design, so scripts don't silently depend on output that may
|
|
210
|
+
change later.
|
|
211
|
+
|
|
212
|
+
`push`, `diff`, `status`, `list`, `edit`, and `check` are reserved for
|
|
213
|
+
future official subcommands and will error if invoked, so a third-party
|
|
214
|
+
`git-env-<name>` script doesn't collide with them later.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "git-env"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Sync environment files across linked git worktrees"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Alex Ward", email = "alxwrd@googlemail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"arguably>=1.2.2",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
git-env = "git_env:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.10.0,<0.11.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=9.1.1",
|
|
24
|
+
]
|
|
@@ -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()
|
|
@@ -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
|