@slamb2k/mad-skills 2.0.37 → 2.0.38

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.
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env bash
2
+ # ci-watch.sh — Poll CI/pipeline checks until complete with fail-fast
3
+ # Usage: ci-watch.sh <PLATFORM> <PR_NUMBER> <BRANCH> [azdo options]
4
+ # AzDO options: --azdo-mode=cli|rest --azdo-org-url=URL --azdo-project=NAME --azdo-project-url-safe=NAME
5
+ # Env: AZURE_DEVOPS_EXT_PAT or AZDO_PAT (required for azdo rest mode)
6
+ # Exit codes: 0=all_passed/no_checks, 1=some_failed, 2=pending (timeout), 3=tool error
7
+ set -uo pipefail
8
+
9
+ PLATFORM="${1:?Usage: ci-watch.sh <PLATFORM> <PR_NUMBER> <BRANCH>}"
10
+ PR_NUMBER="${2:?Usage: ci-watch.sh <PLATFORM> <PR_NUMBER> <BRANCH>}"
11
+ BRANCH="${3:?Usage: ci-watch.sh <PLATFORM> <PR_NUMBER> <BRANCH>}"
12
+ shift 3
13
+
14
+ AZDO_MODE="" AZDO_ORG_URL="" AZDO_PROJECT="" AZDO_PROJECT_URL_SAFE=""
15
+ for arg in "$@"; do
16
+ case "$arg" in
17
+ --azdo-mode=*) AZDO_MODE="${arg#*=}" ;;
18
+ --azdo-org-url=*) AZDO_ORG_URL="${arg#*=}" ;;
19
+ --azdo-project=*) AZDO_PROJECT="${arg#*=}" ;;
20
+ --azdo-project-url-safe=*) AZDO_PROJECT_URL_SAFE="${arg#*=}" ;;
21
+ esac
22
+ done
23
+
24
+ STATUS="" CHECKS="" FAILING=""
25
+
26
+ emit_report() {
27
+ echo "CHECKS_REPORT_BEGIN"
28
+ echo "status=$STATUS"
29
+ echo "checks=$CHECKS"
30
+ echo "failing_checks=${FAILING:-none}"
31
+ echo "CHECKS_REPORT_END"
32
+ }
33
+
34
+ # ── GitHub ──────────────────────────────────────────────
35
+ if [ "$PLATFORM" = "github" ]; then
36
+ if ! command -v gh &>/dev/null; then
37
+ STATUS="no_checks"; FAILING="none"
38
+ emit_report; exit 3
39
+ fi
40
+
41
+ # gh pr checks --watch blocks until done; --fail-fast stops on first failure
42
+ gh pr checks "$PR_NUMBER" --watch --fail-fast 2>/dev/null || true
43
+
44
+ # Parse final status
45
+ CHECKS_JSON=$(gh pr checks "$PR_NUMBER" --json name,state 2>/dev/null || echo "[]")
46
+ if [ "$CHECKS_JSON" = "[]" ]; then
47
+ STATUS="no_checks"; CHECKS=""; FAILING="none"
48
+ emit_report; exit 0
49
+ fi
50
+
51
+ FAIL_COUNT=$(echo "$CHECKS_JSON" | jq '[.[] | select(.state=="FAILURE")] | length')
52
+ CHECKS=$(echo "$CHECKS_JSON" | jq -r '.[] | "\(.name):\(.state | ascii_downcase)"' | paste -sd, -)
53
+ FAILING=$(echo "$CHECKS_JSON" | jq -r '.[] | select(.state=="FAILURE") | .name' | paste -sd, -)
54
+
55
+ if [ "${FAIL_COUNT:-0}" -gt 0 ]; then
56
+ STATUS="some_failed"
57
+ emit_report; exit 1
58
+ else
59
+ STATUS="all_passed"; FAILING="none"
60
+ emit_report; exit 0
61
+ fi
62
+ fi
63
+
64
+ # ── Azure DevOps ────────────────────────────────────────
65
+
66
+ # Resolve PAT for REST mode
67
+ PAT="${AZURE_DEVOPS_EXT_PAT:-${AZDO_PAT:-}}"
68
+ AUTH=""
69
+ if [ "$AZDO_MODE" = "rest" ]; then
70
+ if [ -z "$PAT" ]; then
71
+ STATUS="no_checks"; FAILING="none"
72
+ CHECKS="error:no PAT configured"
73
+ emit_report; exit 3
74
+ fi
75
+ AUTH="Authorization: Basic $(echo -n ":$PAT" | base64)"
76
+ fi
77
+
78
+ # ── AzDO CLI mode ──────────────────────────────────────
79
+ if [ "$AZDO_MODE" = "cli" ]; then
80
+ # Wait for CI to start (max 2 min)
81
+ RUNS_FOUND=false
82
+ for _ in $(seq 1 8); do
83
+ RUN_COUNT=$(az pipelines runs list --branch "$BRANCH" --top 5 \
84
+ --org "$AZDO_ORG_URL" --project "$AZDO_PROJECT" \
85
+ --query "length(@)" -o tsv 2>/dev/null)
86
+ if [ -n "$RUN_COUNT" ] && [ "$RUN_COUNT" != "0" ]; then
87
+ RUNS_FOUND=true; break
88
+ fi
89
+ sleep 15
90
+ done
91
+
92
+ if [ "$RUNS_FOUND" = false ]; then
93
+ # Check PR policies
94
+ POLICY_COUNT=$(az repos pr policy list --id "$PR_NUMBER" \
95
+ --org "$AZDO_ORG_URL" --project "$AZDO_PROJECT" \
96
+ --query "length(@)" -o tsv 2>/dev/null || echo "0")
97
+ if [ "$POLICY_COUNT" = "0" ] || [ -z "$POLICY_COUNT" ]; then
98
+ STATUS="no_checks"; CHECKS=""; FAILING="none"
99
+ emit_report; exit 0
100
+ fi
101
+ fi
102
+
103
+ # Wait for runs to complete with fail-fast (max 30 min)
104
+ for _ in $(seq 1 120); do
105
+ FAILED=$(az pipelines runs list --branch "$BRANCH" --top 5 \
106
+ --org "$AZDO_ORG_URL" --project "$AZDO_PROJECT" \
107
+ --query "[?result=='failed'] | length(@)" -o tsv 2>/dev/null)
108
+ if [ -n "$FAILED" ] && [ "$FAILED" != "0" ]; then
109
+ break
110
+ fi
111
+
112
+ IN_PROGRESS=$(az pipelines runs list --branch "$BRANCH" --top 5 \
113
+ --org "$AZDO_ORG_URL" --project "$AZDO_PROJECT" \
114
+ --query "[?status=='inProgress'] | length(@)" -o tsv 2>/dev/null)
115
+ if [ "$IN_PROGRESS" = "0" ] || [ -z "$IN_PROGRESS" ]; then break; fi
116
+ sleep 15
117
+ done
118
+
119
+ # Determine final status
120
+ RUNS_TABLE=$(az pipelines runs list --branch "$BRANCH" --top 5 \
121
+ --org "$AZDO_ORG_URL" --project "$AZDO_PROJECT" \
122
+ --query "[].{name:definition.name, result:result}" -o json 2>/dev/null || echo "[]")
123
+ CHECKS=$(echo "$RUNS_TABLE" | jq -r '.[] | "\(.name):\(.result // "pending")"' | paste -sd, -)
124
+ FAILING=$(echo "$RUNS_TABLE" | jq -r '.[] | select(.result=="failed") | .name' | paste -sd, -)
125
+
126
+ # Also check PR policies
127
+ REJECTED=$(az repos pr policy list --id "$PR_NUMBER" \
128
+ --org "$AZDO_ORG_URL" --project "$AZDO_PROJECT" \
129
+ --query "[?status=='rejected'] | length(@)" -o tsv 2>/dev/null || echo "0")
130
+
131
+ FAIL_COUNT=$(echo "$RUNS_TABLE" | jq '[.[] | select(.result=="failed")] | length')
132
+ STILL_RUNNING=$(echo "$RUNS_TABLE" | jq '[.[] | select(.result==null)] | length')
133
+
134
+ if [ "${FAIL_COUNT:-0}" -gt 0 ] || [ "${REJECTED:-0}" -gt 0 ]; then
135
+ STATUS="some_failed"
136
+ emit_report; exit 1
137
+ elif [ "${STILL_RUNNING:-0}" -gt 0 ]; then
138
+ STATUS="pending"; FAILING="none"
139
+ emit_report; exit 2
140
+ else
141
+ STATUS="all_passed"; FAILING="none"
142
+ emit_report; exit 0
143
+ fi
144
+ fi
145
+
146
+ # ── AzDO REST mode ─────────────────────────────────────
147
+ if [ "$AZDO_MODE" = "rest" ]; then
148
+ BUILDS_URL="$AZDO_ORG_URL/$AZDO_PROJECT_URL_SAFE/_apis/build/builds?branchName=refs/heads/$BRANCH&\$top=5&api-version=7.0"
149
+
150
+ # Wait for CI to start (max 2 min)
151
+ RUNS_FOUND=false
152
+ for _ in $(seq 1 8); do
153
+ RUN_COUNT=$(curl -s -H "$AUTH" "$BUILDS_URL" | jq '.value | length')
154
+ if [ -n "$RUN_COUNT" ] && [ "$RUN_COUNT" != "0" ]; then
155
+ RUNS_FOUND=true; break
156
+ fi
157
+ sleep 15
158
+ done
159
+
160
+ if [ "$RUNS_FOUND" = false ]; then
161
+ EVALS=$(curl -s -H "$AUTH" \
162
+ "$AZDO_ORG_URL/$AZDO_PROJECT_URL_SAFE/_apis/policy/evaluations?artifactId=vstfs:///CodeReview/CodeReviewId/$AZDO_PROJECT_URL_SAFE/$PR_NUMBER&api-version=7.0")
163
+ EVAL_COUNT=$(echo "$EVALS" | jq '.value | length')
164
+ if [ "$EVAL_COUNT" = "0" ] || [ -z "$EVAL_COUNT" ]; then
165
+ STATUS="no_checks"; CHECKS=""; FAILING="none"
166
+ emit_report; exit 0
167
+ fi
168
+ fi
169
+
170
+ # Wait for runs with fail-fast (max 30 min)
171
+ for _ in $(seq 1 120); do
172
+ BUILDS_JSON=$(curl -s -H "$AUTH" "$BUILDS_URL")
173
+
174
+ FAIL_COUNT=$(echo "$BUILDS_JSON" | jq '[.value[] | select(.result=="failed")] | length')
175
+ if [ "${FAIL_COUNT:-0}" -gt 0 ]; then break; fi
176
+
177
+ IN_PROGRESS=$(echo "$BUILDS_JSON" | jq '[.value[] | select(.status=="inProgress")] | length')
178
+ if [ "$IN_PROGRESS" = "0" ]; then break; fi
179
+ sleep 15
180
+ done
181
+
182
+ # Final status
183
+ BUILDS_JSON=$(curl -s -H "$AUTH" "$BUILDS_URL")
184
+ CHECKS=$(echo "$BUILDS_JSON" | jq -r '.value[] | "\(.definition.name):\(.result // "pending")"' | paste -sd, -)
185
+ FAILING=$(echo "$BUILDS_JSON" | jq -r '.value[] | select(.result=="failed") | .definition.name' | paste -sd, -)
186
+ FAIL_COUNT=$(echo "$BUILDS_JSON" | jq '[.value[] | select(.result=="failed")] | length')
187
+ STILL_RUNNING=$(echo "$BUILDS_JSON" | jq '[.value[] | select(.status=="inProgress")] | length')
188
+
189
+ # Check policy evaluations
190
+ EVALS=$(curl -s -H "$AUTH" \
191
+ "$AZDO_ORG_URL/$AZDO_PROJECT_URL_SAFE/_apis/policy/evaluations?artifactId=vstfs:///CodeReview/CodeReviewId/$AZDO_PROJECT_URL_SAFE/$PR_NUMBER&api-version=7.0")
192
+ REJECTED=$(echo "$EVALS" | jq '[.value[] | select(.status=="rejected")] | length')
193
+
194
+ if [ "${FAIL_COUNT:-0}" -gt 0 ] || [ "${REJECTED:-0}" -gt 0 ]; then
195
+ STATUS="some_failed"
196
+ emit_report; exit 1
197
+ elif [ "${STILL_RUNNING:-0}" -gt 0 ]; then
198
+ STATUS="pending"; FAILING="none"
199
+ emit_report; exit 2
200
+ else
201
+ STATUS="all_passed"; FAILING="none"
202
+ emit_report; exit 0
203
+ fi
204
+ fi
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bash
2
+ # merge.sh — Merge a PR on GitHub or Azure DevOps
3
+ # Usage: merge.sh <PLATFORM> <PR_NUMBER> [--squash|--merge] [--delete-branch|--keep-branch] [azdo options]
4
+ # AzDO options: --azdo-mode=cli|rest --azdo-org-url=URL --azdo-project=NAME --azdo-project-url-safe=NAME
5
+ # Env: AZURE_DEVOPS_EXT_PAT or AZDO_PAT (required for azdo rest mode)
6
+ # Exit codes: 0=merged, 1=failed, 2=failed after retry
7
+ set -uo pipefail
8
+
9
+ PLATFORM="${1:?Usage: merge.sh <PLATFORM> <PR_NUMBER> [flags]}"
10
+ PR_NUMBER="${2:?Usage: merge.sh <PLATFORM> <PR_NUMBER> [flags]}"
11
+ shift 2
12
+
13
+ SQUASH=true DELETE_BRANCH=true
14
+ AZDO_MODE="" AZDO_ORG_URL="" AZDO_PROJECT="" AZDO_PROJECT_URL_SAFE=""
15
+
16
+ for arg in "$@"; do
17
+ case "$arg" in
18
+ --merge) SQUASH=false ;;
19
+ --squash) SQUASH=true ;;
20
+ --keep-branch) DELETE_BRANCH=false ;;
21
+ --delete-branch) DELETE_BRANCH=true ;;
22
+ --azdo-mode=*) AZDO_MODE="${arg#*=}" ;;
23
+ --azdo-org-url=*) AZDO_ORG_URL="${arg#*=}" ;;
24
+ --azdo-project=*) AZDO_PROJECT="${arg#*=}" ;;
25
+ --azdo-project-url-safe=*) AZDO_PROJECT_URL_SAFE="${arg#*=}" ;;
26
+ esac
27
+ done
28
+
29
+ STATUS="" MERGE_COMMIT="" MERGE_TYPE="" BRANCH_DELETED="" ERRORS="none"
30
+
31
+ emit_report() {
32
+ echo "LAND_REPORT_BEGIN"
33
+ echo "status=$STATUS"
34
+ echo "merge_commit=$MERGE_COMMIT"
35
+ echo "merge_type=$MERGE_TYPE"
36
+ echo "branch_deleted=$BRANCH_DELETED"
37
+ echo "errors=$ERRORS"
38
+ echo "LAND_REPORT_END"
39
+ }
40
+
41
+ MERGE_TYPE=$( [ "$SQUASH" = true ] && echo "squash" || echo "merge" )
42
+
43
+ # ── GitHub ──────────────────────────────────────────────
44
+ if [ "$PLATFORM" = "github" ]; then
45
+ GH_MERGE_FLAG=$( [ "$SQUASH" = true ] && echo "--squash" || echo "--merge" )
46
+ GH_BRANCH_FLAG=$( [ "$DELETE_BRANCH" = true ] && echo "--delete-branch" || echo "" )
47
+
48
+ if gh pr merge "$PR_NUMBER" $GH_MERGE_FLAG $GH_BRANCH_FLAG 2>/dev/null; then
49
+ STATUS="success"
50
+ MERGE_COMMIT=$(gh pr view "$PR_NUMBER" --json mergeCommit -q '.mergeCommit.oid' 2>/dev/null | head -c 7)
51
+ BRANCH_DELETED=$DELETE_BRANCH
52
+ else
53
+ STATUS="failed"
54
+ ERRORS="gh pr merge failed"
55
+ BRANCH_DELETED=false
56
+ fi
57
+ emit_report
58
+ [ "$STATUS" = "success" ] && exit 0 || exit 1
59
+ fi
60
+
61
+ # ── Azure DevOps ────────────────────────────────────────
62
+ PAT="${AZURE_DEVOPS_EXT_PAT:-${AZDO_PAT:-}}"
63
+ SQUASH_FLAG=$( [ "$SQUASH" = true ] && echo "true" || echo "false" )
64
+ DELETE_FLAG=$( [ "$DELETE_BRANCH" = true ] && echo "true" || echo "false" )
65
+
66
+ # ── AzDO CLI mode ──────────────────────────────────────
67
+ if [ "$AZDO_MODE" = "cli" ]; then
68
+ # Check for rejected policies
69
+ REJECTED=$(az repos pr policy list --id "$PR_NUMBER" \
70
+ --org "$AZDO_ORG_URL" --project "$AZDO_PROJECT" \
71
+ --query "[?status=='rejected'] | length(@)" -o tsv 2>/dev/null)
72
+ if [ -n "$REJECTED" ] && [ "$REJECTED" != "0" ]; then
73
+ STATUS="failed"
74
+ ERRORS="$REJECTED PR policies are rejected"
75
+ MERGE_COMMIT=""; BRANCH_DELETED=false
76
+ emit_report; exit 1
77
+ fi
78
+
79
+ # Complete the PR
80
+ if az repos pr update --id "$PR_NUMBER" --status completed \
81
+ --org "$AZDO_ORG_URL" --project "$AZDO_PROJECT" \
82
+ --squash "$SQUASH_FLAG" \
83
+ --delete-source-branch "$DELETE_FLAG" 2>/dev/null; then
84
+ STATUS="success"
85
+ MERGE_COMMIT=$(git rev-parse --short HEAD 2>/dev/null)
86
+ BRANCH_DELETED=$DELETE_BRANCH
87
+ else
88
+ # Retry once after 30s (policies may still be evaluating)
89
+ sleep 30
90
+ if az repos pr update --id "$PR_NUMBER" --status completed \
91
+ --org "$AZDO_ORG_URL" --project "$AZDO_PROJECT" \
92
+ --squash "$SQUASH_FLAG" \
93
+ --delete-source-branch "$DELETE_FLAG" 2>/dev/null; then
94
+ STATUS="success"
95
+ MERGE_COMMIT=$(git rev-parse --short HEAD 2>/dev/null)
96
+ BRANCH_DELETED=$DELETE_BRANCH
97
+ else
98
+ STATUS="failed"
99
+ ERRORS="Merge failed after retry"
100
+ MERGE_COMMIT=""; BRANCH_DELETED=false
101
+ emit_report; exit 2
102
+ fi
103
+ fi
104
+ emit_report
105
+ exit 0
106
+ fi
107
+
108
+ # ── AzDO REST mode ─────────────────────────────────────
109
+ if [ "$AZDO_MODE" = "rest" ]; then
110
+ AUTH="Authorization: Basic $(echo -n ":$PAT" | base64)"
111
+ REPO_NAME=$(basename -s .git "$(git remote get-url origin)")
112
+ PR_API="$AZDO_ORG_URL/$AZDO_PROJECT_URL_SAFE/_apis/git/repositories/$REPO_NAME/pullrequests/$PR_NUMBER"
113
+
114
+ # Check for rejected policies
115
+ EVALS=$(curl -s -H "$AUTH" \
116
+ "$AZDO_ORG_URL/$AZDO_PROJECT_URL_SAFE/_apis/policy/evaluations?artifactId=vstfs:///CodeReview/CodeReviewId/$AZDO_PROJECT_URL_SAFE/$PR_NUMBER&api-version=7.0")
117
+ REJECTED=$(echo "$EVALS" | jq '[.value[] | select(.status=="rejected")] | length')
118
+ if [ "$REJECTED" != "0" ]; then
119
+ STATUS="failed"
120
+ ERRORS="PR has rejected policy evaluations"
121
+ MERGE_COMMIT=""; BRANCH_DELETED=false
122
+ emit_report; exit 1
123
+ fi
124
+
125
+ # Resolve merge strategy
126
+ MERGE_STRATEGY=$( [ "$SQUASH" = true ] && echo "squash" || echo "noFastForward" )
127
+
128
+ # Complete the PR
129
+ RESPONSE=$(curl -s -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
130
+ "$PR_API?api-version=7.0" \
131
+ -d "{\"status\": \"completed\", \"completionOptions\": {\"mergeStrategy\": \"$MERGE_STRATEGY\", \"deleteSourceBranch\": $DELETE_FLAG}}")
132
+
133
+ PR_STATUS=$(echo "$RESPONSE" | jq -r '.status // empty')
134
+ if [ "$PR_STATUS" = "completed" ]; then
135
+ STATUS="success"
136
+ MERGE_COMMIT=$(echo "$RESPONSE" | jq -r '.lastMergeCommit.commitId // empty' | head -c 7)
137
+ BRANCH_DELETED=$DELETE_BRANCH
138
+ else
139
+ STATUS="failed"
140
+ ERRORS="REST merge returned status: ${PR_STATUS:-unknown}"
141
+ MERGE_COMMIT=""; BRANCH_DELETED=false
142
+ emit_report; exit 1
143
+ fi
144
+ emit_report
145
+ exit 0
146
+ fi
@@ -19,12 +19,12 @@
19
19
  ]
20
20
  },
21
21
  {
22
- "name": "subagent-architecture",
22
+ "name": "script-based-architecture",
23
23
  "prompt": "Ship everything to production",
24
24
  "assertions": [
25
25
  { "type": "contains", "value": "██" },
26
- { "type": "regex", "value": "ship.analyzer", "flags": "i" },
27
- { "type": "semantic", "value": "Mentions using specialized subagents for different stages, including a ship-analyzer agent for code analysis and commit/PR creation" }
26
+ { "type": "regex", "value": "(script|sync\\.sh|ci-watch\\.sh|merge\\.sh|general.purpose)", "flags": "i" },
27
+ { "type": "semantic", "value": "Uses deterministic scripts for sync, CI monitoring, and merge stages, with LLM subagents only for commit/PR authoring and CI fix analysis" }
28
28
  ]
29
29
  }
30
30
  ]
@@ -59,8 +59,9 @@ Status icons: ✅ done · ❌ failed · ⚠️ degraded · ⏳ working · ⏭️
59
59
 
60
60
  ---
61
61
 
62
- Synchronize local repository with the remote default branch using a single
63
- Bash subagent to isolate all git operations from the primary conversation.
62
+ Synchronize local repository with the remote default branch using a
63
+ deterministic bash script no LLM subagent needed since all steps are
64
+ pure git commands.
64
65
 
65
66
  ## Flags
66
67
 
@@ -112,110 +113,29 @@ Pass `{REMOTE}` and `{DEFAULT_BRANCH}` into the subagent prompt.
112
113
 
113
114
  ## Execution
114
115
 
115
- Launch a **Bash subagent** (haikupure git commands, no code analysis needed):
116
+ Run the sync script directlyno LLM subagent needed since all steps are
117
+ deterministic git commands:
116
118
 
117
- ```
118
- Task(
119
- subagent_type: "Bash",
120
- model: "haiku",
121
- description: "Sync repo with {DEFAULT_BRANCH}",
122
- prompt: <see prompt below>
123
- )
119
+ ```bash
120
+ # Resolve skill root (plugin install or direct install)
121
+ for SKILL_ROOT in \
122
+ "${CLAUDE_PLUGIN_ROOT:-}" \
123
+ "$HOME/.claude/plugins/marketplaces/slamb2k" \
124
+ "$(dirname "$(readlink -f "$0")")/.."
125
+ do
126
+ [ -f "$SKILL_ROOT/skills/sync/scripts/sync.sh" ] && break
127
+ done
128
+
129
+ bash "$SKILL_ROOT/skills/sync/scripts/sync.sh" \
130
+ "{REMOTE}" "{DEFAULT_BRANCH}" {FLAGS}
124
131
  ```
125
132
 
126
- ### Subagent Prompt
133
+ Parse the output between `SYNC_REPORT_BEGIN` and `SYNC_REPORT_END` markers.
134
+ Extract `key=value` pairs for the report fields: `status`, `remote`,
135
+ `default_branch`, `main_updated_to`, `current_branch`, `stash`, `rebase`,
136
+ `branches_cleaned`, `errors`.
127
137
 
128
- ```
129
- Synchronize this git repository with {REMOTE}/{DEFAULT_BRANCH}. Execute the
130
- following steps in order and report results.
131
-
132
- Limit SYNC_REPORT to 15 lines maximum.
133
-
134
- FLAGS: {flags from request, or "none"}
135
-
136
- ## Steps
137
-
138
- 1. **Check state**
139
- BRANCH=$(git branch --show-current 2>/dev/null || echo "DETACHED")
140
- CHANGES=$(git status --porcelain | head -20)
141
- Record: current_branch=$BRANCH, has_changes=(non-empty CHANGES)
142
-
143
- If BRANCH == "DETACHED":
144
- Record error: "Detached HEAD — cannot sync. Checkout a branch first."
145
- Skip to Output.
146
-
147
- 2. **Stash changes** (skip if --no-stash or no changes)
148
- If has_changes and not --no-stash:
149
- git stash push -m "sync-auto-stash-$(date +%Y%m%d-%H%M%S)"
150
- Record: stash_created=true
151
-
152
- 3. **Sync {DEFAULT_BRANCH}**
153
- git fetch {REMOTE}
154
- If BRANCH != "{DEFAULT_BRANCH}":
155
- git checkout {DEFAULT_BRANCH}
156
- git pull {REMOTE} {DEFAULT_BRANCH} --ff-only
157
- If pull fails (diverged):
158
- git pull {REMOTE} {DEFAULT_BRANCH} --rebase
159
- Record: main_commit=$(git rev-parse --short HEAD)
160
- Record: main_message=$(git log -1 --format=%s)
161
-
162
- 4. **Return to branch and update** (skip if already on {DEFAULT_BRANCH})
163
- If current_branch != "{DEFAULT_BRANCH}":
164
- git checkout $BRANCH
165
- If --no-rebase:
166
- git merge {DEFAULT_BRANCH} --no-edit
167
- Else:
168
- git rebase {DEFAULT_BRANCH}
169
- If rebase fails:
170
- git rebase --abort
171
- Record: rebase_status="conflict — aborted, branch unchanged"
172
-
173
- 5. **Restore stash** (if created in step 2)
174
- If stash_created:
175
- git stash pop
176
- If pop fails (conflict):
177
- Record: stash="conflict — run 'git stash show' to inspect"
178
- Else:
179
- Record: stash="restored"
180
-
181
- 6. **Cleanup branches** (skip if --no-cleanup)
182
- git fetch --prune
183
-
184
- # Delete branches whose remote is gone
185
- for branch in $(git branch -vv | grep ': gone]' | awk '{print $1}'); do
186
- if [ "$branch" != "$BRANCH" ]; then
187
- git branch -d "$branch" 2>/dev/null && echo "Deleted: $branch"
188
- fi
189
- done
190
-
191
- # Delete branches fully merged into {DEFAULT_BRANCH} (except current)
192
- for branch in $(git branch --merged {DEFAULT_BRANCH} | grep -v '^\*' | grep -v '{DEFAULT_BRANCH}'); do
193
- branch=$(echo "$branch" | xargs)
194
- if [ "$branch" != "$BRANCH" ] && [ -n "$branch" ]; then
195
- git branch -d "$branch" 2>/dev/null && echo "Deleted: $branch"
196
- fi
197
- done
198
-
199
- Record: branches_cleaned={list of deleted branches, or "none"}
200
-
201
- 7. **Final state**
202
- echo "Branch: $(git branch --show-current)"
203
- echo "HEAD: $(git log -1 --format='%h %s')"
204
- echo "Status: $(git status --short | wc -l) modified files"
205
-
206
- ## Output Format
207
-
208
- SYNC_REPORT:
209
- - status: success|failed
210
- - remote: {REMOTE}
211
- - default_branch: {DEFAULT_BRANCH}
212
- - main_updated_to: {commit} - {message}
213
- - current_branch: {branch}
214
- - stash: restored|none|conflict
215
- - rebase: success|conflict|skipped
216
- - branches_cleaned: {list or "none"}
217
- - errors: {any errors encountered}
218
- ```
138
+ Exit codes: 0=success, 1=fatal error, 2=partial success (conflict warnings).
219
139
 
220
140
  ---
221
141
 
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # sync.sh — Deterministic repo sync with origin/default-branch
3
+ # Usage: sync.sh <REMOTE> <DEFAULT_BRANCH> [--no-stash] [--no-cleanup] [--no-rebase]
4
+ # Output: Key-value SYNC_REPORT between BEGIN/END markers on stdout
5
+ # Exit codes: 0=success, 1=fatal, 2=partial (conflict warnings)
6
+ set -uo pipefail
7
+
8
+ REMOTE="${1:?Usage: sync.sh <REMOTE> <DEFAULT_BRANCH> [flags]}"
9
+ DEFAULT_BRANCH="${2:?Usage: sync.sh <REMOTE> <DEFAULT_BRANCH> [flags]}"
10
+ shift 2
11
+
12
+ NO_STASH=false; NO_CLEANUP=false; NO_REBASE=false
13
+ for arg in "$@"; do
14
+ case "$arg" in
15
+ --no-stash) NO_STASH=true ;;
16
+ --no-cleanup) NO_CLEANUP=true ;;
17
+ --no-rebase) NO_REBASE=true ;;
18
+ esac
19
+ done
20
+
21
+ # Report fields
22
+ STATUS="success"
23
+ MAIN_UPDATED_TO=""
24
+ CURRENT_BRANCH=""
25
+ STASH_STATUS="none"
26
+ REBASE_STATUS="skipped"
27
+ BRANCHES_CLEANED="none"
28
+ ERRORS="none"
29
+ STASH_CREATED=false
30
+ EXIT_CODE=0
31
+
32
+ emit_report() {
33
+ echo "SYNC_REPORT_BEGIN"
34
+ echo "status=$STATUS"
35
+ echo "remote=$REMOTE"
36
+ echo "default_branch=$DEFAULT_BRANCH"
37
+ echo "main_updated_to=$MAIN_UPDATED_TO"
38
+ echo "current_branch=$CURRENT_BRANCH"
39
+ echo "stash=$STASH_STATUS"
40
+ echo "rebase=$REBASE_STATUS"
41
+ echo "branches_cleaned=$BRANCHES_CLEANED"
42
+ echo "errors=$ERRORS"
43
+ echo "SYNC_REPORT_END"
44
+ exit "$EXIT_CODE"
45
+ }
46
+
47
+ # Step 1: Check state
48
+ BRANCH=$(git branch --show-current 2>/dev/null || echo "DETACHED")
49
+ CURRENT_BRANCH="$BRANCH"
50
+
51
+ if [ "$BRANCH" = "DETACHED" ]; then
52
+ STATUS="failed"
53
+ ERRORS="Detached HEAD — cannot sync. Checkout a branch first."
54
+ EXIT_CODE=1
55
+ emit_report
56
+ fi
57
+
58
+ HAS_CHANGES=false
59
+ if [ -n "$(git status --porcelain 2>/dev/null | head -1)" ]; then
60
+ HAS_CHANGES=true
61
+ fi
62
+
63
+ # Step 2: Stash changes
64
+ if [ "$HAS_CHANGES" = true ] && [ "$NO_STASH" = false ]; then
65
+ if git stash push -m "sync-auto-stash-$(date +%Y%m%d-%H%M%S)" 2>/dev/null; then
66
+ STASH_CREATED=true
67
+ fi
68
+ fi
69
+
70
+ # Step 3: Sync default branch
71
+ git fetch "$REMOTE" 2>/dev/null
72
+
73
+ if [ "$BRANCH" != "$DEFAULT_BRANCH" ]; then
74
+ git checkout "$DEFAULT_BRANCH" 2>/dev/null
75
+ fi
76
+
77
+ if ! git pull "$REMOTE" "$DEFAULT_BRANCH" --ff-only 2>/dev/null; then
78
+ if ! git pull "$REMOTE" "$DEFAULT_BRANCH" --rebase 2>/dev/null; then
79
+ STATUS="failed"
80
+ ERRORS="Failed to pull $REMOTE/$DEFAULT_BRANCH"
81
+ EXIT_CODE=1
82
+ # Try to get back to original branch
83
+ [ "$BRANCH" != "$DEFAULT_BRANCH" ] && git checkout "$BRANCH" 2>/dev/null
84
+ # Restore stash if we created one
85
+ [ "$STASH_CREATED" = true ] && git stash pop 2>/dev/null
86
+ emit_report
87
+ fi
88
+ fi
89
+
90
+ MAIN_COMMIT=$(git rev-parse --short HEAD 2>/dev/null)
91
+ MAIN_MESSAGE=$(git log -1 --format=%s 2>/dev/null)
92
+ MAIN_UPDATED_TO="$MAIN_COMMIT - $MAIN_MESSAGE"
93
+
94
+ # Step 4: Return to branch and update
95
+ if [ "$BRANCH" != "$DEFAULT_BRANCH" ]; then
96
+ git checkout "$BRANCH" 2>/dev/null
97
+
98
+ if [ "$NO_REBASE" = true ]; then
99
+ if ! git merge "$DEFAULT_BRANCH" --no-edit 2>/dev/null; then
100
+ REBASE_STATUS="conflict — merge aborted"
101
+ EXIT_CODE=2
102
+ else
103
+ REBASE_STATUS="success"
104
+ fi
105
+ else
106
+ if ! git rebase "$DEFAULT_BRANCH" 2>/dev/null; then
107
+ git rebase --abort 2>/dev/null
108
+ REBASE_STATUS="conflict — aborted, branch unchanged"
109
+ EXIT_CODE=2
110
+ else
111
+ REBASE_STATUS="success"
112
+ fi
113
+ fi
114
+ fi
115
+
116
+ CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)
117
+
118
+ # Step 5: Restore stash
119
+ if [ "$STASH_CREATED" = true ]; then
120
+ if git stash pop 2>/dev/null; then
121
+ STASH_STATUS="restored"
122
+ else
123
+ STASH_STATUS="conflict — run 'git stash show' to inspect"
124
+ EXIT_CODE=2
125
+ fi
126
+ fi
127
+
128
+ # Step 6: Cleanup branches
129
+ if [ "$NO_CLEANUP" = false ]; then
130
+ git fetch --prune 2>/dev/null
131
+
132
+ CLEANED=()
133
+
134
+ # Delete branches whose remote is gone
135
+ while IFS= read -r b; do
136
+ [ -z "$b" ] && continue
137
+ [ "$b" = "$CURRENT_BRANCH" ] && continue
138
+ if git branch -d "$b" 2>/dev/null; then
139
+ CLEANED+=("$b")
140
+ fi
141
+ done < <(git branch -vv 2>/dev/null | grep ': gone]' | awk '{print $1}')
142
+
143
+ # Delete branches fully merged into default branch
144
+ while IFS= read -r b; do
145
+ b=$(echo "$b" | xargs)
146
+ [ -z "$b" ] && continue
147
+ [ "$b" = "$CURRENT_BRANCH" ] && continue
148
+ if git branch -d "$b" 2>/dev/null; then
149
+ CLEANED+=("$b")
150
+ fi
151
+ done < <(git branch --merged "$DEFAULT_BRANCH" 2>/dev/null | grep -v '^\*' | grep -v "$DEFAULT_BRANCH")
152
+
153
+ if [ ${#CLEANED[@]} -gt 0 ]; then
154
+ BRANCHES_CLEANED=$(IFS=,; echo "${CLEANED[*]}")
155
+ fi
156
+ fi
157
+
158
+ emit_report