From da8844207d9284a9b5605bdd9cfd42f521eb128f Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 4 May 2026 07:51:41 +0700 Subject: [PATCH 1/4] Clarify device login repo permission errors --- src/server/skillsRoutes.ts | 24 ++++++++++++++++++------ tests.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/server/skillsRoutes.ts b/src/server/skillsRoutes.ts index 6e9dc476b..a89befa5c 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 diff --git a/tests.md b/tests.md index 8dbc45337..74ac43754 100644 --- a/tests.md +++ b/tests.md @@ -376,6 +376,35 @@ 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. + +--- + ### Header Git branch dropdown with commit reset #### Feature/Change Name From 1862aba072e76b410e3d0b0bb60ee89dd83b15c6 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 6 May 2026 08:38:23 +0700 Subject: [PATCH 2/4] Guard skills sync reset behind stash checkpoint --- src/server/skillsRoutes.ts | 6 +++++- tests.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/server/skillsRoutes.ts b/src/server/skillsRoutes.ts index a89befa5c..50780ea09 100644 --- a/src/server/skillsRoutes.ts +++ b/src/server/skillsRoutes.ts @@ -918,7 +918,11 @@ async function ensureSkillsWorkingTreeRepo(repoUrl: string, branch: string): Pro 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 {} + } 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 }) diff --git a/tests.md b/tests.md index 74ac43754..4f9b1c8b1 100644 --- a/tests.md +++ b/tests.md @@ -405,6 +405,37 @@ Device Login shows an actionable message when GitHub rejects automatic private s --- +### 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. + +--- + ### Header Git branch dropdown with commit reset #### Feature/Change Name From e1cc11d22997a49f8ca7e8e3c26a412c37427469 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 6 May 2026 08:44:07 +0700 Subject: [PATCH 3/4] Restore untracked skills after stash conflict --- src/server/skillsRoutes.ts | 23 +++++++++++++++++++++++ tests.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/server/skillsRoutes.ts b/src/server/skillsRoutes.ts index 50780ea09..3057f3ae8 100644 --- a/src/server/skillsRoutes.ts +++ b/src/server/skillsRoutes.ts @@ -915,9 +915,13 @@ 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') + 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')}`) @@ -932,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 @@ -1055,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) diff --git a/tests.md b/tests.md index 4f9b1c8b1..ef6e1629d 100644 --- a/tests.md +++ b/tests.md @@ -436,6 +436,38 @@ Skills Sync aborts before `reset --hard` if local changes cannot be stashed firs --- +### 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 From aed6caadca422a22607099d24a80bd55ec73d647 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 6 May 2026 08:53:35 +0700 Subject: [PATCH 4/4] Surface git diff failures in skills sync --- src/server/skillsRoutes.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/server/skillsRoutes.ts b/src/server/skillsRoutes.ts index 3057f3ae8..4fd9b5fcb 100644 --- a/src/server/skillsRoutes.ts +++ b/src/server/skillsRoutes.ts @@ -1090,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 }