Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

### Changed
- **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.
- **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.
- **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.

## [0.7.0] - 2026-04-30

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ overwrite = false
<summary><i>Templating</i></summary>
<br/>

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:
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:

```toml
[[copy]]
Expand Down Expand Up @@ -419,7 +419,7 @@ log_level: debug
{% endif %}
```

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.
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.

</details>

Expand Down
13 changes: 10 additions & 3 deletions src/git_workspace/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,11 @@ class Copier(AssetManager[Copy]):
with ``GIT_WORKSPACE_*`` variables from the provided environment dict;
this supports ``{{ var }}`` substitution as well as ``{% if %}`` /
``{% for %}`` and filters. Undefined variables render verbatim as
``{{ name }}``. All other files are copied verbatim. The target path is
taken verbatim from the manifest — ``.j2`` is not stripped automatically.
``{{ name }}``. All other files are copied verbatim. For top-level
copies the target path is taken verbatim from the manifest — ``.j2``
is not stripped automatically. Inside a directory copy, the ``.j2``
suffix is stripped from each rendered file's on-disk name (since the
manifest does not name those files individually).
"""

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

def copy_fn(s: str, d: str) -> None:
nonlocal total
total += self._copy_with_substitution(Path(s), Path(d))
src_path = Path(s)
dst_path = Path(d)
if src_path.name.endswith(".j2"):
dst_path = dst_path.with_name(dst_path.name[:-3])
total += self._copy_with_substitution(src_path, dst_path)

shutil.copytree(source, target, copy_function=copy_fn)
return total
Expand Down
1 change: 1 addition & 0 deletions src/git_workspace/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

app = typer.Typer(
no_args_is_help=True,
add_completion=False,
callback=callback,
help="""
Manage isolated git worktrees for a repository.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"recommendations": []}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"branch": "{{ GIT_WORKSPACE_BRANCH }}"}
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ target = "template.txt"
[[copy]]
source = "literal.txt"
target = "literal.txt"

[[copy]]
source = "vscode"
target = ".vscode"
17 changes: 17 additions & 0 deletions tests/integration/test_copying.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,20 @@ def test_j2_target_path_is_honoured_verbatim(
assert rendered.exists()
j2_path = workspace_with_jinja_copies.dir / "main" / "template.txt.j2"
assert not j2_path.exists()


def test_directory_copy_strips_j2_suffix_from_rendered_files(
workspace_with_jinja_copies: Workspace,
) -> None:
up(ctx=make_context(str(workspace_with_jinja_copies.dir)), branch="main")
rendered = workspace_with_jinja_copies.dir / "main" / ".vscode" / "settings.json"
assert rendered.read_text() == '{"branch": "main"}\n'
assert not (workspace_with_jinja_copies.dir / "main" / ".vscode" / "settings.json.j2").exists()


def test_directory_copy_preserves_non_j2_filenames(
workspace_with_jinja_copies: Workspace,
) -> None:
up(ctx=make_context(str(workspace_with_jinja_copies.dir)), branch="main")
plain = workspace_with_jinja_copies.dir / "main" / ".vscode" / "extensions.json"
assert plain.read_text() == '{"recommendations": []}\n'
35 changes: 34 additions & 1 deletion tests/unit/test_copier.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,5 +730,38 @@ def test_directory_renders_j2_and_copies_plain(

copier._copy_dir_with_substitution(src_dir, dst_dir)

assert (dst_dir / "app.yaml.j2").read_text() == "main"
assert (dst_dir / "app.yaml").read_text() == "main"
assert not (dst_dir / "app.yaml.j2").exists()
assert (dst_dir / "static.txt").read_text() == "{{ GIT_WORKSPACE_BRANCH }}"

def test_directory_strips_j2_suffix_from_nested_files(
self, copier: Copier, fs: FakeFilesystem
) -> None:
src_dir = self.ASSETS_DIR / "vscode"
dst_dir = self.WORKTREE_DIR / ".vscode"
fs.create_file(
str(src_dir / "settings.json.j2"),
contents='{"branch": "{{ GIT_WORKSPACE_BRANCH }}"}',
)
fs.create_file(
str(src_dir / "nested" / "launch.json.j2"),
contents='{"branch": "{{ GIT_WORKSPACE_BRANCH }}"}',
)
fs.create_dir(str(self.WORKTREE_DIR))

copier._copy_dir_with_substitution(src_dir, dst_dir)

assert (dst_dir / "settings.json").read_text() == '{"branch": "main"}'
assert not (dst_dir / "settings.json.j2").exists()
assert (dst_dir / "nested" / "launch.json").read_text() == '{"branch": "main"}'
assert not (dst_dir / "nested" / "launch.json.j2").exists()

def test_directory_only_strips_trailing_j2(self, copier: Copier, fs: FakeFilesystem) -> None:
src_dir = self.ASSETS_DIR / "config"
dst_dir = self.WORKTREE_DIR / "config"
fs.create_file(str(src_dir / "config.j2.bak"), contents="{{ GIT_WORKSPACE_BRANCH }}")
fs.create_dir(str(self.WORKTREE_DIR))

copier._copy_dir_with_substitution(src_dir, dst_dir)

assert (dst_dir / "config.j2.bak").read_text() == "{{ GIT_WORKSPACE_BRANCH }}"
Loading