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: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -829,14 +829,16 @@ jobs:
with:
bun-version: "1.3.14"

- name: Install merge tooling deps
run: bun install
working-directory: script/upstream

- name: Add upstream remote
run: |
git remote add upstream https://github.com/anomalyco/opencode.git || true
git fetch upstream --quiet --no-tags

- name: Install merge tooling deps
run: bun install
working-directory: script/upstream
baseline=$(bun run script/upstream/analyze.ts --print-baseline)
git fetch upstream "refs/tags/${baseline}:refs/upstream-baseline/${baseline}" --no-tags

- name: Run marker parser tests
run: bun test
Expand Down
16 changes: 15 additions & 1 deletion script/upstream/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ We maintain a fork of [anomalyco/opencode](https://github.com/anomalyco/opencode
- **Custom code is protected** — files in `keepOurs` patterns are never overwritten by upstream. Our Python engine, bridge layer, altimate tools, and CI workflows stay untouched.
- **Upstream-only packages are skipped** — platform packages (app, web, desktop, etc.), nix, SST infra, and translated READMEs are discarded during merge.

### Major versions

`upstreamBaseline` in `utils/config.ts` records the upstream tag the fork is based on (currently `v1.17.9`). Bump it after each upstream merge lands. `list-versions.ts` lists and suggests only releases with that major version, and reports when newer-major tags exist. Both `merge.ts` and `bridge-merge.ts` reject cross-major targets, including in dry-run mode, before changing Git state. Upstream v2 replaces `packages/opencode` and the plugin, tool, session and server APIs, so moving to it is a re-port (Altimate features as v2 plugins on a thin branded build), not a merge with these scripts.

## Prerequisites

```bash
Expand Down Expand Up @@ -126,6 +130,7 @@ The merge script prints a `gh pr create` command at the end. Review the diff, th
| `bun run script/upstream/analyze.ts` | Check `altimate_change` marker integrity |
| `bun run script/upstream/analyze.ts --markers --base main` | Check PR for missing markers |
| `bun run script/upstream/analyze.ts --markers --base main --strict` | Same, but fail CI on warnings |
| `bun run script/upstream/analyze.ts --print-baseline` | Print the pinned upstream baseline tag |
| `bun run script/upstream/verify-restructure.ts` | Verify branch restructure |

## Configuration
Expand Down Expand Up @@ -280,7 +285,16 @@ script/upstream/

## CI Integration

The **Marker Guard** CI job (in `.github/workflows/ci.yml`) runs automatically on every PR. It checks whether any upstream-shared files (`packages/opencode/src/`) have new code without `altimate_change` markers. This prevents accidental overwrites during future upstream merges.
The **Marker Guard** CI job (in `.github/workflows/ci.yml`) runs automatically on every PR. It checks whether any upstream-shared files (such as `packages/opencode/src/`) have new code without `altimate_change` markers. Ownership is determined from the pinned `upstreamBaseline` tag in `utils/config.ts`, so moving upstream branches to a new major version does not disable checks on v1 files. This prevents accidental overwrites during future upstream merges.

After installing the merge tooling dependencies, CI fetches the upstream remote and then explicitly fetches the baseline tag into the dedicated `refs/upstream-baseline/` namespace. The analyzer reads this full ref to avoid collisions with same-named fork tags or local branches:

```bash
baseline=$(bun run script/upstream/analyze.ts --print-baseline)
git fetch upstream "refs/tags/${baseline}:refs/upstream-baseline/${baseline}" --no-tags
```

If the namespaced baseline ref is missing locally, the analyzer attempts this fetch once and retries the check. If the ref is still unavailable, it warns on stderr with the tag and fetch command, then falls back to directory-based ownership checks. Fetch the tag into this namespace to restore the baseline-based check.

```bash
# What CI runs:
Expand Down
144 changes: 142 additions & 2 deletions script/upstream/analyze.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, test, expect } from "bun:test"
import { parseDiffForMarkerWarnings, computeMarkedLines } from "./analyze"
import { describe, test, expect, spyOn } from "bun:test"
import childProcess from "child_process"
import { parseDiffForMarkerWarnings, computeMarkedLines, getUpstreamFiles, isUpstreamShared } from "./analyze"
import { loadConfig, repoRoot } from "./utils/config"

// Helper to create a unified diff string from lines
function makeDiff(hunks: string): string {
Expand All @@ -10,6 +12,144 @@ index abc1234..def5678 100644
${hunks}`
}

describe("upstream baseline ownership", () => {
const file = "packages/opencode/src/session/prompt.ts"

test("uses the dedicated baseline ref, never moving dev/main branches, and flags unmarked code", () => {
const config = loadConfig()
const lsTree = spyOn(childProcess, "execFileSync").mockReturnValue(`${file}\n`)
try {
expect(getUpstreamFiles(config).has(file)).toBe(true)
expect(lsTree).toHaveBeenCalledWith(
"git",
["ls-tree", "-r", "--name-only", `refs/upstream-baseline/${config.upstreamBaseline}`],
expect.objectContaining({ cwd: repoRoot() }),
)
expect(isUpstreamShared(file, config)).toBe(true)
expect(isUpstreamShared("packages/opencode/src/fork-only.ts", config)).toBe(false)
expect(lsTree).toHaveBeenCalledTimes(1)
const diff = makeDiff("@@ -1,1 +1,2 @@\n const existing = true\n+const unmarked = true")
expect(parseDiffForMarkerWarnings(file, diff)).toEqual([
expect.objectContaining({ file, line: 2, context: "const unmarked = true" }),
])
} finally {
lsTree.mockRestore()
}
})

test("changing the baseline refreshes the cached file list", () => {
const config = { ...loadConfig(), upstreamBaseline: "v1.18.34" }
const lsTree = spyOn(childProcess, "execFileSync").mockReturnValue("packages/opencode/src/new.ts\n")
try {
expect(getUpstreamFiles(config).has(file)).toBe(false)
expect(isUpstreamShared("packages/opencode/src/new.ts", config)).toBe(true)
expect(lsTree).toHaveBeenCalledTimes(1)
expect(lsTree.mock.calls[0][1]).toEqual(["ls-tree", "-r", "--name-only", "refs/upstream-baseline/v1.18.34"])
} finally {
lsTree.mockRestore()
}
})

test("missing baseline is fetched once and used for ownership checks", () => {
const config = { ...loadConfig(), upstreamBaseline: "v1.99.998", upstreamRemote: "baseline-upstream" }
const exec = spyOn(childProcess, "execFileSync")
.mockImplementationOnce(() => {
throw new Error("Not a valid object name")
})
.mockReturnValueOnce("")
.mockReturnValueOnce(`${file}\n`)
const warn = spyOn(console, "warn").mockImplementation(() => {})
try {
expect(getUpstreamFiles(config).has(file)).toBe(true)
expect(isUpstreamShared(file, config)).toBe(true)
expect(isUpstreamShared("packages/opencode/src/fork-only.ts", config)).toBe(false)
expect(exec.mock.calls.map((call) => call[1])).toEqual([
["ls-tree", "-r", "--name-only", "refs/upstream-baseline/v1.99.998"],
["fetch", config.upstreamRemote, "refs/tags/v1.99.998:refs/upstream-baseline/v1.99.998", "--no-tags", "--quiet"],
["ls-tree", "-r", "--name-only", "refs/upstream-baseline/v1.99.998"],
])
expect(exec.mock.calls[1][0]).toBe("git")
expect(exec.mock.calls[1][2]).toEqual({ cwd: repoRoot(), stdio: "ignore" })
expect(warn).not.toHaveBeenCalled()
} finally {
exec.mockRestore()
warn.mockRestore()
}
})

test("failed baseline fetch warns with fetch instructions and falls back to the directory heuristic", () => {
const config = { ...loadConfig(), upstreamBaseline: "v1.99.999" }
const exec = spyOn(childProcess, "execFileSync").mockImplementation(() => {
throw new Error("Not a valid object name")
})
const warn = spyOn(console, "warn").mockImplementation(() => {})
try {
expect(getUpstreamFiles(config).size).toBe(0)
expect(isUpstreamShared(file, config)).toBe(true)
expect(isUpstreamShared("packages/opencode/src/altimate/custom.ts", config)).toBe(false)
expect(isUpstreamShared("packages/other/src/custom.ts", config)).toBe(false)
expect(exec.mock.calls.map((call) => call[1])).toEqual([
["ls-tree", "-r", "--name-only", "refs/upstream-baseline/v1.99.999"],
["fetch", config.upstreamRemote, "refs/tags/v1.99.999:refs/upstream-baseline/v1.99.999", "--no-tags", "--quiet"],
["ls-tree", "-r", "--name-only", "refs/upstream-baseline/v1.99.999"],
])
expect(warn).toHaveBeenCalledTimes(1)
expect(warn.mock.calls[0][0]).toContain("upstream baseline 'v1.99.999' is unavailable")
expect(warn.mock.calls[0][0]).toContain("git fetch upstream refs/tags/v1.99.999:refs/upstream-baseline/v1.99.999 --no-tags")
expect(warn.mock.calls[0][0]).toContain("Falling back to directory-based ownership checks")
} finally {
exec.mockRestore()
warn.mockRestore()
}
})

test.each([true, false])("ignores same-named fork tags and branches (baseline ref present: %s)", (present) => {
const config = { ...loadConfig(), upstreamBaseline: present ? "v1.99.996" : "v1.99.997" }
const tag = config.upstreamBaseline
const ref = `refs/upstream-baseline/${tag}`
const forkFile = "packages/opencode/src/fork-only.ts"
const refs = new Map([
[tag, `${forkFile}\n`],
[`refs/tags/${tag}`, `${forkFile}\n`],
[`refs/heads/${tag}`, `${forkFile}\n`],
])
if (present) refs.set(ref, `${file}\n`)
const exec = spyOn(childProcess, "execFileSync").mockImplementation((_command, args) => {
if (args?.[0] === "fetch") {
refs.set(ref, `${file}\n`)
return ""
}
const output = refs.get(args?.[3] ?? "")
if (output === undefined) throw new Error("Not a valid object name")
return output
})
try {
expect(getUpstreamFiles(config)).toEqual(new Set([file]))
expect(isUpstreamShared(forkFile, config)).toBe(false)
expect(exec.mock.calls.filter((call) => call[1]?.[0] === "ls-tree").map((call) => call[1])).toEqual(
Array.from({ length: present ? 1 : 2 }, () => ["ls-tree", "-r", "--name-only", ref]),
)
expect(exec.mock.calls.filter((call) => call[1]?.[0] === "fetch").map((call) => call[1])).toEqual(
present ? [] : [["fetch", config.upstreamRemote, `refs/tags/${tag}:${ref}`, "--no-tags", "--quiet"]],
)
expect(refs.get(`refs/tags/${tag}`)).toBe(`${forkFile}\n`)
expect(refs.get(`refs/heads/${tag}`)).toBe(`${forkFile}\n`)
} finally {
exec.mockRestore()
}
})

test("--print-baseline prints only the tag", () => {
const result = childProcess.spawnSync(process.execPath, ["run", "script/upstream/analyze.ts", "--print-baseline"], {
cwd: repoRoot(),
encoding: "utf-8",
})
expect(result.status).toBe(0)
expect(result.stdout).toBe(`${loadConfig().upstreamBaseline}\n`)
expect(result.stderr).toBe("")
})
})

describe("parseDiffForMarkerWarnings", () => {
test("returns no warnings for empty diff", () => {
expect(parseDiffForMarkerWarnings("file.ts", "")).toEqual([])
Expand Down
78 changes: 43 additions & 35 deletions script/upstream/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const { values: args } = parseArgs({
branding: { type: "boolean", default: false },
markers: { type: "boolean", default: false },
"require-markers": { type: "boolean", default: false },
"print-baseline": { type: "boolean", default: false },
"audit-fixes": { type: "boolean", default: false },
strict: { type: "boolean", default: false },
base: { type: "string" },
Expand Down Expand Up @@ -622,6 +623,7 @@ function printUsage(): void {
--version, -v <tag> Upstream version to analyze
--branding Scan codebase for upstream branding leaks
--markers Check changed files for missing altimate_change markers
--print-baseline Print only the upstream baseline tag (for CI)
--audit-fixes List all upstream_fix: markers (bug fixes we made to upstream code)
--base <branch> Base branch for --markers comparison (default: HEAD)
--strict Exit with code 1 on warnings (for CI)
Expand Down Expand Up @@ -678,30 +680,46 @@ function getChangedFiles(base?: string): string[] {
}
}

// Cache for upstream file existence checks (populated on first use)
let _upstreamFilesCache: Set<string> | null = null
// Cache by baseline so checks with a different configuration cannot reuse stale ownership.
let _upstreamFilesCache: { ref: string; files: Set<string> } | null = null

function getUpstreamFiles(config: MergeConfig): Set<string> {
if (_upstreamFilesCache) return _upstreamFilesCache
const { execSync } = require("child_process")
export function getUpstreamFiles(config: MergeConfig): Set<string> {
const tag = config.upstreamBaseline
// Fork releases can use the same tag or branch name as the upstream baseline.
const ref = `refs/upstream-baseline/${tag}`
if (_upstreamFilesCache?.ref === ref) return _upstreamFilesCache.files
const { execFileSync } = require("child_process")
const root = repoRoot()
const refs = [`${config.upstreamRemote}/dev`, `${config.upstreamRemote}/main`]
for (const ref of refs) {
const readFiles = () =>
execFileSync("git", ["ls-tree", "-r", "--name-only", ref], {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
anandgupta42 marked this conversation as resolved.
cwd: root,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
})
try {
let output: string
try {
const output = execSync(`git ls-tree -r --name-only ${ref}`, {
cwd: root,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
})
_upstreamFilesCache = new Set(output.trim().split("\n").filter(Boolean))
return _upstreamFilesCache
output = readFiles()
} catch {
// ref not available, try next
try {
execFileSync("git", ["fetch", config.upstreamRemote, `refs/tags/${tag}:${ref}`, "--no-tags", "--quiet"], {
cwd: root,
stdio: "ignore",
})
} catch {
// Retry the local ref even when the best-effort fetch fails.
}
output = readFiles()
}
_upstreamFilesCache = { ref, files: new Set(output.trim().split("\n").filter(Boolean)) }
} catch {
console.warn(
`Warning: upstream baseline '${tag}' is unavailable. Fetch it with: git fetch ${config.upstreamRemote} refs/tags/${tag}:${ref} --no-tags --quiet. Falling back to directory-based ownership checks.`,
)
_upstreamFilesCache = { ref, files: new Set() }
}
// Upstream not available — return empty set (all files treated as ours-only)
_upstreamFilesCache = new Set()
return _upstreamFilesCache
return _upstreamFilesCache.files
}

// altimate_change start — exclude test files, generated code, and config files from marker checks
Expand All @@ -721,7 +739,7 @@ const markerExcludePatterns = [
]
// altimate_change end

function isUpstreamShared(file: string, config: MergeConfig): boolean {
export function isUpstreamShared(file: string, config: MergeConfig): boolean {
const { minimatch } = require("minimatch")
if (config.keepOurs.some((p: string) => minimatch(file, p))) return false
if (config.skipFiles.some((p: string) => minimatch(file, p))) return false
Expand Down Expand Up @@ -898,20 +916,6 @@ function checkFileForMarkers(file: string, base?: string): MarkerWarning[] {
}

function runMarkerCheck(config: MergeConfig, base?: string, strict?: boolean): number {
const { execSync } = require("child_process")
const root = repoRoot()

// Ensure upstream remote exists and is fetched so we can check file existence
try {
execSync(`git remote get-url ${config.upstreamRemote}`, { cwd: root, stdio: "ignore" })
// --no-tags: upstream tags (v0.x, v1.x) collide with our own release tags
// (see git.ts's fetchRemote(), which this call duplicates but historically omitted the flag).
execSync(`git fetch ${config.upstreamRemote} --quiet --no-tags`, { cwd: root, stdio: "ignore" })
} catch {
// If upstream remote doesn't exist, fall back to pattern-only checks
logger.warn(`Could not fetch '${config.upstreamRemote}' remote — falling back to pattern-based detection`)
}

const changedFiles = getChangedFiles(base)
const sharedFiles = changedFiles.filter((f) => isUpstreamShared(f, config))

Expand Down Expand Up @@ -1030,13 +1034,17 @@ function runRequireMarkersCheck(config: MergeConfig, strict?: boolean): number {
// ---------------------------------------------------------------------------

async function main(): Promise<void> {
const config = loadConfig()
if (args["print-baseline"]) {
console.log(config.upstreamBaseline)
return
}

if (args.help) {
printUsage()
process.exit(0)
}

const config = loadConfig()

const hasVersion = Boolean(args.version)
const hasBranding = Boolean(args.branding)
const hasMarkers = Boolean(args.markers)
Expand Down
3 changes: 3 additions & 0 deletions script/upstream/bridge-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { writeFileSync, existsSync, readFileSync, rmSync } from "fs"
import { loadConfig, repoRoot, type MergeConfig } from "./utils/config.ts"
import * as logger from "./utils/logger.ts"
import { bold, cyan, dim, green, yellow, red } from "./utils/logger.ts"
import { assertSameMajor } from "./utils/version.ts"

// ---------------------------------------------------------------------------
// CLI args
Expand Down Expand Up @@ -477,6 +478,8 @@ async function editSessionLlmTs(report: string[]): Promise<void> {

async function main(): Promise<void> {
const config = loadConfig()
assertSameMajor(VERSION, config.upstreamBaseline)

const { minimatch } = await import("minimatch")
const report: string[] = []
report.push(`# Bridge Merge Report — overlay ${VERSION}\n`)
Expand Down
Loading
Loading