diff --git a/README.md b/README.md index 35cf50d..c6bbf4a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Supported synchronized data: - global `skills/` - plaintext prompt history and prompt stash in a private repository - only the `favorite` projection from `state/opencode/model.json` -- `main-model.txt` and `cheap-model.txt` +- `main-model.txt`, `cheap-model.txt`, and `frontier-model.txt` Always local and rejected by configuration validation: @@ -94,6 +94,10 @@ artifact into OpenCode's package cache. Do not use a mutable branch reference. Prompt flags fail unless the risk acknowledgement is exactly `true`. The repository visibility is checked before prompt data is read or written. +On Windows, sync locations follow OpenCode's XDG-compatible layout: `%USERPROFILE%\.config`, +`%USERPROFILE%\.local\share`, and `%USERPROFILE%\.local\state`. The native `APPDATA` and +`LOCALAPPDATA` roots are not used for sync locations. + ## Commands | Command | Description | @@ -121,6 +125,7 @@ state/ model-selectors/ main-model.txt cheap-model.txt + frontier-model.txt prompts/ prompt-history.jsonl prompt-stash.jsonl diff --git a/docs/extended-sync-v1.md b/docs/extended-sync-v1.md index 06692d6..3aec8b5 100644 --- a/docs/extended-sync-v1.md +++ b/docs/extended-sync-v1.md @@ -15,7 +15,7 @@ The fork may synchronize only these paths: - `skills/`, excluding generated cache and platform metadata - prompt history and prompt stash as plaintext in a private repository - the `favorite` projection from `model.json` -- `main-model.txt` and `cheap-model.txt` +- `main-model.txt`, `cheap-model.txt`, and `frontier-model.txt` The following remain local and are rejected when explicitly enabled: @@ -43,6 +43,9 @@ This policy uses operation order, never wall-clock timestamps. (`*:Zone.Identifier`, `.DS_Store`) are excluded. - Files are staged and atomically renamed instead of overwriting live files in place. Directory replacement must preserve a rollback copy until completion. +- On Windows, sync locations follow OpenCode's XDG-compatible `%USERPROFILE%\.config`, + `%USERPROFILE%\.local\share`, and `%USERPROFILE%\.local\state` roots instead of + native `APPDATA` and `LOCALAPPDATA` roots. - Generated local state and overrides use mode `0600`; their parent directory uses mode `0700`. - Secrets, sessions, sync configuration, and overrides are never part of a plan. diff --git a/package.json b/package.json index cb60855..2d00b66 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "vitest": "^3.2.4" }, "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/command dist/command", + "build": "node scripts/build.mjs", "prepack": "npm run build", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..e8838a6 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,15 @@ +import { spawnSync } from 'node:child_process'; +import { cp, rm } from 'node:fs/promises'; + +await rm('dist', { recursive: true, force: true }); + +const result = spawnSync( + process.execPath, + ['node_modules/typescript/bin/tsc', '-p', 'tsconfig.build.json'], + { stdio: 'inherit' } +); + +if (result.error) throw result.error; +if (result.status !== 0) process.exit(result.status ?? 1); + +await cp('src/command', 'dist/command', { recursive: true }); diff --git a/src/index.ts b/src/index.ts index 5344d2e..0240195 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,7 +140,7 @@ export const opencodeConfigSync: Plugin = async (ctx) => { includeModelSelectors: tool.schema .boolean() .optional() - .describe('Sync main-model.txt and cheap-model.txt'), + .describe('Sync main-model.txt, cheap-model.txt, and frontier-model.txt'), acknowledgePlaintextPromptRisk: tool.schema .boolean() .optional() diff --git a/src/sync/apply.test.ts b/src/sync/apply.test.ts index fbb7618..d3cc9f8 100644 --- a/src/sync/apply.test.ts +++ b/src/sync/apply.test.ts @@ -99,7 +99,16 @@ describe('safe synchronization', () => { await writeFile(path.join(skills, 'demo', 'run.sh'), '#!/bin/sh\nexit 0\n'); await chmod(path.join(skills, 'demo', 'run.sh'), 0o755); await writeFile(path.join(skills, 'demo', '__pycache__', 'cache.pyc'), 'cache'); - await writeFile(path.join(skills, 'demo', 'SKILL.md:Zone.Identifier'), 'metadata'); + const zoneIdentifierNames = + process.platform === 'win32' + ? [ + `SKILL.md${String.fromCharCode(0xf03a)}Zone.Identifier`, + `SKILL.md${String.fromCharCode(0xff1a)}Zone.Identifier`, + ] + : ['SKILL.md:Zone.Identifier']; + for (const zoneIdentifierName of zoneIdentifierNames) { + await writeFile(path.join(skills, 'demo', zoneIdentifierName), 'metadata'); + } await writeFile(path.join(skills, '.DS_Store'), 'metadata'); const destination = path.join(repo, 'config', 'skills'); @@ -120,13 +129,17 @@ describe('safe synchronization', () => { await expect(lstat(path.join(destination, 'demo', '__pycache__'))).rejects.toMatchObject({ code: 'ENOENT', }); - await expect( - lstat(path.join(destination, 'demo', 'SKILL.md:Zone.Identifier')) - ).rejects.toMatchObject({ code: 'ENOENT' }); + for (const zoneIdentifierName of zoneIdentifierNames) { + await expect( + lstat(path.join(destination, 'demo', zoneIdentifierName)) + ).rejects.toMatchObject({ code: 'ENOENT' }); + } await expect(lstat(path.join(destination, '.DS_Store'))).rejects.toMatchObject({ code: 'ENOENT', }); - expect((await lstat(path.join(destination, 'demo', 'run.sh'))).mode & 0o777).toBe(0o755); + if (process.platform !== 'win32') { + expect((await lstat(path.join(destination, 'demo', 'run.sh'))).mode & 0o777).toBe(0o755); + } }); it('rejects sensitive files in skills', async () => { @@ -377,8 +390,10 @@ describe('safe synchronization', () => { await syncLocalToRepo(plan, null); expect(await readFile(destination, 'utf8')).toBe(content); - expect((await lstat(destination)).mode & 0o777).toBe(0o600); - expect((await lstat(path.dirname(destination))).mode & 0o777).toBe(0o700); + if (process.platform !== 'win32') { + expect((await lstat(destination)).mode & 0o777).toBe(0o600); + expect((await lstat(path.dirname(destination))).mode & 0o777).toBe(0o700); + } }); it('rejects malformed prompt JSONL before it reaches the repository', async () => { diff --git a/src/sync/apply.ts b/src/sync/apply.ts index 39210d6..2838105 100644 --- a/src/sync/apply.ts +++ b/src/sync/apply.ts @@ -501,7 +501,8 @@ function classifySkillPath( ]); if (isDirectory && ignoredDirectories.has(name)) return 'ignore'; if (name.endsWith('.pyc') || name.endsWith('.pyo')) return 'ignore'; - if (name.endsWith(':zone.identifier') || name === '.ds_store') return 'ignore'; + const normalizedMetadataName = name.replace(/[\uF03A\uFF1A]/g, ':'); + if (normalizedMetadataName.endsWith(':zone.identifier') || name === '.ds_store') return 'ignore'; const sensitiveDirectories = new Set(['.gnupg', '.ssh', 'private', 'secrets']); if (lowerSegments.some((segment) => sensitiveDirectories.has(segment))) return 'reject'; diff --git a/src/sync/config.test.ts b/src/sync/config.test.ts index e310aef..0724b4c 100644 --- a/src/sync/config.test.ts +++ b/src/sync/config.test.ts @@ -182,8 +182,10 @@ describe('secure local files', () => { includeSkills: true, }); - expect((await lstat(locations.syncConfigPath)).mode & 0o777).toBe(0o600); - expect((await lstat(path.dirname(locations.syncConfigPath))).mode & 0o777).toBe(0o700); + if (process.platform !== 'win32') { + expect((await lstat(locations.syncConfigPath)).mode & 0o777).toBe(0o600); + expect((await lstat(path.dirname(locations.syncConfigPath))).mode & 0o777).toBe(0o700); + } } finally { await rm(tempDir, { recursive: true, force: true }); } @@ -200,8 +202,10 @@ describe('secure local files', () => { lastPull: 'pull-time', lastPush: 'push-time', }); - expect((await lstat(locations.statePath)).mode & 0o777).toBe(0o600); - expect((await lstat(path.dirname(locations.statePath))).mode & 0o777).toBe(0o700); + if (process.platform !== 'win32') { + expect((await lstat(locations.statePath)).mode & 0o777).toBe(0o600); + expect((await lstat(path.dirname(locations.statePath))).mode & 0o777).toBe(0o700); + } } finally { await rm(tempDir, { recursive: true, force: true }); } diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index bd7de12..8ebf364 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { describe, expect, it } from 'vitest'; import type { SyncConfig } from './config.js'; @@ -8,8 +10,8 @@ describe('resolveXdgPaths', () => { const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; const paths = resolveXdgPaths(env, 'linux'); - expect(paths.configDir).toBe('/home/test/.config'); - expect(paths.dataDir).toBe('/home/test/.local/share'); + expect(paths.configDir).toBe(path.join('/home/test', '.config')); + expect(paths.dataDir).toBe(path.join('/home/test', '.local', 'share')); }); it('resolves windows defaults', () => { @@ -20,8 +22,9 @@ describe('resolveXdgPaths', () => { } as NodeJS.ProcessEnv; const paths = resolveXdgPaths(env, 'win32'); - expect(paths.configDir).toBe('C:\\Users\\Test\\AppData\\Roaming'); - expect(paths.dataDir).toBe('C:\\Users\\Test\\AppData\\Local'); + expect(paths.configDir).toBe(path.join('C:\\Users\\Test', '.config')); + expect(paths.dataDir).toBe(path.join('C:\\Users\\Test', '.local', 'share')); + expect(paths.stateDir).toBe(path.join('C:\\Users\\Test', '.local', 'state')); }); }); @@ -33,7 +36,7 @@ describe('resolveSyncLocations', () => { } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); - expect(locations.configRoot).toBe('/official/opencode'); + expect(locations.configRoot).toBe(path.resolve('/official/opencode')); }); it('respects opencode_config_dir', () => { @@ -43,9 +46,13 @@ describe('resolveSyncLocations', () => { } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); - expect(locations.configRoot).toBe('/custom/opencode'); - expect(locations.syncConfigPath).toBe('/custom/opencode/opencode-synced.jsonc'); - expect(locations.overridesPath).toBe('/custom/opencode/opencode-synced.overrides.jsonc'); + expect(locations.configRoot).toBe(path.resolve('/custom/opencode')); + expect(locations.syncConfigPath).toBe( + path.join(path.resolve('/custom/opencode'), 'opencode-synced.jsonc') + ); + expect(locations.overridesPath).toBe( + path.join(path.resolve('/custom/opencode'), 'opencode-synced.overrides.jsonc') + ); }); }); @@ -64,7 +71,10 @@ describe('buildSyncPlan', () => { 'linux' ); - expect(plan.localRoots).toEqual(['/mnt/config/opencode', '/mnt/state/opencode']); + expect(plan.localRoots).toEqual([ + path.join('/mnt/config', 'opencode'), + path.join('/mnt/state', 'opencode'), + ]); }); it('excludes secrets and arbitrary extra paths', () => { @@ -108,7 +118,7 @@ describe('buildSyncPlan', () => { const plan = buildSyncPlan(config, locations, '/repo', 'linux'); const favoritesItem = plan.items.find((item) => - item.localPath.endsWith('/.local/state/opencode/model.json') + item.localPath.endsWith(path.join('.local', 'state', 'opencode', 'model.json')) ); expect(favoritesItem).toBeTruthy(); @@ -120,7 +130,7 @@ describe('buildSyncPlan', () => { 'linux' ); const disabledItem = disabledPlan.items.find((item) => - item.localPath.endsWith('/.local/state/opencode/model.json') + item.localPath.endsWith(path.join('.local', 'state', 'opencode', 'model.json')) ); expect(disabledItem).toBeUndefined(); @@ -135,13 +145,13 @@ describe('buildSyncPlan', () => { }; const disabled = buildSyncPlan(base, locations, '/repo', 'linux'); - expect(disabled.items.some((item) => item.localPath.endsWith('/skills'))).toBe(false); + expect(disabled.items.some((item) => item.localPath.endsWith('skills'))).toBe(false); const enabled = buildSyncPlan({ ...base, includeSkills: true }, locations, '/repo', 'linux'); - const skills = enabled.items.find((item) => item.localPath.endsWith('/skills')); + const skills = enabled.items.find((item) => item.localPath.endsWith('skills')); expect(skills).toMatchObject({ - repoPath: '/repo/config/skills', + repoPath: path.join('/repo', 'config', 'skills'), type: 'dir', strategy: 'skills', }); @@ -164,20 +174,20 @@ describe('buildSyncPlan', () => { expect(promptItems).toEqual( expect.arrayContaining([ expect.objectContaining({ - localPath: '/home/test/.local/state/opencode/prompt-history.jsonl', - repoPath: '/repo/state/prompts/prompt-history.jsonl', + localPath: path.join('/home/test', '.local', 'state', 'opencode', 'prompt-history.jsonl'), + repoPath: path.join('/repo', 'state', 'prompts', 'prompt-history.jsonl'), isSecret: true, }), expect.objectContaining({ - localPath: '/home/test/.local/state/opencode/prompt-stash.jsonl', - repoPath: '/repo/state/prompts/prompt-stash.jsonl', + localPath: path.join('/home/test', '.local', 'state', 'opencode', 'prompt-stash.jsonl'), + repoPath: path.join('/repo', 'state', 'prompts', 'prompt-stash.jsonl'), isSecret: true, }), ]) ); - expect(plan.items.some((item) => item.localPath.endsWith('/auth.json'))).toBe(false); - expect(plan.items.some((item) => item.localPath.endsWith('/mcp-auth.json'))).toBe(false); + expect(plan.items.some((item) => item.localPath.endsWith('auth.json'))).toBe(false); + expect(plan.items.some((item) => item.localPath.endsWith('mcp-auth.json'))).toBe(false); }); it('adds portable model selector files when enabled', () => { @@ -194,12 +204,16 @@ describe('buildSyncPlan', () => { expect(selectors).toEqual( expect.arrayContaining([ expect.objectContaining({ - localPath: '/home/test/.config/opencode/main-model.txt', - repoPath: '/repo/state/model-selectors/main-model.txt', + localPath: path.join('/home/test', '.config', 'opencode', 'main-model.txt'), + repoPath: path.join('/repo', 'state', 'model-selectors', 'main-model.txt'), + }), + expect.objectContaining({ + localPath: path.join('/home/test', '.config', 'opencode', 'cheap-model.txt'), + repoPath: path.join('/repo', 'state', 'model-selectors', 'cheap-model.txt'), }), expect.objectContaining({ - localPath: '/home/test/.config/opencode/cheap-model.txt', - repoPath: '/repo/state/model-selectors/cheap-model.txt', + localPath: path.join('/home/test', '.config', 'opencode', 'frontier-model.txt'), + repoPath: path.join('/repo', 'state', 'model-selectors', 'frontier-model.txt'), }), ]) ); @@ -215,8 +229,8 @@ describe('buildSyncPlan', () => { 'linux' ); - const favorites = plan.items.find((item) => item.localPath.endsWith('/model.json')); + const favorites = plan.items.find((item) => item.localPath.endsWith('model.json')); expect(favorites?.strategy).toBe('model-favorites'); - expect(favorites?.repoPath).toBe('/repo/state/model-favorites.json'); + expect(favorites?.repoPath).toBe(path.join('/repo', 'state', 'model-favorites.json')); }); }); diff --git a/src/sync/paths.ts b/src/sync/paths.ts index a9e5dea..6b3efad 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -54,7 +54,7 @@ const CONFIG_DIRS = ['agent', 'command', 'mode', 'tool', 'themes', 'plugin']; const MODEL_FAVORITES_FILE = 'model.json'; const PROMPT_HISTORY_FILE = 'prompt-history.jsonl'; const PROMPT_STASH_FILE = 'prompt-stash.jsonl'; -const MODEL_SELECTOR_FILES = ['main-model.txt', 'cheap-model.txt']; +const MODEL_SELECTOR_FILES = ['main-model.txt', 'cheap-model.txt', 'frontier-model.txt']; export function resolveHomeDir( env: NodeJS.ProcessEnv = process.env, @@ -83,10 +83,10 @@ export function resolveXdgPaths( } if (platform === 'win32') { - const configDir = env.APPDATA ?? path.join(homeDir, 'AppData', 'Roaming'); - const dataDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local'); - // Windows doesn't have XDG_STATE_HOME equivalent, use LOCALAPPDATA - const stateDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local'); + // This OpenCode installation uses XDG-compatible roots on Windows. + const configDir = env.XDG_CONFIG_HOME ?? path.join(homeDir, '.config'); + const dataDir = env.XDG_DATA_HOME ?? path.join(homeDir, '.local', 'share'); + const stateDir = env.XDG_STATE_HOME ?? path.join(homeDir, '.local', 'state'); return { homeDir, configDir, dataDir, stateDir }; } diff --git a/src/sync/repo.test.ts b/src/sync/repo.test.ts index 270bd23..57e8c19 100644 --- a/src/sync/repo.test.ts +++ b/src/sync/repo.test.ts @@ -21,7 +21,7 @@ describe('normalizeRepoRemote', () => { }); it('normalizes local repository paths', () => { - expect(normalizeRepoRemote('/tmp/example.git')).toBe('local:/tmp/example.git'); + expect(normalizeRepoRemote('/tmp/example.git')).toBe(`local:${path.resolve('/tmp/example.git')}`); }); it('does not equate alternate protocols or ports with canonical GitHub', () => { diff --git a/src/sync/repo.ts b/src/sync/repo.ts index 114e1fe..6808b41 100644 --- a/src/sync/repo.ts +++ b/src/sync/repo.ts @@ -306,7 +306,7 @@ async function resolveRebaseConflictsLocalWins( } try { - await $`env GIT_EDITOR=true git -C ${repoDir} rebase --continue`.quiet(); + await $`git -C ${repoDir} -c core.editor=true rebase --continue`.quiet(); return; } catch { const remaining = await $`git -C ${repoDir} diff --name-only --diff-filter=U -z` diff --git a/src/sync/service.test.ts b/src/sync/service.test.ts index 44c0233..1ad8910 100644 --- a/src/sync/service.test.ts +++ b/src/sync/service.test.ts @@ -24,12 +24,14 @@ describe('SyncService local-wins integration', () => { it('does not wait for TUI to exist when startup sync is not configured', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-synced-startup-')); tempDirs.push(root); - process.env.HOME = path.join(root, 'home'); - delete process.env.XDG_CONFIG_HOME; - delete process.env.XDG_DATA_HOME; - delete process.env.XDG_STATE_HOME; + const home = path.join(root, 'home'); + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.XDG_CONFIG_HOME = path.join(root, 'xdg-config'); + process.env.XDG_DATA_HOME = path.join(root, 'xdg-data'); + process.env.XDG_STATE_HOME = path.join(root, 'xdg-state'); delete process.env.opencode_config_dir; - await mkdir(process.env.HOME, { recursive: true }); + await mkdir(home, { recursive: true }); const client = { app: { log: async () => ({}) }, tui: { showToast: () => new Promise(() => {}) }, @@ -53,13 +55,14 @@ describe('SyncService local-wins integration', () => { const remoteWriter = path.join(root, 'remote-writer'); await mkdir(home, { recursive: true }); process.env.HOME = home; - delete process.env.XDG_CONFIG_HOME; - delete process.env.XDG_DATA_HOME; - delete process.env.XDG_STATE_HOME; + process.env.USERPROFILE = home; + process.env.XDG_CONFIG_HOME = path.join(root, 'xdg-config'); + process.env.XDG_DATA_HOME = path.join(root, 'xdg-data'); + process.env.XDG_STATE_HOME = path.join(root, 'xdg-state'); delete process.env.opencode_config_dir; await run('git', ['init', '--bare', origin]); - await run('git', ['clone', origin, seed]); + await run('git', ['-c', 'core.autocrlf=false', 'clone', origin, seed]); await configureGit(seed); await mkdir(path.join(seed, 'config'), { recursive: true }); await writeFile(path.join(seed, 'config', 'AGENTS.md'), 'base-agents\n'); @@ -72,7 +75,7 @@ describe('SyncService local-wins integration', () => { const locations = resolveSyncLocations(); await mkdir(path.dirname(locations.defaultRepoDir), { recursive: true }); - await run('git', ['clone', origin, locations.defaultRepoDir]); + await run('git', ['-c', 'core.autocrlf=false', 'clone', origin, locations.defaultRepoDir]); await configureGit(locations.defaultRepoDir); await mkdir(locations.configRoot, { recursive: true }); await writeFile(path.join(locations.configRoot, 'AGENTS.md'), 'base-agents\n'); @@ -84,7 +87,7 @@ describe('SyncService local-wins integration', () => { await writeFile(path.join(locations.configRoot, 'AGENTS.md'), 'local-agents\n'); - await run('git', ['clone', origin, remoteWriter]); + await run('git', ['-c', 'core.autocrlf=false', 'clone', origin, remoteWriter]); await configureGit(remoteWriter); await writeFile(path.join(remoteWriter, 'config', 'AGENTS.md'), 'remote-agents\n'); await writeFile(path.join(remoteWriter, 'config', 'opencode.json'), '{"theme":"remote"}\n'); @@ -112,7 +115,10 @@ describe('SyncService local-wins integration', () => { const rollbackDirs = await readdir(rollbackBase); expect(rollbackDirs).toHaveLength(1); expect( - await readFile(path.join(rollbackBase, rollbackDirs[0], 'config', 'AGENTS.md'), 'utf8') + (await readFile(path.join(rollbackBase, rollbackDirs[0], 'config', 'AGENTS.md'), 'utf8')).replace( + /\r\n/g, + '\n' + ) ).toBe('remote-agents\n'); expect(await loadState(locations)).toMatchObject({ lastOutcome: 'pushed' }); @@ -144,7 +150,7 @@ describe('SyncService local-wins integration', () => { expect(await readFile(path.join(locations.configRoot, 'AGENTS.md'), 'utf8')).toBe( 'pending-local-agents\n' ); - }); + }, 30_000); it('rejects an existing clone with a different origin', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-synced-origin-')); @@ -154,16 +160,17 @@ describe('SyncService local-wins integration', () => { const configuredOrigin = path.join(root, 'configured-origin.git'); await mkdir(home, { recursive: true }); process.env.HOME = home; - delete process.env.XDG_CONFIG_HOME; - delete process.env.XDG_DATA_HOME; - delete process.env.XDG_STATE_HOME; + process.env.USERPROFILE = home; + process.env.XDG_CONFIG_HOME = path.join(root, 'xdg-config'); + process.env.XDG_DATA_HOME = path.join(root, 'xdg-data'); + process.env.XDG_STATE_HOME = path.join(root, 'xdg-state'); delete process.env.opencode_config_dir; await run('git', ['init', '--bare', oldOrigin]); await run('git', ['init', '--bare', configuredOrigin]); const locations = resolveSyncLocations(); await mkdir(path.dirname(locations.defaultRepoDir), { recursive: true }); - await run('git', ['clone', oldOrigin, locations.defaultRepoDir]); + await run('git', ['-c', 'core.autocrlf=false', 'clone', oldOrigin, locations.defaultRepoDir]); await writeSyncConfig(locations, { repo: { url: configuredOrigin, branch: 'main' }, includeModelFavorites: false, @@ -208,6 +215,7 @@ function createShell(): PluginInput['$'] { async function configureGit(repo: string): Promise { await run('git', ['-C', repo, 'config', 'user.name', 'Test User']); await run('git', ['-C', repo, 'config', 'user.email', 'test@example.invalid']); + await run('git', ['-C', repo, 'config', 'core.autocrlf', 'false']); } async function gitShow(repo: string, object: string): Promise {