Skip to content

checkout: surface silent rejections in batchAllSettled#2306

Merged
jcubic merged 3 commits into
isomorphic-git:mainfrom
andrewboldi:main
Apr 27, 2026
Merged

checkout: surface silent rejections in batchAllSettled#2306
jcubic merged 3 commits into
isomorphic-git:mainfrom
andrewboldi:main

Conversation

@andrewboldi

@andrewboldi andrewboldi commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Problem

batchAllSettled in src/commands/checkout.js silently discards all rejected promise results from Promise.allSettled, returning only the fulfilled values. Combined with the function's outer catch that only console.errors and falls through to return results, every individual file write that fails during checkout is swallowed without surfacing — to either the caller or the user.

This contributes to a long-standing class of "clone appears to succeed but vault is empty / partially populated" bug reports across downstream consumers, especially on resource-constrained runtimes like iOS WKWebView where individual fs.write calls can fail under memory pressure mid-checkout.

Notable downstream reports referencing this exact symptom:

Change

batchAllSettled now:

  • Collects rejection reasons into a rejections array.
  • console.errors each individual rejection with its stack (preserving previous diagnostic noise level for users running with verbose logging).
  • After the batch loop, throws a single aggregated Error if any task rejected, including the count and first 5 error messages.
  • The outer try/catch still logs the FATAL summary then rethrows instead of silently returning a partial results array.

This converts a class of silent failures into actionable errors. Behaviour for the all-success path is unchanged.

Risk

Callers that were silently relying on partial-success results (i.e. swallowing a checkout that left files un-written) will now receive an exception instead of a misleading success. This is the intended fix — there should be no caller relying on the previous behaviour, since they had no way to know it was happening.

Summary by CodeRabbit

  • Bug Fixes
    • Improved batch operation error handling: per-task failures are now collected rather than suppressed, producing a summarized failure report (counts and concise messages, including stack info when available). All task rejections are logged with clearer context, and fatal batch errors surface as an aggregated error for easier troubleshooting.

@coderabbitai

coderabbitai Bot commented Apr 26, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Checkout workdir/index update paths now treat per-task failures as collectable results: non-blocking writes use Promise.allSettled, all rejection reasons are logged (prefer reason.stack) and aggregated; if any failures occurred a MultipleGitError containing collected reasons is thrown.

Changes

Cohort / File(s) Summary
Checkout error aggregation
src/commands/checkout.js
Non-blocking workdir update switched to Promise.allSettled; per-task failures are accumulated into a rejections array, each rejection is logged with an operation-scoped prefix (prefers reason.stack), and a MultipleGitError is thrown when any rejections exist. batchAllSettled removed outer try/catch and now returns fulfilled values while collecting and reporting rejections.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • jcubic

Poem

🐰 I hopped through promises, one by one,
Gathered the stumbles beneath the sun.
Stacks glow like lanterns, every fall told true,
Together they tell what each task went through. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: surfacing previously-silent rejections in the batchAllSettled function, which is the core fix for checkout operations failing silently.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/commands/checkout.js (1)

758-762: Minor: outer catch now double-logs the aggregated error.

Each rejection is already logged with its stack inside the loop (lines 732-735), and the synthetic aggregated error thrown at line 752 will then be caught here and re-logged as FATAL. That's not harmful, but the FATAL log adds little signal beyond the per-task logs above it. Consider only logging FATAL when the caught error is not the synthetic aggregate (i.e., an unexpected error escaped the inner try) — otherwise the per-rejection logs above are sufficient.

This is a stylistic suggestion; current behavior is acceptable and matches the PR's stated intent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/checkout.js` around lines 758 - 762, The outer catch (the catch
(error) that logs `[isomorphic-git ${operationName}] FATAL:`) double-logs the
synthetic aggregated error that was already logged per-rejection inside the
loop; change the outer handler to only log when the caught error is not the
synthetic aggregate (e.g., if !(error instanceof AggregateError) or the error
lacks an .errors array), then rethrow in all cases. Locate the outer catch block
and guard the console.error call accordingly so per-task rejection logs remain
the only logs for aggregated failures while still logging unexpected
non-aggregate exceptions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/commands/checkout.js`:
- Around line 732-736: Prettier flags the ternary used in the console.error
call; replace the inline ternary with a Prettier-friendly expression such as
using optional chaining and nullish coalescing to pick the stack or the reason
(target the console.error call that references operationName and result.reason)
so the second argument becomes an expression like result.reason?.stack ??
result.reason to satisfy formatting rules.

---

Nitpick comments:
In `@src/commands/checkout.js`:
- Around line 758-762: The outer catch (the catch (error) that logs
`[isomorphic-git ${operationName}] FATAL:`) double-logs the synthetic aggregated
error that was already logged per-rejection inside the loop; change the outer
handler to only log when the caught error is not the synthetic aggregate (e.g.,
if !(error instanceof AggregateError) or the error lacks an .errors array), then
rethrow in all cases. Locate the outer catch block and guard the console.error
call accordingly so per-task rejection logs remain the only logs for aggregated
failures while still logging unexpected non-aggregate exceptions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8736323d-ec57-4524-8d18-882cced45a91

📥 Commits

Reviewing files that changed from the base of the PR and between 846a9f9 and 05fdcde.

📒 Files selected for processing (1)
  • src/commands/checkout.js

Comment thread src/commands/checkout.js Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/commands/checkout.js (1)

278-354: ⚠️ Potential issue | 🟠 Major

Default (blocking) checkout path still silently swallows write errors — fix is incomplete.

batchAllSettled is only used in the if (nonBlocking) branch, but nonBlocking defaults to false (line 56). In the default else branch the per-file try { … } catch (e) { console.log(e) } at lines 348‑350 is unchanged, so the very failure mode this PR is meant to surface (per‑file write failures during checkout/clone reported as silent partial successes) is still present whenever callers don't opt into nonBlocking: true.

If the goal is to fix the linked downstream reports (clones appearing successful with empty/partial repos), the same rejection‑aggregation/throw treatment needs to be applied to the default branch as well — either by routing the default path through batchAllSettled or by collecting and rethrowing errors from the inline Promise.all(...) map. As written, most consumers (including _checkout users that don't pass nonBlocking) will see no behavior change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/checkout.js` around lines 278 - 354, The default (blocking)
checkout path still swallows per-file write errors inside the Promise.all map in
the GitIndexManager.acquire callback (the try/catch that just console.log(e)),
so when nonBlocking is false callers won't see failures; update the else branch
handling used by _checkout to either route the ops.map through the existing
batchAllSettled helper (same behavior as the nonBlocking branch) or collect
per-item rejections from the Promise.all and throw an aggregated Error after
awaiting all ops, replacing the silent console.log in the map; look for symbols
GitIndexManager.acquire, nonBlocking, batchAllSettled, and the
ops.filter(...).map(...) block and ensure any caught errors are accumulated and
rethrown (or use batchAllSettled) so the checkout fails on write errors.
🧹 Nitpick comments (1)
src/commands/checkout.js (1)

760-764: Outer try/catch produces duplicate logging without adding value.

Every rejection is already console.error'd individually inside the loop (lines 732‑737), and the aggregated Error thrown on line 754 already contains the operation name and a summary. The outer catch then logs the same aggregated error again as FATAL before rethrowing it unchanged — net effect is one extra log line per failed batch with no extra information.

If the only intent is to ensure unexpected throws (e.g., from onProgress) are visible, that's already achieved by callers' own error handling. Consider dropping the outer try/catch entirely, or restricting it to truly unexpected failures (i.e., don't relog the deliberately-thrown aggregate):

♻️ Simplification
 async function batchAllSettled(operationName, tasks, onProgress, batchSize) {
   const results = []
   const rejections = []
-  try {
-    for (let i = 0; i < tasks.length; i += batchSize) {
-
-    }
-
-    if (rejections.length > 0) {
-
-      throw new Error(…)
-    }
-
-    return results
-  } catch (error) {
-    // eslint-disable-next-line no-console
-    console.error(`[isomorphic-git ${operationName}] FATAL:`, error)
-    throw error
-  }
+  for (let i = 0; i < tasks.length; i += batchSize) {
+
+  }
+  if (rejections.length > 0) {
+
+    throw new Error(…)
+  }
+  return results
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/checkout.js` around lines 760 - 764, The outer try/catch that
catches errors and does console.error(`[isomorphic-git ${operationName}]
FATAL:`, error) should be removed (or at minimum the catch should not re-log
already-aggregated failures) because each rejection is logged inside the loop
and an aggregated Error is explicitly thrown later; locate the try/catch
surrounding the loop that references operationName and remove the catch block
(or change it to simply rethrow without logging), leaving the loop-level logging
and the aggregated Error throw intact so unexpected errors still propagate from
functions like onProgress but are not double-logged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/commands/checkout.js`:
- Around line 749-757: Replace the plain Error thrown for multiple rejections
with the project’s MultipleGitError so callers can access structured failures;
import MultipleGitError if missing, keep the existing human-readable message
(using operationName, tasks.length and the summary) but construct and throw a
MultipleGitError that carries the original rejections in its .errors and .data
fields (follow the pattern used in src/api/add.js) so programmatic consumers and
GitError wrapper can inspect the underlying errors.

---

Outside diff comments:
In `@src/commands/checkout.js`:
- Around line 278-354: The default (blocking) checkout path still swallows
per-file write errors inside the Promise.all map in the GitIndexManager.acquire
callback (the try/catch that just console.log(e)), so when nonBlocking is false
callers won't see failures; update the else branch handling used by _checkout to
either route the ops.map through the existing batchAllSettled helper (same
behavior as the nonBlocking branch) or collect per-item rejections from the
Promise.all and throw an aggregated Error after awaiting all ops, replacing the
silent console.log in the map; look for symbols GitIndexManager.acquire,
nonBlocking, batchAllSettled, and the ops.filter(...).map(...) block and ensure
any caught errors are accumulated and rethrown (or use batchAllSettled) so the
checkout fails on write errors.

---

Nitpick comments:
In `@src/commands/checkout.js`:
- Around line 760-764: The outer try/catch that catches errors and does
console.error(`[isomorphic-git ${operationName}] FATAL:`, error) should be
removed (or at minimum the catch should not re-log already-aggregated failures)
because each rejection is logged inside the loop and an aggregated Error is
explicitly thrown later; locate the try/catch surrounding the loop that
references operationName and remove the catch block (or change it to simply
rethrow without logging), leaving the loop-level logging and the aggregated
Error throw intact so unexpected errors still propagate from functions like
onProgress but are not double-logged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a025b12b-b560-4e5a-9988-70b42656853b

📥 Commits

Reviewing files that changed from the base of the PR and between 05fdcde and f14d51c.

📒 Files selected for processing (1)
  • src/commands/checkout.js

Comment thread src/commands/checkout.js Outdated
@jcubic

jcubic commented Apr 26, 2026

Copy link
Copy Markdown
Member

Thanks for the fix. The code looks good. Can you apply changes suggested by CodeRabbit?

If you don't agree with the suggestions, please give a reason.

- Apply rejection collection to default (blocking) branch too — previously
  only the nonBlocking path used batchAllSettled, so the original silent
  catch (e) { console.log(e) } in the else branch still swallowed per-file
  write errors for default callers (the very failure mode this PR targets).
- Throw MultipleGitError instead of a plain Error in batchAllSettled, so
  callers can introspect .errors / .data programmatically (matches the
  pattern used in src/api/add.js).
- Drop the outer try/catch around the loop in batchAllSettled to avoid
  double-logging the synthetic aggregate (each rejection is already logged
  per-task inside the loop).
- Replace inline ternary with optional chaining + nullish coalescing in
  the per-task console.error.
@andrewboldi

Copy link
Copy Markdown
Contributor Author

@jcubic thanks for the review! Pushed 40d73a3 addressing CodeRabbit's feedback:

  1. Major — extended the rejection collection to the default (blocking) checkout branch (lines 278-354). The original try { ... } catch (e) { console.log(e) } per-file silent swallow is now replaced with Promise.allSettled + aggregated MultipleGitError, so write failures now actually fail the checkout for default callers (the failure mode this PR was originally meant to surface).

  2. Inline (749-757)batchAllSettled now throws MultipleGitError instead of a plain Error, so callers can introspect .errors / .data programmatically (matches the pattern in src/api/add.js).

  3. Nitpick (760-764) — removed the outer try/catch that double-logged the synthetic aggregate; per-task rejection logs in the loop are sufficient.

  4. Inline (732-736) — replaced inline ternary with result.reason?.stack ?? result.reason to satisfy Prettier.

ESLint and Prettier both pass locally on src/commands/checkout.js.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/commands/checkout.js (2)

758-758: Optional: enrich the aggregated error message with operation context.

new MultipleGitError(rejections) produces a fixed generic message (There are multiple errors that were thrown by the method...), so unhandled-rejection logs and stack traces lose the operationName and the n/total failed summary that the original PR description called out. Programmatic consumers still get full detail via .errors / .data, so this is purely a diagnostics nicety — feel free to skip if you prefer to keep MultipleGitError instances uniform.

💡 Optional message augmentation
   if (rejections.length > 0) {
-    throw new MultipleGitError(rejections)
+    const err = new MultipleGitError(rejections)
+    err.message = `[isomorphic-git ${operationName}] ${rejections.length}/${tasks.length} tasks failed: ${err.message}`
+    throw err
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/checkout.js` at line 758, The throw currently does "throw new
MultipleGitError(rejections)" which yields a generic message; instead build a
concise diagnostic string that includes the operationName and a "failed n/total"
summary (e.g., `${operationName}: ${rejections.length}/${total} failed`) and
attach it to the thrown error: either pass it into MultipleGitError if its
constructor accepts a message, or create the MultipleGitError instance and set
its message property (and/or a .summary field) before throwing; use the existing
symbols MultipleGitError and rejections (and operationName / total in the
surrounding scope) so logs and unhandled-rejection traces show the enriched
context.

350-363: Extract the duplicated rejection-handling logic, and unify the log prefix.

The rejection-collection / per-task logging / aggregated throw is now duplicated between the blocking branch (lines 350‑363) and batchAllSettled (lines 736‑747, 757‑759). Extracting a small helper keeps the two paths in lockstep when this evolves (e.g., adding fail-fast, telemetry, or message customization).

Minor consistency nit: the blocking branch logs [isomorphic-git checkout] task rejected: (hard-coded) while batchAllSettled logs [isomorphic-git ${operationName}] (e.g., "Update Working Dir"). Passing an operationName through to the helper makes log filtering uniform across both paths.

♻️ Sketch: shared helper
+function collectRejections(operationName, settled) {
+  const results = []
+  const rejections = []
+  for (const result of settled) {
+    if (result.status === 'fulfilled') {
+      results.push(result.value)
+    } else {
+      rejections.push(result.reason)
+      // eslint-disable-next-line no-console
+      console.error(
+        `[isomorphic-git ${operationName}] task rejected:`,
+        result.reason?.stack ?? result.reason
+      )
+    }
+  }
+  return { results, rejections }
+}

Then both call sites become:

-          const rejections = []
-          for (const result of settled) {
-            if (result.status === 'rejected') {
-              rejections.push(result.reason)
-              // eslint-disable-next-line no-console
-              console.error(
-                '[isomorphic-git checkout] task rejected:',
-                result.reason?.stack ?? result.reason
-              )
-            }
-          }
-          if (rejections.length > 0) {
-            throw new MultipleGitError(rejections)
-          }
+          const { rejections } = collectRejections('checkout', settled)
+          if (rejections.length > 0) throw new MultipleGitError(rejections)

Also applies to: 730-761

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/checkout.js` around lines 350 - 363, Extract the duplicated
rejection collection, per-task logging and aggregated throwing into a small
helper (e.g., handleSettledRejections(settled, operationName)) and replace the
duplicated blocks in the blocking branch inside checkout.js (the loop that
builds rejections and calls console.error then throws MultipleGitError) and the
batchAllSettled code paths with calls to that helper; the helper should iterate
settled results, push rejected reasons into an array, log each rejection using a
unified prefix that uses operationName (format like `[isomorphic-git
${operationName}] task rejected:`) and, if any rejections exist, throw new
MultipleGitError(rejections). Ensure both callers pass a descriptive
operationName (e.g., "checkout" or the existing operationName used by
batchAllSettled) so log filtering is consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/commands/checkout.js`:
- Line 758: The throw currently does "throw new MultipleGitError(rejections)"
which yields a generic message; instead build a concise diagnostic string that
includes the operationName and a "failed n/total" summary (e.g.,
`${operationName}: ${rejections.length}/${total} failed`) and attach it to the
thrown error: either pass it into MultipleGitError if its constructor accepts a
message, or create the MultipleGitError instance and set its message property
(and/or a .summary field) before throwing; use the existing symbols
MultipleGitError and rejections (and operationName / total in the surrounding
scope) so logs and unhandled-rejection traces show the enriched context.
- Around line 350-363: Extract the duplicated rejection collection, per-task
logging and aggregated throwing into a small helper (e.g.,
handleSettledRejections(settled, operationName)) and replace the duplicated
blocks in the blocking branch inside checkout.js (the loop that builds
rejections and calls console.error then throws MultipleGitError) and the
batchAllSettled code paths with calls to that helper; the helper should iterate
settled results, push rejected reasons into an array, log each rejection using a
unified prefix that uses operationName (format like `[isomorphic-git
${operationName}] task rejected:`) and, if any rejections exist, throw new
MultipleGitError(rejections). Ensure both callers pass a descriptive
operationName (e.g., "checkout" or the existing operationName used by
batchAllSettled) so log filtering is consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d8a7dcf8-1cdc-41ad-9326-e950946af69b

📥 Commits

Reviewing files that changed from the base of the PR and between f14d51c and 40d73a3.

📒 Files selected for processing (1)
  • src/commands/checkout.js

@jcubic jcubic merged commit acb2dcd into isomorphic-git:main Apr 27, 2026
4 checks passed
@jcubic

jcubic commented Apr 27, 2026

Copy link
Copy Markdown
Member

🎉 This PR is included in version 1.37.6 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants