boilersync 1.2.16__tar.gz → 1.2.17__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.
- {boilersync-1.2.16 → boilersync-1.2.17}/PKG-INFO +15 -4
- {boilersync-1.2.16 → boilersync-1.2.17}/README.md +13 -1
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/_version.py +2 -2
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/cli.py +4 -0
- boilersync-1.2.17/boilersync/commands/diff.py +256 -0
- boilersync-1.2.17/boilersync/commands/pull_proposal.py +233 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/commands/push.py +35 -128
- boilersync-1.2.17/boilersync/comparison.py +194 -0
- boilersync-1.2.17/boilersync/file_ignores.py +104 -0
- boilersync-1.2.17/boilersync/project_context.py +54 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/project_metadata.py +1 -0
- boilersync-1.2.17/boilersync/template_ownership.py +10 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/template_processor.py +10 -2
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/template_sources.py +6 -1
- boilersync-1.2.17/boilersync/template_workspace.py +109 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/docs/ensemble.md +33 -3
- {boilersync-1.2.16 → boilersync-1.2.17}/docs/index.md +3 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/docs/project-metadata.md +5 -1
- {boilersync-1.2.16 → boilersync-1.2.17}/pyproject.toml +1 -2
- boilersync-1.2.17/tests/test_diff.py +483 -0
- boilersync-1.2.17/tests/test_pull_proposal.py +201 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/test_push_inheritance.py +21 -9
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/test_template_sources.py +23 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/.cursor/rules/adding-commands.mdc +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/.cursor/rules/project-description.mdc +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/.cursor/rules/python-conventions.mdc +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/.github/workflows/auto-tag.yml +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/.github/workflows/deploy-docs.yml +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/.github/workflows/publish.yml +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/.gitignore +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/.vscode/launch.json +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/.vscode/settings.json +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/.vscode/tasks.json +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/PROBLEMS.md +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/__init__.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/__main__.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/cli_helpers.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/commands/__init__.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/commands/check_pull.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/commands/init.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/commands/pull.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/commands/templates.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/errors.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/interpolation_context.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/logging.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/names.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/paths.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/utils.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/boilersync/variable_collector.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/docs/template-metadata.md +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/mkdocs.yml +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/scripts/build.sh +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/__init__.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/test_check_pull.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/test_filename_interpolation.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/test_init_runtime_features.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/test_interpolation_context.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/test_paths.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/test_template_inheritance.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/test_templates_commands.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/test_templates_init.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/tests/test_variable_collection.py +0 -0
- {boilersync-1.2.16 → boilersync-1.2.17}/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.
|
|
3
|
+
Version: 1.2.17
|
|
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.
|
|
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)
|
|
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)
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 2,
|
|
21
|
+
__version__ = version = '1.2.17'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 2, 17)
|
|
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}")
|