boilersync 1.2.16__tar.gz → 1.2.18__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.
Files changed (63) hide show
  1. {boilersync-1.2.16 → boilersync-1.2.18}/PKG-INFO +15 -4
  2. {boilersync-1.2.16 → boilersync-1.2.18}/README.md +13 -1
  3. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/_version.py +2 -2
  4. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/cli.py +4 -0
  5. boilersync-1.2.18/boilersync/commands/diff.py +256 -0
  6. boilersync-1.2.18/boilersync/commands/pull_proposal.py +233 -0
  7. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/commands/push.py +35 -128
  8. boilersync-1.2.18/boilersync/comparison.py +194 -0
  9. boilersync-1.2.18/boilersync/file_ignores.py +104 -0
  10. boilersync-1.2.18/boilersync/project_context.py +54 -0
  11. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/project_metadata.py +1 -0
  12. boilersync-1.2.18/boilersync/template_ownership.py +10 -0
  13. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/template_processor.py +10 -2
  14. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/template_sources.py +6 -1
  15. boilersync-1.2.18/boilersync/template_workspace.py +109 -0
  16. {boilersync-1.2.16 → boilersync-1.2.18}/docs/ensemble.md +33 -3
  17. {boilersync-1.2.16 → boilersync-1.2.18}/docs/index.md +3 -0
  18. {boilersync-1.2.16 → boilersync-1.2.18}/docs/project-metadata.md +5 -1
  19. {boilersync-1.2.16 → boilersync-1.2.18}/pyproject.toml +1 -2
  20. boilersync-1.2.18/tests/test_diff.py +483 -0
  21. boilersync-1.2.18/tests/test_pull_proposal.py +201 -0
  22. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_push_inheritance.py +21 -9
  23. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_template_sources.py +23 -0
  24. boilersync-1.2.16/.github/workflows/publish.yml +0 -33
  25. {boilersync-1.2.16 → boilersync-1.2.18}/.cursor/rules/adding-commands.mdc +0 -0
  26. {boilersync-1.2.16 → boilersync-1.2.18}/.cursor/rules/project-description.mdc +0 -0
  27. {boilersync-1.2.16 → boilersync-1.2.18}/.cursor/rules/python-conventions.mdc +0 -0
  28. {boilersync-1.2.16 → boilersync-1.2.18}/.github/workflows/auto-tag.yml +0 -0
  29. {boilersync-1.2.16 → boilersync-1.2.18}/.github/workflows/deploy-docs.yml +0 -0
  30. {boilersync-1.2.16 → boilersync-1.2.18}/.gitignore +0 -0
  31. {boilersync-1.2.16 → boilersync-1.2.18}/.vscode/launch.json +0 -0
  32. {boilersync-1.2.16 → boilersync-1.2.18}/.vscode/settings.json +0 -0
  33. {boilersync-1.2.16 → boilersync-1.2.18}/.vscode/tasks.json +0 -0
  34. {boilersync-1.2.16 → boilersync-1.2.18}/PROBLEMS.md +0 -0
  35. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/__init__.py +0 -0
  36. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/__main__.py +0 -0
  37. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/cli_helpers.py +0 -0
  38. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/commands/__init__.py +0 -0
  39. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/commands/check_pull.py +0 -0
  40. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/commands/init.py +0 -0
  41. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/commands/pull.py +0 -0
  42. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/commands/templates.py +0 -0
  43. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/errors.py +0 -0
  44. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/interpolation_context.py +0 -0
  45. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/logging.py +0 -0
  46. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/names.py +0 -0
  47. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/paths.py +0 -0
  48. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/utils.py +0 -0
  49. {boilersync-1.2.16 → boilersync-1.2.18}/boilersync/variable_collector.py +0 -0
  50. {boilersync-1.2.16 → boilersync-1.2.18}/docs/template-metadata.md +0 -0
  51. {boilersync-1.2.16 → boilersync-1.2.18}/mkdocs.yml +0 -0
  52. {boilersync-1.2.16 → boilersync-1.2.18}/scripts/build.sh +0 -0
  53. {boilersync-1.2.16 → boilersync-1.2.18}/tests/__init__.py +0 -0
  54. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_check_pull.py +0 -0
  55. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_filename_interpolation.py +0 -0
  56. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_init_runtime_features.py +0 -0
  57. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_interpolation_context.py +0 -0
  58. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_paths.py +0 -0
  59. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_template_inheritance.py +0 -0
  60. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_templates_commands.py +0 -0
  61. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_templates_init.py +0 -0
  62. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_variable_collection.py +0 -0
  63. {boilersync-1.2.16 → boilersync-1.2.18}/tests/test_variable_type_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boilersync
3
- Version: 1.2.16
3
+ Version: 1.2.18
4
4
  Summary: BoilerSync
5
5
  Project-URL: Repository, https://github.com/gabemontague/boilersync
6
6
  Project-URL: Issues, https://github.com/gabemontague/boilersync/issues
@@ -10,9 +10,8 @@ Classifier: Environment :: Console
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.9
14
13
  Classifier: Topic :: Software Development :: Version Control :: Git
15
- Requires-Python: >=3.9
14
+ Requires-Python: >=3.10
16
15
  Requires-Dist: click>=8.2.0
17
16
  Requires-Dist: gitpython>=3.1.44
18
17
  Requires-Dist: jinja2>=3.1.6
@@ -56,7 +55,13 @@ boilersync init your-org/your-templates#python/service-template --non-interactiv
56
55
  # 3) Pull template updates into the current project when needed
57
56
  boilersync pull
58
57
 
59
- # 4) Push committed project changes back into the template source
58
+ # 4) Check project divergence from the current template
59
+ boilersync diff
60
+
61
+ # 5) Create a reviewable pull proposal without mutating the project
62
+ boilersync pull-proposal create
63
+
64
+ # 6) Push committed project changes back into the template source
60
65
  boilersync push
61
66
  ```
62
67
 
@@ -65,7 +70,11 @@ boilersync push
65
70
  - `boilersync init TEMPLATE_REF`: create a project from a template (empty target directory).
66
71
  - `boilersync init TEMPLATE_REF --non-interactive`: create a project without prompts when defaults and automatic values cover all inputs.
67
72
  - `boilersync check-pull`: compare the project's recorded template repo commit against the current cached template repo HEAD.
73
+ - `boilersync diff`: render the upstream template with the project's saved variables and show how the current project has diverged.
68
74
  - `boilersync pull [TEMPLATE_REF]`: apply template updates to an existing project.
75
+ - `boilersync pull-proposal create`: create a temp git repo containing proposed pull changes for review.
76
+ - `boilersync pull-proposal apply-file PROPOSAL_DIR PATH`: copy one proposed file into the current project.
77
+ - `boilersync pull-proposal apply-patch PATCH`: apply selected hunks from a saved patch.
69
78
  - `boilersync push`: review and copy committed project changes back to the template.
70
79
  - `boilersync templates init`: clone a template source repository into the local cache.
71
80
 
@@ -75,7 +84,9 @@ Use command help for full flags:
75
84
  boilersync --help
76
85
  boilersync init --help
77
86
  boilersync check-pull --help
87
+ boilersync diff --help
78
88
  boilersync pull --help
89
+ boilersync pull-proposal --help
79
90
  boilersync push --help
80
91
  boilersync templates --help
81
92
  ```
@@ -30,7 +30,13 @@ boilersync init your-org/your-templates#python/service-template --non-interactiv
30
30
  # 3) Pull template updates into the current project when needed
31
31
  boilersync pull
32
32
 
33
- # 4) Push committed project changes back into the template source
33
+ # 4) Check project divergence from the current template
34
+ boilersync diff
35
+
36
+ # 5) Create a reviewable pull proposal without mutating the project
37
+ boilersync pull-proposal create
38
+
39
+ # 6) Push committed project changes back into the template source
34
40
  boilersync push
35
41
  ```
36
42
 
@@ -39,7 +45,11 @@ boilersync push
39
45
  - `boilersync init TEMPLATE_REF`: create a project from a template (empty target directory).
40
46
  - `boilersync init TEMPLATE_REF --non-interactive`: create a project without prompts when defaults and automatic values cover all inputs.
41
47
  - `boilersync check-pull`: compare the project's recorded template repo commit against the current cached template repo HEAD.
48
+ - `boilersync diff`: render the upstream template with the project's saved variables and show how the current project has diverged.
42
49
  - `boilersync pull [TEMPLATE_REF]`: apply template updates to an existing project.
50
+ - `boilersync pull-proposal create`: create a temp git repo containing proposed pull changes for review.
51
+ - `boilersync pull-proposal apply-file PROPOSAL_DIR PATH`: copy one proposed file into the current project.
52
+ - `boilersync pull-proposal apply-patch PATCH`: apply selected hunks from a saved patch.
43
53
  - `boilersync push`: review and copy committed project changes back to the template.
44
54
  - `boilersync templates init`: clone a template source repository into the local cache.
45
55
 
@@ -49,7 +59,9 @@ Use command help for full flags:
49
59
  boilersync --help
50
60
  boilersync init --help
51
61
  boilersync check-pull --help
62
+ boilersync diff --help
52
63
  boilersync pull --help
64
+ boilersync pull-proposal --help
53
65
  boilersync push --help
54
66
  boilersync templates --help
55
67
  ```
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.2.16'
22
- __version_tuple__ = version_tuple = (1, 2, 16)
21
+ __version__ = version = '1.2.18'
22
+ __version_tuple__ = version_tuple = (1, 2, 18)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -3,8 +3,10 @@ import click
3
3
  from boilersync._version import __version__
4
4
  from boilersync.cli_helpers import common_command_wrapper
5
5
  from boilersync.commands.check_pull import check_pull_cmd
6
+ from boilersync.commands.diff import diff_cmd
6
7
  from boilersync.commands.init import init_cmd
7
8
  from boilersync.commands.pull import pull_cmd
9
+ from boilersync.commands.pull_proposal import pull_proposal_cmd
8
10
  from boilersync.commands.push import push_cmd
9
11
  from boilersync.commands.templates import templates_cmd
10
12
 
@@ -33,7 +35,9 @@ def main():
33
35
  # Register commands
34
36
  main.add_command(common_command_wrapper(init_cmd))
35
37
  main.add_command(common_command_wrapper(check_pull_cmd))
38
+ main.add_command(common_command_wrapper(diff_cmd))
36
39
  main.add_command(common_command_wrapper(pull_cmd))
40
+ main.add_command(pull_proposal_cmd)
37
41
  main.add_command(common_command_wrapper(push_cmd))
38
42
  main.add_command(templates_cmd)
39
43
 
@@ -0,0 +1,256 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from boilersync.comparison import (
8
+ build_comparison_workspace,
9
+ diff_entries,
10
+ git_diff,
11
+ )
12
+ from boilersync.paths import paths
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ProjectDiff:
17
+ project_dir: Path
18
+ template_ref: str | None
19
+ comparison_dir: Path | None
20
+ entries: list[dict[str, object]]
21
+ output: str
22
+ error: str | None = None
23
+
24
+
25
+ def collect_project_dirs(
26
+ root_dir: Path, *, include_children: bool, recursive: bool
27
+ ) -> list[Path]:
28
+ project_dirs = [root_dir]
29
+ if not include_children and not recursive:
30
+ return project_dirs
31
+
32
+ visited = {root_dir.resolve()}
33
+ queue = [root_dir]
34
+
35
+ while queue:
36
+ parent = queue.pop(0)
37
+ for child in paths.get_children_from_boilersync(parent / ".boilersync"):
38
+ resolved_child = child.resolve()
39
+ if resolved_child in visited:
40
+ continue
41
+ visited.add(resolved_child)
42
+ project_dirs.append(child)
43
+ if recursive:
44
+ queue.append(child)
45
+
46
+ return project_dirs
47
+
48
+
49
+ def create_project_diff(
50
+ project_dir: Path,
51
+ *,
52
+ include_starter: bool,
53
+ mode: str,
54
+ ) -> ProjectDiff:
55
+ comparison = build_comparison_workspace(project_dir)
56
+ entries = diff_entries(comparison, include_starter=include_starter)
57
+ output = git_diff(comparison, include_starter=include_starter, mode=mode)
58
+ return ProjectDiff(
59
+ project_dir=project_dir,
60
+ template_ref=comparison.template_ref,
61
+ comparison_dir=comparison.comparison_dir,
62
+ entries=entries,
63
+ output=output,
64
+ )
65
+
66
+
67
+ def create_child_error(project_dir: Path, error: Exception) -> ProjectDiff:
68
+ return ProjectDiff(
69
+ project_dir=project_dir,
70
+ template_ref=None,
71
+ comparison_dir=None,
72
+ entries=[],
73
+ output="",
74
+ error=str(error),
75
+ )
76
+
77
+
78
+ def create_diffs(
79
+ root_dir: Path,
80
+ *,
81
+ include_starter: bool,
82
+ include_children: bool,
83
+ recursive: bool,
84
+ mode: str,
85
+ ) -> list[ProjectDiff]:
86
+ project_dirs = collect_project_dirs(
87
+ root_dir,
88
+ include_children=include_children,
89
+ recursive=recursive,
90
+ )
91
+ results = [
92
+ create_project_diff(project_dirs[0], include_starter=include_starter, mode=mode)
93
+ ]
94
+
95
+ for project_dir in project_dirs[1:]:
96
+ try:
97
+ results.append(
98
+ create_project_diff(
99
+ project_dir,
100
+ include_starter=include_starter,
101
+ mode=mode,
102
+ )
103
+ )
104
+ except Exception as error:
105
+ results.append(create_child_error(project_dir, error))
106
+
107
+ return results
108
+
109
+
110
+ def results_to_json(
111
+ results: list[ProjectDiff],
112
+ *,
113
+ include_starter: bool,
114
+ ) -> str:
115
+ payload = {
116
+ "include_starter": include_starter,
117
+ "projects": [
118
+ {
119
+ "project_dir": str(result.project_dir),
120
+ "template_ref": result.template_ref,
121
+ "comparison_dir": str(result.comparison_dir)
122
+ if result.comparison_dir
123
+ else None,
124
+ "changed_count": len(result.entries),
125
+ "changed_files": result.entries,
126
+ "error": result.error,
127
+ }
128
+ for result in results
129
+ ],
130
+ }
131
+ return json.dumps(payload, indent=2)
132
+
133
+
134
+ def results_to_text(results: list[ProjectDiff]) -> str:
135
+ sections = []
136
+ for result in results:
137
+ header = f"Project: {result.project_dir}"
138
+ if result.error:
139
+ sections.append(f"{header}\nError: {result.error}")
140
+ continue
141
+ if not result.output.strip():
142
+ sections.append(f"{header}\nNo divergence from template.")
143
+ continue
144
+ sections.append(
145
+ "\n".join(
146
+ [
147
+ header,
148
+ f"Template: {result.template_ref}",
149
+ f"Comparison directory: {result.comparison_dir}",
150
+ result.output,
151
+ ]
152
+ )
153
+ )
154
+ return "\n\n".join(sections)
155
+
156
+
157
+ def resolve_mode(stat: bool, name_status: bool, patch: bool, json_output: bool) -> str:
158
+ selected = [
159
+ option
160
+ for option, enabled in (
161
+ ("stat", stat),
162
+ ("name-status", name_status),
163
+ ("patch", patch),
164
+ ("json", json_output),
165
+ )
166
+ if enabled
167
+ ]
168
+ if len(selected) > 1:
169
+ raise click.UsageError(
170
+ "Choose only one of --stat, --name-status, --patch, or --json."
171
+ )
172
+ return selected[0] if selected else "stat"
173
+
174
+
175
+ def diff(
176
+ *,
177
+ include_starter: bool = False,
178
+ include_children: bool = False,
179
+ recursive: bool = False,
180
+ mode: str = "stat",
181
+ ) -> str:
182
+ output_mode = "name-status" if mode == "json" else mode
183
+ results = create_diffs(
184
+ paths.root_dir,
185
+ include_starter=include_starter,
186
+ include_children=include_children,
187
+ recursive=recursive,
188
+ mode=output_mode,
189
+ )
190
+ if mode == "json":
191
+ return results_to_json(results, include_starter=include_starter)
192
+ return results_to_text(results)
193
+
194
+
195
+ @click.command(name="diff")
196
+ @click.option(
197
+ "--stat",
198
+ "stat",
199
+ is_flag=True,
200
+ help="Show a diffstat summary. This is the default text mode.",
201
+ )
202
+ @click.option(
203
+ "--name-status",
204
+ "name_status",
205
+ is_flag=True,
206
+ help="Show changed file names and statuses.",
207
+ )
208
+ @click.option("--patch", "patch", is_flag=True, help="Show the full patch.")
209
+ @click.option("--json", "json_output", is_flag=True, help="Emit JSON summary output.")
210
+ @click.option(
211
+ "--include-starter",
212
+ is_flag=True,
213
+ help="Include files derived from .starter template files.",
214
+ )
215
+ @click.option(
216
+ "--children",
217
+ "include_children",
218
+ is_flag=True,
219
+ help="Include direct child projects listed in .boilersync.",
220
+ )
221
+ @click.option(
222
+ "--recursive",
223
+ is_flag=True,
224
+ help="Include child projects recursively.",
225
+ )
226
+ @click.option(
227
+ "--output",
228
+ "output_path",
229
+ type=click.Path(dir_okay=False, path_type=Path),
230
+ help="Write output to a file instead of stdout.",
231
+ )
232
+ def diff_cmd(
233
+ stat: bool,
234
+ name_status: bool,
235
+ patch: bool,
236
+ json_output: bool,
237
+ include_starter: bool,
238
+ include_children: bool,
239
+ recursive: bool,
240
+ output_path: Path | None,
241
+ ) -> None:
242
+ """Show how the current project has diverged from its upstream template."""
243
+ mode = resolve_mode(stat, name_status, patch, json_output)
244
+ result = diff(
245
+ include_starter=include_starter,
246
+ include_children=include_children,
247
+ recursive=recursive,
248
+ mode=mode,
249
+ )
250
+
251
+ if output_path:
252
+ output_path.expanduser().write_text(result + "\n", encoding="utf-8")
253
+ click.echo(f"Wrote BoilerSync diff to {output_path}")
254
+ return
255
+
256
+ click.echo(result)
@@ -0,0 +1,233 @@
1
+ import hashlib
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import tempfile
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import click
10
+ from git import Repo
11
+
12
+ from boilersync.commands.pull import pull
13
+ from boilersync.file_ignores import copy_project_files_to_directory
14
+ from boilersync.paths import paths
15
+ from boilersync.project_context import resolve_project_template_context
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class PullProposal:
20
+ project_dir: Path
21
+ workspace_dir: Path
22
+ proposal_dir: Path
23
+ changed_files: list[dict[str, str]]
24
+
25
+
26
+ def _is_safe_relative_path(path: Path) -> bool:
27
+ return not path.is_absolute() and ".." not in path.parts
28
+
29
+
30
+ def _copy_project_to_proposal(project_dir: Path, proposal_dir: Path) -> None:
31
+ shutil.rmtree(proposal_dir, ignore_errors=True)
32
+ proposal_dir.mkdir(parents=True, exist_ok=True)
33
+ copy_project_files_to_directory(project_dir, proposal_dir)
34
+
35
+
36
+ def _commit_proposal_baseline(proposal_dir: Path) -> Repo:
37
+ repo = Repo.init(proposal_dir)
38
+ with repo.config_writer() as config:
39
+ config.set_value("user", "name", "BoilerSync")
40
+ config.set_value("user", "email", "boilersync@example.invalid")
41
+ repo.git.add(A=True)
42
+ repo.index.commit("Current project before BoilerSync pull")
43
+ return repo
44
+
45
+
46
+ def _changed_files(repo: Repo) -> list[dict[str, str]]:
47
+ output = repo.git.diff("--name-status")
48
+ entries: list[dict[str, str]] = []
49
+ for line in output.splitlines():
50
+ parts = line.split("\t")
51
+ if not parts:
52
+ continue
53
+ if len(parts) == 2:
54
+ entries.append({"status": parts[0], "path": parts[1]})
55
+ elif len(parts) >= 3:
56
+ entries.append({"status": parts[0], "old_path": parts[1], "path": parts[2]})
57
+ return entries
58
+
59
+
60
+ def create_pull_proposal(
61
+ project_dir: Path | None = None,
62
+ *,
63
+ include_starter: bool = False,
64
+ include_children: bool = False,
65
+ workspace_dir: Path | None = None,
66
+ ) -> PullProposal:
67
+ resolved_project_dir = (project_dir or paths.root_dir).resolve()
68
+ path_hash = hashlib.md5(str(resolved_project_dir).encode()).hexdigest()[:8]
69
+ resolved_workspace_dir = (
70
+ workspace_dir.expanduser()
71
+ if workspace_dir
72
+ else Path(tempfile.gettempdir()) / f"boilersync-pull-{path_hash}"
73
+ )
74
+ proposal_dir = resolved_workspace_dir / "proposal"
75
+
76
+ _copy_project_to_proposal(resolved_project_dir, proposal_dir)
77
+ repo = _commit_proposal_baseline(proposal_dir)
78
+ project_context = resolve_project_template_context(proposal_dir)
79
+ collected_variables = dict(project_context.variables)
80
+ if project_context.name_snake is not None:
81
+ collected_variables["name_snake"] = project_context.name_snake
82
+ if project_context.name_pretty is not None:
83
+ collected_variables["name_pretty"] = project_context.name_pretty
84
+
85
+ original_cwd = Path.cwd()
86
+ try:
87
+ click.echo(f"Creating pull proposal in {proposal_dir}", err=True)
88
+ os.chdir(proposal_dir)
89
+ pull(
90
+ project_context.template_ref,
91
+ collected_variables=collected_variables,
92
+ allow_non_empty=True,
93
+ include_starter=include_starter,
94
+ no_input=True,
95
+ target_dir=proposal_dir,
96
+ _recursive=include_children,
97
+ )
98
+ repo.git.add("-N", A=True)
99
+ finally:
100
+ os.chdir(original_cwd)
101
+
102
+ return PullProposal(
103
+ project_dir=resolved_project_dir,
104
+ workspace_dir=resolved_workspace_dir,
105
+ proposal_dir=proposal_dir,
106
+ changed_files=_changed_files(repo),
107
+ )
108
+
109
+
110
+ def proposal_to_json(proposal: PullProposal) -> str:
111
+ payload = {
112
+ "project_dir": str(proposal.project_dir),
113
+ "workspace_dir": str(proposal.workspace_dir),
114
+ "proposal_dir": str(proposal.proposal_dir),
115
+ "changed_count": len(proposal.changed_files),
116
+ "changed_files": proposal.changed_files,
117
+ }
118
+ return json.dumps(payload, indent=2)
119
+
120
+
121
+ def proposal_to_text(proposal: PullProposal) -> str:
122
+ lines = [
123
+ f"Project: {proposal.project_dir}",
124
+ f"Proposal: {proposal.proposal_dir}",
125
+ "",
126
+ "Review proposed changes with:",
127
+ f" git -C {proposal.proposal_dir} diff",
128
+ "",
129
+ "Apply a whole proposed file from the project root with:",
130
+ f" boilersync pull-proposal apply-file {proposal.proposal_dir} PATH",
131
+ "",
132
+ "Apply selected hunks by saving a patch and running:",
133
+ " boilersync pull-proposal apply-patch PATCH",
134
+ "",
135
+ f"Changed files: {len(proposal.changed_files)}",
136
+ ]
137
+ for entry in proposal.changed_files:
138
+ path = entry.get("path", "")
139
+ lines.append(f" {entry.get('status', '')}\t{path}")
140
+ return "\n".join(lines)
141
+
142
+
143
+ def apply_proposed_file(
144
+ proposal_dir: Path,
145
+ relative_path: Path,
146
+ *,
147
+ project_dir: Path | None = None,
148
+ ) -> Path:
149
+ if not _is_safe_relative_path(relative_path):
150
+ raise ValueError(
151
+ f"Path must be relative and stay inside the project: {relative_path}"
152
+ )
153
+
154
+ resolved_project_dir = (project_dir or paths.root_dir).resolve()
155
+ source_path = proposal_dir.expanduser() / relative_path
156
+ target_path = resolved_project_dir / relative_path
157
+
158
+ if not source_path.exists() or not source_path.is_file():
159
+ raise FileNotFoundError(f"Proposed file does not exist: {source_path}")
160
+
161
+ target_path.parent.mkdir(parents=True, exist_ok=True)
162
+ shutil.copy2(source_path, target_path)
163
+ return target_path
164
+
165
+
166
+ def apply_patch_file(
167
+ patch_path: Path,
168
+ *,
169
+ project_dir: Path | None = None,
170
+ ) -> None:
171
+ resolved_project_dir = (project_dir or paths.root_dir).resolve()
172
+ subprocess.run(
173
+ ["git", "apply", str(patch_path.expanduser())],
174
+ cwd=resolved_project_dir,
175
+ check=True,
176
+ )
177
+
178
+
179
+ @click.group(name="pull-proposal")
180
+ def pull_proposal_cmd() -> None:
181
+ """Create and apply reviewable BoilerSync pull proposals."""
182
+
183
+
184
+ @pull_proposal_cmd.command(name="create")
185
+ @click.option(
186
+ "--include-starter",
187
+ is_flag=True,
188
+ help="Include starter files in the proposed pull.",
189
+ )
190
+ @click.option(
191
+ "--children",
192
+ "include_children",
193
+ is_flag=True,
194
+ help="Include child projects in the proposed pull.",
195
+ )
196
+ @click.option(
197
+ "--workspace-dir",
198
+ type=click.Path(file_okay=False, path_type=Path),
199
+ help="Directory for the proposal workspace. Defaults to /tmp/boilersync-pull-<hash>.",
200
+ )
201
+ @click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
202
+ def create_cmd(
203
+ include_starter: bool,
204
+ include_children: bool,
205
+ workspace_dir: Path | None,
206
+ json_output: bool,
207
+ ) -> None:
208
+ """Create a temp git repo containing proposed pull changes."""
209
+ proposal = create_pull_proposal(
210
+ include_starter=include_starter,
211
+ include_children=include_children,
212
+ workspace_dir=workspace_dir,
213
+ )
214
+ click.echo(
215
+ proposal_to_json(proposal) if json_output else proposal_to_text(proposal)
216
+ )
217
+
218
+
219
+ @pull_proposal_cmd.command(name="apply-file")
220
+ @click.argument("proposal_dir", type=click.Path(file_okay=False, path_type=Path))
221
+ @click.argument("path", type=click.Path(dir_okay=False, path_type=Path))
222
+ def apply_file_cmd(proposal_dir: Path, path: Path) -> None:
223
+ """Copy one file from a proposal into the current project."""
224
+ target_path = apply_proposed_file(proposal_dir, path)
225
+ click.echo(f"Applied proposed file: {target_path}")
226
+
227
+
228
+ @pull_proposal_cmd.command(name="apply-patch")
229
+ @click.argument("patch_path", type=click.Path(dir_okay=False, path_type=Path))
230
+ def apply_patch_cmd(patch_path: Path) -> None:
231
+ """Apply a selected patch to the current project with git apply."""
232
+ apply_patch_file(patch_path)
233
+ click.echo(f"Applied patch: {patch_path}")