@itcase/config 1.7.25 → 1.7.27

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itcase/config",
3
- "version": "1.7.25",
3
+ "version": "1.7.27",
4
4
  "author": "ITCase",
5
5
  "description": "ITCase Config",
6
6
  "engines": {
@@ -43,11 +43,11 @@
43
43
  "@commitlint/config-conventional": "^21.2.3",
44
44
  "@commitlint/cz-commitlint": "^21.2.3",
45
45
  "@lehoczky/postcss-fluid": "^1.0.3",
46
- "@lerna-lite/cli": "^5.6.2",
47
- "@lerna-lite/exec": "^5.6.2",
48
- "@lerna-lite/list": "^5.6.2",
49
- "@lerna-lite/run": "^5.6.2",
50
- "@lerna-lite/watch": "^5.6.2",
46
+ "@lerna-lite/cli": "^5.6.3",
47
+ "@lerna-lite/exec": "^5.6.3",
48
+ "@lerna-lite/list": "^5.6.3",
49
+ "@lerna-lite/run": "^5.6.3",
50
+ "@lerna-lite/watch": "^5.6.3",
51
51
  "@rollup/plugin-alias": "^6.0.0",
52
52
  "@rollup/plugin-babel": "^7.1.0",
53
53
  "@rollup/plugin-commonjs": "^29.0.3",
@@ -68,11 +68,11 @@
68
68
  "commitizen": "^4.3.2",
69
69
  "conventional-changelog-conventionalcommits": "9.3.1",
70
70
  "conventional-changelog-writer": "9.3.0",
71
- "cssnano": "^9.3.2",
72
- "cssnano-preset-default": "^9.3.2",
71
+ "cssnano": "^9.9.0",
72
+ "cssnano-preset-default": "^9.8.0",
73
73
  "inquirer": "^14.2.3",
74
74
  "log-update": "^8.0.0",
75
- "postcss": "^8.5.29",
75
+ "postcss": "^8.5.30",
76
76
  "postcss-aspect-ratio-polyfill": "^2.0.0",
77
77
  "postcss-clamp": "^4.1.0",
78
78
  "postcss-cli": "^12.0.0",
@@ -80,19 +80,19 @@
80
80
  "postcss-combine-duplicated-selectors": "^11.0.0",
81
81
  "postcss-convert-unit": "^0.1.3",
82
82
  "postcss-dark-theme-class": "^2.0.0",
83
- "postcss-discard-duplicates": "^9.0.5",
83
+ "postcss-discard-duplicates": "^9.0.7",
84
84
  "postcss-each": "^1.1.0",
85
85
  "postcss-easings": "^4.0.0",
86
86
  "postcss-extend-rule": "^4.0.0",
87
87
  "postcss-flexbugs-fixes": "^5.0.2",
88
88
  "postcss-hexrgba": "^2.1.0",
89
- "postcss-import": "^17.0.0",
89
+ "postcss-import": "^17.0.1",
90
90
  "postcss-import-ext-glob": "^2.1.1",
91
91
  "postcss-loader": "^8.2.1",
92
92
  "postcss-merge-at-rules": "^2.0.1",
93
93
  "postcss-mixins": "^12.1.2",
94
94
  "postcss-mq-extract": "^1.0.0",
95
- "postcss-nested": "^8.0.1",
95
+ "postcss-nested": "^9.0.0",
96
96
  "postcss-nested-ancestors": "^3.0.0",
97
97
  "postcss-normalize": "^13.0.1",
98
98
  "postcss-prepend-imports": "^1.0.1",
@@ -110,17 +110,17 @@
110
110
  "rollup-plugin-peer-deps-external": "^2.2.4",
111
111
  "rollup-preserve-directives": "^1.1.3",
112
112
  "semantic-release": "^25.0.9",
113
- "semantic-release-lerna": "^3.6.5"
113
+ "semantic-release-lerna": "^3.6.7"
114
114
  },
115
115
  "devDependencies": {
116
116
  "@itcase/agent-skills": "^1.0.13",
117
- "@itcase/lint": "^1.1.126",
117
+ "@itcase/lint": "^1.1.130",
118
118
  "eslint": "9.39.2",
119
119
  "husky": "^9.1.7",
120
120
  "inquirer": "^14.2.3",
121
121
  "lint-staged": "^17.6.0",
122
- "prettier": "^3.9.9",
123
- "storybook": "^10.6.1",
122
+ "prettier": "^3.9.10",
123
+ "storybook": "^10.6.2",
124
124
  "stylelint": "^17.16.0",
125
125
  "typescript": "^6.0.3"
126
126
  }
@@ -0,0 +1,161 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { readFileSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const MAILRU_WEBDAV_BASE = 'https://webdav.cloud.mail.ru'
6
+
7
+ /**
8
+ * Prepare: bump Android → make APK → Mail.ru (если GIT_WORKFLOW=true).
9
+ *
10
+ * Env: SKIP_APK_BUILD, RELEASE_APK_TARGETS (stage,prod),
11
+ * MAILRU_USER, MAILRU_APP_PASSWORD, MAILRU_PUBLIC_URL.
12
+ *
13
+ * @param {{ apkPrefix: string, mailruDir: string }} options
14
+ */
15
+ function createAndroidAppPreparePlugin({ apkPrefix, mailruDir }) {
16
+ if (!apkPrefix) {
17
+ throw new Error('createAndroidAppPreparePlugin: apkPrefix is required')
18
+ }
19
+ if (!mailruDir) {
20
+ throw new Error('createAndroidAppPreparePlugin: mailruDir is required')
21
+ }
22
+
23
+ const uploadDir = mailruDir.replace(/^\/+|\/+$/g, '')
24
+
25
+ return {
26
+ prepare: async (_pluginConfig, context) => {
27
+ const { logger, nextRelease, options } = context
28
+ const version = nextRelease.version
29
+
30
+ bumpAndroidVersion(version, logger)
31
+
32
+ if (options.dryRun || process.env.SKIP_APK_BUILD === '1') {
33
+ logger.log('Skip APK build (dry-run or SKIP_APK_BUILD=1)')
34
+ return
35
+ }
36
+
37
+ const apks = resolveApks(apkPrefix, version)
38
+ for (const apk of apks) {
39
+ logger.log(`make ${apk.make}`)
40
+ execFileSync('make', [apk.make], { stdio: 'inherit' })
41
+ }
42
+
43
+ uploadApks(apks, uploadDir, logger)
44
+ },
45
+ }
46
+ }
47
+
48
+ function bumpAndroidVersion(version, logger) {
49
+ if (!/^\d+\.\d+\.\d+(-[\w.-]+)?$/.test(version)) {
50
+ throw new Error(`Unsupported version format: "${version}"`)
51
+ }
52
+
53
+ const gradlePath = join(process.cwd(), 'android/app/build.gradle')
54
+ const content = readFileSync(gradlePath, 'utf8')
55
+ const vNameMatch = content.match(/def vName = "([^"]+)"/)
56
+ const vCodeMatch = content.match(/def vCode = (\d+)/)
57
+
58
+ if (!vNameMatch || !vCodeMatch) {
59
+ throw new Error('Could not find version in build.gradle')
60
+ }
61
+
62
+ if (vNameMatch[1] === version) {
63
+ logger.log(`Android vName already ${version}, skip bump`)
64
+ return
65
+ }
66
+
67
+ const newCode = Number(vCodeMatch[1]) + 1
68
+ writeFileSync(
69
+ gradlePath,
70
+ content
71
+ .replace(/def vName = "[^"]+"/, `def vName = "${version}"`)
72
+ .replace(/def vCode = \d+/, `def vCode = ${newCode}`),
73
+ )
74
+ logger.log(`Android vName = ${version}, vCode = ${newCode}`)
75
+ }
76
+
77
+ /** @returns {{ make: string, name: string, fileName: string }[]} */
78
+ function resolveApks(apkPrefix, version) {
79
+ const defs = {
80
+ prod: {
81
+ name: 'PROD',
82
+ fileName: `${apkPrefix}-v${version}.apk`,
83
+ make: 'android-apk-prod',
84
+ },
85
+ stage: {
86
+ name: 'STAGE',
87
+ fileName: `${apkPrefix}-v${version}-STAGE.apk`,
88
+ make: 'android-apk-stage',
89
+ },
90
+ }
91
+
92
+ const keys = (process.env.RELEASE_APK_TARGETS || 'stage,prod')
93
+ .split(',')
94
+ .map((part) => part.trim())
95
+ .filter(Boolean)
96
+
97
+ if (keys.length === 0) {
98
+ throw new Error('RELEASE_APK_TARGETS is empty')
99
+ }
100
+
101
+ return keys.map((key) => {
102
+ const apk = defs[key]
103
+ if (!apk) {
104
+ throw new Error(
105
+ `Unknown RELEASE_APK_TARGETS entry: "${key}". Use stage, prod`,
106
+ )
107
+ }
108
+ return apk
109
+ })
110
+ }
111
+
112
+ function uploadApks(apks, uploadDir, logger) {
113
+ if (process.env.GIT_WORKFLOW !== 'true') {
114
+ logger.log('Skip Mail.ru upload (not CI)')
115
+ return
116
+ }
117
+
118
+ const user = process.env.MAILRU_USER
119
+ const password = process.env.MAILRU_APP_PASSWORD
120
+ const publicUrl = (process.env.MAILRU_PUBLIC_URL || '').replace(/\/$/, '')
121
+
122
+ if (!user || !password || !publicUrl) {
123
+ throw new Error(
124
+ 'MAILRU_USER, MAILRU_APP_PASSWORD and MAILRU_PUBLIC_URL are required',
125
+ )
126
+ }
127
+
128
+ const uploadBase = `${MAILRU_WEBDAV_BASE}/${uploadDir}`
129
+ const downloads = []
130
+
131
+ for (const apk of apks) {
132
+ logger.log(`Upload ${apk.fileName} to ${uploadBase}/`)
133
+ execFileSync(
134
+ 'curl',
135
+ [
136
+ '--fail',
137
+ '--silent',
138
+ '--show-error',
139
+ '--retry',
140
+ '2',
141
+ '--retry-delay',
142
+ '2',
143
+ '-T',
144
+ join(process.cwd(), 'builds', apk.fileName),
145
+ '--user',
146
+ `${user}:${password}`,
147
+ `${uploadBase}/${apk.fileName}`,
148
+ ],
149
+ { stdio: 'inherit' },
150
+ )
151
+ downloads.push({ name: apk.name, url: `${publicUrl}/${apk.fileName}` })
152
+ }
153
+
154
+ writeFileSync(
155
+ join(process.cwd(), 'builds', 'mailru-downloads.json'),
156
+ JSON.stringify(downloads),
157
+ )
158
+ logger.log('APK upload finished — Mail.ru links saved')
159
+ }
160
+
161
+ export { createAndroidAppPreparePlugin }
@@ -1,7 +1,4 @@
1
- import {
2
- createSemanticReleaseApp,
3
- semanticReleaseApp,
4
- } from './semanticReleaseApp.js'
1
+ import { semanticReleaseApp } from './semanticReleaseApp.js'
5
2
  import { semanticReleaseBot } from './semanticReleaseBot.js'
6
3
  import { semanticReleaseDoc } from './semanticReleaseDoc.js'
7
4
  import { semanticReleaseMonorepo } from './semanticReleaseMonorepo.js'
@@ -10,7 +7,6 @@ import { semanticReleaseProject } from './semanticReleaseProject.js'
10
7
  import { semanticReleaseVSMarketplace } from './semanticReleaseVSMarketplace.js'
11
8
 
12
9
  export {
13
- createSemanticReleaseApp,
14
10
  semanticReleaseApp,
15
11
  semanticReleaseBot,
16
12
  semanticReleaseDoc,
@@ -1,3 +1,4 @@
1
+ import { createAndroidAppPreparePlugin } from './androidAppPrepare.js'
1
2
  import {
2
3
  createSemanticReleaseConfig,
3
4
  releaseNotesFromIssuesPlugin,
@@ -17,27 +18,44 @@ const DEFAULT_RELEASE_ASSETS = ['builds/*.apk']
17
18
  *
18
19
  * - Не публикует в npm
19
20
  * - Коммитит package + Android version-файлы
20
- * - Прикрепляет к GitHub release APK из `builds/` (если есть; отсутствующие пропускаются)
21
- * - Заметки релиза: сначала задачи из releaseNotesFromIssues.js, если они есть, потом обычные коммиты
21
+ * - Прикрепляет к GitHub release APK из `builds/`
22
+ * - Заметки релиза: сначала задачи, потом коммиты
22
23
  *
23
- * Сборку APK делайте в prepare проекта или отдельным CI job до publish.
24
+ * Если передан `apk`, в prepare: bump → make → Mail.ru (GIT_WORKFLOW=true).
24
25
  *
25
26
  * @param {Object} [options]
26
27
  * @param {string|string[]} [options.branches=['master']]
27
- * @param {string[]} [options.versionAssets] - файлы для @semantic-release/git
28
- * @param {string[]|Object[]} [options.releaseAssets] - assets для @semantic-release/github
29
- * @param {Array} [options.preparePlugins] - плагины после npm (например bump Android version + build APK)
30
- * @param {Object} [options.githubConfig] - доп. опции github-плагина
31
- * @param {Object} [options.gitConfig] - доп. опции git-плагина (мержатся поверх message/assets)
28
+ * @param {Object} [options.apk]
29
+ * @param {string} options.apk.prefix - agro-pps → builds/agro-pps-v*.apk
30
+ * @param {string} options.apk.mailruDir - папка WebDAV, напр. APK
31
+ * @param {string[]} [options.versionAssets]
32
+ * @param {string[]|Object[]} [options.releaseAssets] - по умолчанию builds/{prefix}-v*.apk
33
+ * @param {Array} [options.preparePlugins]
34
+ * @param {Object} [options.githubConfig]
35
+ * @param {Object} [options.gitConfig]
32
36
  */
33
- function createSemanticReleaseApp({
37
+ function semanticReleaseApp({
38
+ apk,
34
39
  branches = ['master'],
35
40
  gitConfig = {},
36
41
  githubConfig = {},
37
42
  preparePlugins = [],
38
- releaseAssets = DEFAULT_RELEASE_ASSETS,
43
+ releaseAssets,
39
44
  versionAssets = DEFAULT_VERSION_ASSETS,
40
45
  } = {}) {
46
+ const androidPrepare = apk
47
+ ? [
48
+ createAndroidAppPreparePlugin({
49
+ apkPrefix: apk.prefix,
50
+ mailruDir: apk.mailruDir,
51
+ }),
52
+ ]
53
+ : []
54
+
55
+ const githubAssets =
56
+ releaseAssets ??
57
+ (apk?.prefix ? [`builds/${apk.prefix}-v*.apk`] : DEFAULT_RELEASE_ASSETS)
58
+
41
59
  return createSemanticReleaseConfig({
42
60
  additionalPlugins: [releaseNotesFromIssuesPlugin],
43
61
  branches: branches,
@@ -48,16 +66,14 @@ function createSemanticReleaseApp({
48
66
  ...gitConfig,
49
67
  },
50
68
  githubConfig: {
51
- assets: releaseAssets,
69
+ assets: githubAssets,
52
70
  ...githubConfig,
53
71
  },
54
72
  npmConfig: {
55
73
  npmPublish: false,
56
74
  },
57
- preparePlugins: preparePlugins,
75
+ preparePlugins: [...androidPrepare, ...preparePlugins],
58
76
  })
59
77
  }
60
78
 
61
- const semanticReleaseApp = createSemanticReleaseApp()
62
-
63
- export { createSemanticReleaseApp, semanticReleaseApp }
79
+ export { semanticReleaseApp }
@@ -24,10 +24,18 @@ const CLI_RELATIVE = 'smart-commit/cli.js'
24
24
  // Cheap Cursor Models pool — commit messages do not need Auto / frontier models.
25
25
  const DEFAULT_MODEL = 'composer-2.5'
26
26
 
27
- const MAX_DIFF = 80_000
27
+ // A small change set still sends the patch. A large tree used to be many
28
+ // agent calls with up to 80k chars of diff each. Compact numstat keeps that
29
+ // to one call.
30
+ const MAX_DIFF = 20_000
31
+ const COMPACT_SUMMARY_MAX = 12_000
32
+ const SMALL_PLAN_FILES = 12
33
+ const PER_FILE_EXCERPT = 500
34
+ const EXCERPT_SKIP_LINES = 400
35
+ const EXCERPT_MAX_BYTES = 256 * 1024
28
36
  const GIT_MAX_BUFFER = 32 * 1024 * 1024
29
37
  const GIT_PATH_BATCH = 40
30
- const PLAN_FILE_BATCH = 40
38
+ const PLAN_FILE_BATCH = 250
31
39
  const ARG_SAFE = 100_000
32
40
  const LOCKFILE_NAMES = new Set([
33
41
  'package-lock.json',
@@ -608,8 +616,10 @@ function buildPrompt(scopes, types, files, cwd, type) {
608
616
 
609
617
  const sourceFiles = files.filter((file) => !isLockfile(file))
610
618
  const lockFiles = files.filter((file) => isLockfile(file))
619
+ const diffLimit =
620
+ sourceFiles.length > SMALL_PLAN_FILES ? COMPACT_SUMMARY_MAX : MAX_DIFF
611
621
  let diff = buildSourceDiff(cwd, sourceFiles)
612
- if (diff.length > MAX_DIFF) diff = `${diff.slice(0, MAX_DIFF)}\n…truncated`
622
+ if (diff.length > diffLimit) diff = `${diff.slice(0, diffLimit)}\n…truncated`
613
623
 
614
624
  return `Plan git commits. Do not edit files or run git commit.
615
625
  Return ONLY JSON: {"commits":[{"message":"type(scope): subject","files":["a.ts"]}]}
@@ -622,6 +632,7 @@ Prefer one commit when changes belong to the same feature.
622
632
  Split by scope. Each listed file in exactly one commit — including deleted files (D) and new/untracked files (??).
623
633
  Put each lockfile in the same commit as its sibling package.json. Do not make a lockfile-only commit.
624
634
  Do not add Co-authored-by or Made-with trailers.
635
+ Do not use tools or read files. The file list and diff below are the only source.
625
636
  ${type ? `Prefer type "${type}".\n` : ''}Files:
626
637
  ${files
627
638
  .map((file) => {
@@ -644,6 +655,11 @@ ${diff || '(none)'}
644
655
  }
645
656
 
646
657
  function buildSourceDiff(cwd, files) {
658
+ if (files.length > SMALL_PLAN_FILES) return buildCompactSourceDiff(cwd, files)
659
+ return buildFullSourceDiff(cwd, files, MAX_DIFF)
660
+ }
661
+
662
+ function buildFullSourceDiff(cwd, files, limit) {
647
663
  const tracked = []
648
664
  const untracked = []
649
665
  for (const file of files) {
@@ -654,13 +670,13 @@ function buildSourceDiff(cwd, files) {
654
670
  const parts = []
655
671
  let used = 0
656
672
  const pushLimited = (text) => {
657
- if (used >= MAX_DIFF) return false
673
+ if (used >= limit) return false
658
674
  // Empty batch (no changes / ENOBUFS skip) — keep scanning remaining files.
659
675
  if (!text) return true
660
- const room = MAX_DIFF - used
676
+ const room = limit - used
661
677
  if (text.length > room) {
662
678
  parts.push(`${text.slice(0, room)}\n…truncated`)
663
- used = MAX_DIFF
679
+ used = limit
664
680
  return false
665
681
  }
666
682
  parts.push(text)
@@ -669,17 +685,120 @@ function buildSourceDiff(cwd, files) {
669
685
  }
670
686
 
671
687
  forEachBatch(tracked, (batch) => {
672
- if (used >= MAX_DIFF) return false
688
+ if (used >= limit) return false
673
689
  return pushLimited(tryOut(cwd, ['diff', 'HEAD', '--', ...batch]))
674
690
  })
675
691
  for (const file of untracked) {
676
- if (used >= MAX_DIFF) break
692
+ if (used >= limit) break
677
693
  const piece = buildUntrackedDiff(cwd, file)
678
694
  if (!pushLimited(piece)) break
679
695
  }
680
696
  return parts.join('\n\n')
681
697
  }
682
698
 
699
+ function buildCompactSourceDiff(cwd, files) {
700
+ const tracked = []
701
+ const untracked = new Set()
702
+ for (const file of files) {
703
+ if (isUntracked(cwd, file)) untracked.add(file)
704
+ else tracked.push(file)
705
+ }
706
+
707
+ const statLines = []
708
+ forEachBatch(tracked, (batch) => {
709
+ const text = tryOut(cwd, ['diff', '--numstat', 'HEAD', '--', ...batch])
710
+ if (text) statLines.push(...text.split('\n').filter(Boolean))
711
+ })
712
+ for (const file of untracked) statLines.push(untrackedNumstatLine(cwd, file))
713
+
714
+ const weightByFile = new Map()
715
+ for (const line of statLines) {
716
+ const file = line.split('\t').slice(2).join('\t')
717
+ weightByFile.set(file, parseNumstatWeight(line))
718
+ }
719
+
720
+ const header = `numstat (added, removed, path):\n${statLines.join('\n')}`
721
+ if (header.length >= COMPACT_SUMMARY_MAX) {
722
+ return `${header.slice(0, COMPACT_SUMMARY_MAX)}\n…truncated`
723
+ }
724
+
725
+ const ranked = [...files].sort((left, right) => {
726
+ const leftWeight = weightByFile.get(left)
727
+ const rightWeight = weightByFile.get(right)
728
+ return (rightWeight ?? -1) - (leftWeight ?? -1)
729
+ })
730
+
731
+ const excerptHeader = '\n\nchanged lines:\n'
732
+ let room = COMPACT_SUMMARY_MAX - header.length - excerptHeader.length
733
+ const excerpts = []
734
+ for (const file of ranked) {
735
+ if (room < 40) break
736
+ const weight = weightByFile.get(file)
737
+ if (weight == null || weight > EXCERPT_SKIP_LINES) continue
738
+ if (weight === 0 && !untracked.has(file)) continue
739
+ const excerpt = changedLineExcerpt(
740
+ cwd,
741
+ file,
742
+ untracked.has(file),
743
+ Math.min(PER_FILE_EXCERPT, room),
744
+ )
745
+ if (!excerpt) continue
746
+ excerpts.push(excerpt)
747
+ room -= excerpt.length + 1
748
+ }
749
+
750
+ if (!excerpts.length) return header
751
+ return `${header}${excerptHeader}${excerpts.join('\n')}`
752
+ }
753
+
754
+ function parseNumstatWeight(line) {
755
+ const [added, removed] = line.split('\t')
756
+ if (added === '-' || removed === '-') return null
757
+ return (Number(added) || 0) + (Number(removed) || 0)
758
+ }
759
+
760
+ function untrackedNumstatLine(cwd, file) {
761
+ const text = readTextFileHead(cwd, file)
762
+ if (text == null) return `-\t-\t${file}`
763
+ const lines = text ? text.split('\n').length : 0
764
+ return `${lines}\t0\t${file}`
765
+ }
766
+
767
+ function readTextFileHead(cwd, file) {
768
+ const filePath = path.join(cwd, file)
769
+ let stat
770
+ try {
771
+ stat = fs.statSync(filePath)
772
+ } catch {
773
+ return null
774
+ }
775
+ if (!stat.isFile() || stat.size > EXCERPT_MAX_BYTES) return null
776
+ const buffer = fs.readFileSync(filePath)
777
+ if (buffer.includes(0)) return null
778
+ return buffer.toString('utf8')
779
+ }
780
+
781
+ function changedLineExcerpt(cwd, file, untracked, cap) {
782
+ if (untracked) {
783
+ const text = readTextFileHead(cwd, file)
784
+ if (!text) return ''
785
+ const body = text.length > cap ? `${text.slice(0, cap)}…` : text
786
+ return `${file}:\n${body}`
787
+ }
788
+
789
+ const raw = tryOut(cwd, ['diff', '-U0', '--no-color', 'HEAD', '--', file])
790
+ if (!raw) return ''
791
+ const lines = []
792
+ for (const line of raw.split('\n')) {
793
+ if (line.startsWith('+++') || line.startsWith('---')) continue
794
+ if (line.startsWith('+') || line.startsWith('-')) lines.push(line)
795
+ }
796
+ if (!lines.length) return ''
797
+ let body = lines.join('\n')
798
+ if (body.length > cap) body = `${body.slice(0, cap)}…`
799
+ return `${file}:\n${body}`
800
+ }
801
+
683
802
  function buildUntrackedDiff(cwd, file) {
684
803
  const filePath = path.join(cwd, file)
685
804
  if (!fs.existsSync(filePath)) return ''