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/sync.py ADDED
@@ -0,0 +1,243 @@
1
+ """Core `git env sync` logic: copy env files from the primary worktree into
2
+ the current linked worktree.
3
+
4
+ Implements the "Copy semantics" and "Conflict handling" rules from spec.md,
5
+ including line-diff summaries, `.envsync.bak` backups, and the
6
+ `env.sync.onConflict` modes. The safety-rail refusals beyond repository
7
+ detection are tracked as separate follow-up work.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import filecmp
14
+ import fnmatch
15
+ import os
16
+ import shutil
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from .config import SyncConfig
21
+ from .discovery import DiscoveredFile, discover_env_files
22
+ from .output import Reporter
23
+ from .repo import Repository
24
+
25
+ #: Suffix used for the atomic-write temp file (spec: copy to `<dest>.envsync.tmp`
26
+ #: then `rename(2)` over the destination).
27
+ _TMP_SUFFIX = ".envsync.tmp"
28
+
29
+
30
+ class SyncIOError(Exception):
31
+ """Raised on an I/O failure during copy. Always corresponds to exit code 4."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class SyncResult:
36
+ """Outcome of a sync run, used to derive the process exit code."""
37
+
38
+ synced: int = 0
39
+ skipped_unchanged: int = 0
40
+ conflicts: int = 0
41
+ would_change: int = 0
42
+ """Files that would be copied/overwritten; only meaningful for --dry-run."""
43
+
44
+ @property
45
+ def exit_code(self) -> int:
46
+ if self.conflicts:
47
+ return 1
48
+ return 0
49
+
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
+
58
+ def _resolve_dest(worktree_root: Path, relative_path: Path) -> Path:
59
+ """Resolve `relative_path` against `worktree_root`, refusing escapes.
60
+
61
+ Guards against a pattern or symlinked source resolving outside the
62
+ current worktree (spec: "Never write outside the current worktree root").
63
+ """
64
+ dest = (worktree_root / relative_path).resolve()
65
+ if worktree_root not in dest.parents and dest != worktree_root:
66
+ raise SyncIOError(
67
+ f"refusing to write outside the worktree: {relative_path}"
68
+ )
69
+ return dest
70
+
71
+
72
+ def _diff_summary(src: Path, dest: Path) -> str:
73
+ """Describe how `dest` differs from `src`, e.g. "3 lines differ"."""
74
+ try:
75
+ src_lines = src.read_text().splitlines()
76
+ dest_lines = dest.read_text().splitlines()
77
+ except (UnicodeDecodeError, OSError):
78
+ return "binary files differ"
79
+ matcher = difflib.SequenceMatcher(a=dest_lines, b=src_lines)
80
+ changed = sum(
81
+ max(i2 - i1, j2 - j1)
82
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes()
83
+ if tag != "equal"
84
+ )
85
+ noun = "line" if changed == 1 else "lines"
86
+ return f"{changed} {noun} differ"
87
+
88
+
89
+ def _backup(dest: Path) -> None:
90
+ """Back up `dest`'s current content to `<dest>.envsync.bak` before an
91
+ overwrite (spec: "single backup, overwritten on subsequent forces").
92
+ """
93
+ backup_path = dest.with_name(dest.name + ".envsync.bak")
94
+ try:
95
+ shutil.copyfile(dest, backup_path)
96
+ shutil.copymode(dest, backup_path)
97
+ except OSError as exc:
98
+ raise SyncIOError(f"failed to back up {dest}: {exc}") from exc
99
+
100
+
101
+ def _prompt_overwrite(rel: Path, diff_desc: str, reporter: Reporter) -> bool:
102
+ try:
103
+ answer = input(f"{rel}: {diff_desc}. Overwrite? [y/N] ")
104
+ except EOFError:
105
+ reporter.warn(f"{rel}: cannot prompt for input, skipping")
106
+ return False
107
+ return answer.strip().lower() in {"y", "yes"}
108
+
109
+
110
+ def _atomic_copy(src: Path, dest: Path) -> None:
111
+ dest.parent.mkdir(parents=True, exist_ok=True)
112
+ tmp = dest.with_name(dest.name + _TMP_SUFFIX)
113
+ try:
114
+ shutil.copyfile(src, tmp)
115
+ shutil.copymode(src, tmp)
116
+ os.replace(tmp, dest)
117
+ except OSError as exc:
118
+ try:
119
+ tmp.unlink(missing_ok=True)
120
+ except OSError:
121
+ pass
122
+ raise SyncIOError(f"failed to copy {src} -> {dest}: {exc}") from exc
123
+
124
+
125
+ def _cleanup_excluded_in_linked(
126
+ worktree_root: Path,
127
+ config: SyncConfig,
128
+ path: str | None,
129
+ *,
130
+ dry_run: bool,
131
+ reporter: Reporter,
132
+ ) -> int:
133
+ """Remove files from the linked worktree that match include AND exclude patterns.
134
+
135
+ Excluded patterns are for committed templates (e.g. .env.example) that git
136
+ checks out into linked worktrees but that sync should never treat as env files.
137
+ """
138
+ root = worktree_root
139
+ search_root = (root / path) if path else root
140
+ if not search_root.is_dir():
141
+ return 0
142
+
143
+ count = 0
144
+ for dirpath, dirnames, filenames in os.walk(search_root):
145
+ dir_path = Path(dirpath)
146
+ dirnames[:] = [d for d in dirnames if d != ".git"]
147
+ for filename in filenames:
148
+ if not any(fnmatch.fnmatch(filename, p) for p in config.patterns):
149
+ continue
150
+ if not any(fnmatch.fnmatch(filename, p) for p in config.exclude):
151
+ continue
152
+ abs_path = dir_path / filename
153
+ if abs_path.is_symlink():
154
+ continue
155
+ rel_path = abs_path.relative_to(root)
156
+ if dry_run:
157
+ reporter.detail(f"would remove {rel_path} (excluded template)")
158
+ else:
159
+ abs_path.unlink()
160
+ reporter.detail(f"removed {rel_path} (excluded template)")
161
+ count += 1
162
+ return count
163
+
164
+
165
+ def run_sync(
166
+ repo: Repository,
167
+ config: SyncConfig,
168
+ *,
169
+ dry_run: bool = False,
170
+ force: bool = False,
171
+ path: str | None = None,
172
+ reporter: Reporter,
173
+ ) -> SyncResult:
174
+ """Sync env files from `repo.primary_root` into `repo.worktree_root`."""
175
+ files, warnings = discover_env_files(repo.primary_root, config)
176
+ files = _filter_path(files, path)
177
+
178
+ for warning in warnings:
179
+ reporter.warn(f"{warning.relative_path}: {warning.message}")
180
+
181
+ synced = 0
182
+ skipped_unchanged = 0
183
+ conflicts = 0
184
+ would_change = 0
185
+
186
+ for discovered in files:
187
+ rel = discovered.relative_path
188
+ dest = _resolve_dest(repo.worktree_root, rel)
189
+
190
+ if not dest.exists():
191
+ if dry_run:
192
+ would_change += 1
193
+ reporter.info(f"would sync {rel}")
194
+ continue
195
+ _atomic_copy(discovered.absolute_path, dest)
196
+ synced += 1
197
+ reporter.info(f"synced {rel}")
198
+ continue
199
+
200
+ if filecmp.cmp(discovered.absolute_path, dest, shallow=False):
201
+ skipped_unchanged += 1
202
+ reporter.detail(f"unchanged {rel}")
203
+ continue
204
+
205
+ diff_desc = _diff_summary(discovered.absolute_path, dest)
206
+ should_overwrite = force or config.on_conflict == "overwrite"
207
+
208
+ if not should_overwrite and config.on_conflict == "prompt" and not dry_run:
209
+ should_overwrite = _prompt_overwrite(rel, diff_desc, reporter)
210
+
211
+ if should_overwrite:
212
+ if dry_run:
213
+ would_change += 1
214
+ reporter.info(f"would overwrite {rel} ({diff_desc})")
215
+ continue
216
+ if config.backup:
217
+ _backup(dest)
218
+ _atomic_copy(discovered.absolute_path, dest)
219
+ synced += 1
220
+ reporter.info(f"synced {rel} (overwrote, {diff_desc})")
221
+ continue
222
+
223
+ conflicts += 1
224
+ reporter.warn(f"{rel}: {diff_desc}, skipping (use --force to overwrite)")
225
+
226
+ cleaned = _cleanup_excluded_in_linked(
227
+ repo.worktree_root, config, path, dry_run=dry_run, reporter=reporter
228
+ )
229
+ if dry_run:
230
+ would_change += cleaned
231
+
232
+ total_changes = synced + (would_change if dry_run else 0)
233
+ reporter.info(
234
+ f"{total_changes if dry_run else synced} files synced, "
235
+ f"{skipped_unchanged} unchanged, {conflicts} conflicts skipped"
236
+ )
237
+
238
+ return SyncResult(
239
+ synced=synced,
240
+ skipped_unchanged=skipped_unchanged,
241
+ conflicts=conflicts,
242
+ would_change=would_change,
243
+ )
@@ -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.
@@ -0,0 +1,15 @@
1
+ git_env/__init__.py,sha256=tk97bBRWI7MgDIPVeB7TEsKQEX9g1g4xhGyR51qimRw,303
2
+ git_env/cli.py,sha256=uZxcICdbmwLwlAfkqWzwlFx0zusZyKxYHCp4ttLIf-g,6107
3
+ git_env/completions/_git-env,sha256=rKY9vn5BzLKDf4FltOEOmU3AmfWv0daFDEiNJ-PDeyk,1482
4
+ git_env/completions/git-env.bash,sha256=VMDt8C2o3roJ9c6wcjn1MExNwAFC3hiBOmZe_-7FICY,1758
5
+ git_env/completions/git-env.fish,sha256=RQJ0a10TpH_iI5-pN_Hphg_tM77ZXEvjJm8ZEwHmNKk,1691
6
+ git_env/config.py,sha256=6J9LKCCIb9sd4DWoEprK5McCkPi0jCogHUB4v6nQONI,6226
7
+ git_env/discovery.py,sha256=QPuPOs4MhL3v_atuAXi9oRBDmUxHMqsx5vKgHQsudTU,6631
8
+ git_env/output.py,sha256=Du4j64mVcWsURYDG_JRvq9HmZcz5TG4EH0iFuY40FW8,1137
9
+ git_env/repo.py,sha256=o8B4HypFlytwHiqWQqgrzf608fcAsapAQpzk0peL6Dw,5210
10
+ git_env/shell_completions.py,sha256=Htbb5J_jVhTcKfz1rbYyw4xDjHQ_vsDr2qSohH3ZF7U,3049
11
+ git_env/sync.py,sha256=m9moOKaUPfK6NmCpu9Kz-_wlQz4qjXWkq8Lej1ACqo8,7897
12
+ git_env-0.1.0.dist-info/WHEEL,sha256=jROcLULcdzropX2J55opKw4UHhPFREZax2XzS-Mvpxs,80
13
+ git_env-0.1.0.dist-info/entry_points.txt,sha256=2HJnPa1q9g0KpBhhZOlq-wI8_vozgLnrm5aLLKJPGM4,42
14
+ git_env-0.1.0.dist-info/METADATA,sha256=vz_a2r2YcbSdu6q1PezZWyrzHud-TZXjpJJgvAR2SrY,8677
15
+ git_env-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.10.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ git-env = git_env:main
3
+