@itcase/config 1.7.23 → 1.7.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/smart-commit/cli.js +158 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itcase/config",
3
- "version": "1.7.23",
3
+ "version": "1.7.25",
4
4
  "author": "ITCase",
5
5
  "description": "ITCase Config",
6
6
  "engines": {
@@ -202,28 +202,46 @@ async function main(argv) {
202
202
  continue
203
203
  }
204
204
 
205
+ const ignoredFiles = gitIgnoredFiles(cwd, planFiles)
206
+ const filesToStage = planFiles.filter(
207
+ (file) => !ignoredFiles.includes(file),
208
+ )
209
+ process.stdout.write(`\n${paint('1;36', step)} ${plan.message}\n`)
210
+ if (ignoredFiles.length) {
211
+ process.stdout.write(
212
+ `${dim(` skipped (gitignore): ${ignoredFiles.join(', ')}`)}\n`,
213
+ )
214
+ }
215
+ if (!filesToStage.length) continue
216
+
205
217
  const startedAt = Date.now()
206
218
  const fileLabel =
207
- planFiles.length === 1 ? '1 file' : `${planFiles.length} files`
208
- process.stdout.write(`\n${paint('1;36', step)} ${plan.message}\n`)
219
+ filesToStage.length === 1 ? '1 file' : `${filesToStage.length} files`
209
220
  process.stdout.write(`${dim(` staging ${fileLabel}…`)}\n`)
210
221
 
211
222
  // Drop leftover index entries so only this plan's files are committed.
212
223
  git(cwd, ['reset', '-q', 'HEAD'])
213
- stageFiles(cwd, planFiles)
224
+ const skippedOnStage = stageFiles(cwd, filesToStage)
214
225
  const staged = lines(cwd, ['diff', '--cached', '--name-only'])
215
- const stagedForPlan = planFiles.filter((file) => staged.includes(file))
226
+ const stagedForPlan = filesToStage.filter((file) => staged.includes(file))
216
227
  if (!stagedForPlan.length) {
228
+ const skipped = new Set(skippedOnStage)
229
+ // Ignored paths were not staged on purpose. Stop this commit and keep
230
+ // going — one ignored directory must not drop the rest of the run.
231
+ if (filesToStage.every((file) => skipped.has(file))) {
232
+ process.stdout.write(`${dim(` skip ignored: ${plan.message}`)}\n`)
233
+ continue
234
+ }
217
235
  throw new Error(
218
236
  `Nothing staged for commit: ${plan.message}\n` +
219
- `Files: ${planFiles.join(', ')}`,
237
+ `Files: ${filesToStage.join(', ')}`,
220
238
  )
221
239
  }
222
240
 
223
241
  const commitLabel = option.noVerify
224
242
  ? 'git commit --no-verify'
225
243
  : 'git commit'
226
- const hookLocks = siblingLocksForFiles(planFiles)
244
+ const hookLocks = siblingLocksForFiles(filesToStage)
227
245
  const dirtyLocksBefore = new Set(
228
246
  hookLocks.filter((file) => fileHasChanges(cwd, file)),
229
247
  )
@@ -233,7 +251,7 @@ async function main(argv) {
233
251
  // equivalent flag — the hook simply does not run.
234
252
  if (option.noVerify) commitArgs.push('--no-verify')
235
253
  await gitCommitWithProgress(cwd, commitArgs, commitLabel)
236
- const included = includeHookLockfiles(cwd, planFiles, dirtyLocksBefore)
254
+ const included = includeHookLockfiles(cwd, filesToStage, dirtyLocksBefore)
237
255
  if (included.length) {
238
256
  process.stdout.write(
239
257
  `${dim(` hook updated ${included.join(', ')}, added to commit`)}\n`,
@@ -1534,6 +1552,101 @@ function isOnDisk(cwd, file) {
1534
1552
  return fs.existsSync(path.join(cwd, file))
1535
1553
  }
1536
1554
 
1555
+ function gitIgnoredFiles(cwd, files) {
1556
+ if (!files.length) return []
1557
+
1558
+ // Without --no-index, check-ignore hides tracked files that match
1559
+ // gitignore. Those still stage, unless a parent directory is ignored:
1560
+ // `git add` then exits 1 and names the directory, not the file.
1561
+ const direct = pathsIgnoredByGit(cwd, files)
1562
+ const underIgnoredDirectory = filesUnderIgnoredDirectory(cwd, files)
1563
+ return [...new Set([...direct, ...underIgnoredDirectory])]
1564
+ }
1565
+
1566
+ function pathsIgnoredByGit(cwd, paths, option = {}) {
1567
+ if (!paths.length) return []
1568
+
1569
+ const args = ['check-ignore', '-z', '--stdin']
1570
+ if (option.noIndex) args.push('--no-index')
1571
+ const result = spawnSync('git', args, {
1572
+ maxBuffer: GIT_MAX_BUFFER,
1573
+ cwd: cwd,
1574
+ encoding: 'utf8',
1575
+ input: `${paths.join('\0')}\0`,
1576
+ stdio: ['pipe', 'pipe', 'pipe'],
1577
+ })
1578
+ // 0 — at least one path is ignored, 1 — none. Any other status is a
1579
+ // fatal; treat it as "nothing ignored" and let git add surface it.
1580
+ if (result.status !== 0) return []
1581
+
1582
+ const ignored = new Set(
1583
+ String(result.stdout || '')
1584
+ .split('\0')
1585
+ .filter(Boolean),
1586
+ )
1587
+ return paths.filter((file) => ignored.has(file))
1588
+ }
1589
+
1590
+ function ancestorDirectories(file) {
1591
+ const ancestors = []
1592
+ let directory = path.posix.dirname(file)
1593
+ while (directory && directory !== '.') {
1594
+ ancestors.push(directory)
1595
+ directory = path.posix.dirname(directory)
1596
+ }
1597
+ return ancestors
1598
+ }
1599
+
1600
+ function filesUnderIgnoredDirectory(cwd, files) {
1601
+ const ancestorsByFile = new Map()
1602
+ const uniqueAncestors = []
1603
+ const seen = new Set()
1604
+ for (const file of files) {
1605
+ const ancestors = ancestorDirectories(file)
1606
+ ancestorsByFile.set(file, ancestors)
1607
+ for (const ancestor of ancestors) {
1608
+ if (seen.has(ancestor)) continue
1609
+ seen.add(ancestor)
1610
+ uniqueAncestors.push(ancestor)
1611
+ }
1612
+ }
1613
+
1614
+ const ignoredAncestors = new Set(
1615
+ pathsIgnoredByGit(cwd, uniqueAncestors, { noIndex: true }),
1616
+ )
1617
+ if (!ignoredAncestors.size) return []
1618
+
1619
+ return files.filter((file) =>
1620
+ ancestorsByFile
1621
+ .get(file)
1622
+ .some((ancestor) => ignoredAncestors.has(ancestor)),
1623
+ )
1624
+ }
1625
+
1626
+ function pathMatchesIgnored(file, ignoredPaths) {
1627
+ return ignoredPaths.some(
1628
+ (ignored) => file === ignored || file.startsWith(`${ignored}/`),
1629
+ )
1630
+ }
1631
+
1632
+ function ignoredPathsFromGitAdd(stderr) {
1633
+ const header =
1634
+ 'The following paths are ignored by one of your .gitignore files:'
1635
+ const start = String(stderr || '').indexOf(header)
1636
+ if (start < 0) return []
1637
+
1638
+ const paths = []
1639
+ for (const line of String(stderr)
1640
+ .slice(start + header.length)
1641
+ .split('\n')) {
1642
+ const trimmed = line.trim().replace(/\/+$/, '')
1643
+ if (!trimmed) continue
1644
+ if (trimmed.startsWith('hint:')) break
1645
+ paths.push(trimmed)
1646
+ }
1647
+ return paths
1648
+ }
1649
+
1537
1650
  function isTracked(cwd, file) {
1538
1651
  return gitOk(cwd, ['ls-files', '--error-unmatch', '--', file])
1539
1652
  }
@@ -1600,11 +1713,21 @@ function includeHookLockfiles(cwd, planFiles, dirtyBefore) {
1600
1713
  }
1601
1714
 
1602
1715
  function stageFiles(cwd, files) {
1603
- if (!files.length) return
1716
+ if (!files.length) return []
1717
+
1718
+ // `git add -- path/inside/ignored-dir` exits 1 and names the directory,
1719
+ // after it has already updated the index. Drop those paths first. If git
1720
+ // add still reports an ignored directory, take those paths back out and
1721
+ // keep the rest of the batch. Tracked files that match gitignore but do
1722
+ // not sit inside an ignored directory still stage.
1723
+ const ignored = new Set(gitIgnoredFiles(cwd, files))
1724
+ const stageableFiles = files.filter((file) => !ignored.has(file))
1725
+ const skipped = [...ignored]
1726
+ if (!stageableFiles.length) return skipped
1604
1727
 
1605
1728
  const existing = []
1606
1729
  const removed = []
1607
- for (const file of files) {
1730
+ for (const file of stageableFiles) {
1608
1731
  if (isOnDisk(cwd, file)) existing.push(file)
1609
1732
  else removed.push(file)
1610
1733
  }
@@ -1612,11 +1735,33 @@ function stageFiles(cwd, files) {
1612
1735
  // `git add -A -- deleted/path` warns or misses the deletion when the
1613
1736
  // parent directory is already gone. `git rm` stages the index removal.
1614
1737
  forEachBatch(existing, (batch) => {
1615
- git(cwd, ['add', '-A', '--', ...batch])
1738
+ const result = git(cwd, ['add', '-A', '--', ...batch], {
1739
+ allowIgnored: true,
1740
+ })
1741
+ if (!result.status) return
1742
+
1743
+ const ignoredPaths = ignoredPathsFromGitAdd(result.stderr || '')
1744
+ const blocked = batch.filter((file) =>
1745
+ pathMatchesIgnored(file, ignoredPaths),
1746
+ )
1747
+ if (!blocked.length) {
1748
+ throw new Error(
1749
+ (result.stderr || '').trim() || `git add -A -- ${batch.join(' ')}`,
1750
+ )
1751
+ }
1752
+
1753
+ // git add writes the index before returning 1, including tracked files
1754
+ // under the ignored directory. Take those back out of this commit.
1755
+ git(cwd, ['reset', '-q', 'HEAD', '--', ...blocked])
1756
+ skipped.push(...blocked)
1757
+ process.stdout.write(
1758
+ `${dim(` skipped (gitignore): ${blocked.join(', ')}`)}\n`,
1759
+ )
1616
1760
  })
1617
1761
  forEachBatch(removed, (batch) => {
1618
1762
  git(cwd, ['rm', '--ignore-unmatch', '-q', '--', ...batch])
1619
1763
  })
1764
+ return skipped
1620
1765
  }
1621
1766
 
1622
1767
  async function confirm(count) {
@@ -1861,6 +2006,9 @@ function git(cwd, args, option = {}) {
1861
2006
 
1862
2007
  const stderr = result.stderr || ''
1863
2008
  if (!retryIndexLock(cwd, attempt, stderr)) {
2009
+ if (option.allowIgnored && ignoredPathsFromGitAdd(stderr).length) {
2010
+ return result
2011
+ }
1864
2012
  if (option.inherit && stderr) process.stderr.write(stderr)
1865
2013
  throw new Error(stderr.trim() || `git ${args.join(' ')}`)
1866
2014
  }