squash-only 2.0.0 → 3.0.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/README.md CHANGED
@@ -15,7 +15,11 @@ This script updates all repositories you own on GitHub to:
15
15
  - ❌ Disable merge commits
16
16
  - ❌ Disable rebase merges
17
17
 
18
- It automatically skips repositories you don't own and provides a summary of the results.
18
+ It automatically:
19
+ - Skips repositories you don't own
20
+ - Skips repositories that are already configured for squash-only (efficient reruns; no unnecessary network requests)
21
+ - Only processes repositories that need updating
22
+ - Provides a summary of the results
19
23
 
20
24
  ## Requirements
21
25
 
@@ -68,6 +72,11 @@ With custom sleep interval:
68
72
  npx github:pRizz/squash-only --sleep 0.5
69
73
  ```
70
74
 
75
+ With force flag (process all repos, including already squash-only):
76
+ ```bash
77
+ npx github:pRizz/squash-only --force
78
+ ```
79
+
71
80
  **Note:** If the package is published to npm, you can also use:
72
81
  ```bash
73
82
  npx squash-only
@@ -87,6 +96,13 @@ With custom sleep interval:
87
96
  ./scripts/squash-only.sh -s 1.0
88
97
  ```
89
98
 
99
+ With force flag (process all repos, including already squash-only):
100
+ ```bash
101
+ ./scripts/squash-only.sh --force
102
+ # or
103
+ ./scripts/squash-only.sh -f
104
+ ```
105
+
90
106
  ### Option 3: Run Locally with npm (Development)
91
107
 
92
108
  If you've cloned the repository, you can run it locally using npm scripts:
@@ -101,6 +117,11 @@ With custom sleep interval:
101
117
  npm start -- --sleep 0.5
102
118
  ```
103
119
 
120
+ With force flag:
121
+ ```bash
122
+ npm start -- --force
123
+ ```
124
+
104
125
  You can also use `npx` to run the local version:
105
126
  ```bash
106
127
  npx .
@@ -149,19 +170,51 @@ npm start -- --sleep 0.5
149
170
  ./scripts/squash-only.sh -s 1.0
150
171
  ```
151
172
 
173
+ ### Force mode
174
+ Process all repositories, including those already configured for squash-only:
175
+ ```bash
176
+ # Using npx
177
+ npx github:pRizz/squash-only --force
178
+
179
+ # Or run locally with npm
180
+ npm start -- --force
181
+
182
+ # Or using the bash script
183
+ ./scripts/squash-only.sh --force
184
+ # or
185
+ ./scripts/squash-only.sh -f
186
+ ```
187
+
188
+ Combine options:
189
+ ```bash
190
+ npx github:pRizz/squash-only --force --sleep 0.5
191
+ ```
192
+
152
193
  ## Options
153
194
 
154
- - `-s, --sleep SECONDS` - Set the sleep interval between API requests (must be a number)
195
+ - `-s, --sleep SECONDS` - Set the sleep interval between API requests (default: 0.1 seconds). This delay helps prevent triggering GitHub's rate limits. Authenticated requests (OAuth or PAT) are limited to ~5,000 requests per hour per user or app. See [GitHub's API rate limits documentation](https://github.com/orgs/community/discussions/163553) for more details.
196
+ - `-f, --force` - Process all repositories, including those already configured for squash-only (default: skip already configured repos)
155
197
 
156
198
  ## Features
157
199
 
158
200
  - 🔐 **Automatic authentication** - Tries multiple authentication methods
159
201
  - 📄 **Pagination support** - Handles users with 100+ repositories
160
202
  - 🔍 **Ownership filtering** - Only updates repositories you own
161
- - 📊 **Progress tracking** - Shows success, skipped, and failed counts
203
+ - **Smart skipping** - Automatically skips repositories already configured for squash-only (efficient reruns and new repo handling)
204
+ - 📊 **Progress tracking** - Shows success, skipped, already configured, and failed counts
162
205
  - ⏱️ **Performance metrics** - Displays elapsed time
163
206
  - 🛡️ **Error handling** - Validates inputs and provides clear error messages
164
207
 
208
+ ## Technical Details
209
+
210
+ This script uses a hybrid approach for GitHub API access:
211
+
212
+ - **GraphQL API** - Used to fetch repositories and their merge strategies in a single, efficient query with cursor-based pagination. This allows us to retrieve all repository information and merge strategy settings in fewer API calls.
213
+
214
+ - **REST API** - Used to update repository merge strategies. As of January 11, 2026, the GitHub GraphQL API does not support mutations for repository merge strategy settings, so the REST API is required for this operation. Therefore, we must call the REST endpoint for each repository individually to update its merge strategy settings.
215
+
216
+ - **Rate Limiting** - To avoid hitting GitHub's API rate limits, the script includes a configurable sleep interval between REST API requests (default: 0.1 seconds). Authenticated requests (OAuth or PAT) are limited to ~5,000 requests per hour per user or app. The sleep delay helps ensure we stay well below this limit when processing large numbers of repositories. See [GitHub's API rate limits documentation](https://github.com/orgs/community/discussions/163553) for more details.
217
+
165
218
  ## Output
166
219
 
167
220
  The script provides:
@@ -169,6 +222,7 @@ The script provides:
169
222
  - Success/failure status for each update
170
223
  - A summary at the end showing:
171
224
  - Number of successfully updated repositories
225
+ - Number of repositories already configured for squash-only (skipped)
172
226
  - Number of skipped repositories (not owned by you)
173
227
  - Number of failed updates (if any)
174
228
  - Total elapsed time
@@ -177,9 +231,10 @@ Example output:
177
231
  ```
178
232
  ────────────────────────────────────
179
233
  Summary:
180
- ✅ Successfully updated: 15
181
- ⏭️ Skipped: 3
182
- ⏱️ Elapsed time: 2m 30s
234
+ ✅ Successfully updated: 5
235
+ ⏭️ Already squash-only (skipped): 10
236
+ ⏭️ Skipped (not owned by you): 3
237
+ ⏱️ Elapsed time: 1m 15s
183
238
  ```
184
239
 
185
240
  ## Examples
@@ -205,6 +260,15 @@ npx github:pRizz/squash-only --sleep 1.0
205
260
  ./scripts/squash-only.sh --sleep 1.0
206
261
  ```
207
262
 
263
+ Force update all repositories (including already configured):
264
+ ```bash
265
+ # Using npx
266
+ npx github:pRizz/squash-only --force
267
+
268
+ # Or using the bash script
269
+ ./scripts/squash-only.sh --force
270
+ ```
271
+
208
272
  Use with environment variable:
209
273
  ```bash
210
274
  # Using npx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squash-only",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Scripts for making all your repos on GitHub Squash Only!",
5
5
  "bin": {
6
6
  "squash-only": "./bin/squash-only.js"
@@ -2,16 +2,20 @@
2
2
 
3
3
  set -uo pipefail
4
4
 
5
- SLEEP_SECS=0.2
5
+ SLEEP_SECS=0.1
6
6
  SHOULD_EXIT=0
7
7
 
8
8
  # Temporary files for tracking counts across subshells
9
9
  SUCCESS_COUNT_FILE=$(mktemp)
10
10
  SKIP_COUNT_FILE=$(mktemp)
11
11
  FAILED_COUNT_FILE=$(mktemp)
12
+ ALREADY_SQUASH_ONLY_COUNT_FILE=$(mktemp)
13
+
14
+ # Flag to force processing all repos even if they already have squash-only enabled
15
+ FORCE_FLAG=0
12
16
 
13
17
  cleanup() {
14
- rm -f "$SUCCESS_COUNT_FILE" "$SKIP_COUNT_FILE" "$FAILED_COUNT_FILE"
18
+ rm -f "$SUCCESS_COUNT_FILE" "$SKIP_COUNT_FILE" "$FAILED_COUNT_FILE" "$ALREADY_SQUASH_ONLY_COUNT_FILE"
15
19
  }
16
20
  trap cleanup EXIT
17
21
 
@@ -99,9 +103,36 @@ increment_counter() {
99
103
  echo $((current + 1)) > "$counter_file"
100
104
  }
101
105
 
106
+ is_squash_only() {
107
+ local allow_squash_merge=$1
108
+ local allow_merge_commit=$2
109
+ local allow_rebase_merge=$3
110
+
111
+ # Treat empty, "null", or missing values as false
112
+ [ -z "$allow_squash_merge" ] && allow_squash_merge="false"
113
+ [ "$allow_squash_merge" = "null" ] && allow_squash_merge="false"
114
+ [ -z "$allow_merge_commit" ] && allow_merge_commit="false"
115
+ [ "$allow_merge_commit" = "null" ] && allow_merge_commit="false"
116
+ [ -z "$allow_rebase_merge" ] && allow_rebase_merge="false"
117
+ [ "$allow_rebase_merge" = "null" ] && allow_rebase_merge="false"
118
+
119
+ # Normalize case (in case of unexpected casing)
120
+ allow_squash_merge=$(echo "$allow_squash_merge" | tr '[:upper:]' '[:lower:]')
121
+ allow_merge_commit=$(echo "$allow_merge_commit" | tr '[:upper:]' '[:lower:]')
122
+ allow_rebase_merge=$(echo "$allow_rebase_merge" | tr '[:upper:]' '[:lower:]')
123
+
124
+ if [ "$allow_squash_merge" = "true" ] && [ "$allow_merge_commit" = "false" ] && [ "$allow_rebase_merge" = "false" ]; then
125
+ return 0
126
+ fi
127
+ return 1
128
+ }
129
+
102
130
  process_repo() {
103
131
  local repo=$1
104
132
  local owner=$2
133
+ local allow_squash_merge=$3
134
+ local allow_merge_commit=$4
135
+ local allow_rebase_merge=$5
105
136
 
106
137
  # Check if we should exit
107
138
  [ "$SHOULD_EXIT" -eq 1 ] && return 1
@@ -117,8 +148,20 @@ process_repo() {
117
148
  return 0
118
149
  fi
119
150
 
151
+ # Check if repo already has squash-only enabled
152
+ if [ "$FORCE_FLAG" -eq 0 ] && is_squash_only "$allow_squash_merge" "$allow_merge_commit" "$allow_rebase_merge"; then
153
+ echo "────────────────────────────────────"
154
+ echo "⏭️ Skipping repo: $repo (already squash-only)"
155
+ increment_counter "$ALREADY_SQUASH_ONLY_COUNT_FILE"
156
+ return 0
157
+ fi
158
+
120
159
  echo "────────────────────────────────────"
121
- echo "Updating repo: $repo"
160
+ if [ "$FORCE_FLAG" -eq 1 ] && is_squash_only "$allow_squash_merge" "$allow_merge_commit" "$allow_rebase_merge"; then
161
+ echo "Updating repo: $repo (forced, already squash-only)"
162
+ else
163
+ echo "Updating repo: $repo"
164
+ fi
122
165
 
123
166
  local http_code
124
167
  http_code=$(curl -s --max-time 30 -o /dev/null -w "%{http_code}" \
@@ -151,42 +194,174 @@ process_repo() {
151
194
  sleep "$SLEEP_SECS"
152
195
  }
153
196
 
154
- fetch_all_repos() {
155
- local page=1
156
- local per_page=100
197
+ # Executes a GraphQL query and returns the response
198
+ execute_graphql_query() {
199
+ local query=$1
200
+ local variables_json=$2
201
+
202
+ local payload
203
+ if [ -n "$variables_json" ]; then
204
+ payload=$(jq -n \
205
+ --arg query "$query" \
206
+ --argjson variables "$variables_json" \
207
+ '{query: $query, variables: $variables}')
208
+ else
209
+ payload=$(jq -n \
210
+ --arg query "$query" \
211
+ '{query: $query}')
212
+ fi
213
+
214
+ local response
215
+ response=$(curl -sS --max-time 30 \
216
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
217
+ -H "Content-Type: application/json" \
218
+ -H "Accept: application/vnd.github+json" \
219
+ https://api.github.com/graphql \
220
+ -d "$payload")
221
+
222
+ # Check for GraphQL errors
223
+ local errors
224
+ errors=$(echo "$response" | jq -r '.errors // empty')
225
+ if [ -n "$errors" ]; then
226
+ echo "GraphQL error: $errors" >&2
227
+ return 1
228
+ fi
229
+
230
+ echo "$response"
231
+ }
232
+
233
+ # Fetches all repos with merge strategies for a given owner using GraphQL
234
+ # Uses repositoryOwner query with pagination
235
+ # Returns JSON array with repos and their merge strategy info
236
+ fetch_repos_with_strategies() {
237
+ local owner=$1
238
+
239
+ read -r -d '' QUERY <<'GRAPHQL'
240
+ query($login: String!, $first: Int!, $after: String) {
241
+ repositoryOwner(login: $login) {
242
+ repositories(first: $first, after: $after) {
243
+ pageInfo {
244
+ hasNextPage
245
+ endCursor
246
+ }
247
+ nodes {
248
+ name
249
+ owner {
250
+ login
251
+ }
252
+ mergeCommitAllowed
253
+ squashMergeAllowed
254
+ rebaseMergeAllowed
255
+ autoMergeAllowed
256
+ }
257
+ }
258
+ }
259
+ }
260
+ GRAPHQL
261
+
262
+ local all_repos="[]"
263
+ local after_cursor=""
264
+ local first=100
157
265
 
158
266
  while true; do
159
- # Check if we should exit
160
267
  [ "$SHOULD_EXIT" -eq 1 ] && break
161
268
 
162
- local body
163
- body=$(curl -s --max-time 30 -H "Authorization: token $GITHUB_TOKEN" \
164
- "https://api.github.com/user/repos?per_page=$per_page&page=$page")
269
+ # Build variables JSON
270
+ local variables_json
271
+ if [ -n "$after_cursor" ]; then
272
+ variables_json=$(jq -n \
273
+ --arg login "$owner" \
274
+ --argjson first "$first" \
275
+ --arg after "$after_cursor" \
276
+ '{login: $login, first: $first, after: $after}')
277
+ else
278
+ variables_json=$(jq -n \
279
+ --arg login "$owner" \
280
+ --argjson first "$first" \
281
+ '{login: $login, first: $first}')
282
+ fi
283
+
284
+ # Execute GraphQL query
285
+ local response
286
+ response=$(execute_graphql_query "$QUERY" "$variables_json")
287
+ if [ $? -ne 0 ]; then
288
+ return 1
289
+ fi
165
290
 
166
- # Check if we should exit after curl
167
291
  [ "$SHOULD_EXIT" -eq 1 ] && break
168
292
 
293
+ # Extract repos from response
294
+ local repos
295
+ repos=$(echo "$response" | jq '.data.repositoryOwner.repositories.nodes // []')
296
+
297
+ # Check if we got any repos
169
298
  local repo_count
170
- repo_count=$(echo "$body" | jq '. | length')
171
- if [ -z "$repo_count" ] || [ "$repo_count" = "0" ] || [ "$repo_count" = "null" ]; then
299
+ repo_count=$(echo "$repos" | jq '. | length')
300
+ if [ "$repo_count" -eq 0 ]; then
172
301
  break
173
302
  fi
174
303
 
175
- # NOTE: Avoid a pipeline into `while` here; that runs the loop in a subshell and
176
- # breaks exit/interrupt handling and control flow.
177
- while IFS='|' read -r repo owner; do
178
- [ "$SHOULD_EXIT" -eq 1 ] && break
179
- process_repo "$repo" "$owner" || break
180
- done < <(echo "$body" | jq -r '.[] | "\(.full_name)|\(.owner.login)"')
304
+ # Merge repos into all_repos
305
+ all_repos=$(echo "$all_repos" "$repos" | jq -s 'add')
181
306
 
182
- [ "$SHOULD_EXIT" -eq 1 ] && break
307
+ # Check pagination info
308
+ local has_next_page
309
+ has_next_page=$(echo "$response" | jq -r '.data.repositoryOwner.repositories.pageInfo.hasNextPage')
310
+ after_cursor=$(echo "$response" | jq -r '.data.repositoryOwner.repositories.pageInfo.endCursor // ""')
183
311
 
184
- if [ "$repo_count" -lt "$per_page" ]; then
312
+ if [ "$has_next_page" != "true" ] || [ -z "$after_cursor" ]; then
185
313
  break
186
314
  fi
187
-
188
- page=$((page + 1))
189
315
  done
316
+
317
+ echo "$all_repos"
318
+ }
319
+
320
+ # Processes repos with their merge strategies
321
+ # Takes JSON array of repos with name, owner.login, and merge strategy flags
322
+ process_repos_with_strategies() {
323
+ local repos_json=$1
324
+
325
+ while IFS= read -r line; do
326
+ [ "$SHOULD_EXIT" -eq 1 ] && break
327
+
328
+ # Parse the line: full_name|owner|squash|merge|rebase
329
+ IFS='|' read -r repo owner allow_squash_merge allow_merge_commit allow_rebase_merge <<< "$line"
330
+
331
+ # Trim leading/trailing whitespace
332
+ repo=$(echo "$repo" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
333
+ owner=$(echo "$owner" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
334
+ allow_squash_merge=$(echo "$allow_squash_merge" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
335
+ allow_merge_commit=$(echo "$allow_merge_commit" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
336
+ allow_rebase_merge=$(echo "$allow_rebase_merge" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
337
+
338
+ process_repo "$repo" "$owner" "$allow_squash_merge" "$allow_merge_commit" "$allow_rebase_merge" || break
339
+ done < <(echo "$repos_json" | jq -r '.[] |
340
+ "\(.owner.login)/\(.name)|\(.owner.login)|\(
341
+ if .squashMergeAllowed == null then false else .squashMergeAllowed end
342
+ )|\(
343
+ if .mergeCommitAllowed == null then false else .mergeCommitAllowed end
344
+ )|\(
345
+ if .rebaseMergeAllowed == null then false else .rebaseMergeAllowed end
346
+ )"')
347
+ }
348
+
349
+ fetch_all_repos() {
350
+ # Fetch all repos with merge strategies using GraphQL
351
+ local repos_json
352
+ repos_json=$(fetch_repos_with_strategies "$GITHUB_USER")
353
+ if [ $? -ne 0 ]; then
354
+ echo "❌ Failed to fetch repos" >&2
355
+ return 1
356
+ fi
357
+
358
+ if [ -z "$repos_json" ] || [ "$repos_json" = "[]" ]; then
359
+ echo "❌ No repos found" >&2
360
+ return 0
361
+ fi
362
+
363
+ # Process repos with their merge strategies
364
+ process_repos_with_strategies "$repos_json"
190
365
  }
191
366
 
192
367
  is_number() {
@@ -212,9 +387,13 @@ parse_args() {
212
387
  SLEEP_SECS="$2"
213
388
  shift 2
214
389
  ;;
390
+ -f|--force)
391
+ FORCE_FLAG=1
392
+ shift
393
+ ;;
215
394
  *)
216
395
  echo "❌ Error: Unknown option: $1"
217
- echo "Usage: $0 [--sleep SECONDS]"
396
+ echo "Usage: $0 [--sleep SECONDS] [--force]"
218
397
  exit 1
219
398
  ;;
220
399
  esac
@@ -232,11 +411,17 @@ main() {
232
411
  echo "0" > "$SUCCESS_COUNT_FILE"
233
412
  echo "0" > "$SKIP_COUNT_FILE"
234
413
  echo "0" > "$FAILED_COUNT_FILE"
414
+ echo "0" > "$ALREADY_SQUASH_ONLY_COUNT_FILE"
235
415
 
236
416
  echo "Updating all your repos to Squash Only!"
237
417
  echo "→ Disables merge commits"
238
418
  echo "→ Disables rebase merges"
239
419
  echo "→ Enables squash merges"
420
+ if [ "$FORCE_FLAG" -eq 1 ]; then
421
+ echo "→ Force mode: processing all repos (including already squash-only)"
422
+ else
423
+ echo "→ Skipping repos that are already squash-only (use --force to process all)"
424
+ fi
240
425
 
241
426
  echo "→ Logging in to GitHub…"
242
427
  GITHUB_TOKEN=$(get_github_token)
@@ -271,8 +456,14 @@ main() {
271
456
  SUCCESS_COUNT=$(cat "$SUCCESS_COUNT_FILE")
272
457
  SKIP_COUNT=$(cat "$SKIP_COUNT_FILE")
273
458
  FAILED_COUNT=$(cat "$FAILED_COUNT_FILE")
459
+ ALREADY_SQUASH_ONLY_COUNT=$(cat "$ALREADY_SQUASH_ONLY_COUNT_FILE")
274
460
  echo " ✅ Successfully updated: $SUCCESS_COUNT"
275
- echo " ⏭️ Skipped: $SKIP_COUNT"
461
+ if [ "$ALREADY_SQUASH_ONLY_COUNT" -gt 0 ]; then
462
+ echo " ⏭️ Already squash-only (skipped): $ALREADY_SQUASH_ONLY_COUNT"
463
+ fi
464
+ if [ "$SKIP_COUNT" -gt 0 ]; then
465
+ echo " ⏭️ Skipped (not owned by you): $SKIP_COUNT"
466
+ fi
276
467
  if [ "$FAILED_COUNT" -gt 0 ]; then
277
468
  echo " ❌ Failed: $FAILED_COUNT"
278
469
  fi