@@ -12,6 +12,11 @@ const path = require("path");
1212const { getErrorMessage } = require ( "./error_helpers.cjs" ) ;
1313const { execGitSync, getGitAuthEnv } = require ( "./git_helpers.cjs" ) ;
1414const { ERR_SYSTEM } = require ( "./error_codes.cjs" ) ;
15+ const { sanitizeForFilename, sanitizeBranchNameForPatch, sanitizeRepoSlugForPatch, getPatchPath, getPatchPathForRepo, buildExcludePathspecs, computeIncrementalDiffSize } = require ( "./git_patch_utils.cjs" ) ;
16+
17+ // sanitizeForFilename is re-exported below for backward compatibility with
18+ // existing callers that imported it from this module.
19+ void sanitizeForFilename ;
1520
1621/**
1722 * Debug logging helper - logs to stderr when DEBUG env var matches
@@ -24,63 +29,6 @@ function debugLog(message) {
2429 }
2530}
2631
27- /**
28- * Sanitize a string for use as a patch filename component.
29- * Replaces path separators and special characters with dashes.
30- * @param {string } value - The value to sanitize
31- * @param {string } fallback - Fallback value when input is empty or nullish
32- * @returns {string } The sanitized string safe for use in a filename
33- */
34- function sanitizeForFilename ( value , fallback ) {
35- if ( ! value ) return fallback ;
36- return value
37- . replace ( / [ / \\ : * ? " < > | ] / g, "-" )
38- . replace ( / - { 2 , } / g, "-" )
39- . replace ( / ^ - | - $ / g, "" )
40- . toLowerCase ( ) ;
41- }
42-
43- /**
44- * Sanitize a branch name for use as a patch filename
45- * @param {string } branchName - The branch name to sanitize
46- * @returns {string } The sanitized branch name safe for use in a filename
47- */
48- function sanitizeBranchNameForPatch ( branchName ) {
49- return sanitizeForFilename ( branchName , "unknown" ) ;
50- }
51-
52- /**
53- * Get the patch file path for a given branch name
54- * @param {string } branchName - The branch name
55- * @returns {string } The full patch file path
56- */
57- function getPatchPath ( branchName ) {
58- const sanitized = sanitizeBranchNameForPatch ( branchName ) ;
59- return `/tmp/gh-aw/aw-${ sanitized } .patch` ;
60- }
61-
62- /**
63- * Sanitize a repo slug for use in a filename
64- * @param {string } repoSlug - The repo slug (owner/repo)
65- * @returns {string } The sanitized slug safe for use in a filename
66- */
67- function sanitizeRepoSlugForPatch ( repoSlug ) {
68- return sanitizeForFilename ( repoSlug , "" ) ;
69- }
70-
71- /**
72- * Get the patch file path for a given branch name and repo slug
73- * Used for multi-repo scenarios to prevent patch file collisions
74- * @param {string } branchName - The branch name
75- * @param {string } repoSlug - The repository slug (owner/repo)
76- * @returns {string } The full patch file path including repo disambiguation
77- */
78- function getPatchPathForRepo ( branchName , repoSlug ) {
79- const sanitizedBranch = sanitizeBranchNameForPatch ( branchName ) ;
80- const sanitizedRepo = sanitizeRepoSlugForPatch ( repoSlug ) ;
81- return `/tmp/gh-aw/aw-${ sanitizedRepo } -${ sanitizedBranch } .patch` ;
82- }
83-
8432/**
8533 * Generates a git patch file for the current changes
8634 * @param {string } branchName - The branch name to generate patch for
@@ -111,15 +59,14 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
11159 // These are appended after "--" so git treats them as pathspecs, not revisions.
11260 // Using git's native pathspec magic keeps the exclusions out of the patch entirely
11361 // without any post-processing of the generated patch file.
114- const excludePathspecs = Array . isArray ( options . excludedFiles ) && options . excludedFiles . length > 0 ? options . excludedFiles . map ( p => `:(exclude) ${ p } ` ) : [ ] ;
62+ const excludeArgsArr = buildExcludePathspecs ( options . excludedFiles ) ;
11563
11664 /**
11765 * Returns the arguments to append to a format-patch call when excludedFiles is set.
118- * Produces ["--", ":(exclude)pattern1", ":(exclude)pattern2", ...] or [].
11966 * @returns {string[] }
12067 */
12168 function excludeArgs ( ) {
122- return excludePathspecs . length > 0 ? [ "--" , ... excludePathspecs ] : [ ] ;
69+ return excludeArgsArr ;
12370 }
12471 const patchPath = options . repoSlug ? getPatchPathForRepo ( branchName , options . repoSlug ) : getPatchPath ( branchName ) ;
12572
@@ -294,6 +241,25 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
294241 patchLines : 0 ,
295242 } ;
296243 }
244+
245+ // In incremental mode, the patch must be measured relative to the existing
246+ // PR branch head (origin/<branch>), never relative to the default branch.
247+ // If Strategy 1 did not produce a patch (e.g. format-patch yielded empty
248+ // output for an unusual commit shape — excluded-files filtering away every
249+ // change, or binary-only commits with unusual encoding), do NOT fall
250+ // through to Strategy 2 or Strategy 3 — those use GITHUB_SHA..HEAD or
251+ // merge-base with a remote ref and would produce a checkout-base diff
252+ // (which can be many MB on a long-running branch). Returning an explicit
253+ // error preserves the "incremental" contract that the patch reflects only
254+ // the new commits.
255+ if ( ! patchGenerated && mode === "incremental" ) {
256+ debugLog ( `Strategy 1 (incremental): format-patch produced no output for ${ baseRef } ..${ branchName } despite ${ commitCount } incremental commit(s), refusing to fall through to checkout-base strategies` ) ;
257+ return {
258+ success : false ,
259+ error : `Cannot generate incremental patch: git format-patch produced no output for ${ baseRef } ..${ branchName } despite ${ commitCount } incremental commit(s).` ,
260+ patchPath : patchPath ,
261+ } ;
262+ }
297263 } catch ( branchError ) {
298264 // Branch does not exist locally
299265 debugLog ( `Strategy 1: Branch '${ branchName } ' does not exist locally - ${ getErrorMessage ( branchError ) } ` ) ;
@@ -450,12 +416,36 @@ async function generateGitPatch(branchName, baseBranch, options = {}) {
450416 } ;
451417 }
452418
453- debugLog ( `Final: SUCCESS - patchSize=${ patchSize } bytes, patchLines=${ patchLines } , baseCommit=${ baseCommitSha || "(unknown)" } ` ) ;
419+ // In incremental mode, also compute the net diff size between baseRef and the
420+ // branch tip. The format-patch file size (patchSize) is the sum of every
421+ // commit's individual diff plus per-commit metadata headers, which can be
422+ // significantly larger than the actual net change. Consumers (e.g.
423+ // push_to_pull_request_branch) should validate `max_patch_size` against the
424+ // incremental net diff so the limit reflects how much the branch will
425+ // actually change, not the cumulative size of the commit history.
426+ //
427+ // The measurement itself (stream to temp file via `git diff --output`, stat,
428+ // cleanup) is extracted into git_patch_utils.computeIncrementalDiffSize so
429+ // it is O(1) memory and independently unit-testable against a real repo.
430+ let diffSize = null ;
431+ if ( mode === "incremental" && baseCommitSha && branchName ) {
432+ diffSize = computeIncrementalDiffSize ( {
433+ baseRef : baseCommitSha ,
434+ headRef : branchName ,
435+ cwd,
436+ tmpPath : `${ patchPath } .diff.tmp` ,
437+ excludedFiles : options . excludedFiles ,
438+ } ) ;
439+ debugLog ( `Final: diffSize=${ diffSize ?? "(n/a)" } bytes (baseRef=${ baseCommitSha } ..${ branchName } )` ) ;
440+ }
441+
442+ debugLog ( `Final: SUCCESS - patchSize=${ patchSize } bytes, patchLines=${ patchLines } , diffSize=${ diffSize ?? "(n/a)" } bytes, baseCommit=${ baseCommitSha || "(unknown)" } ` ) ;
454443 return {
455444 success : true ,
456445 patchPath : patchPath ,
457446 patchSize : patchSize ,
458447 patchLines : patchLines ,
448+ diffSize : diffSize ,
459449 baseCommit : baseCommitSha ,
460450 } ;
461451 }
0 commit comments