@@ -14,11 +14,14 @@ import {
1414 pushToOrigin ,
1515 rebase ,
1616 getLog ,
17+ gitSafe ,
1718 GitOptions
1819} from './git' ;
1920import { WorktreeContext } from './worktree' ;
2021import { askConsent } from './prompt' ;
2122import { isValidSemver , normalizeTag , extractVersion } from './version' ;
23+ import * as fs from 'fs' ;
24+ import * as path from 'path' ;
2225
2326/**
2427 * Runs an npm script in the repository root.
@@ -32,6 +35,32 @@ function runNpmScript(script: string, cwd: string): void {
3235 } ) ;
3336}
3437
38+ /**
39+ * Checks whether all workspaces (including root) already have the target version.
40+ * If any package.json is missing or has a different version, returns false.
41+ * @param cwd - Repo root
42+ * @param targetVersion - Version string without 'v' prefix
43+ */
44+ function packagesAlreadyOnVersion ( cwd : string , targetVersion : string ) : boolean {
45+ try {
46+ const rootPkgPath = path . join ( cwd , 'package.json' ) ;
47+ const rootPkg = JSON . parse ( fs . readFileSync ( rootPkgPath , 'utf-8' ) ) ;
48+ const workspaces : string [ ] = rootPkg . workspaces ?? [ ] ;
49+ const packageFiles = [ rootPkgPath , ...workspaces . map ( w => path . join ( cwd , w , 'package.json' ) ) ] ;
50+
51+ for ( const pkgPath of packageFiles ) {
52+ const pkg = JSON . parse ( fs . readFileSync ( pkgPath , 'utf-8' ) ) ;
53+ if ( pkg . version !== targetVersion ) {
54+ return false ;
55+ }
56+ }
57+ return true ;
58+ } catch {
59+ // Fall back to running version:set if anything goes wrong
60+ return false ;
61+ }
62+ }
63+
3564/**
3665 * Checks if working tree is clean and prompts user to handle changes.
3766 * @param gitOpts - Git options
@@ -83,9 +112,11 @@ async function executeStep(
83112 description : string ,
84113 command : string ,
85114 options : ReleaseOptions ,
86- action : ( ) => void
115+ action : ( ) => void ,
116+ defaultYes = true ,
117+ continueOnFailure = false
87118) : Promise < StepResult > {
88- const consent = await askConsent ( description , command , options . skipPrompts , options . dryRun ) ;
119+ const consent = await askConsent ( description , command , options . skipPrompts , options . dryRun , defaultYes ) ;
89120
90121 if ( ! consent ) {
91122 if ( options . dryRun ) {
@@ -99,6 +130,10 @@ async function executeStep(
99130 return { success : true , message : description } ;
100131 } catch ( err ) {
101132 const errorMessage = err instanceof Error ? err . message : String ( err ) ;
133+ if ( continueOnFailure ) {
134+ console . warn ( `Non-blocking failure: ${ description } - ${ errorMessage } ` ) ;
135+ return { success : true , message : `Non-blocking failure: ${ description } ` } ;
136+ }
102137 return { success : false , message : `Failed: ${ description } - ${ errorMessage } ` } ;
103138 }
104139}
@@ -132,18 +167,23 @@ export async function executeRelease(options: ReleaseOptions): Promise<boolean>
132167
133168 // Pre-release Step 1: Update package versions
134169 console . log ( '\n==== Pre-release: Update package versions ====' ) ;
135- const versionResult = await executeStep (
136- `Update package.json versions to ${ version } ` ,
137- `npm run version:set -- ${ version } ` ,
138- options ,
139- ( ) => runNpmScript ( `version:set -- ${ version } ` , rootDir )
140- ) ;
141- if ( ! versionResult . success && ! dryRun ) {
142- console . error ( versionResult . message ) ;
143- return false ;
144- }
145- if ( ! dryRun && ! requireCleanWorktree ( gitOpts , 'Version update' ) ) {
146- return false ;
170+ const versionsAligned = packagesAlreadyOnVersion ( rootDir , version ) ;
171+ if ( versionsAligned ) {
172+ console . log ( `All packages already at ${ version } ; skipping version:set.` ) ;
173+ } else {
174+ const versionResult = await executeStep (
175+ `Update package.json versions to ${ version } ` ,
176+ `npm run version:set -- ${ version } ` ,
177+ options ,
178+ ( ) => runNpmScript ( `version:set -- ${ version } ` , rootDir )
179+ ) ;
180+ if ( ! versionResult . success && ! dryRun ) {
181+ console . error ( versionResult . message ) ;
182+ return false ;
183+ }
184+ if ( ! dryRun && ! requireCleanWorktree ( gitOpts , 'Version update' ) ) {
185+ return false ;
186+ }
147187 }
148188
149189 // Pre-release Step 2: Rebuild all bundles
@@ -152,7 +192,8 @@ export async function executeRelease(options: ReleaseOptions): Promise<boolean>
152192 'Regenerate dist outputs for fresh builds' ,
153193 'npm run build' ,
154194 options ,
155- ( ) => runNpmScript ( 'build' , rootDir )
195+ ( ) => runNpmScript ( 'build' , rootDir ) ,
196+ true // default Yes; rebuilding should generally proceed
156197 ) ;
157198 if ( ! buildResult . success && ! dryRun ) {
158199 console . error ( buildResult . message ) ;
@@ -168,7 +209,9 @@ export async function executeRelease(options: ReleaseOptions): Promise<boolean>
168209 'Run depcheck to verify dependency health' ,
169210 'npm run depcheck' ,
170211 options ,
171- ( ) => runNpmScript ( 'depcheck' , rootDir )
212+ ( ) => runNpmScript ( 'depcheck' , rootDir ) ,
213+ true , // default Yes
214+ true // continue even if depcheck reports unused deps
172215 ) ;
173216 if ( ! depcheckResult . success && ! dryRun ) {
174217 console . error ( depcheckResult . message ) ;
@@ -219,15 +262,45 @@ export async function executeRelease(options: ReleaseOptions): Promise<boolean>
219262 const remoteDevelop = getCommitSha ( 'origin/develop' , gitOpts ) ;
220263
221264 if ( localDevelop !== remoteDevelop ) {
222- console . error ( 'Local develop branch is not up to date with remote.' ) ;
223- console . error ( `Local develop: ${ localDevelop } ` ) ;
224- console . error ( ` ${ getCommitMessage ( 'refs/heads/develop' , gitOpts ) } ` ) ;
225- console . error ( `Remote develop: ${ remoteDevelop } ` ) ;
226- console . error ( ` ${ getCommitMessage ( 'origin/develop' , gitOpts ) } ` ) ;
227- console . error ( '\nPlease update your local develop branch first.' ) ;
228- return false ;
265+ const remoteIsAncestor = gitSafe (
266+ [ 'merge-base' , '--is-ancestor' , 'origin/develop' , 'refs/heads/develop' ] ,
267+ gitOpts
268+ ) ;
269+ const localIsAncestor = gitSafe (
270+ [ 'merge-base' , '--is-ancestor' , 'refs/heads/develop' , 'origin/develop' ] ,
271+ gitOpts
272+ ) ;
273+
274+ if ( remoteIsAncestor && ! localIsAncestor ) {
275+ console . log ( 'Local develop is ahead of origin/develop.' ) ;
276+ console . log ( `Local develop: ${ localDevelop } ${ getCommitMessage ( 'refs/heads/develop' , gitOpts ) } ` ) ;
277+ console . log ( `Remote develop: ${ remoteDevelop } ${ getCommitMessage ( 'origin/develop' , gitOpts ) } ` ) ;
278+ const pushDevelop = await executeStep (
279+ 'Push develop to origin' ,
280+ 'git push origin develop' ,
281+ options ,
282+ ( ) => pushToOrigin ( 'develop' , gitOpts ) ,
283+ true // default yes
284+ ) ;
285+ if ( ! pushDevelop . success ) {
286+ console . error ( pushDevelop . message ) ;
287+ return false ;
288+ }
289+ } else if ( localIsAncestor && ! remoteIsAncestor ) {
290+ console . error ( 'Local develop is behind origin/develop.' ) ;
291+ console . error ( `Local develop: ${ localDevelop } ${ getCommitMessage ( 'refs/heads/develop' , gitOpts ) } ` ) ;
292+ console . error ( `Remote develop: ${ remoteDevelop } ${ getCommitMessage ( 'origin/develop' , gitOpts ) } ` ) ;
293+ console . error ( '\nPlease pull or rebase develop before releasing.' ) ;
294+ return false ;
295+ } else {
296+ console . error ( 'Local and origin develop have diverged. Please reconcile before releasing.' ) ;
297+ console . error ( `Local develop: ${ localDevelop } ${ getCommitMessage ( 'refs/heads/develop' , gitOpts ) } ` ) ;
298+ console . error ( `Remote develop: ${ remoteDevelop } ${ getCommitMessage ( 'origin/develop' , gitOpts ) } ` ) ;
299+ return false ;
300+ }
301+ } else {
302+ console . log ( `\u2705 develop branch is up to date (${ localDevelop . substring ( 0 , 8 ) } )` ) ;
229303 }
230- console . log ( `\u2705 develop branch is up to date (${ localDevelop . substring ( 0 , 8 ) } )` ) ;
231304 } else {
232305 console . log ( '[DRY RUN] Would verify develop matches origin/develop' ) ;
233306 }
@@ -255,6 +328,10 @@ export async function executeRelease(options: ReleaseOptions): Promise<boolean>
255328 if ( ! dryRun && worktreeOpts ) {
256329 const masterSha = getCommitSha ( 'HEAD' , worktreeOpts ) ;
257330 const remoteDevelop = getCommitSha ( 'origin/develop' , gitOpts ) ;
331+ const worktreePath = worktreeContext . getPath ( ) ;
332+ const rebaseCmd = worktreePath
333+ ? `git -C ${ worktreePath } rebase origin/develop`
334+ : 'git rebase origin/develop' ;
258335
259336 if ( masterSha !== remoteDevelop ) {
260337 console . log ( 'master is not up to date with origin/develop' ) ;
@@ -268,8 +345,8 @@ export async function executeRelease(options: ReleaseOptions): Promise<boolean>
268345 }
269346
270347 const rebaseResult = await executeStep (
271- 'Rebase master onto origin/develop' ,
272- 'git rebase origin/develop' ,
348+ 'Rebase master worktree onto origin/develop (does not touch your local develop) ' ,
349+ rebaseCmd ,
273350 options ,
274351 ( ) => rebase ( 'origin/develop' , worktreeOpts ! )
275352 ) ;
0 commit comments