claude-dev-env 4.0.0 → 4.1.0
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.
- package/.agents/skills/syncing-submodules/SKILL.md +101 -0
- package/.agents/skills/syncing-submodules/reference/run-tasks.md +9 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync.py +383 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync_constants/__init__.py +1 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync_constants/config/__init__.py +1 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync_constants/config/constants.py +66 -0
- package/.agents/skills/syncing-submodules/scripts/sync_parent_pointer.py +88 -0
- package/.agents/skills/syncing-submodules/scripts/test_submodule_sync.py +303 -0
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +83 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/ever-shipped-skills.test.mjs +30 -0
- package/bin/git_hooks_installer.mjs +0 -1
- package/bin/git_hooks_installer.test.mjs +16 -1
- package/bin/install.mjs +5 -2
- package/bin/install.prune.test.mjs +36 -24
- package/bin/install.test.mjs +7 -0
- package/package.json +1 -1
- package/hooks/git-hooks/post_commit.py +0 -241
- package/hooks/git-hooks/test_post_commit.py +0 -298
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: syncing-submodules
|
|
3
|
+
description: >-
|
|
4
|
+
Records a submodule's current commit in its parent repository.
|
|
5
|
+
Triggers: submodule sync, /submodule-sync, sync parent submodule,
|
|
6
|
+
update parent pointer, record submodule commit, commit submodule pointer,
|
|
7
|
+
sync superproject, update superproject pointer, parent repo submodule update,
|
|
8
|
+
finish submodule commit. Does not handle clone, checkout, update main,
|
|
9
|
+
or ordinary commits.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Syncing submodules
|
|
13
|
+
|
|
14
|
+
## Principle
|
|
15
|
+
|
|
16
|
+
A submodule commit changes the child repository first. Change the parent pointer only when this skill runs. Limit the parent commit to the submodule path so other staged work stays staged.
|
|
17
|
+
|
|
18
|
+
## Gotchas
|
|
19
|
+
|
|
20
|
+
- Inherited `GIT_*` variables can redirect child Git commands. The script removes them from its Git environment.
|
|
21
|
+
- Submodule paths can contain brackets or wildcard characters. The script sends Git a literal pathspec.
|
|
22
|
+
- `git diff --cached --quiet` uses exit 1 for a changed pointer. Any higher exit is a failure.
|
|
23
|
+
- The parent repository may already contain staged work. The path-only commit must leave it staged.
|
|
24
|
+
- A repository without a superproject is a successful `not_submodule` result.
|
|
25
|
+
- The old hook returned success after a failed parent commit. This command returns a nonzero exit.
|
|
26
|
+
|
|
27
|
+
## When this applies
|
|
28
|
+
|
|
29
|
+
Use this skill after committing inside a checked-out submodule when its parent repository should record the new commit.
|
|
30
|
+
|
|
31
|
+
Refusals use the first matching line:
|
|
32
|
+
|
|
33
|
+
- Clone, initialize, or check out a submodule: `Use Git's submodule commands. This skill records an existing submodule commit in its parent.`
|
|
34
|
+
- Fast-forward `main`: `Use /update. This skill changes one parent submodule pointer.`
|
|
35
|
+
- Make an ordinary commit or push: `Use /source-command-commit. This skill commits only a parent submodule pointer.`
|
|
36
|
+
|
|
37
|
+
## Process
|
|
38
|
+
|
|
39
|
+
### Seed the run tasks
|
|
40
|
+
|
|
41
|
+
Register every item in `reference/run-tasks.md` as a session task with the host task tool. Complete each task with an exit code, JSON field, or repository readback. If the host has no task tool, stop with `A task tool is required to track this repository-changing operation.`
|
|
42
|
+
|
|
43
|
+
### Run the command
|
|
44
|
+
|
|
45
|
+
Execute the bundled script. Do not reconstruct the Git sequence by hand.
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
python "${CLAUDE_SKILL_DIR}/scripts/sync_parent_pointer.py" --repository "<submodule-path>"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python 3 and Git are required. GitHub CLI is optional and only supplies an open pull request URL.
|
|
52
|
+
|
|
53
|
+
The script writes one JSON object to standard output. Exit 0 permits `updated`, `unchanged`, and `not_submodule`. Exit 1 reports a Git or repository failure. Exit 2 reports invalid arguments. Treat every nonzero exit as incomplete work. Fix the reported condition, rerun the command, and keep the task open until the command exits 0.
|
|
54
|
+
|
|
55
|
+
### Report the result
|
|
56
|
+
|
|
57
|
+
- `updated`: report `parent_repository`, `submodule_path`, `commit`, and `parent_commit`.
|
|
58
|
+
- `unchanged`: report that the parent already records `commit`.
|
|
59
|
+
- `not_submodule`: report that no parent repository changed.
|
|
60
|
+
- `pull_request_url`: invoke `/pr-title-description` with that URL after reporting the sync result.
|
|
61
|
+
|
|
62
|
+
If `/pr-title-description` is not installed, report `pull_request_url` and finish the successful sync.
|
|
63
|
+
|
|
64
|
+
## Constraints
|
|
65
|
+
|
|
66
|
+
- Run the script from this skill package.
|
|
67
|
+
- Commit only the literal submodule path in the parent repository.
|
|
68
|
+
- Preserve every unrelated staged parent path.
|
|
69
|
+
- Never push, switch branches, rewrite commits, or change Git configuration.
|
|
70
|
+
- Never treat a failed Git command as success.
|
|
71
|
+
|
|
72
|
+
## Examples
|
|
73
|
+
|
|
74
|
+
- Updated pointer: `status` is `updated`, `submodule_path` is `modules/child`, and `parent_commit` names the new parent commit.
|
|
75
|
+
- No superproject: `status` is `not_submodule`; every parent and commit field is null.
|
|
76
|
+
- Failed stage: exit 1, `status` is `error`, and standard error starts with `syncing-submodules:`.
|
|
77
|
+
|
|
78
|
+
## Sub-skills
|
|
79
|
+
|
|
80
|
+
| Skill | When | Produces | Missing behavior |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| `/pr-title-description` | The result includes `pull_request_url` | A title and description based on the full pull request diff | Report the URL and keep the completed sync |
|
|
83
|
+
|
|
84
|
+
## File index
|
|
85
|
+
|
|
86
|
+
| File | Purpose |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `SKILL.md` | Selection, operation boundary, result handling, and peer routing |
|
|
89
|
+
| `reference/run-tasks.md` | Task seeds for each repository-changing run |
|
|
90
|
+
| `scripts/sync_parent_pointer.py` | Execute the parent pointer sync and return one JSON result |
|
|
91
|
+
| `scripts/submodule_sync.py` | Own the Git operation and typed sync report |
|
|
92
|
+
| `scripts/test_sync_parent_pointer.py` | Prove command parsing, JSON output, and hook deletion |
|
|
93
|
+
| `scripts/test_submodule_sync.py` | Prove exact commits, preserved staging, no-op states, and failures |
|
|
94
|
+
| `scripts/submodule_sync_constants/__init__.py` | Mark the constants package |
|
|
95
|
+
| `scripts/submodule_sync_constants/config/__init__.py` | Mark the configuration package |
|
|
96
|
+
| `scripts/submodule_sync_constants/config/constants.py` | Named command, message, field, and exit-code constants |
|
|
97
|
+
|
|
98
|
+
## Folder map
|
|
99
|
+
|
|
100
|
+
- `reference/` contains the run task seeds.
|
|
101
|
+
- `scripts/` contains the executable command, Git operation, tests, and constants package.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Run task seeds
|
|
2
|
+
|
|
3
|
+
- Resolve the requested repository path.
|
|
4
|
+
- Execute `scripts/sync_parent_pointer.py` for that path.
|
|
5
|
+
- Record the exit code and parse the single JSON result.
|
|
6
|
+
- Fix any reported failure and rerun until the command exits 0.
|
|
7
|
+
- Read back the parent pointer and unrelated staged paths when the status is `updated`.
|
|
8
|
+
- Report the result fields.
|
|
9
|
+
- Route an open pull request URL to `/pr-title-description` or report the URL when that skill is missing.
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from submodule_sync_constants.config.constants import (
|
|
10
|
+
ALL_GH_PULL_REQUEST_ARGUMENTS,
|
|
11
|
+
DECODE_ERRORS_POLICY,
|
|
12
|
+
EXIT_CODE_FAILURE,
|
|
13
|
+
EXIT_CODE_SUCCESS,
|
|
14
|
+
GH_COMMAND_TIMEOUT_SECONDS,
|
|
15
|
+
GH_EXECUTABLE_NAME,
|
|
16
|
+
GIT_ADD_SUBCOMMAND,
|
|
17
|
+
GIT_ARGUMENT_SEPARATOR,
|
|
18
|
+
GIT_CACHED_ARGUMENT,
|
|
19
|
+
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
20
|
+
GIT_COMMIT_SUBCOMMAND,
|
|
21
|
+
GIT_DIFF_SUBCOMMAND,
|
|
22
|
+
GIT_EXECUTABLE_NAME,
|
|
23
|
+
GIT_EXIT_FAILURE_MESSAGE_TEMPLATE,
|
|
24
|
+
GIT_HEAD_REFERENCE,
|
|
25
|
+
GIT_LATEST_COMMIT_ARGUMENT,
|
|
26
|
+
GIT_LAUNCH_FAILURE_MESSAGE_TEMPLATE,
|
|
27
|
+
GIT_LITERAL_PATHSPEC_PREFIX,
|
|
28
|
+
GIT_LOG_SUBCOMMAND,
|
|
29
|
+
GIT_MESSAGE_ARGUMENT,
|
|
30
|
+
GIT_ONLY_ARGUMENT,
|
|
31
|
+
GIT_QUIET_ARGUMENT,
|
|
32
|
+
GIT_REV_PARSE_SUBCOMMAND,
|
|
33
|
+
GIT_SHOW_SUPERPROJECT_ARGUMENT,
|
|
34
|
+
GIT_SHOW_TOP_LEVEL_ARGUMENT,
|
|
35
|
+
GIT_SUBJECT_FORMAT_ARGUMENT,
|
|
36
|
+
GIT_TIMEOUT_MESSAGE_TEMPLATE,
|
|
37
|
+
PARENT_COMMIT_MESSAGE_TEMPLATE,
|
|
38
|
+
REPOSITORY_NOT_DIRECTORY_MESSAGE_TEMPLATE,
|
|
39
|
+
REPOSITORY_RESOLUTION_FAILURE_MESSAGE,
|
|
40
|
+
SUBMODULE_COMMIT_MESSAGE_TEMPLATE,
|
|
41
|
+
SUBMODULE_PATH_FAILURE_MESSAGE,
|
|
42
|
+
SYNC_STATUS_ERROR,
|
|
43
|
+
SYNC_STATUS_NOT_SUBMODULE,
|
|
44
|
+
SYNC_STATUS_UNCHANGED,
|
|
45
|
+
SYNC_STATUS_UPDATED,
|
|
46
|
+
UTF8_ENCODING,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SyncStatus(StrEnum):
|
|
51
|
+
"""States reported by one parent-pointer sync."""
|
|
52
|
+
|
|
53
|
+
UPDATED = SYNC_STATUS_UPDATED
|
|
54
|
+
UNCHANGED = SYNC_STATUS_UNCHANGED
|
|
55
|
+
NOT_SUBMODULE = SYNC_STATUS_NOT_SUBMODULE
|
|
56
|
+
ERROR = SYNC_STATUS_ERROR
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class SyncReport:
|
|
61
|
+
"""Serializable facts from one parent-pointer sync."""
|
|
62
|
+
|
|
63
|
+
status: SyncStatus
|
|
64
|
+
repository: str
|
|
65
|
+
parent_repository: str | None = None
|
|
66
|
+
submodule_path: str | None = None
|
|
67
|
+
commit: str | None = None
|
|
68
|
+
parent_commit: str | None = None
|
|
69
|
+
pull_request_url: str | None = None
|
|
70
|
+
diagnostic: str | None = None
|
|
71
|
+
|
|
72
|
+
def as_record(self) -> dict[str, str | None]:
|
|
73
|
+
"""Build the JSON-ready record.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
All report fields with nullable values preserved.
|
|
77
|
+
"""
|
|
78
|
+
return {
|
|
79
|
+
"status": self.status.value,
|
|
80
|
+
"repository": self.repository,
|
|
81
|
+
"parent_repository": self.parent_repository,
|
|
82
|
+
"submodule_path": self.submodule_path,
|
|
83
|
+
"commit": self.commit,
|
|
84
|
+
"parent_commit": self.parent_commit,
|
|
85
|
+
"pull_request_url": self.pull_request_url,
|
|
86
|
+
"diagnostic": self.diagnostic,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class _SyncContext:
|
|
92
|
+
repository: Path
|
|
93
|
+
parent_repository: Path
|
|
94
|
+
submodule_path: str
|
|
95
|
+
literal_pathspec: str
|
|
96
|
+
commit: str
|
|
97
|
+
commit_subject: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _GitCommandFailure(RuntimeError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _build_child_environment() -> dict[str, str]:
|
|
105
|
+
return {
|
|
106
|
+
each_name: each_content
|
|
107
|
+
for each_name, each_content in os.environ.items()
|
|
108
|
+
if not each_name.upper().startswith("GIT_")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _execute_git(
|
|
113
|
+
all_arguments: tuple[str, ...],
|
|
114
|
+
cwd: Path,
|
|
115
|
+
) -> subprocess.CompletedProcess[str]:
|
|
116
|
+
try:
|
|
117
|
+
return subprocess.run(
|
|
118
|
+
[GIT_EXECUTABLE_NAME, *all_arguments],
|
|
119
|
+
cwd=cwd,
|
|
120
|
+
check=False,
|
|
121
|
+
capture_output=True,
|
|
122
|
+
encoding=UTF8_ENCODING,
|
|
123
|
+
errors=DECODE_ERRORS_POLICY,
|
|
124
|
+
env=_build_child_environment(),
|
|
125
|
+
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
126
|
+
)
|
|
127
|
+
except subprocess.TimeoutExpired as error:
|
|
128
|
+
raise _GitCommandFailure(
|
|
129
|
+
GIT_TIMEOUT_MESSAGE_TEMPLATE.format(seconds=GIT_COMMAND_TIMEOUT_SECONDS)
|
|
130
|
+
) from error
|
|
131
|
+
except OSError as error:
|
|
132
|
+
raise _GitCommandFailure(
|
|
133
|
+
GIT_LAUNCH_FAILURE_MESSAGE_TEMPLATE.format(error=error)
|
|
134
|
+
) from error
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _git_failure_diagnostic(
|
|
138
|
+
completed_process: subprocess.CompletedProcess[str],
|
|
139
|
+
) -> str:
|
|
140
|
+
diagnostic = completed_process.stderr.strip() or completed_process.stdout.strip()
|
|
141
|
+
if diagnostic:
|
|
142
|
+
return diagnostic
|
|
143
|
+
return GIT_EXIT_FAILURE_MESSAGE_TEMPLATE.format(
|
|
144
|
+
status=completed_process.returncode,
|
|
145
|
+
command=GIT_EXECUTABLE_NAME,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _read_required_git_text(
|
|
150
|
+
all_arguments: tuple[str, ...],
|
|
151
|
+
cwd: Path,
|
|
152
|
+
empty_text_diagnostic: str,
|
|
153
|
+
) -> str:
|
|
154
|
+
completed_process = _execute_git(all_arguments, cwd)
|
|
155
|
+
if completed_process.returncode != EXIT_CODE_SUCCESS:
|
|
156
|
+
raise _GitCommandFailure(_git_failure_diagnostic(completed_process))
|
|
157
|
+
git_text = completed_process.stdout.strip()
|
|
158
|
+
if not git_text:
|
|
159
|
+
raise _GitCommandFailure(empty_text_diagnostic)
|
|
160
|
+
return git_text
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def lookup_pull_request_url(repository: Path) -> str | None:
|
|
164
|
+
"""Read the open pull request URL when GitHub CLI can provide one.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
repository: Child repository to query.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The pull request URL, or None when the lookup is unavailable.
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
completed_process = subprocess.run(
|
|
174
|
+
[GH_EXECUTABLE_NAME, *ALL_GH_PULL_REQUEST_ARGUMENTS],
|
|
175
|
+
cwd=repository,
|
|
176
|
+
check=False,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
encoding=UTF8_ENCODING,
|
|
179
|
+
errors=DECODE_ERRORS_POLICY,
|
|
180
|
+
env=_build_child_environment(),
|
|
181
|
+
timeout=GH_COMMAND_TIMEOUT_SECONDS,
|
|
182
|
+
)
|
|
183
|
+
except (OSError, subprocess.SubprocessError):
|
|
184
|
+
return None
|
|
185
|
+
if completed_process.returncode != EXIT_CODE_SUCCESS:
|
|
186
|
+
return None
|
|
187
|
+
return completed_process.stdout.strip() or None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _build_error_report(repository: Path, diagnostic: str) -> SyncReport:
|
|
191
|
+
return SyncReport(
|
|
192
|
+
status=SyncStatus.ERROR,
|
|
193
|
+
repository=repository.resolve().as_posix(),
|
|
194
|
+
diagnostic=diagnostic,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _resolve_repository_root(repository: Path) -> Path:
|
|
199
|
+
requested_repository = repository.resolve()
|
|
200
|
+
if not requested_repository.is_dir():
|
|
201
|
+
raise _GitCommandFailure(
|
|
202
|
+
REPOSITORY_NOT_DIRECTORY_MESSAGE_TEMPLATE.format(
|
|
203
|
+
path=requested_repository,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
repository_root_text = _read_required_git_text(
|
|
207
|
+
(GIT_REV_PARSE_SUBCOMMAND, GIT_SHOW_TOP_LEVEL_ARGUMENT),
|
|
208
|
+
requested_repository,
|
|
209
|
+
REPOSITORY_RESOLUTION_FAILURE_MESSAGE,
|
|
210
|
+
)
|
|
211
|
+
return Path(repository_root_text).resolve()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _find_parent_repository(repository: Path) -> Path | None:
|
|
215
|
+
completed_process = _execute_git(
|
|
216
|
+
(GIT_REV_PARSE_SUBCOMMAND, GIT_SHOW_SUPERPROJECT_ARGUMENT),
|
|
217
|
+
repository,
|
|
218
|
+
)
|
|
219
|
+
if completed_process.returncode != EXIT_CODE_SUCCESS:
|
|
220
|
+
raise _GitCommandFailure(_git_failure_diagnostic(completed_process))
|
|
221
|
+
parent_repository_text = completed_process.stdout.strip()
|
|
222
|
+
if not parent_repository_text:
|
|
223
|
+
return None
|
|
224
|
+
return Path(parent_repository_text).resolve()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _read_child_commit_subject(repository: Path) -> str:
|
|
228
|
+
completed_process = _execute_git(
|
|
229
|
+
(
|
|
230
|
+
GIT_LOG_SUBCOMMAND,
|
|
231
|
+
GIT_LATEST_COMMIT_ARGUMENT,
|
|
232
|
+
GIT_SUBJECT_FORMAT_ARGUMENT,
|
|
233
|
+
),
|
|
234
|
+
repository,
|
|
235
|
+
)
|
|
236
|
+
if completed_process.returncode != EXIT_CODE_SUCCESS:
|
|
237
|
+
raise _GitCommandFailure(_git_failure_diagnostic(completed_process))
|
|
238
|
+
return completed_process.stdout.strip()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _build_sync_context(
|
|
242
|
+
repository: Path,
|
|
243
|
+
parent_repository: Path,
|
|
244
|
+
) -> _SyncContext:
|
|
245
|
+
try:
|
|
246
|
+
submodule_relative_path = repository.relative_to(parent_repository)
|
|
247
|
+
except ValueError as error:
|
|
248
|
+
raise _GitCommandFailure(SUBMODULE_PATH_FAILURE_MESSAGE) from error
|
|
249
|
+
commit_hash = _read_required_git_text(
|
|
250
|
+
(GIT_REV_PARSE_SUBCOMMAND, GIT_HEAD_REFERENCE),
|
|
251
|
+
repository,
|
|
252
|
+
REPOSITORY_RESOLUTION_FAILURE_MESSAGE,
|
|
253
|
+
)
|
|
254
|
+
submodule_path = submodule_relative_path.as_posix()
|
|
255
|
+
return _SyncContext(
|
|
256
|
+
repository=repository,
|
|
257
|
+
parent_repository=parent_repository,
|
|
258
|
+
submodule_path=submodule_path,
|
|
259
|
+
literal_pathspec=f"{GIT_LITERAL_PATHSPEC_PREFIX}{submodule_path}",
|
|
260
|
+
commit=commit_hash,
|
|
261
|
+
commit_subject=_read_child_commit_subject(repository),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _stage_parent_pointer(sync_context: _SyncContext) -> None:
|
|
266
|
+
completed_process = _execute_git(
|
|
267
|
+
(GIT_ADD_SUBCOMMAND, GIT_ARGUMENT_SEPARATOR, sync_context.literal_pathspec),
|
|
268
|
+
sync_context.parent_repository,
|
|
269
|
+
)
|
|
270
|
+
if completed_process.returncode != EXIT_CODE_SUCCESS:
|
|
271
|
+
raise _GitCommandFailure(_git_failure_diagnostic(completed_process))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _parent_pointer_needs_commit(sync_context: _SyncContext) -> bool:
|
|
275
|
+
completed_process = _execute_git(
|
|
276
|
+
(
|
|
277
|
+
GIT_DIFF_SUBCOMMAND,
|
|
278
|
+
GIT_CACHED_ARGUMENT,
|
|
279
|
+
GIT_QUIET_ARGUMENT,
|
|
280
|
+
GIT_ARGUMENT_SEPARATOR,
|
|
281
|
+
sync_context.literal_pathspec,
|
|
282
|
+
),
|
|
283
|
+
sync_context.parent_repository,
|
|
284
|
+
)
|
|
285
|
+
if completed_process.returncode == EXIT_CODE_SUCCESS:
|
|
286
|
+
return False
|
|
287
|
+
if completed_process.returncode == EXIT_CODE_FAILURE:
|
|
288
|
+
return True
|
|
289
|
+
raise _GitCommandFailure(_git_failure_diagnostic(completed_process))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _build_parent_commit_message(sync_context: _SyncContext) -> str:
|
|
293
|
+
parent_message = PARENT_COMMIT_MESSAGE_TEMPLATE.format(
|
|
294
|
+
repository_name=sync_context.repository.name,
|
|
295
|
+
commit_hash=sync_context.commit,
|
|
296
|
+
)
|
|
297
|
+
if not sync_context.commit_subject:
|
|
298
|
+
return parent_message
|
|
299
|
+
return SUBMODULE_COMMIT_MESSAGE_TEMPLATE.format(
|
|
300
|
+
parent_message=parent_message,
|
|
301
|
+
subject=sync_context.commit_subject,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _read_parent_commit(sync_context: _SyncContext) -> str:
|
|
306
|
+
return _read_required_git_text(
|
|
307
|
+
(GIT_REV_PARSE_SUBCOMMAND, GIT_HEAD_REFERENCE),
|
|
308
|
+
sync_context.parent_repository,
|
|
309
|
+
REPOSITORY_RESOLUTION_FAILURE_MESSAGE,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _commit_parent_pointer(sync_context: _SyncContext) -> str:
|
|
314
|
+
completed_process = _execute_git(
|
|
315
|
+
(
|
|
316
|
+
GIT_COMMIT_SUBCOMMAND,
|
|
317
|
+
GIT_ONLY_ARGUMENT,
|
|
318
|
+
GIT_MESSAGE_ARGUMENT,
|
|
319
|
+
_build_parent_commit_message(sync_context),
|
|
320
|
+
GIT_ARGUMENT_SEPARATOR,
|
|
321
|
+
sync_context.literal_pathspec,
|
|
322
|
+
),
|
|
323
|
+
sync_context.parent_repository,
|
|
324
|
+
)
|
|
325
|
+
if completed_process.returncode != EXIT_CODE_SUCCESS:
|
|
326
|
+
raise _GitCommandFailure(_git_failure_diagnostic(completed_process))
|
|
327
|
+
return _read_parent_commit(sync_context)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _report_from_context(
|
|
331
|
+
sync_context: _SyncContext,
|
|
332
|
+
status: SyncStatus,
|
|
333
|
+
parent_commit: str,
|
|
334
|
+
) -> SyncReport:
|
|
335
|
+
return SyncReport(
|
|
336
|
+
status=status,
|
|
337
|
+
repository=sync_context.repository.as_posix(),
|
|
338
|
+
parent_repository=sync_context.parent_repository.as_posix(),
|
|
339
|
+
submodule_path=sync_context.submodule_path,
|
|
340
|
+
commit=sync_context.commit,
|
|
341
|
+
parent_commit=parent_commit,
|
|
342
|
+
pull_request_url=lookup_pull_request_url(sync_context.repository),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _sync_parent_pointer(sync_context: _SyncContext) -> SyncReport:
|
|
347
|
+
_stage_parent_pointer(sync_context)
|
|
348
|
+
if not _parent_pointer_needs_commit(sync_context):
|
|
349
|
+
return _report_from_context(
|
|
350
|
+
sync_context,
|
|
351
|
+
SyncStatus.UNCHANGED,
|
|
352
|
+
_read_parent_commit(sync_context),
|
|
353
|
+
)
|
|
354
|
+
return _report_from_context(
|
|
355
|
+
sync_context,
|
|
356
|
+
SyncStatus.UPDATED,
|
|
357
|
+
_commit_parent_pointer(sync_context),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def sync_repository(repository: Path) -> SyncReport:
|
|
362
|
+
"""Record the child commit in its parent repository.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
repository: Child repository or a directory inside it.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
The sync status and every path or commit needed for readback.
|
|
369
|
+
"""
|
|
370
|
+
requested_repository = repository.resolve()
|
|
371
|
+
try:
|
|
372
|
+
repository_root = _resolve_repository_root(requested_repository)
|
|
373
|
+
parent_repository = _find_parent_repository(repository_root)
|
|
374
|
+
if parent_repository is None:
|
|
375
|
+
return SyncReport(
|
|
376
|
+
status=SyncStatus.NOT_SUBMODULE,
|
|
377
|
+
repository=repository_root.as_posix(),
|
|
378
|
+
pull_request_url=lookup_pull_request_url(repository_root),
|
|
379
|
+
)
|
|
380
|
+
sync_context = _build_sync_context(repository_root, parent_repository)
|
|
381
|
+
return _sync_parent_pointer(sync_context)
|
|
382
|
+
except (_GitCommandFailure, OSError) as error:
|
|
383
|
+
return _build_error_report(requested_repository, str(error))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Constants package for the parent-pointer synchronization command."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration package for the parent-pointer synchronization command."""
|
package/.agents/skills/syncing-submodules/scripts/submodule_sync_constants/config/constants.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Constants used by the parent-pointer synchronization command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
DECODE_ERRORS_POLICY: str = "replace"
|
|
6
|
+
DEFAULT_REPOSITORY_ARGUMENT: str = "."
|
|
7
|
+
DIAGNOSTIC_PREFIX: str = "syncing-submodules: "
|
|
8
|
+
EXIT_CODE_FAILURE: int = 1
|
|
9
|
+
EXIT_CODE_INVALID_ARGUMENTS: int = 2
|
|
10
|
+
EXIT_CODE_SUCCESS: int = 0
|
|
11
|
+
GIT_COMMAND_TIMEOUT_SECONDS: int = 30
|
|
12
|
+
GIT_EXECUTABLE_NAME: str = "git"
|
|
13
|
+
GIT_HEAD_REFERENCE: str = "HEAD"
|
|
14
|
+
GIT_LATEST_COMMIT_ARGUMENT: str = "-1"
|
|
15
|
+
GIT_LITERAL_PATHSPEC_PREFIX: str = ":(literal)"
|
|
16
|
+
GIT_REV_PARSE_SUBCOMMAND: str = "rev-parse"
|
|
17
|
+
GIT_SHOW_SUPERPROJECT_ARGUMENT: str = "--show-superproject-working-tree"
|
|
18
|
+
GIT_SHOW_TOP_LEVEL_ARGUMENT: str = "--show-toplevel"
|
|
19
|
+
GIT_LOG_SUBCOMMAND: str = "log"
|
|
20
|
+
GIT_SUBJECT_FORMAT_ARGUMENT: str = "--pretty=%s"
|
|
21
|
+
GIT_ADD_SUBCOMMAND: str = "add"
|
|
22
|
+
GIT_ARGUMENT_SEPARATOR: str = "--"
|
|
23
|
+
GIT_DIFF_SUBCOMMAND: str = "diff"
|
|
24
|
+
GIT_CACHED_ARGUMENT: str = "--cached"
|
|
25
|
+
GIT_QUIET_ARGUMENT: str = "--quiet"
|
|
26
|
+
GIT_COMMIT_SUBCOMMAND: str = "commit"
|
|
27
|
+
GIT_ONLY_ARGUMENT: str = "--only"
|
|
28
|
+
GIT_MESSAGE_ARGUMENT: str = "-m"
|
|
29
|
+
GIT_LAUNCH_FAILURE_MESSAGE_TEMPLATE: str = "git command could not run: {error}"
|
|
30
|
+
GIT_TIMEOUT_MESSAGE_TEMPLATE: str = "git command timed out after {seconds} seconds"
|
|
31
|
+
GIT_EXIT_FAILURE_MESSAGE_TEMPLATE: str = (
|
|
32
|
+
"git command exited with status {status}: {command}"
|
|
33
|
+
)
|
|
34
|
+
GH_EXECUTABLE_NAME: str = "gh"
|
|
35
|
+
ALL_GH_PULL_REQUEST_ARGUMENTS: tuple[str, ...] = (
|
|
36
|
+
"pr",
|
|
37
|
+
"view",
|
|
38
|
+
"--json",
|
|
39
|
+
"url",
|
|
40
|
+
"--jq",
|
|
41
|
+
".url",
|
|
42
|
+
)
|
|
43
|
+
GH_COMMAND_TIMEOUT_SECONDS: int = 5
|
|
44
|
+
PARENT_COMMIT_MESSAGE_TEMPLATE: str = (
|
|
45
|
+
"chore: update {repository_name} submodule to {commit_hash}"
|
|
46
|
+
)
|
|
47
|
+
SUBMODULE_COMMIT_MESSAGE_TEMPLATE: str = (
|
|
48
|
+
"{parent_message}\n\nSubmodule commit: {subject}"
|
|
49
|
+
)
|
|
50
|
+
INVALID_ARGUMENT_MESSAGE_TEMPLATE: str = "invalid command arguments: {error}"
|
|
51
|
+
SUBMODULE_PATH_FAILURE_MESSAGE: str = (
|
|
52
|
+
"could not resolve the submodule path in its parent repository"
|
|
53
|
+
)
|
|
54
|
+
REPOSITORY_RESOLUTION_FAILURE_MESSAGE: str = (
|
|
55
|
+
"git did not return a repository top-level path"
|
|
56
|
+
)
|
|
57
|
+
REPOSITORY_NOT_DIRECTORY_MESSAGE_TEMPLATE: str = (
|
|
58
|
+
"repository path is not a directory: {path}"
|
|
59
|
+
)
|
|
60
|
+
REPOSITORY_ARGUMENT_NAME: str = "--repository"
|
|
61
|
+
SYNC_STATUS_UPDATED: str = "updated"
|
|
62
|
+
SYNC_STATUS_UNCHANGED: str = "unchanged"
|
|
63
|
+
SYNC_STATUS_NOT_SUBMODULE: str = "not_submodule"
|
|
64
|
+
SYNC_STATUS_ERROR: str = "error"
|
|
65
|
+
JSON_LINE_SEPARATOR: str = "\n"
|
|
66
|
+
UTF8_ENCODING: str = "utf-8"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
|
|
10
|
+
from submodule_sync import (
|
|
11
|
+
SyncReport,
|
|
12
|
+
SyncStatus,
|
|
13
|
+
sync_repository,
|
|
14
|
+
)
|
|
15
|
+
from submodule_sync_constants.config.constants import (
|
|
16
|
+
DEFAULT_REPOSITORY_ARGUMENT,
|
|
17
|
+
DIAGNOSTIC_PREFIX,
|
|
18
|
+
EXIT_CODE_FAILURE,
|
|
19
|
+
EXIT_CODE_INVALID_ARGUMENTS,
|
|
20
|
+
EXIT_CODE_SUCCESS,
|
|
21
|
+
INVALID_ARGUMENT_MESSAGE_TEMPLATE,
|
|
22
|
+
JSON_LINE_SEPARATOR,
|
|
23
|
+
REPOSITORY_ARGUMENT_NAME,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _InvalidCommandArguments(ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _SyncArgumentParser(argparse.ArgumentParser):
|
|
32
|
+
def error(self, message: str) -> NoReturn:
|
|
33
|
+
"""Reject invalid command arguments.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
message: Parser diagnostic.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
_InvalidCommandArguments: Always raised with the parser diagnostic.
|
|
40
|
+
"""
|
|
41
|
+
raise _InvalidCommandArguments(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_arguments(all_arguments: Sequence[str]) -> Path:
|
|
45
|
+
parser = _SyncArgumentParser(add_help=False)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
REPOSITORY_ARGUMENT_NAME,
|
|
48
|
+
default=DEFAULT_REPOSITORY_ARGUMENT,
|
|
49
|
+
)
|
|
50
|
+
parsed_arguments = parser.parse_args(all_arguments)
|
|
51
|
+
return Path(str(parsed_arguments.repository))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _emit_report(sync_report: SyncReport) -> None:
|
|
55
|
+
sys.stdout.write(json.dumps(sync_report.as_record()) + JSON_LINE_SEPARATOR)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(all_arguments: Sequence[str]) -> int:
|
|
59
|
+
"""Run the parent-pointer synchronization command.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
all_arguments: Command arguments without the executable name.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Zero for completed states, one for Git failures, or two for bad arguments.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
repository = _parse_arguments(all_arguments)
|
|
69
|
+
except _InvalidCommandArguments as error:
|
|
70
|
+
diagnostic = INVALID_ARGUMENT_MESSAGE_TEMPLATE.format(error=error)
|
|
71
|
+
sync_report = SyncReport(
|
|
72
|
+
status=SyncStatus.ERROR,
|
|
73
|
+
repository=Path(DEFAULT_REPOSITORY_ARGUMENT).resolve().as_posix(),
|
|
74
|
+
diagnostic=diagnostic,
|
|
75
|
+
)
|
|
76
|
+
_emit_report(sync_report)
|
|
77
|
+
print(f"{DIAGNOSTIC_PREFIX}{diagnostic}", file=sys.stderr)
|
|
78
|
+
return EXIT_CODE_INVALID_ARGUMENTS
|
|
79
|
+
sync_report = sync_repository(repository)
|
|
80
|
+
_emit_report(sync_report)
|
|
81
|
+
if sync_report.status is SyncStatus.ERROR:
|
|
82
|
+
print(f"{DIAGNOSTIC_PREFIX}{sync_report.diagnostic}", file=sys.stderr)
|
|
83
|
+
return EXIT_CODE_FAILURE
|
|
84
|
+
return EXIT_CODE_SUCCESS
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
raise SystemExit(main(sys.argv[1:]))
|