From c6e042db867a44c85fb5a91058c5821e604d020e Mon Sep 17 00:00:00 2001 From: Agent Date: Sun, 18 Oct 2026 10:55:21 +0000 Subject: [PATCH] feat: add repository git credential fixer --- README.md | 5 +++- forge.mjs | 49 +++++++++++++++++++++++++++++++++--- index.js | 19 ++++++++++++-- locale/en.json | 2 +- locale/zh.json | 2 +- package.json | 4 +-- test/credential-fix.test.mjs | 49 ++++++++++++++++++++++++++++++++++++ 7 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 test/credential-fix.test.mjs diff --git a/README.md b/README.md index dfc234e..d702819 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,11 @@ GitHub and Gitea/Forgejo integration for the DeepSeek Harness (DSH): **clone rep | `forge_pulls` | list / get / diff / create / comment / merge / close / reopen. | | `forge_clone` | Clone with account credentials (private repos; SSH URLs switched to HTTPS+token when no SSH key). | | `forge_push` | Push with the account token (sets upstream; `--force-with-lease` only on request). | +| `forge_fix_credentials` | Configure the local repository’s `.git/config` to use the bundled credential helper, replacing stale or broken local `credential.helper` entries. | | `forge_api` | Raw authenticated REST call (GitHub `https://api.github.com`, Gitea/Forgejo `/api/v1`). | +`forge_fix_credentials` changes only the target repository’s local config; it does not modify global git configuration or expose tokens. Omit `path` to target the session working directory, or pass a repository path (including a nested path) to update that repository. + Clone and push use a bundled git **credential helper** (`credential-helper.mjs`) passed via `git -c credential.helper=…`; it answers with the token of the gh/tea account whose host matches, and prints nothing otherwise (git falls through to your normal helpers). Your global git config is not modified. ## Requirements @@ -38,7 +41,7 @@ At least one of `gh` / `tea` must be installed and logged in for the account fea | File | Role | | --- | --- | | `index.js` | Host plugin: HTTP routes under `/git-forge/*` (accounts, repos, auth, ls, clone) and the `forge_*` tools. Injects `webServer`, `connection`, `tools`. | -| `forge.mjs` | Account discovery (gh + tea), REST access, git URL parsing, clone and push. | +| `forge.mjs` | Account discovery (gh + tea), REST access, git URL parsing, clone, push, and repository credential-helper configuration. | | `auth.mjs` | Drives `gh auth login` / `tea login add` / logout for the settings panel. | | `credential-helper.mjs` | git credential helper used by clone/push. | | `client.js` | Web client: clone dialog, local folder picker, accounts settings panel. | diff --git a/forge.mjs b/forge.mjs index 349735f..323910b 100644 --- a/forge.mjs +++ b/forge.mjs @@ -297,9 +297,13 @@ export async function listRepos(account, { query, owner, max = 300, signal } = { // ---- git operations --------------------------------------------------------- +export function credentialHelperCmd() { + return `!'${process.execPath.replace(/'/g, "'\\''")}' '${HELPER_PATH.replace(/'/g, "'\\''")}'` +} + /** git -c options that make git ask our helper for credentials of known accounts. */ export function credentialArgs() { - const helper = `!'${process.execPath.replace(/'/g, "'\\''")}' '${HELPER_PATH.replace(/'/g, "'\\''")}'` + const helper = credentialHelperCmd() return ['-c', 'credential.helper=', '-c', `credential.helper=${helper}`] } @@ -347,8 +351,7 @@ export async function cloneRepo({ url, parent, name, branch, depth, signal }) { } // Keep the repo's own pushes/fetches authenticated through the helper. if (account) { - await run('git', ['-C', target, 'config', '--local', '--add', 'credential.helper', '']) - await run('git', ['-C', target, 'config', '--local', '--add', 'credential.helper', credentialArgs()[3].slice('credential.helper='.length)]) + await fixRepoConfig(target) } return { path: target, url: effective, account: account ? account.id : null } } @@ -421,3 +424,43 @@ export async function resolveRepo({ repo, account: accountId, cwd, remote = 'ori if (!account) throw new ForgeError(`no authorized account for host ${parsed.host}; available: ${accounts.map((a) => `${a.id} (${a.host})`).join(', ')}`, 400) return { account, owner: parsed.owner, name: parsed.name } } + +/** Configure or update a local repository to use the git-forge credential helper. */ +export async function fixRepoConfig(repoPath) { + const top = await gitIn(repoPath, ['rev-parse', '--show-toplevel']) + if (top.code !== 0) throw new ForgeError(`not a git repository: ${repoPath}`, 400) + const root = top.stdout.trim() + + const values = (result) => { + if (!result || result.code !== 0 || result.stdout === '') return [] + const text = String(result.stdout) + return (text.endsWith('\n') ? text.slice(0, -1) : text).split('\n') + } + const existing = await gitIn(root, ['config', '--local', '--get-all', 'credential.helper']) + const previous = values(existing) + const cmd = credentialHelperCmd() + + const unset = await gitIn(root, ['config', '--local', '--unset-all', 'credential.helper']) + // git config exits with 5 when there was no value to unset; that is expected. + if (unset.code !== 0 && unset.code !== 5) { + throw new ForgeError(`could not update local git credentials in ${root}: ${unset.stderr || 'failed to clear credential.helper'}`, 422) + } + const reset = await gitIn(root, ['config', '--local', '--add', 'credential.helper', '']) + if (reset.code !== 0) { + throw new ForgeError(`could not update local git credentials in ${root}: ${reset.stderr || 'failed to reset credential.helper'}`, 422) + } + const add = await gitIn(root, ['config', '--local', '--add', 'credential.helper', cmd]) + if (add.code !== 0) { + throw new ForgeError(`could not update local git credentials in ${root}: ${add.stderr || 'failed to install credential.helper'}`, 422) + } + + const check = await gitIn(root, ['config', '--local', '--get-all', 'credential.helper']) + if (check.code !== 0) throw new ForgeError(`could not verify local git credentials in ${root}`, 422) + return { + path: root, + fixed: true, + helper: cmd, + previous, + current: values(check), + } +} diff --git a/index.js b/index.js index 7a3447e..025399f 100644 --- a/index.js +++ b/index.js @@ -4,8 +4,7 @@ import os from 'node:os' import path from 'node:path' import { - ForgeError, api, apiList, cloneRepo, deriveName, gitIn, listAccounts, listRepos, - normRepo, publicAccount, pushRepo, resolveRepo, + ForgeError, api, apiList, cloneRepo, deriveName, fixRepoConfig, gitIn, listAccounts, listRepos, normRepo, publicAccount, pushRepo, resolveRepo, } from './forge.mjs' import { accountsWithStatus, cliStatus, giteaLogin, githubDeviceCancel, githubDeviceStart, githubDeviceStatus, @@ -348,6 +347,15 @@ async function toolPush(args, exec) { return pushRepo({ cwd, remote: args.remote, branch: args.branch, force: !!args.force, tags: !!args.tags, signal: exec?.signal }) } +async function toolFixCredentials(args, exec) { + const cwd = sessionCwd(exec) + const requested = String(args.path ?? '').trim() + const repoPath = requested + ? path.resolve(cwd, requested.replace(/^~(?=\/|$)/, os.homedir())) + : cwd + return fixRepoConfig(repoPath) +} + // ---- tool definitions --------------------------------------------------------- const S = (type, description, extra = {}) => ({ type, description, ...extra }) @@ -482,6 +490,13 @@ const TOOLS = [ }, required: ['path'], }, toolApi), + defineTool({ + name: 'forge_fix_credentials', + description: 'Fix the local git credential helper configuration inside a repository. Replaces only that repository’s .git/config credential.helper values with the git-forge helper, so plain git fetch, pull, and push can authenticate through authorized GitHub and Gitea accounts. Does not change global git configuration.', + properties: { + path: S('string', 'Repository directory (default: the session working directory). Relative paths use the session working directory; a nested path resolves to its repository root.'), + }, + }, toolFixCredentials), ] // ---- plugin ----------------------------------------------------------------- diff --git a/locale/en.json b/locale/en.json index bdc4ef9..dab7273 100644 --- a/locale/en.json +++ b/locale/en.json @@ -1 +1 @@ -{ "meta": { "title": "Git Forge", "description": "Clone repositories from your GitHub (gh) and Gitea/Forgejo (tea-cli) accounts or any git URL straight into a new workspace, and give agents forge_* tools for repos, issues, pull requests and authenticated push." } } +{ "meta": { "title": "Git Forge", "description": "Clone repositories from your GitHub (gh) and Gitea/Forgejo (tea-cli) accounts or any git URL straight into a new workspace, and give agents forge_* tools for repos, issues, pull requests, credentials and authenticated push." } } diff --git a/locale/zh.json b/locale/zh.json index e22c973..c37d916 100644 --- a/locale/zh.json +++ b/locale/zh.json @@ -1 +1 @@ -{ "meta": { "title": "Git Forge", "description": "从 GitHub(gh)和 Gitea/Forgejo(tea-cli)账号或任意 git 地址克隆仓库为新工作区,并为智能体提供仓库、议题、拉取请求和认证推送等 forge_* 工具。" } } +{ "meta": { "title": "Git Forge", "description": "从 GitHub(gh)和 Gitea/Forgejo(tea-cli)账号或任意 git 地址克隆仓库为新工作区,并为智能体提供仓库、议题、拉取请求、凭据修复和认证推送等 forge_* 工具。" } } diff --git a/package.json b/package.json index e0684c4..bb2f6e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@local/git-forge", - "version": "1.0.1", - "description": "Clone repositories from GitHub (gh) and Gitea/Forgejo (tea-cli) accounts into new workspaces, plus forge_* agent tools for repos, issues, pull requests and authenticated push.", + "version": "1.1.0", + "description": "Clone repositories from GitHub (gh) and Gitea/Forgejo (tea-cli) accounts into new workspaces, plus forge_* agent tools for repos, issues, pull requests, authenticated push, and fixing git credentials.", "private": true, "type": "module", "exports": { diff --git a/test/credential-fix.test.mjs b/test/credential-fix.test.mjs new file mode 100644 index 0000000..e1c96c3 --- /dev/null +++ b/test/credential-fix.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { credentialHelperCmd, fixRepoConfig } from '../forge.mjs' + +function git(cwd, ...args) { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trimEnd() +} + +function tempRepo() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'git-forge-credentials-')) + const repo = path.join(root, 'repo') + fs.mkdirSync(repo) + execFileSync('git', ['init', '--quiet', repo]) + return { root, repo } +} + +test('forge_fix_credentials replaces local helpers and resolves nested paths', async () => { + const { root, repo } = tempRepo() + try { + git(repo, 'config', '--local', '--add', 'credential.helper', 'store') + git(repo, 'config', '--local', '--add', 'credential.helper', 'old-helper') + const nested = path.join(repo, 'nested') + fs.mkdirSync(nested) + + const result = await fixRepoConfig(nested) + const expected = credentialHelperCmd() + + assert.equal(result.path, repo) + assert.equal(result.fixed, true) + assert.deepEqual(result.previous, ['store', 'old-helper']) + assert.deepEqual(result.current, ['', expected]) + assert.deepEqual(git(repo, 'config', '--local', '--get-all', 'credential.helper').split('\n'), ['', expected]) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('forge_fix_credentials rejects a non-repository path', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'git-forge-not-repo-')) + try { + await assert.rejects(() => fixRepoConfig(root), /not a git repository/) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +})