diff --git a/script/upstream/index.ts b/script/upstream/index.ts index f16730abc1c..89a194b9902 100644 --- a/script/upstream/index.ts +++ b/script/upstream/index.ts @@ -16,3 +16,11 @@ export * from "./utils/report" export { transformAll as transformPackageNames, transformFile } from "./transforms/package-names" export { preserveAllVersions, preserveVersion, getCurrentVersion } from "./transforms/preserve-versions" export { keepOursFiles, resetToOurs, shouldKeepOurs } from "./transforms/keep-ours" +export { skipFiles, skipSpecificFiles, shouldSkip } from "./transforms/skip-files" +export { + transformAllI18n, + transformConflictedI18n, + transformI18nFile, + transformI18nContent, + isI18nFile, +} from "./transforms/transform-i18n" diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index e78e741c170..50f1b491dd2 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -26,6 +26,8 @@ import { defaultConfig, loadConfig, type MergeConfig } from "./utils/config" import { transformAll as transformPackageNames } from "./transforms/package-names" import { preserveAllVersions } from "./transforms/preserve-versions" import { keepOursFiles, resetToOurs } from "./transforms/keep-ours" +import { skipFiles, skipSpecificFiles } from "./transforms/skip-files" +import { transformConflictedI18n, transformAllI18n } from "./transforms/transform-i18n" interface MergeOptions { version?: string @@ -154,7 +156,12 @@ async function main() { // Step 4: Generate conflict report logger.step(4, 8, "Analyzing potential conflicts...") - const conflicts = await report.analyzeConflicts(`upstream/${targetVersion.tag}`, config.baseBranch, config.keepOurs) + const conflicts = await report.analyzeConflicts( + `upstream/${targetVersion.tag}`, + config.baseBranch, + config.keepOurs, + config.skipFiles, + ) const conflictReport: report.ConflictReport = { timestamp: new Date().toISOString(), @@ -168,18 +175,32 @@ async function main() { } // Add recommendations + const skipCount = conflicts.filter((c) => c.recommendation === "skip").length + const i18nCount = conflicts.filter((c) => c.recommendation === "i18n-transform").length + const keepOursCount = conflicts.filter((c) => c.recommendation === "keep-ours").length + const codemodCount = conflicts.filter((c) => c.recommendation === "codemod").length const manualCount = conflicts.filter((c) => c.recommendation === "manual").length + + if (skipCount > 0) { + conflictReport.recommendations.push(`${skipCount} files will be skipped (auto-removed)`) + } + if (i18nCount > 0) { + conflictReport.recommendations.push(`${i18nCount} i18n files will be auto-transformed`) + } + if (keepOursCount > 0) { + conflictReport.recommendations.push(`${keepOursCount} files will keep Kilo's version`) + } + if (codemodCount > 0) { + conflictReport.recommendations.push(`${codemodCount} files will be processed by codemods`) + } if (manualCount > 0) { conflictReport.recommendations.push(`${manualCount} files require manual review`) } - const codemodCount = conflicts.filter((c) => c.recommendation === "codemod").length - if (codemodCount > 0) { - conflictReport.recommendations.push(`${codemodCount} files will be processed by codemods`) - } - logger.info(`Total files changed: ${conflicts.length}`) - logger.info(` - Keep ours: ${conflicts.filter((c) => c.recommendation === "keep-ours").length}`) + logger.info(` - Skip (auto-remove): ${skipCount}`) + logger.info(` - i18n transform: ${i18nCount}`) + logger.info(` - Keep ours: ${keepOursCount}`) logger.info(` - Codemod: ${codemodCount}`) logger.info(` - Manual review: ${manualCount}`) @@ -263,7 +284,25 @@ async function main() { logger.info("Conflicted files:") logger.list(mergeResult.conflicts) - // Auto-resolve keep-ours conflicts + // Step 7a: Skip files that shouldn't exist in Kilo + logger.info("Removing files that shouldn't exist in Kilo...") + const skipResults = await skipFiles({ dryRun: false, verbose: options.verbose }) + const skippedCount = skipResults.filter((r) => r.action === "removed").length + if (skippedCount > 0) { + logger.success(`Skipped ${skippedCount} files (removed from merge)`) + } + + // Step 7b: Transform i18n files (take upstream + apply Kilo branding) + logger.info("Transforming i18n files...") + const conflictedFiles = await git.getConflictedFiles() + const i18nResults = await transformConflictedI18n(conflictedFiles, { dryRun: false, verbose: options.verbose }) + const i18nTransformed = i18nResults.filter((r) => r.replacements > 0).length + if (i18nTransformed > 0) { + logger.success(`Transformed ${i18nTransformed} i18n files with Kilo branding`) + } + + // Step 7c: Auto-resolve keep-ours conflicts + logger.info("Keeping Kilo-specific files...") const resolved = await keepOursFiles({ dryRun: false, verbose: options.verbose }) const autoResolved = resolved.filter((r) => r.action === "kept") @@ -286,6 +325,16 @@ async function main() { } } else { logger.success("Merge completed without conflicts") + + // Even without conflicts, transform i18n files to ensure Kilo branding + logger.info("Transforming i18n files for Kilo branding...") + const i18nResults = await transformAllI18n({ dryRun: false, verbose: options.verbose }) + const i18nTransformed = i18nResults.filter((r) => r.replacements > 0).length + if (i18nTransformed > 0) { + await git.stageAll() + await git.commit(`chore: apply Kilo branding to i18n files`) + logger.success(`Transformed ${i18nTransformed} i18n files with Kilo branding`) + } } // Step 8: Push and cleanup diff --git a/script/upstream/transforms/skip-files.ts b/script/upstream/transforms/skip-files.ts new file mode 100644 index 00000000000..f4d10bfee6e --- /dev/null +++ b/script/upstream/transforms/skip-files.ts @@ -0,0 +1,208 @@ +#!/usr/bin/env bun +/** + * Skip files transform - handles files that should be completely skipped during merge + * + * These are files that exist in upstream but should NOT exist in Kilo fork. + * Examples: README.*.md (translated READMEs), STATS.md, etc. + * + * During merge, these files will be: + * - Removed if they were added from upstream + * - Kept deleted if they don't exist in Kilo + */ + +import { $ } from "bun" +import { info, success, warn, debug } from "../utils/logger" +import { defaultConfig } from "../utils/config" + +export interface SkipResult { + file: string + action: "removed" | "skipped" | "not-found" + dryRun: boolean +} + +export interface SkipOptions { + dryRun?: boolean + verbose?: boolean + patterns?: string[] +} + +/** + * Check if a file matches any skip patterns + */ +export function shouldSkip(filePath: string, patterns: string[]): boolean { + return patterns.some((pattern) => { + // Exact match + if (filePath === pattern) return true + + // Regex pattern (e.g., README\.[a-z]+\.md) + if (pattern.startsWith("^") || pattern.includes("\\")) { + const regex = new RegExp(pattern) + return regex.test(filePath) + } + + // Glob-style pattern + if (pattern.includes("*")) { + const regex = new RegExp("^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$") + return regex.test(filePath) + } + + return false + }) +} + +/** + * Get list of files that were added/modified from upstream during merge + */ +async function getUpstreamFiles(): Promise { + // Get files that are staged (after merge) + const result = await $`git diff --cached --name-only`.quiet().nothrow() + + if (result.exitCode !== 0) return [] + + return result.stdout + .toString() + .trim() + .split("\n") + .filter((f) => f.length > 0) +} + +/** + * Get list of unmerged (conflicted) files + */ +async function getUnmergedFiles(): Promise { + const result = await $`git diff --name-only --diff-filter=U`.quiet().nothrow() + + if (result.exitCode !== 0) return [] + + return result.stdout + .toString() + .trim() + .split("\n") + .filter((f) => f.length > 0) +} + +/** + * Check if a file exists in a specific git ref + */ +async function fileExistsInRef(file: string, ref: string): Promise { + const result = await $`git cat-file -e ${ref}:${file}`.quiet().nothrow() + return result.exitCode === 0 +} + +/** + * Remove a file from the merge (git rm) + */ +async function removeFile(file: string): Promise { + const result = await $`git rm -f ${file}`.quiet().nothrow() + return result.exitCode === 0 +} + +/** + * Skip files that shouldn't exist in Kilo fork + * + * This function handles files that: + * 1. Match skip patterns (like README.*.md) + * 2. Were added from upstream during merge + * 3. Don't exist in Kilo's version (HEAD before merge) + */ +export async function skipFiles(options: SkipOptions = {}): Promise { + const results: SkipResult[] = [] + const patterns = options.patterns || defaultConfig.skipFiles + + if (!patterns || patterns.length === 0) { + info("No skip patterns configured") + return results + } + + // Get all files involved in the merge + const stagedFiles = await getUpstreamFiles() + const unmergedFiles = await getUnmergedFiles() + const allFiles = [...new Set([...stagedFiles, ...unmergedFiles])] + + if (allFiles.length === 0) { + info("No files to process") + return results + } + + debug(`Checking ${allFiles.length} files against ${patterns.length} skip patterns`) + + for (const file of allFiles) { + if (!shouldSkip(file, patterns)) continue + + // Check if file existed in Kilo before merge (HEAD~1 or the merge base) + const existedInKilo = await fileExistsInRef(file, "HEAD") + + if (existedInKilo) { + debug(`Skipping ${file} - exists in Kilo, not removing`) + results.push({ file, action: "skipped", dryRun: options.dryRun ?? false }) + continue + } + + // File doesn't exist in Kilo - should be removed + if (options.dryRun) { + info(`[DRY-RUN] Would remove: ${file}`) + results.push({ file, action: "removed", dryRun: true }) + } else { + const removed = await removeFile(file) + if (removed) { + success(`Removed: ${file}`) + results.push({ file, action: "removed", dryRun: false }) + } else { + warn(`Failed to remove: ${file}`) + results.push({ file, action: "not-found", dryRun: false }) + } + } + } + + return results +} + +/** + * Skip files from a specific list (used during conflict resolution) + */ +export async function skipSpecificFiles(files: string[], options: SkipOptions = {}): Promise { + const results: SkipResult[] = [] + + for (const file of files) { + if (options.dryRun) { + info(`[DRY-RUN] Would remove: ${file}`) + results.push({ file, action: "removed", dryRun: true }) + } else { + const removed = await removeFile(file) + if (removed) { + success(`Removed: ${file}`) + results.push({ file, action: "removed", dryRun: false }) + } else { + warn(`Failed to remove: ${file}`) + results.push({ file, action: "not-found", dryRun: false }) + } + } + } + + return results +} + +// CLI entry point +if (import.meta.main) { + const args = process.argv.slice(2) + const dryRun = args.includes("--dry-run") + const verbose = args.includes("--verbose") + + // Get specific files if provided + const files = args.filter((a) => !a.startsWith("--")) + + if (dryRun) { + info("Running in dry-run mode (no files will be modified)") + } + + const results = + files.length > 0 ? await skipSpecificFiles(files, { dryRun, verbose }) : await skipFiles({ dryRun, verbose }) + + const removed = results.filter((r) => r.action === "removed") + console.log() + success(`Removed ${removed.length} files`) + + if (dryRun) { + info("Run without --dry-run to apply changes") + } +} diff --git a/script/upstream/transforms/transform-i18n.ts b/script/upstream/transforms/transform-i18n.ts new file mode 100644 index 00000000000..f71709ff8e8 --- /dev/null +++ b/script/upstream/transforms/transform-i18n.ts @@ -0,0 +1,363 @@ +#!/usr/bin/env bun +/** + * Transform i18n translation files with Kilo branding + * + * This script handles i18n files by: + * 1. Taking upstream's version as the base (to get new translation keys) + * 2. Applying intelligent string replacements for Kilo branding + * 3. Preserving lines marked with `// kilocode_change` + * + * String replacement rules: + * - opencode.ai -> kilo.ai (domain) + * - app.opencode.ai -> app.kilo.ai (app domain) + * - OpenCode Desktop -> Kilo Desktop (desktop app name) + * - OpenCode -> Kilo (product name in user-visible text) + * - opencode upgrade -> kilo upgrade (CLI commands) + * - npx opencode -> npx kilo (CLI invocation) + * - anomalyco/opencode -> Kilo-Org/kilo (GitHub repo) + * + * Preserved (not replaced): + * - opencode.json (actual config filename) + * - .opencode/ (actual directory name) + * - Lines with `// kilocode_change` + */ + +import { $ } from "bun" +import { Glob } from "bun" +import { info, success, warn, debug } from "../utils/logger" +import { defaultConfig } from "../utils/config" + +export interface I18nTransformResult { + file: string + replacements: number + preserved: number + dryRun: boolean +} + +export interface I18nTransformOptions { + dryRun?: boolean + verbose?: boolean + patterns?: string[] +} + +interface StringReplacement { + pattern: RegExp + replacement: string + description: string +} + +// Order matters! More specific patterns should come first +const I18N_REPLACEMENTS: StringReplacement[] = [ + // GitHub repo references + { + pattern: /github\.com\/anomalyco\/opencode/g, + replacement: "github.com/Kilo-Org/kilo", + description: "GitHub URL", + }, + { + pattern: /anomalyco\/opencode/g, + replacement: "Kilo-Org/kilo", + description: "GitHub repo reference", + }, + + // Domain replacements (specific first) + { + pattern: /app\.opencode\.ai/g, + replacement: "app.kilo.ai", + description: "App domain", + }, + { + pattern: /opencode\.ai/g, + replacement: "kilo.ai", + description: "Main domain", + }, + + // Product name (specific phrases first) + { + pattern: /OpenCode Desktop/g, + replacement: "Kilo Desktop", + description: "Desktop app name", + }, + { + pattern: /OpenCode Zen/g, + replacement: "Kilo Zen", + description: "Zen product name", + }, + + // CLI commands (be careful with order) + { + pattern: /npx opencode(?!\w)/g, + replacement: "npx kilo", + description: "npx command", + }, + { + pattern: /bun add opencode(?!\w)/g, + replacement: "bun add kilo", + description: "bun add command", + }, + { + pattern: /npm install opencode(?!\w)/g, + replacement: "npm install kilo", + description: "npm install command", + }, + { + pattern: /opencode upgrade(?!\w)/g, + replacement: "kilo upgrade", + description: "upgrade command", + }, + { + pattern: /opencode dev(?!\w)/g, + replacement: "kilo dev", + description: "dev command", + }, + { + pattern: /opencode serve(?!\w)/g, + replacement: "kilo serve", + description: "serve command", + }, + { + pattern: /opencode auth(?!\w)/g, + replacement: "kilo auth", + description: "auth command", + }, + + // Generic product name replacement (must come after specific patterns) + // Only replace "OpenCode" when it's a standalone word (not part of opencode.json, etc.) + { + pattern: /\bOpenCode\b(?!\.json|\/)/g, + replacement: "Kilo", + description: "Product name", + }, +] + +// Patterns that should NOT be replaced (preserved as-is) +const PRESERVE_PATTERNS = [ + /opencode\.json/g, // Config filename + /\.opencode\//g, // Directory name + /\.opencode`/g, // Directory name in template strings + /"\.opencode"/g, // Directory name in quotes + /'\.opencode'/g, // Directory name in single quotes +] + +/** + * Check if a line should be preserved (has kilocode_change marker) + */ +function shouldPreserveLine(line: string): boolean { + return line.includes("// kilocode_change") +} + +/** + * Apply string replacements to content, preserving kilocode_change lines + */ +export function transformI18nContent( + content: string, + verbose = false, +): { result: string; replacements: number; preserved: number } { + const lines = content.split("\n") + const transformedLines: string[] = [] + let totalReplacements = 0 + let preservedCount = 0 + + for (const line of lines) { + // Skip lines marked with kilocode_change + if (shouldPreserveLine(line)) { + transformedLines.push(line) + preservedCount++ + if (verbose) debug(`Preserved line: ${line.trim().substring(0, 50)}...`) + continue + } + + let transformedLine = line + let lineReplacements = 0 + + // Check if line contains patterns that should be preserved entirely + let hasPreservePattern = false + for (const pattern of PRESERVE_PATTERNS) { + if (pattern.test(line)) { + hasPreservePattern = true + // Reset the regex lastIndex + pattern.lastIndex = 0 + } + } + + // Apply replacements + for (const { pattern, replacement, description } of I18N_REPLACEMENTS) { + // Reset lastIndex for global regexes + pattern.lastIndex = 0 + + if (pattern.test(transformedLine)) { + pattern.lastIndex = 0 + + // Special handling: if line has preserve patterns, be more careful + if (hasPreservePattern) { + // Only replace if the match is not part of a preserve pattern + // This is a simplified check - we replace and let preserve patterns win + } + + const before = transformedLine + transformedLine = transformedLine.replace(pattern, replacement) + + if (before !== transformedLine) { + lineReplacements++ + if (verbose) debug(` ${description}: "${before.trim()}" -> "${transformedLine.trim()}"`) + } + } + } + + transformedLines.push(transformedLine) + totalReplacements += lineReplacements + } + + return { + result: transformedLines.join("\n"), + replacements: totalReplacements, + preserved: preservedCount, + } +} + +/** + * Transform a single i18n file + */ +export async function transformI18nFile( + filePath: string, + options: I18nTransformOptions = {}, +): Promise { + const file = Bun.file(filePath) + const content = await file.text() + + const { result, replacements, preserved } = transformI18nContent(content, options.verbose) + + if (replacements > 0 && !options.dryRun) { + await Bun.write(filePath, result) + } + + return { + file: filePath, + replacements, + preserved, + dryRun: options.dryRun ?? false, + } +} + +/** + * Check if a file is an i18n translation file + */ +export function isI18nFile(filePath: string, patterns?: string[]): boolean { + const i18nPatterns = patterns || defaultConfig.i18nPatterns + + return i18nPatterns.some((pattern) => { + // Convert glob pattern to regex + const regex = new RegExp("^" + pattern.replace(/\./g, "\\.").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*") + "$") + return regex.test(filePath) + }) +} + +/** + * Transform all i18n files + */ +export async function transformAllI18n(options: I18nTransformOptions = {}): Promise { + const results: I18nTransformResult[] = [] + const patterns = options.patterns || defaultConfig.i18nPatterns + + for (const pattern of patterns) { + const glob = new Glob(pattern) + + for await (const path of glob.scan({ absolute: true })) { + // Skip index.ts files (they're usually just exports) + if (path.endsWith("/index.ts")) continue + + const result = await transformI18nFile(path, options) + + if (result.replacements > 0 || result.preserved > 0) { + results.push(result) + + if (options.dryRun) { + info( + `[DRY-RUN] Would transform ${result.file}: ${result.replacements} replacements, ${result.preserved} preserved`, + ) + } else if (result.replacements > 0) { + success(`Transformed ${result.file}: ${result.replacements} replacements, ${result.preserved} preserved`) + } + } + } + } + + return results +} + +/** + * Transform i18n files that are in conflict during merge + * Takes upstream version (theirs) and applies Kilo branding + */ +export async function transformConflictedI18n( + files: string[], + options: I18nTransformOptions = {}, +): Promise { + const results: I18nTransformResult[] = [] + + for (const file of files) { + if (!isI18nFile(file)) { + debug(`Skipping non-i18n file: ${file}`) + continue + } + + // First, take upstream's version (theirs) + if (!options.dryRun) { + await $`git checkout --theirs ${file}`.quiet().nothrow() + await $`git add ${file}`.quiet().nothrow() + } + + // Then apply Kilo branding transformations + const result = await transformI18nFile(file, options) + results.push(result) + + if (options.dryRun) { + info(`[DRY-RUN] Would take upstream and transform ${file}: ${result.replacements} replacements`) + } else if (result.replacements > 0) { + success(`Transformed ${file}: took upstream + ${result.replacements} Kilo branding replacements`) + } + } + + return results +} + +// CLI entry point +if (import.meta.main) { + const args = process.argv.slice(2) + const dryRun = args.includes("--dry-run") + const verbose = args.includes("--verbose") + const conflicted = args.includes("--conflicted") + + // Get specific files if provided + const files = args.filter((a) => !a.startsWith("--")) + + if (dryRun) { + info("Running in dry-run mode (no files will be modified)") + } + + let results: I18nTransformResult[] + + if (conflicted && files.length > 0) { + results = await transformConflictedI18n(files, { dryRun, verbose }) + } else if (files.length > 0) { + results = [] + for (const file of files) { + const result = await transformI18nFile(file, { dryRun, verbose }) + results.push(result) + } + } else { + results = await transformAllI18n({ dryRun, verbose }) + } + + const totalReplacements = results.reduce((sum, r) => sum + r.replacements, 0) + const totalPreserved = results.reduce((sum, r) => sum + r.preserved, 0) + + console.log() + success(`Processed ${results.length} files`) + info(`Total replacements: ${totalReplacements}`) + info(`Total preserved lines: ${totalPreserved}`) + + if (dryRun) { + info("Run without --dry-run to apply changes") + } +} diff --git a/script/upstream/utils/config.ts b/script/upstream/utils/config.ts index 5e61392b5a1..b641026d75d 100644 --- a/script/upstream/utils/config.ts +++ b/script/upstream/utils/config.ts @@ -15,6 +15,9 @@ export interface MergeConfig { /** Files to always keep Kilo's version (never take upstream changes) */ keepOurs: string[] + /** Files to skip entirely (don't add from upstream, remove if added) */ + skipFiles: string[] + /** Directories that are Kilo-specific and should be preserved */ kiloDirectories: string[] @@ -32,6 +35,9 @@ export interface MergeConfig { /** Remote name for origin */ originRemote: string + + /** i18n file patterns that need string transformation */ + i18nPatterns: string[] } export const defaultConfig: MergeConfig = { @@ -52,6 +58,30 @@ export const defaultConfig: MergeConfig = { ".github/workflows/publish-stable.yml", ], + // Files that only exist in upstream and should NOT be added to Kilo + // These are removed during merge if they appear + skipFiles: [ + // Translated README files (Kilo doesn't have these) + "README.ar.md", + "README.br.md", + "README.da.md", + "README.de.md", + "README.es.md", + "README.fr.md", + "README.it.md", + "README.ja.md", + "README.ko.md", + "README.no.md", + "README.pl.md", + "README.ru.md", + "README.th.md", + "README.tr.md", + "README.zh.md", + "README.zht.md", + // Stats file + "STATS.md", + ], + kiloDirectories: [ "packages/opencode/src/kilocode", "packages/opencode/test/kilocode", @@ -73,6 +103,9 @@ export const defaultConfig: MergeConfig = { branchPrefix: "upstream-merge", upstreamRemote: "upstream", originRemote: "origin", + + // i18n translation files that need Kilo branding transforms + i18nPatterns: ["packages/*/src/i18n/*.ts", "packages/desktop/src/i18n/*.ts"], } export function loadConfig(overrides?: Partial): MergeConfig { diff --git a/script/upstream/utils/report.ts b/script/upstream/utils/report.ts index d464c968f2d..5aaddec2443 100644 --- a/script/upstream/utils/report.ts +++ b/script/upstream/utils/report.ts @@ -18,15 +18,24 @@ export interface ConflictReport { export interface ConflictFile { path: string - type: "markdown" | "package" | "code" | "config" | "other" - recommendation: "keep-ours" | "keep-theirs" | "manual" | "codemod" + type: "markdown" | "package" | "code" | "config" | "i18n" | "other" + recommendation: "keep-ours" | "keep-theirs" | "manual" | "codemod" | "skip" | "i18n-transform" reason: string } +/** + * Check if a file is an i18n translation file + */ +function isI18nFile(path: string): boolean { + // Match patterns like packages/*/src/i18n/*.ts + return /packages\/[^/]+\/src\/i18n\/[^/]+\.ts$/.test(path) && !path.endsWith("/index.ts") +} + /** * Classify a file based on its path */ export function classifyFile(path: string): ConflictFile["type"] { + if (isI18nFile(path)) return "i18n" if (path.endsWith(".md")) return "markdown" if (path.includes("package.json")) return "package" if (path.endsWith(".ts") || path.endsWith(".tsx") || path.endsWith(".js") || path.endsWith(".jsx")) return "code" @@ -41,13 +50,29 @@ export function classifyFile(path: string): ConflictFile["type"] { return "other" } +/** + * Check if a file should be skipped (not added from upstream) + */ +function shouldSkipFile(path: string, skipPatterns: string[]): boolean { + return skipPatterns.some((pattern) => path === pattern || path.includes(pattern)) +} + /** * Get recommendation for a conflicted file */ export function getRecommendation( path: string, keepOurs: string[], + skipFiles: string[] = [], ): { recommendation: ConflictFile["recommendation"]; reason: string } { + // Check if file should be skipped entirely (doesn't exist in Kilo, shouldn't be added) + if (shouldSkipFile(path, skipFiles)) { + return { + recommendation: "skip", + reason: "File should be skipped (does not exist in Kilo fork)", + } + } + // Check if file should always keep ours if (keepOurs.some((pattern) => path.includes(pattern) || path === pattern)) { return { @@ -67,6 +92,11 @@ export function getRecommendation( const type = classifyFile(path) switch (type) { + case "i18n": + return { + recommendation: "i18n-transform", + reason: "i18n file: take upstream translations and apply Kilo branding", + } case "markdown": return { recommendation: "keep-ours", @@ -102,6 +132,7 @@ export async function analyzeConflicts( upstreamRef: string, baseBranch: string, keepOurs: string[], + skipFiles: string[] = [], ): Promise { // Get list of files that differ between branches // Use quiet to suppress output and nothrow to handle errors @@ -121,7 +152,7 @@ export async function analyzeConflicts( for (const path of files) { const type = classifyFile(path) - const { recommendation, reason } = getRecommendation(path, keepOurs) + const { recommendation, reason } = getRecommendation(path, keepOurs, skipFiles) conflicts.push({ path, @@ -162,20 +193,29 @@ export function generateMarkdownReport(report: ConflictReport): string { byRecommendation.set(conflict.recommendation, list) } - const order: ConflictFile["recommendation"][] = ["keep-ours", "codemod", "keep-theirs", "manual"] + const order: ConflictFile["recommendation"][] = [ + "skip", + "i18n-transform", + "keep-ours", + "codemod", + "keep-theirs", + "manual", + ] for (const rec of order) { const files = byRecommendation.get(rec) if (!files || files.length === 0) continue - const title = - rec === "keep-ours" - ? "Keep Kilo Version (Ours)" - : rec === "keep-theirs" - ? "Take Upstream Version (Theirs)" - : rec === "codemod" - ? "Apply Codemod" - : "Manual Review Required" + const titleMap: Record = { + skip: "Skip (Auto-Remove)", + "i18n-transform": "i18n Transform (Auto-Apply Kilo Branding)", + "keep-ours": "Keep Kilo Version (Ours)", + "keep-theirs": "Take Upstream Version (Theirs)", + codemod: "Apply Codemod", + manual: "Manual Review Required", + } + + const title = titleMap[rec] lines.push(`### ${title}`) lines.push("")