Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion script/upstream/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,15 @@ async function main() {
logger.step(6, 8, "Applying transformations to opencode branch (pre-merge)...")

logger.info("Removing files skipped in Kilo...")
const skips = await skipFiles({ dryRun: false, verbose: options.verbose, force: true })
// Pre-merge skip runs on the opencode branch, so HEAD points at upstream.
// Pass the base branch explicitly so the "directory missing in base"
// grouping folds removals under directories Kilo doesn't have.
const skips = await skipFiles({
dryRun: false,
verbose: options.verbose,
force: true,
baseRef: config.baseBranch,
})
const count = skips.filter((r) => r.action === "removed").length
if (count > 0) {
logger.success(`Removed ${count} skipped file(s) from opencode branch`)
Expand Down
109 changes: 91 additions & 18 deletions script/upstream/transforms/skip-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export interface SkipOptions {
verbose?: boolean
patterns?: string[]
force?: boolean
/**
* Ref to treat as "the main repo we're merging into" when deciding whether
* a removed file's parent directory is absent from the base. Defaults to
* "HEAD", which is correct post-merge (HEAD = ours), but pre-merge on the
* opencode branch the caller should pass the actual base branch.
*/
baseRef?: string
}

/**
Expand Down Expand Up @@ -82,13 +89,61 @@ async function getTrackedFiles(): Promise<string[]> {
}

/**
* Check if a file exists in a specific git ref
* Check if a path (file or directory) exists in a specific git ref.
* Works for trees too — `git cat-file -e <ref>:<path>` returns 0 for any
* object the ref can resolve, including directory trees.
*/
async function fileExistsInRef(file: string, ref: string): Promise<boolean> {
const result = await $`git cat-file -e ${ref}:${file}`.quiet().nothrow()
async function pathExistsInRef(path: string, ref: string): Promise<boolean> {
const result = await $`git cat-file -e ${ref}:${path}`.quiet().nothrow()
return result.exitCode === 0
}

/**
* Group successfully-removed files by their highest ancestor directory that
* is missing from `baseRef`. Files whose every ancestor exists in the base
* are returned as "singles" and logged individually.
*/
async function groupByMissingDir(
files: string[],
baseRef: string,
): Promise<{ dirs: Map<string, number>; singles: string[] }> {
const cache = new Map<string, boolean>()
async function exists(dir: string): Promise<boolean> {
const hit = cache.get(dir)
if (hit !== undefined) return hit
const ok = await pathExistsInRef(dir, baseRef)
cache.set(dir, ok)
return ok
}
const dirs = new Map<string, number>()
const singles: string[] = []
for (const file of files) {
const parts = file.split("/")
let top: string | null = null
for (let i = 1; i < parts.length; i++) {
const dir = parts.slice(0, i).join("/")
if (!(await exists(dir))) {
top = dir
break
}
}
if (top) dirs.set(top, (dirs.get(top) ?? 0) + 1)
else singles.push(file)
}
return { dirs, singles }
}

function logRemovals(dirs: Map<string, number>, singles: string[], dryRun: boolean): void {
const prefix = dryRun ? "[DRY-RUN] Would remove" : "Removed"
const log = dryRun ? info : success
for (const [dir, n] of dirs) {
log(`${prefix} directory: ${dir} (${n} file${n === 1 ? "" : "s"})`)
}
for (const file of singles) {
log(`${prefix}: ${file}`)
}
}

/**
* Remove a file from the merge (git rm). Retries once on failure since
* transient index contention (editor watchers, rerere passes) has been
Expand Down Expand Up @@ -116,6 +171,8 @@ async function removeFile(file: string): Promise<{ ok: boolean; err?: string }>
export async function skipFiles(options: SkipOptions = {}): Promise<SkipResult[]> {
const results: SkipResult[] = []
const patterns = options.patterns || defaultConfig.skipFiles
const baseRef = options.baseRef ?? "HEAD"
const dryRun = options.dryRun ?? false

if (!patterns || patterns.length === 0) {
info("No skip patterns configured")
Expand All @@ -135,34 +192,50 @@ export async function skipFiles(options: SkipOptions = {}): Promise<SkipResult[]

debug(`Checking ${allFiles.length} files against ${patterns.length} skip patterns`)

// Phase 1: classify files (skip, toRemove).
const toRemove: string[] = []
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 = options.force ? false : await fileExistsInRef(file, "HEAD")
// Check if file existed in Kilo before merge. In force mode we don't gate
// on existence — the caller wants all tracked matches gone.
const existedInKilo = options.force ? false : await pathExistsInRef(file, baseRef)

if (existedInKilo) {
debug(`Skipping ${file} - exists in Kilo, not removing`)
results.push({ file, action: "skipped", dryRun: options.dryRun ?? false })
debug(`Skipping ${file} - exists in base ref, not removing`)
results.push({ file, action: "skipped", dryRun })
continue
}

// File doesn't exist in Kilo - should be removed
if (options.dryRun) {
info(`[DRY-RUN] Would remove: ${file}`)
toRemove.push(file)
}

// Phase 2: perform removals, tracking which actually succeeded so we can
// batch log only real removals (failures get an immediate warn).
const removed: string[] = []
for (const file of toRemove) {
if (dryRun) {
removed.push(file)
results.push({ file, action: "removed", dryRun: true })
continue
}
const res = await removeFile(file)
if (res.ok) {
removed.push(file)
results.push({ file, action: "removed", dryRun: false })
} else {
const res = await removeFile(file)
if (res.ok) {
success(`Removed: ${file}`)
results.push({ file, action: "removed", dryRun: false })
} else {
warn(`Failed to remove ${file}: ${res.err ?? "unknown error"}`)
results.push({ file, action: "not-found", dryRun: false })
}
warn(`Failed to remove ${file}: ${res.err ?? "unknown error"}`)
results.push({ file, action: "not-found", dryRun: false })
}
}

// Phase 3: fold per-file log lines into a single "Removed directory: X"
// line whenever every removed file's top-level directory is absent from
// the base ref. Removes a huge chunk of noise when entire directories
// (packages/web, packages/console, …) don't exist in Kilo.
const { dirs, singles } = await groupByMissingDir(removed, baseRef)
logRemovals(dirs, singles, dryRun)

return results
}

Expand Down
Loading