Skip to content

Commit 891fe52

Browse files
clydinmgechev
authored andcommitted
feat(@angular/cli): update with migrate only creates commit per migration (#15414)
* feat(@angular/cli): update with migrate only creates commit per migration * refactor(@angular/cli): simplify update command schematic execution
1 parent 0f2cca0 commit 891fe52

File tree

5 files changed

+246
-51
lines changed

5 files changed

+246
-51
lines changed

packages/angular/cli/commands/update-impl.ts

+234-48
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8+
import { normalize, virtualFs } from '@angular-devkit/core';
9+
import { NodeJsSyncHost } from '@angular-devkit/core/node';
10+
import { UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics';
11+
import { NodeWorkflow, validateOptionsWithSchema } from '@angular-devkit/schematics/tools';
812
import { execSync } from 'child_process';
913
import * as fs from 'fs';
1014
import * as path from 'path';
1115
import * as semver from 'semver';
12-
import { Arguments, Option } from '../models/interface';
13-
import { SchematicCommand } from '../models/schematic-command';
16+
import { Command } from '../models/command';
17+
import { Arguments } from '../models/interface';
18+
import { colors } from '../utilities/color';
1419
import { getPackageManager } from '../utilities/package-manager';
1520
import {
1621
PackageIdentifier,
@@ -28,11 +33,156 @@ const npa = require('npm-package-arg');
2833

2934
const oldConfigFileNames = ['.angular-cli.json', 'angular-cli.json'];
3035

31-
export class UpdateCommand extends SchematicCommand<UpdateCommandSchema> {
36+
export class UpdateCommand extends Command<UpdateCommandSchema> {
3237
public readonly allowMissingWorkspace = true;
3338

34-
async parseArguments(_schematicOptions: string[], _schema: Option[]): Promise<Arguments> {
35-
return {};
39+
private workflow: NodeWorkflow;
40+
41+
async initialize() {
42+
this.workflow = new NodeWorkflow(
43+
new virtualFs.ScopedHost(new NodeJsSyncHost(), normalize(this.workspace.root)),
44+
{
45+
packageManager: await getPackageManager(this.workspace.root),
46+
root: normalize(this.workspace.root),
47+
},
48+
);
49+
50+
this.workflow.engineHost.registerOptionsTransform(
51+
validateOptionsWithSchema(this.workflow.registry),
52+
);
53+
}
54+
55+
async executeSchematic(
56+
collection: string,
57+
schematic: string,
58+
options = {},
59+
): Promise<{ success: boolean; files: Set<string> }> {
60+
let error = false;
61+
const logs: string[] = [];
62+
const files = new Set<string>();
63+
64+
const reporterSubscription = this.workflow.reporter.subscribe(event => {
65+
// Strip leading slash to prevent confusion.
66+
const eventPath = event.path.startsWith('/') ? event.path.substr(1) : event.path;
67+
68+
switch (event.kind) {
69+
case 'error':
70+
error = true;
71+
const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist.';
72+
this.logger.error(`ERROR! ${eventPath} ${desc}.`);
73+
break;
74+
case 'update':
75+
logs.push(`${colors.whiteBright('UPDATE')} ${eventPath} (${event.content.length} bytes)`);
76+
files.add(eventPath);
77+
break;
78+
case 'create':
79+
logs.push(`${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes)`);
80+
files.add(eventPath);
81+
break;
82+
case 'delete':
83+
logs.push(`${colors.yellow('DELETE')} ${eventPath}`);
84+
files.add(eventPath);
85+
break;
86+
case 'rename':
87+
logs.push(`${colors.blue('RENAME')} ${eventPath} => ${event.to}`);
88+
files.add(eventPath);
89+
break;
90+
}
91+
});
92+
93+
const lifecycleSubscription = this.workflow.lifeCycle.subscribe(event => {
94+
if (event.kind == 'end' || event.kind == 'post-tasks-start') {
95+
if (!error) {
96+
// Output the logging queue, no error happened.
97+
logs.forEach(log => this.logger.info(log));
98+
}
99+
}
100+
});
101+
102+
// TODO: Allow passing a schematic instance directly
103+
try {
104+
await this.workflow
105+
.execute({
106+
collection,
107+
schematic,
108+
options,
109+
logger: this.logger,
110+
})
111+
.toPromise();
112+
113+
reporterSubscription.unsubscribe();
114+
lifecycleSubscription.unsubscribe();
115+
116+
return { success: !error, files };
117+
} catch (e) {
118+
if (e instanceof UnsuccessfulWorkflowExecution) {
119+
this.logger.error('The update failed. See above.');
120+
} else {
121+
this.logger.fatal(e.message);
122+
}
123+
124+
return { success: false, files };
125+
}
126+
}
127+
128+
async executeMigrations(
129+
packageName: string,
130+
collectionPath: string,
131+
range: semver.Range,
132+
commit = false,
133+
) {
134+
const collection = this.workflow.engine.createCollection(collectionPath);
135+
136+
const migrations = [];
137+
for (const name of collection.listSchematicNames()) {
138+
const schematic = this.workflow.engine.createSchematic(name, collection);
139+
const description = schematic.description as typeof schematic.description & {
140+
version?: string;
141+
};
142+
if (!description.version) {
143+
continue;
144+
}
145+
146+
if (semver.satisfies(description.version, range, { includePrerelease: true })) {
147+
migrations.push(description as typeof schematic.description & { version: string });
148+
}
149+
}
150+
151+
if (migrations.length === 0) {
152+
return true;
153+
}
154+
155+
const startingGitSha = this.findCurrentGitSha();
156+
157+
migrations.sort((a, b) => semver.compare(a.version, b.version) || a.name.localeCompare(b.name));
158+
159+
for (const migration of migrations) {
160+
this.logger.info(
161+
`** Executing migrations for version ${migration.version} of package '${packageName}' **`,
162+
);
163+
164+
const result = await this.executeSchematic(migration.collection.name, migration.name);
165+
if (!result.success) {
166+
if (startingGitSha !== null) {
167+
const currentGitSha = this.findCurrentGitSha();
168+
if (currentGitSha !== startingGitSha) {
169+
this.logger.warn(`git HEAD was at ${startingGitSha} before migrations.`);
170+
}
171+
}
172+
173+
return false;
174+
}
175+
176+
// Commit migration
177+
if (commit) {
178+
let message = `migrate workspace for ${packageName}@${migration.version}`;
179+
if (migration.description) {
180+
message += '\n' + migration.description;
181+
}
182+
// TODO: Use result.files once package install tasks are accounted
183+
this.createCommit(message, []);
184+
}
185+
}
36186
}
37187

38188
// tslint:disable-next-line:no-big-function
@@ -112,9 +262,9 @@ export class UpdateCommand extends SchematicCommand<UpdateCommandSchema> {
112262
this.workspace.configFile &&
113263
oldConfigFileNames.includes(this.workspace.configFile)
114264
) {
115-
options.migrateOnly = true;
116-
options.from = '1.0.0';
117-
}
265+
options.migrateOnly = true;
266+
options.from = '1.0.0';
267+
}
118268

119269
this.logger.info('Collecting installed dependencies...');
120270

@@ -125,19 +275,15 @@ export class UpdateCommand extends SchematicCommand<UpdateCommandSchema> {
125275

126276
if (options.all || packages.length === 0) {
127277
// Either update all packages or show status
128-
return this.runSchematic({
129-
collectionName: '@schematics/update',
130-
schematicName: 'update',
131-
dryRun: !!options.dryRun,
132-
showNothingDone: false,
133-
additionalOptions: {
134-
force: options.force || false,
135-
next: options.next || false,
136-
verbose: options.verbose || false,
137-
packageManager,
138-
packages: options.all ? Object.keys(rootDependencies) : [],
139-
},
278+
const { success } = await this.executeSchematic('@schematics/update', 'update', {
279+
force: options.force || false,
280+
next: options.next || false,
281+
verbose: options.verbose || false,
282+
packageManager,
283+
packages: options.all ? Object.keys(rootDependencies) : [],
140284
});
285+
286+
return success ? 0 : 1;
141287
}
142288

143289
if (options.migrateOnly) {
@@ -153,6 +299,13 @@ export class UpdateCommand extends SchematicCommand<UpdateCommandSchema> {
153299
return 1;
154300
}
155301

302+
const from = coerceVersionNumber(options.from);
303+
if (!from) {
304+
this.logger.error(`"from" value [${options.from}] is not a valid version.`);
305+
306+
return 1;
307+
}
308+
156309
if (options.next) {
157310
this.logger.warn('"next" option has no effect when using "migrate-only" option.');
158311
}
@@ -230,20 +383,18 @@ export class UpdateCommand extends SchematicCommand<UpdateCommandSchema> {
230383
}
231384
}
232385

233-
return this.runSchematic({
234-
collectionName: '@schematics/update',
235-
schematicName: 'migrate',
236-
dryRun: !!options.dryRun,
237-
force: false,
238-
showNothingDone: false,
239-
additionalOptions: {
240-
package: packageName,
241-
collection: migrations,
242-
from: options.from,
243-
verbose: options.verbose || false,
244-
to: options.to || packageNode.package.version,
245-
},
246-
});
386+
const migrationRange = new semver.Range(
387+
'>' + from + ' <=' + (options.to || packageNode.package.version),
388+
);
389+
390+
const result = await this.executeMigrations(
391+
packageName,
392+
migrations,
393+
migrationRange,
394+
!options.skipCommits,
395+
);
396+
397+
return result ? 1 : 0;
247398
}
248399

249400
const requests: {
@@ -287,7 +438,9 @@ export class UpdateCommand extends SchematicCommand<UpdateCommandSchema> {
287438
try {
288439
// Metadata requests are internally cached; multiple requests for same name
289440
// does not result in additional network traffic
290-
metadata = await fetchPackageMetadata(packageName, this.logger, { verbose: options.verbose });
441+
metadata = await fetchPackageMetadata(packageName, this.logger, {
442+
verbose: options.verbose,
443+
});
291444
} catch (e) {
292445
this.logger.error(`Error fetching metadata for '${packageName}': ` + e.message);
293446

@@ -334,18 +487,14 @@ export class UpdateCommand extends SchematicCommand<UpdateCommandSchema> {
334487
return 0;
335488
}
336489

337-
return this.runSchematic({
338-
collectionName: '@schematics/update',
339-
schematicName: 'update',
340-
dryRun: !!options.dryRun,
341-
showNothingDone: false,
342-
additionalOptions: {
343-
verbose: options.verbose || false,
344-
force: options.force || false,
345-
packageManager,
346-
packages: packagesToUpdate,
347-
},
490+
const { success } = await this.executeSchematic('@schematics/update', 'update', {
491+
verbose: options.verbose || false,
492+
force: options.force || false,
493+
packageManager,
494+
packages: packagesToUpdate,
348495
});
496+
497+
return success ? 0 : 1;
349498
}
350499

351500
checkCleanGit() {
@@ -366,9 +515,46 @@ export class UpdateCommand extends SchematicCommand<UpdateCommandSchema> {
366515
return false;
367516
}
368517
}
369-
370-
} catch { }
518+
} catch {}
371519

372520
return true;
373521
}
522+
523+
createCommit(message: string, files: string[]) {
524+
try {
525+
execSync('git add -A ' + files.join(' '), { encoding: 'utf8', stdio: 'pipe' });
526+
527+
execSync(`git commit --no-verify -m "${message}"`, { encoding: 'utf8', stdio: 'pipe' });
528+
} catch (error) {}
529+
}
530+
531+
findCurrentGitSha(): string | null {
532+
try {
533+
const result = execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: 'pipe' });
534+
535+
return result.trim();
536+
} catch {
537+
return null;
538+
}
539+
}
540+
}
541+
542+
function coerceVersionNumber(version: string): string | null {
543+
if (!version.match(/^\d{1,30}\.\d{1,30}\.\d{1,30}/)) {
544+
const match = version.match(/^\d{1,30}(\.\d{1,30})*/);
545+
546+
if (!match) {
547+
return null;
548+
}
549+
550+
if (!match[1]) {
551+
version = version.substr(0, match[0].length) + '.0.0' + version.substr(match[0].length);
552+
} else if (!match[2]) {
553+
version = version.substr(0, match[0].length) + '.0' + version.substr(match[0].length);
554+
} else {
555+
return null;
556+
}
557+
}
558+
559+
return semver.valid(version);
374560
}

packages/angular/cli/commands/update.json

+6
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@
6262
"description": "Display additional details about internal operations during execution.",
6363
"type": "boolean",
6464
"default": false
65+
},
66+
"skipCommits": {
67+
"description": "Do not create source control commits for updates and migrations.",
68+
"type": "boolean",
69+
"default": false,
70+
"aliases": ["C"]
6571
}
6672
}
6773
}

tests/legacy-cli/e2e/tests/update/update-1.0.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export default async function() {
1010

1111
await useCIChrome('.');
1212
await expectToFail(() => ng('build'));
13-
await ng('update', '@angular/cli');
13+
// Turn off git commits ('-C') per migration to avoid breaking E2E cleanup process
14+
await ng('update', '@angular/cli', '-C');
1415
await useBuiltPackages();
1516
await silentNpm('install');
1617
await ng('update', '@angular/core', ...extraUpdateArgs);

tests/legacy-cli/e2e/tests/update/update-1.7-longhand.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export default async function() {
99
await createProjectFromAsset('1.7-project');
1010

1111
await expectToFail(() => ng('build'));
12-
await ng('update', '@angular/cli', '--migrate-only', '--from=1.7.1');
12+
// Turn off git commits ('-C') per migration to avoid breaking E2E cleanup process
13+
await ng('update', '@angular/cli', '--migrate-only', '--from=1.7.1', '-C');
1314
await useBuiltPackages();
1415
await silentNpm('install');
1516
await ng('update', '@angular/core', ...extraUpdateArgs);

tests/legacy-cli/e2e/tests/update/update-1.7.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ export default async function() {
1212

1313
await useCIChrome('.');
1414
await expectToFail(() => ng('build'));
15-
await ng('update', '@angular/cli');
15+
// Turn off git commits ('-C') per migration to avoid breaking E2E cleanup process
16+
await ng('update', '@angular/cli', '-C');
1617
await useBuiltPackages();
1718
await silentNpm('install');
1819
await ng('update', '@angular/core', ...extraUpdateArgs);

0 commit comments

Comments
 (0)