mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-24 01:24:18 +08:00
refactor: improve README and i18n translations
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string[]> {
|
||||
// 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<string[]> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<SkipResult[]> {
|
||||
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<SkipResult[]> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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<I18nTransformResult> {
|
||||
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<I18nTransformResult[]> {
|
||||
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<I18nTransformResult[]> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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>): MergeConfig {
|
||||
|
||||
@@ -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<ConflictFile[]> {
|
||||
// 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<ConflictFile["recommendation"], string> = {
|
||||
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("")
|
||||
|
||||
Reference in New Issue
Block a user