* fix(pr-branch): handle sub_repos from config with git -C (#666) Adds a `handle_sub_repos` step between `detect_state` and `analyze_commits`. When `planning.sub_repos` is set in config, the workflow now: - Reads sub-repo paths via `gsd_run query config-get sub_repos` - Skips the step entirely when the list is empty/null/[] - Scans each repo with `git -C "$REPO" status --porcelain` - Offers the user all/select/skip choices - For selected repos: creates a PR branch, commits all staged/unstaged changes, pushes, and opens a companion PR via `gh pr create` All git commands use `git -C "$REPO"` — never `cd "$REPO"` — because shell state does not persist between agent-executed commands. Closes #666 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update changeset pr number to 667 * fix(pr-branch): address maintainer review — correct seam, behavioral tests, robustness Resolves all three blockers and seven robustness issues raised in PR #667 review: Blockers: - Use `planning.sub_repos` (not top-level `sub_repos`) so config-get actually resolves - Replace prose grep test with behavioral fixture tests using runGsdTools + local bare repo - Extract sub-repo git work into new `cmdPrSubrepo` seam in src/commands.cts; never uses git add -A — stages explicit files only (universal-anti-patterns.md:44) Robustness: - Dirty-repo list persisted via mktemp/cat, not bash arrays (cross-block safe) - Branch name embeds repo slug (${CURRENT_BRANCH}-${REPO_SAFE}-pr) to avoid collision - push --set-upstream so gh pr create finds the branch - Sub-repo base branch resolved via ls-remote with fallback to repo's default branch - Remote slug parsed with /github\.com[:/]/ (handles SSH + HTTPS + .git-less URLs) - rollback() cleans up branch on any mid-sequence failure - node -e replaces jq (always available, no undeclared hard dep) Refs: #666 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(pr-branch): security guard, push timeout, rollback fix, porcelain fix Security (Blocker 1): - Use security.cjs validatePath() in cmdPrSubrepo for symlink-safe workspace containment check — rejects ../escape, absolute paths, and symlink traversal - Add negative regression test: '../escape' repo path must be rejected Robustness: - Push uses timeout: 60_000 ms (network op needs more than the 10 s default) - Capture prevBranchName before checkout -b so rollback uses explicit name instead of git checkout - (fails on fresh single-branch repos) - Porcelain path parse: line.trimStart().slice(2).trim() handles all XY combinations and the execGit global-trim edge case uniformly Tests: 17/17 pass, lint: 0 errors Refs: #666 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(pr-branch): move regression tests to commands.test.cjs, add core.quotePath=false - Move cmdPrSubrepo behavioral + workflow source-invariant tests from standalone bug-666-*.test.cjs into tests/commands.test.cjs under describe('pr-subrepo') per TESTING-SUITES.md policy (no new bug-* files). Adds allow-test-rule: source-text-is-the-product see #666 for the workflow-source-invariant suite. - Add -c core.quotePath=false to git status --porcelain call so non-ASCII filenames (e.g. café) are not C-escaped, keeping slice(2) parse correct. * fix(pr-branch): remove obsolete regression tests for sub-repos handling * fix(pr-branch): update workflow-size-baseline, add dirty-scan timeout - Regenerate tests/workflow-size-baseline.json for pr-branch.md growth (+handle_sub_repos step, +timeout addition). - Add { timeout: 10_000 } to the execFileSync git status --porcelain call in the handle_sub_repos dirty-scan (repo convention: every git subprocess is bounded, never hangs). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: regenerate INVENTORY-MANIFEST after rebase onto next Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#666): handle rename staging and split changedFiles from filesToStage For git mv renames, the old path no longer exists in the worktree after the move — staging it with git add fails. Split parsing into changedFiles (both paths, for result.files) and filesToStage (new path only for renames; old is already staged by git mv). Also adds porcelain tests for staged renames, non-ASCII filenames, and a fast-check property test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#666): rollback on push failure in cmdPrSubrepo If push fails the branch only exists locally; rollback cleans it up so the sub-repo is not left in a half-committed state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#666): do not rollback after commit on push failure; add push-fail regression test Post-commit push failures are network/auth/policy issues — the user's work is already committed on the local branch. Calling rollback() at that point force-deletes the only ref holding the commit (data loss). Leave the branch in place and emit a retry instruction instead. Adds a regression test (pre-receive hook that rejects all pushes) asserting the branch and commit survive a push rejection so the failure path stays covered going forward. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: regenerate INVENTORY-MANIFEST after rebase onto next Rebased onto current next (#1267 retired core.cjs). Stale tsbuildinfo and a leftover bin/lib/core.cjs build artifact were masking the drift — wiped both, rebuilt clean, and regenerated the manifest. gen-inventory-manifest --check now exits 0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#666): validate sub-repo paths before git invocation in pr-branch.md The handle_sub_repos workflow ran git -C on raw planning.sub_repos config values at two points before the pr-subrepo seam's validatePath guard ever ran: the dirty-scan detection (git status) and the base-branch resolution (git ls-remote / remote show). A traversal entry could point git outside the workspace; an embedded newline could inject a spurious record into the newline-joined dirty-file output and into the shell-interpolated commit message. Adds a containment check + character allowlist to the dirty-scan node script (reject before any execFileSync), and a defense-in-depth shell case guard on the same value before the second, independent git -C invocation in the base-branch resolution block. Adds a behavioral test that extracts and executes the actual shipped node script from pr-branch.md (not a mirror) against a real traversal target and an embedded-newline entry, asserting neither reaches git or the dirty-file output. Also updates the stale cmdPrSubrepo doc comment: push failures no longer delete the branch (see prior commit), only stage/commit failures do. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(#666): make sub-repo traversal scan test genuinely fail-first The outside repo's only change was an untracked file, which the ?? filter excludes — so the repo looked clean even with the guard removed, making the traversal assertion vacuous (it passed against a neutered guard). Commit the file first, then modify it, so the outside repo has a tracked dirty change: without the path guard it WOULD be reported dirty, so the test now fails-first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#666): symlink-safe (realpath) sub-repo containment in pr-branch.md Finding A from re-review: the workflow guard used path.resolve, which only normalizes '..' textually and does not follow symlinks — so an in-tree symlink whose name has no '..' or '/' (e.g. "evil" -> /outside) passed both the charset filter and the resolve+startsWith check, letting git status / ls-remote / remote show run against a directory outside the workspace. The pr-subrepo seam already used fs.realpathSync (validatePath); this brings the workflow layer to parity. - dirty-scan: realpathSync the root once, and realpathSync each candidate before the containment check; skip on throw. - base-branch resolution: replace the weak `case *..*|/*` guard with a realpath containment check that yields a validated absolute SUB_REPO_DIR, and run git -C against that instead of re-concatenating $ROOT/$REPO_REL. - security test: add a symlink-escape entry and a positive control (legit in-root backend must still be reported). Confirmed fails-first — regressing the scan to path.resolve makes the symlink case leak. Also fixes a misleading-fallback minor: the workflow now checks the seam's exit status and skips the companion-PR step on failure, instead of printing "branch pushed, open PR manually" after a real stage/commit/push failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#666): harden pr-branch sub-repo flow against round-12 edge cases Pre-emptive hardening of the workflow changes from the symlink fix: - continue-outside-loop: the "skip companion PR on seam failure" block used a bash `continue`, but the per-sub-repo iteration is prose-driven (the agent loops, not a literal `for`), so `continue` would warn and no-op. Reframed as prose-gated control flow keyed on $SUBREPO_EXIT — no bash loop assumption. - Windows portability: the new symlink security case now degrades gracefully (try/catch around fs.symlinkSync; skip just the symlink assertion when symlink creation lacks privileges) so it doesn't hard-fail on Windows CI. Verified: seam exits 1 on error / 0 on success (error() → process.exit(1), propagated through the shim), so the $SUBREPO_EXIT check is meaningful; bash -n clean on the touched blocks; commands 156/156; lint:ci green; manifest in sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
@@ -43,6 +43,162 @@ Commits: {AHEAD} ahead
|
||||
```
|
||||
</step>
|
||||
|
||||
<step name="handle_sub_repos">
|
||||
Read the sub-repo list from config using the canonical key path — `planning.sub_repos`.
|
||||
A non-zero exit code means the key is absent; treat that as "no sub-repos configured".
|
||||
|
||||
```bash
|
||||
SUB_REPOS_JSON=$(gsd_run query config-get planning.sub_repos 2>/dev/null)
|
||||
if [ $? -ne 0 ] || [ -z "$SUB_REPOS_JSON" ] || [ "$SUB_REPOS_JSON" = "null" ] || [ "$SUB_REPOS_JSON" = "[]" ]; then
|
||||
: # Not configured or empty — skip to analyze_commits
|
||||
fi
|
||||
```
|
||||
|
||||
Scan each sub-repo for uncommitted changes using node (always available — avoids undeclared
|
||||
jq dependency). Write dirty repo names to a temp file so the list survives across
|
||||
subsequent command executions:
|
||||
|
||||
```bash
|
||||
ROOT=$(git rev-parse --show-toplevel)
|
||||
DIRTY_FILE=$(mktemp)
|
||||
|
||||
node -e "
|
||||
const repos = JSON.parse(process.argv[1]);
|
||||
const { execFileSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const root = process.argv[2];
|
||||
// realpath parity with the pr-subrepo seam's validatePath: resolve $ROOT through
|
||||
// symlinks once so the containment check below compares real paths, not text.
|
||||
let realRoot;
|
||||
try { realRoot = fs.realpathSync(root); } catch (_) { realRoot = path.resolve(root); }
|
||||
const out = [];
|
||||
for (const r of repos) {
|
||||
// Reject before any git invocation: this scan runs on raw config values,
|
||||
// ahead of the pr-subrepo seam's own validatePath guard. A traversal,
|
||||
// embedded-newline, or symlink entry here would run git outside the
|
||||
// workspace, or inject a spurious record into the dirty-file output.
|
||||
if (typeof r !== 'string' || !/^[A-Za-z0-9._\/-]+$/.test(r)) continue;
|
||||
// realpathSync follows symlinks — path.resolve only normalizes '..' textually,
|
||||
// so an in-tree symlink pointing outside root would otherwise smuggle git out.
|
||||
let resolved;
|
||||
try { resolved = fs.realpathSync(path.resolve(realRoot, r)); } catch (_) { continue; }
|
||||
if (resolved !== realRoot && !resolved.startsWith(realRoot + path.sep)) continue;
|
||||
try {
|
||||
const res = execFileSync('git', ['-C', resolved, 'status', '--porcelain'],
|
||||
{ encoding: 'utf8', timeout: 10_000 });
|
||||
// Exclude untracked-only repos: seam filters ?? lines, so detection must match.
|
||||
const tracked = res.split('\n').filter(l => l.length > 0 && !l.startsWith('??'));
|
||||
if (tracked.length > 0) out.push(r);
|
||||
} catch (_) {}
|
||||
}
|
||||
fs.writeFileSync(process.argv[3], out.join('\n'));
|
||||
" "$SUB_REPOS_JSON" "$ROOT" "$DIRTY_FILE"
|
||||
|
||||
DIRTY_REPOS=$(cat "$DIRTY_FILE")
|
||||
```
|
||||
|
||||
If `$DIRTY_REPOS` is empty, remove the temp file and continue to `analyze_commits`.
|
||||
|
||||
Display dirty repos and prompt the user:
|
||||
|
||||
```
|
||||
Sub-repos with uncommitted changes:
|
||||
backend
|
||||
frontend
|
||||
|
||||
How should sub-repo changes be handled?
|
||||
1. all — branch, commit (explicit files only), push -u, open companion PR per repo
|
||||
2. select — choose which sub-repos to process
|
||||
3. skip — ignore sub-repos, continue with root repo only
|
||||
```
|
||||
|
||||
If the user chooses **skip**, remove the temp file and continue to `analyze_commits`.
|
||||
|
||||
For each selected sub-repo `$REPO_REL`, delegate all git work to the `pr-subrepo` query
|
||||
seam — it stages explicit changed files (never `git add -A`), creates the branch,
|
||||
commits, and pushes with `--set-upstream`. Branch names include the repo slug to avoid
|
||||
colliding with the root `PR_BRANCH` that `create_pr_branch` creates later:
|
||||
|
||||
```bash
|
||||
# Replace path separators to make the name safe as a branch component
|
||||
REPO_SAFE="${REPO_REL//\//-}"
|
||||
SUB_BRANCH="${CURRENT_BRANCH}-${REPO_SAFE}-pr"
|
||||
COMMIT_MSG="fix(${REPO_REL}): sync uncommitted changes for PR"
|
||||
|
||||
RESULT=$(gsd_run query pr-subrepo "$COMMIT_MSG" \
|
||||
--repo "$REPO_REL" \
|
||||
--branch "$SUB_BRANCH")
|
||||
SUBREPO_EXIT=$?
|
||||
```
|
||||
|
||||
If the seam exited non-zero (stage/commit/push failure), report its error and move on to
|
||||
the next selected sub-repo. **Do not run the companion-PR step below for this repo** —
|
||||
the seam's stderr already explains the failure, and the "branch pushed" path would
|
||||
otherwise contradict it:
|
||||
|
||||
```bash
|
||||
if [ "$SUBREPO_EXIT" -ne 0 ]; then
|
||||
echo "pr-subrepo failed for $REPO_REL — see error above; skipping companion PR." >&2
|
||||
fi
|
||||
```
|
||||
|
||||
Only when `$SUBREPO_EXIT` is `0`, parse the structured result with node and open the
|
||||
companion PR. If `remote_slug` is null (non-GitHub remote), skip `gh pr create` and show
|
||||
the push URL instead:
|
||||
|
||||
```bash
|
||||
REMOTE_SLUG=$(node -e "
|
||||
try { console.log(JSON.parse(process.argv[1]).remote_slug || ''); } catch(_) {}
|
||||
" "$RESULT")
|
||||
|
||||
if [ -n "$REMOTE_SLUG" ]; then
|
||||
# Defense-in-depth: $REPO_REL was already validated by the dirty-scan filter and
|
||||
# the pr-subrepo seam's validatePath, but these are separate, independent git -C
|
||||
# invocations on the same value. Resolve it through symlinks with the SAME realpath
|
||||
# containment the seam uses (path.resolve alone would not catch a symlink escape),
|
||||
# and run git against the validated absolute path rather than re-concatenating.
|
||||
SUB_REPO_DIR=$(node -e "
|
||||
const fs = require('fs'), path = require('path');
|
||||
try {
|
||||
const realRoot = fs.realpathSync(process.argv[1]);
|
||||
const resolved = fs.realpathSync(path.resolve(realRoot, process.argv[2]));
|
||||
if (resolved !== realRoot && !resolved.startsWith(realRoot + path.sep)) process.exit(1);
|
||||
process.stdout.write(resolved);
|
||||
} catch (_) { process.exit(1); }
|
||||
" "$ROOT" "$REPO_REL" 2>/dev/null)
|
||||
|
||||
if [ -z "$SUB_REPO_DIR" ]; then
|
||||
echo "Refusing unsafe sub-repo path: $REPO_REL" >&2
|
||||
SUB_TARGET="$TARGET"
|
||||
else
|
||||
# Resolve base branch: use $TARGET if it exists in sub-repo, else fall back to
|
||||
# the sub-repo's own default branch
|
||||
if git -C "$SUB_REPO_DIR" ls-remote --exit-code --heads origin "$TARGET" \
|
||||
> /dev/null 2>&1; then
|
||||
SUB_TARGET="$TARGET"
|
||||
else
|
||||
SUB_TARGET=$(git -C "$SUB_REPO_DIR" remote show origin 2>/dev/null \
|
||||
| awk '/HEAD branch/ {print $NF}')
|
||||
SUB_TARGET="${SUB_TARGET:-main}"
|
||||
fi
|
||||
fi
|
||||
|
||||
gh pr create \
|
||||
--repo "$REMOTE_SLUG" \
|
||||
--base "$SUB_TARGET" \
|
||||
--head "$SUB_BRANCH" \
|
||||
--title "$COMMIT_MSG" \
|
||||
--body "Companion PR for root repo branch \`$CURRENT_BRANCH\`."
|
||||
else
|
||||
echo "No GitHub remote detected for $REPO_REL — branch pushed, open PR manually."
|
||||
fi
|
||||
```
|
||||
|
||||
After processing all selected sub-repos, remove the temp file and continue to
|
||||
`analyze_commits` for the root repo.
|
||||
</step>
|
||||
|
||||
<step name="analyze_commits">
|
||||
Classify commits:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user