feat: add repository git credential fixer
This commit is contained in:
1 parent
25cea75eb5
commit
c6e042db86
7 files changed
+120
-10
No files matched your search
@@ -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. |
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 -----------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -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." } }
|
||||
+1
-1
@@ -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_* 工具。" } }
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user