diff --git a/src/server/skillsRoutes.ts b/src/server/skillsRoutes.ts index 6e9dc476b..4fd9b5fcb 100644 --- a/src/server/skillsRoutes.ts +++ b/src/server/skillsRoutes.ts @@ -761,12 +761,24 @@ async function ensurePrivateForkFromUpstream(token: string, username: string, re throw new Error(`Failed to check personal repo existence (${existing.status})`) } - await getGithubJson( - 'https://api.github.com/user/repos', - token, - 'POST', - { name: repoName, private: true, auto_init: false, description: 'Codex skills private mirror sync' }, - ) + const createRepo = await fetch('https://api.github.com/user/repos', { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'codex-web-local', + }, + body: JSON.stringify({ name: repoName, private: true, auto_init: false, description: 'Codex skills private mirror sync' }), + }) + if (!createRepo.ok) { + const text = await createRepo.text() + if (createRepo.status === 403 && text.includes('Resource not accessible by integration')) { + throw new Error(`GitHub login cannot create the private ${repoName} sync repo with this token. Create an empty private repo named ${repoName} on GitHub, then retry Device Login, or use the regular GitHub login button with repo access.`) + } + throw new Error(`GitHub API POST https://api.github.com/user/repos failed (${createRepo.status}): ${text}`) + } created = true let ready = false @@ -903,10 +915,18 @@ async function ensureSkillsWorkingTreeRepo(repoUrl: string, branch: string): Pro const hasLocalChangesBeforePull = await hasLocalUncommittedChanges(localDir) const localMtimesBeforePull = hasLocalChangesBeforePull ? await snapshotFileMtimes(localDir) : new Map() let createdAutostash = false + let autostashRef = '' try { const stashOutput = await runCommandWithOutput('git', ['stash', 'push', '--include-untracked', '-m', 'codex-skills-autostash'], { cwd: localDir }) createdAutostash = !stashOutput.includes('No local changes to save') - } catch {} + if (createdAutostash) { + autostashRef = (await runCommandWithOutput('git', ['rev-parse', 'stash@{0}'], { cwd: localDir })).trim() + } + } catch (error) { + if (hasLocalChangesBeforePull) { + throw new Error(`Refusing to reset skills repo because local changes could not be stashed first: ${getErrorMessage(error, 'git stash failed')}`) + } + } let pulledMtimes = new Map() await runGitFetchWithRefLockRetry(localDir, ['fetch', 'origin', branch]) await runCommand('git', ['reset', '--hard', `origin/${branch}`], { cwd: localDir }) @@ -916,6 +936,9 @@ async function ensureSkillsWorkingTreeRepo(repoUrl: string, branch: string): Pro await runCommand('git', ['stash', 'pop'], { cwd: localDir }) } catch { await resolveStashPopConflictsByFileTime(localDir, localMtimesBeforePull, pulledMtimes) + if (autostashRef) { + await restoreMissingUntrackedFilesFromStash(localDir, autostashRef) + } } } return localDir @@ -1039,6 +1062,22 @@ async function resolveStashPopConflictsByFileTime( } } +async function restoreMissingUntrackedFilesFromStash(repoDir: string, stashRef: string): Promise { + let untrackedFiles = '' + try { + untrackedFiles = await runCommandWithOutput('git', ['ls-tree', '-r', '--name-only', `${stashRef}^3`], { cwd: repoDir }) + } catch { + return + } + for (const filePath of untrackedFiles.split(/\r?\n/).map((row) => row.trim()).filter(Boolean)) { + try { + await stat(join(repoDir, filePath)) + continue + } catch {} + await runCommand('git', ['checkout', `${stashRef}^3`, '--', filePath], { cwd: repoDir }) + } +} + async function snapshotFileMtimes(dir: string): Promise> { const mtimes = new Map() await walkFileMtimes(dir, dir, mtimes) @@ -1051,12 +1090,10 @@ async function hasLocalUncommittedChanges(repoDir: string): Promise { } async function hasCommittableWorkingTreeChanges(repoDir: string): Promise { - try { - await runCommand('git', ['diff', '--quiet', '--exit-code', '--ignore-submodules=dirty'], { cwd: repoDir }) - await runCommand('git', ['diff', '--cached', '--quiet', '--exit-code', '--ignore-submodules=dirty'], { cwd: repoDir }) - } catch { - return true - } + const unstaged = (await runCommandWithOutput('git', ['diff', '--name-only', '--ignore-submodules=dirty'], { cwd: repoDir })).trim() + if (unstaged.length > 0) return true + const staged = (await runCommandWithOutput('git', ['diff', '--cached', '--name-only', '--ignore-submodules=dirty'], { cwd: repoDir })).trim() + if (staged.length > 0) return true const untracked = (await runCommandWithOutput('git', ['ls-files', '--others', '--exclude-standard'], { cwd: repoDir })).trim() return untracked.length > 0 } diff --git a/tests.md b/tests.md index 8dbc45337..ef6e1629d 100644 --- a/tests.md +++ b/tests.md @@ -376,6 +376,98 @@ Skills Sync skips unchanged manifest writes and does not fail parent commits whe --- +### Skills sync device login repo-create permission error + +#### Feature/Change Name +Device Login shows an actionable message when GitHub rejects automatic private sync repo creation. + +#### Prerequisites/Setup +1. Dev server running (`pnpm run dev --host 127.0.0.1 --port 5173`) +2. GitHub account used for Device Login does not already have a `codexskills` repository +3. GitHub returns `403 Resource not accessible by integration` for `POST /user/repos` +4. Light theme and dark theme are available from the appearance switcher + +#### Steps +1. In light theme, open `#/skills`. +2. Click `Device Login`. +3. Complete the GitHub device-code prompt. +4. Wait for the sync panel to show the login failure. +5. Confirm the error explains that the token cannot create the private `codexskills` repo and tells the user to create an empty private repo named `codexskills` or use regular GitHub login with repo access. +6. Switch to dark theme and repeat steps 1 through 5. + +#### Expected Results +- The raw `GitHub API POST https://api.github.com/user/repos failed (403)` payload is not shown as the primary error. +- The user sees a clear recovery path for Device Login. +- The error panel remains readable in light theme and dark theme. + +#### Rollback/Cleanup +- Delete any test-only `codexskills` repository created during validation. + +--- + +### Skills sync refuses hard reset when local checkpoint fails + +#### Feature/Change Name +Skills Sync aborts before `reset --hard` if local changes cannot be stashed first. + +#### Prerequisites/Setup +1. Dev server running (`pnpm run dev --host 127.0.0.1 --port 5173`) +2. GitHub Skills Sync is configured and connected +3. `/Users/igor/.codex/skills` has a local tracked or untracked test edit +4. A stash failure can be simulated in the skills repo +5. Light theme and dark theme are available from the appearance switcher + +#### Steps +1. In light theme, open `#/skills`. +2. Create a local test edit under `/Users/igor/.codex/skills`. +3. Simulate `git stash push --include-untracked` failure for the skills repo. +4. Click `Pull` or `Startup Sync`. +5. Confirm sync fails before any hard reset and the local test edit is still present. +6. Switch to dark theme and repeat steps 1 through 5. + +#### Expected Results +- Sync reports that local changes could not be stashed. +- Sync does not run the hard reset path after the failed stash. +- Local test edits remain recoverable in the skills repo. +- The error panel remains readable in light theme and dark theme. + +#### Rollback/Cleanup +- Remove the local test edit and clear the simulated stash failure. + +--- + +### Skills sync restores untracked files after stash-pop conflict + +#### Feature/Change Name +Skills Sync restores untracked local skill files when `git stash pop` conflicts on tracked files. + +#### Prerequisites/Setup +1. Dev server running (`pnpm run dev --host 127.0.0.1 --port 5173`) +2. GitHub Skills Sync is configured and connected +3. `/Users/igor/.codex/skills` has an untracked local skill folder such as `feedback-triage/SKILL.md` +4. Remote sync has a conflicting tracked edit such as a changed `installed-skills.json` +5. Light theme and dark theme are available from the appearance switcher + +#### Steps +1. In light theme, open `#/skills`. +2. Create an untracked local skill folder under `/Users/igor/.codex/skills`. +3. Make remote `installed-skills.json` differ so `git stash pop` conflicts during pull/startup sync. +4. Click `Pull` or `Startup Sync`. +5. Confirm sync resolves the tracked conflict and restores the untracked skill file from the stash. +6. Confirm the restored skill file is included in the later skills repo commit/push path. +7. Switch to dark theme and repeat steps 1 through 5. + +#### Expected Results +- Untracked local skill files do not remain stranded only in `refs/stash`. +- Restored untracked skill files are present in `/Users/igor/.codex/skills` after sync. +- The next parent repo commit can stage and push the restored skill files. +- The sync panel remains readable in light theme and dark theme. + +#### Rollback/Cleanup +- Remove any test-only skill folder and restore remote manifest test edits. + +--- + ### Header Git branch dropdown with commit reset #### Feature/Change Name