-
Notifications
You must be signed in to change notification settings - Fork 3.2k
/
bundle-and-gitignore-deps.js
255 lines (220 loc) · 7.22 KB
/
bundle-and-gitignore-deps.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
const Arborist = require('@npmcli/arborist')
const packlist = require('npm-packlist')
const { join, relative } = require('path')
const localeCompare = require('@isaacs/string-locale-compare')('en')
const PackageJson = require('@npmcli/package-json')
const { run, CWD, git, fs } = require('./util')
const npmGit = require('@npmcli/git')
const ALWAYS_IGNORE = `
.bin/
.cache/
package-lock.json
CHANGELOG*
changelog*
ChangeLog*
Changelog*
README*
readme*
ReadMe*
Readme*
__pycache__
.editorconfig
.idea/
.npmignore
.eslintrc*
.travis*
.github
.jscsrc
.nycrc
.istanbul*
.eslintignore
.jshintrc*
.prettierrc*
.jscs.json
.dir-locals*
.coveralls*
.babelrc*
.nyc_output
.gitkeep
`
const lsAndRmIgnored = async (dir) => {
const files = await git(
'ls-files',
'--cached',
'--ignored',
`--exclude-standard`,
dir,
{ lines: true }
)
for (const file of files) {
await git('rm', file)
}
// check if there are still ignored files left
// if so we will error in the next step
const notRemoved = await git(
'ls-files',
'--cached',
'--ignored',
`--exclude-standard`,
dir,
{ lines: true }
)
return notRemoved
}
const getAllowedPaths = (files) => {
// Get all files within node_modules and remove
// the node_modules/ portion of the path for processing
// since this list will go inside a gitignore at the
// root of the node_modules dir
const nmFiles = files
.filter(f => f.startsWith('node_modules/'))
.map(f => f.replace(/^node_modules\//, ''))
.sort(localeCompare)
class AllowSegments {
#segments
#usedSegments
constructor (pathSegments, rootSegments = []) {
// Copy strings with spread operator since we mutate these arrays
this.#segments = [...pathSegments]
this.#usedSegments = [...rootSegments]
}
get next () {
return this.#segments[0]
}
get remaining () {
return this.#segments
}
get used () {
return this.#usedSegments
}
use () {
const segment = this.#segments.shift()
this.#usedSegments.push(segment)
return segment
}
allowContents ({ use = true, isDirectory = true } = {}) {
if (use) {
this.use()
}
// Allow a previously ignored directy
// Important: this should NOT have a trailing
// slash if we are not sure it is a directory.
// Since a dep can be a directory or a symlink and
// a trailing slash in a .gitignore file
// tells git to treat it only as a directory
return [`!/${this.used.join('/')}${isDirectory ? '/' : ''}`]
}
allow ({ use = true } = {}) {
if (use) {
this.use()
}
// Allow a previously ignored directory but ignore everything inside
return [
...this.allowContents({ use: false, isDirectory: true }),
`/${this.used.join('/')}/*`,
]
}
}
const gatherAllows = (pathParts, usedParts) => {
const ignores = []
const segments = new AllowSegments(pathParts, usedParts)
if (segments.next) {
// 1) Process scope segment of the path, if it has one
if (segments.next.startsWith('@')) {
// For scoped deps we need to allow the entire scope dir
// due to how gitignore works. Without this the gitignore will
// never look to allow our bundled dep since the scope dir was ignored.
// It ends up looking like this for `@colors/colors`:
//
// # Allow @colors dir
// !/@colors/
// # Ignore everything inside. This is safe because there is
// # nothing inside a scope except other packages
// !/colors/*
//
// Then later we will allow the specific dep inside that scope.
// This way if a scope includes bundled AND unbundled deps,
// we only allow the bundled ones.
ignores.push(...segments.allow())
}
// 2) Now we process the name segment of the path
// and allow the dir and everything inside of it (like source code, etc)
ignores.push(...segments.allowContents({ isDirectory: false }))
// 3) If we still have remaining segments and the next segment
// is a nested node_modules directory...
if (segments.next && segments.use() === 'node_modules') {
ignores.push(
// Allow node_modules and ignore everything inside of it
// Set false here since we already "used" the node_modules path segment
...segments.allow({ use: false }),
// Repeat the process with the remaining path segments to include whatever is left
...gatherAllows(segments.remaining, segments.used)
)
}
}
return ignores
}
const allowPaths = new Set()
for (const file of nmFiles) {
for (const allow of gatherAllows(file.split('/'))) {
allowPaths.add(allow)
}
}
return [...allowPaths]
}
const setBundleDeps = async () => {
const pkg = await PackageJson.load(CWD)
pkg.update({
bundleDependencies: Object.keys(pkg.content.dependencies).sort(localeCompare),
})
await pkg.save()
return pkg.content.bundleDependencies
}
/*
This file sets what is checked in to node_modules. The root .gitignore file
includes node_modules and this file writes an ignore file to
node_modules/.gitignore. We ignore everything and then use a query to find all
the bundled deps and allow each one of those explicitly.
Since node_modules can be nested we have to process each portion of the path and
allow it while also ignoring everything inside of it, with the exception of a
deps source. We have to do this since everything is ignored by default, and git
will not allow a nested path if its parent has not also been allowed. BUT! We
also have to ignore other things in those directories.
*/
const main = async () => {
await setBundleDeps()
const arb = new Arborist({ path: CWD })
const files = await arb.loadActual().then(packlist)
const ignoreFile = [
'# Automatically generated to ignore everything except bundled deps',
'# Ignore everything by default except this file',
'/*',
'!/.gitignore',
'# Allow all bundled deps',
...getAllowedPaths(files),
'# Always ignore some specific patterns within any allowed package',
...ALWAYS_IGNORE.trim().split('\n'),
]
const NODE_MODULES = join(CWD, 'node_modules')
const res = await fs.writeFile(join(NODE_MODULES, '.gitignore'), ignoreFile.join('\n'))
if (!await npmGit.is({ cwd: CWD })) {
// if we are not running in a git repo then write the files but we do not
// need to run any git commands to check if we have unmatched files in source
return res
}
// After we write the file we have to check if any of the paths already checked in
// inside node_modules are now going to be ignored. If we find any then fail with
// a list of the paths remaining. We already attempted to `git rm` them so just
// explain what happened and leave the repo in a state to debug.
const trackedAndIgnored = await lsAndRmIgnored(NODE_MODULES)
if (trackedAndIgnored.length) {
const message = [
'The following files are checked in to git but will now be ignored.',
`They could not be removed automatically and will need to be removed manually.`,
...trackedAndIgnored.map(p => relative(NODE_MODULES, p)),
].join('\n')
throw new Error(message)
}
return res
}
run(main)