Skip to content

Commit f6220d3

Browse files
authored
fix: jinja recursive subs (#52)
* fix: j2 error when copying directories * chore: remove --install-completion and --show-completion from help menu
1 parent b0ee505 commit f6220d3

9 files changed

Lines changed: 71 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1212

1313
### Changed
1414
- **Breaking:** `-r/--root` is now a global `git workspace` option instead of a per-command flag for workspace-aware commands. Use `git workspace -r /path/to/workspace up`, `git workspace -r /path/to/workspace compose`, etc. instead of placing `-r` after the subcommand. `init`, `clone`, and `cache` do not use this flag.
15-
- **Breaking:** Copy assets are now rendered as Jinja2 templates only when their source filename ends in `.j2`. Files without `.j2` are copied verbatim, so assets that incidentally contain `{{ … }}` or `{% … %}` (shell scripts, dockerfiles using Bash `${VAR}`, etc.) are no longer silently rewritten. The target path is taken verbatim from the manifest — the `.j2` suffix is **not** stripped automatically; if you want the rendered file to be `docker-compose.override.yml`, write `source = "docker-compose.override.yml.j2"` and `target = "docker-compose.override.yml"`. Migrate by renaming any asset that relies on Jinja substitution from `<name>` to `<name>.j2` and updating the matching `[[copy]]` `source` field.
15+
- **Breaking:** Copy assets are now rendered as Jinja2 templates only when their source filename ends in `.j2`. Files without `.j2` are copied verbatim, so assets that incidentally contain `{{ … }}` or `{% … %}` (shell scripts, dockerfiles using Bash `${VAR}`, etc.) are no longer silently rewritten. For top-level copies the target path is taken verbatim from the manifest — the `.j2` suffix is **not** stripped automatically; if you want the rendered file to be `docker-compose.override.yml`, write `source = "docker-compose.override.yml.j2"` and `target = "docker-compose.override.yml"`. Inside a directory copy, the `.j2` suffix **is** stripped from each rendered file's on-disk name (e.g. `vscode/settings.json.j2` becomes `.vscode/settings.json`), since the manifest does not name nested files individually. Migrate by renaming any asset that relies on Jinja substitution from `<name>` to `<name>.j2` and updating the matching `[[copy]]` `source` field.
1616

1717
## [0.7.0] - 2026-04-30
1818

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ overwrite = false
384384
<summary><i>Templating</i></summary>
385385
<br/>
386386

387-
Files in `.workspace/assets/` whose name ends in `.j2` are rendered as [Jinja2](https://jinja.palletsprojects.com/) templates. Files without `.j2` are copied verbatim. The target path is whatever you write in the manifest — the `.j2` suffix is **not** stripped automatically, so use an explicit `target` if you want a different output name:
387+
Files in `.workspace/assets/` whose name ends in `.j2` are rendered as [Jinja2](https://jinja.palletsprojects.com/) templates. Files without `.j2` are copied verbatim. For top-level copies the target path is whatever you write in the manifest — the `.j2` suffix is **not** stripped automatically, so use an explicit `target` if you want a different output name:
388388

389389
```toml
390390
[[copy]]
@@ -419,7 +419,7 @@ log_level: debug
419419
{% endif %}
420420
```
421421

422-
Unknown variables are left verbatim (e.g. `{{ GIT_WORKSPACE_TYPO }}` is written as-is so typos are obvious). Only `GIT_WORKSPACE_*` keys are exposed — the host process environment is not. Plain (non-`.j2`) files — text or binary — are copied byte-for-byte. In a directory copy, each file is rendered or copied based on its own suffix; on-disk filenames are preserved in the output. `git workspace doctor` flags unknown variables and template syntax errors in `.j2` files.
422+
Unknown variables are left verbatim (e.g. `{{ GIT_WORKSPACE_TYPO }}` is written as-is so typos are obvious). Only `GIT_WORKSPACE_*` keys are exposed — the host process environment is not. Plain (non-`.j2`) files — text or binary — are copied byte-for-byte. In a directory copy, each file is rendered or copied based on its own suffix, and the trailing `.j2` is stripped from rendered files (e.g. `vscode/settings.json.j2` becomes `.vscode/settings.json`); plain files keep their on-disk name. `git workspace doctor` flags unknown variables and template syntax errors in `.j2` files.
423423

424424
</details>
425425

src/git_workspace/assets.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,11 @@ class Copier(AssetManager[Copy]):
186186
with ``GIT_WORKSPACE_*`` variables from the provided environment dict;
187187
this supports ``{{ var }}`` substitution as well as ``{% if %}`` /
188188
``{% for %}`` and filters. Undefined variables render verbatim as
189-
``{{ name }}``. All other files are copied verbatim. The target path is
190-
taken verbatim from the manifest — ``.j2`` is not stripped automatically.
189+
``{{ name }}``. All other files are copied verbatim. For top-level
190+
copies the target path is taken verbatim from the manifest — ``.j2``
191+
is not stripped automatically. Inside a directory copy, the ``.j2``
192+
suffix is stripped from each rendered file's on-disk name (since the
193+
manifest does not name those files individually).
191194
"""
192195

193196
asset_name = "copy"
@@ -232,7 +235,11 @@ def _copy_dir_with_substitution(self, source: Path, target: Path) -> int:
232235

233236
def copy_fn(s: str, d: str) -> None:
234237
nonlocal total
235-
total += self._copy_with_substitution(Path(s), Path(d))
238+
src_path = Path(s)
239+
dst_path = Path(d)
240+
if src_path.name.endswith(".j2"):
241+
dst_path = dst_path.with_name(dst_path.name[:-3])
242+
total += self._copy_with_substitution(src_path, dst_path)
236243

237244
shutil.copytree(source, target, copy_function=copy_fn)
238245
return total

src/git_workspace/cli/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
app = typer.Typer(
2020
no_args_is_help=True,
21+
add_completion=False,
2122
callback=callback,
2223
help="""
2324
Manage isolated git worktrees for a repository.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"recommendations": []}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"branch": "{{ GIT_WORKSPACE_BRANCH }}"}

tests/integration/fixtures/configs/with-jinja-copies/manifest.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ target = "template.txt"
99
[[copy]]
1010
source = "literal.txt"
1111
target = "literal.txt"
12+
13+
[[copy]]
14+
source = "vscode"
15+
target = ".vscode"

tests/integration/test_copying.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,20 @@ def test_j2_target_path_is_honoured_verbatim(
215215
assert rendered.exists()
216216
j2_path = workspace_with_jinja_copies.dir / "main" / "template.txt.j2"
217217
assert not j2_path.exists()
218+
219+
220+
def test_directory_copy_strips_j2_suffix_from_rendered_files(
221+
workspace_with_jinja_copies: Workspace,
222+
) -> None:
223+
up(ctx=make_context(str(workspace_with_jinja_copies.dir)), branch="main")
224+
rendered = workspace_with_jinja_copies.dir / "main" / ".vscode" / "settings.json"
225+
assert rendered.read_text() == '{"branch": "main"}\n'
226+
assert not (workspace_with_jinja_copies.dir / "main" / ".vscode" / "settings.json.j2").exists()
227+
228+
229+
def test_directory_copy_preserves_non_j2_filenames(
230+
workspace_with_jinja_copies: Workspace,
231+
) -> None:
232+
up(ctx=make_context(str(workspace_with_jinja_copies.dir)), branch="main")
233+
plain = workspace_with_jinja_copies.dir / "main" / ".vscode" / "extensions.json"
234+
assert plain.read_text() == '{"recommendations": []}\n'

tests/unit/test_copier.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -730,5 +730,38 @@ def test_directory_renders_j2_and_copies_plain(
730730

731731
copier._copy_dir_with_substitution(src_dir, dst_dir)
732732

733-
assert (dst_dir / "app.yaml.j2").read_text() == "main"
733+
assert (dst_dir / "app.yaml").read_text() == "main"
734+
assert not (dst_dir / "app.yaml.j2").exists()
734735
assert (dst_dir / "static.txt").read_text() == "{{ GIT_WORKSPACE_BRANCH }}"
736+
737+
def test_directory_strips_j2_suffix_from_nested_files(
738+
self, copier: Copier, fs: FakeFilesystem
739+
) -> None:
740+
src_dir = self.ASSETS_DIR / "vscode"
741+
dst_dir = self.WORKTREE_DIR / ".vscode"
742+
fs.create_file(
743+
str(src_dir / "settings.json.j2"),
744+
contents='{"branch": "{{ GIT_WORKSPACE_BRANCH }}"}',
745+
)
746+
fs.create_file(
747+
str(src_dir / "nested" / "launch.json.j2"),
748+
contents='{"branch": "{{ GIT_WORKSPACE_BRANCH }}"}',
749+
)
750+
fs.create_dir(str(self.WORKTREE_DIR))
751+
752+
copier._copy_dir_with_substitution(src_dir, dst_dir)
753+
754+
assert (dst_dir / "settings.json").read_text() == '{"branch": "main"}'
755+
assert not (dst_dir / "settings.json.j2").exists()
756+
assert (dst_dir / "nested" / "launch.json").read_text() == '{"branch": "main"}'
757+
assert not (dst_dir / "nested" / "launch.json.j2").exists()
758+
759+
def test_directory_only_strips_trailing_j2(self, copier: Copier, fs: FakeFilesystem) -> None:
760+
src_dir = self.ASSETS_DIR / "config"
761+
dst_dir = self.WORKTREE_DIR / "config"
762+
fs.create_file(str(src_dir / "config.j2.bak"), contents="{{ GIT_WORKSPACE_BRANCH }}")
763+
fs.create_dir(str(self.WORKTREE_DIR))
764+
765+
copier._copy_dir_with_substitution(src_dir, dst_dir)
766+
767+
assert (dst_dir / "config.j2.bak").read_text() == "{{ GIT_WORKSPACE_BRANCH }}"

0 commit comments

Comments
 (0)