@actionforge/git
v0.1.0
Published
Standalone git helpers — pure ref/refspec parsing plus promise-based git command wrappers built on node:child_process, with no GitHub Actions runtime required
Maintainers
Readme
@actionforge/git
Git helpers with two halves: a set of pure ref/refspec parsers with no I/O
at all, and promise-based command wrappers built directly on
node:child_process.
Why this exists
This started as a GitHub Actions helper library, which meant every git call
went through @actions/exec and every log line through @actions/core. Both
of those only behave correctly inside an Actions runner — which made the ref
parsing (the genuinely useful, genuinely tricky part) unusable from a plain
script.
So the Actions coupling is gone:
- No
@actions/exec. Commands run throughexecFilewith a real argv array — never a shell string — so arguments containing spaces, quotes, or$(...)need no escaping and cannot inject extra commands. - No
@actions/core. Logging goes to an injectableLoggerthat defaults to silent. PassconsoleLogger, the globalconsole, or acore-shaped object if you want the Actions annotations back. - No implicit environment. Nothing reads
GITHUB_TOKEN,GITHUB_ACTOR, orGITHUB_REPOSITORY. Credentials are ordinary function parameters.
The result works the same from a laptop script, a Docker build, or CI.
Install
npm install @actionforge/gitRef parsing (pure, no I/O)
Every parser accepts a RefLike — a ref string, or any object with a ref
string property (a webhook payload, an Octokit response).
import { getBranch, getRefspec, getTag, isPrRef } from '@actionforge/git'
getBranch('refs/heads/main') // 'main'
getBranch('refs/remotes/origin/main') // 'main'
getBranch('refs/tags/v1.2.3') // ''
getBranch('refs/tags/v1.2.3', false) // 'tags/v1.2.3'
getTag('refs/tags/v1.2.3') // 'v1.2.3'
isPrRef('refs/pull/123/merge') // true
getRefspec('refs/heads/main')
// 'refs/heads/main:refs/remotes/origin/main'
getRefspec('refs/tags/v1.2.3')
// 'refs/tags/v1.2.3:refs/tags/v1.2.3'
getRefspec('refs/pull/123/merge')
// 'refs/pull/123/merge:refs/remotes/origin/pull/123/merge'| Function | Purpose |
| --- | --- |
| getRef(ref) | Normalise a RefLike to its string |
| isRef / isBranch / isRemoteBranch / isTagRef / isPrRef | Classification |
| normalizeRef(ref) | Fully qualify a bare name (assumes a branch) |
| getRefForUpdate(ref) | Strip the leading refs/ |
| trimRef(ref) | Strip refs/heads/, refs/tags/, or refs/pull/ |
| getBranch(ref, defaultIsEmpty?) | Branch name, or '' |
| getTag(ref) | Tag name, or '' |
| saveTarget(ref, origin) | Local namespace a ref belongs in |
| getRemoteRefspec / getLocalRefspec / getRefspec | Refspec construction |
Running git
import { allGitTags, gitTagExists, revParse, runGit } from '@actionforge/git'
await revParse() // HEAD sha
await revParse('v1.0.0') // tag sha
await revParse('nope') // '' — unresolvable refs are not an exception
await allGitTags() // ['v1.0.0', 'v1.1.0', ...]
await gitTagExists('v1.0.0') // true
// Escape hatch for anything not wrapped here:
const { stdout } = await runGit(['log', '-1', '--format=%H'], { check: true })Every command helper accepts GitExecOptions: cwd, env, timeout,
logger, gitPath. runGit additionally takes check — by default a
non-zero exit is returned as data ({ exitCode, stdout, stderr }), because
most callers want to branch on it; check: true throws a GitCommandError
carrying the exit code, both streams, and the argv.
Identity
import { setGitIdentity } from '@actionforge/git'
await setGitIdentity({
name: 'release-bot',
email: '[email protected]',
cwd: workDir,
})The original took an Octokit client and looked the name and email up from a GitHub login. That lookup is your business now — do it in your own code and pass the result in, so this function stays usable without a network call or a GitHub account.
Remotes and authentication
import {
addOrigin,
buildAuthenticatedRemoteUrl,
fetchOrigin,
remoteExists,
setRemoteUrl,
} from '@actionforge/git'
const url = buildAuthenticatedRemoteUrl({
owner: 'octocat',
repo: 'hello-world',
token: myToken, // plain parameter — nothing is read from the env
})
if (await remoteExists({ cwd: workDir })) {
await setRemoteUrl({ cwd: workDir, url })
} else {
await addOrigin({ cwd: workDir, url })
}
await fetchOrigin({
cwd: workDir,
args: ['--depth=1', '--no-tags'],
refspec: [getRefspec('refs/heads/main')],
})username defaults to x-access-token (what GitHub expects for an app
installation token or an Actions-issued token); pass a real login for a classic
PAT. host defaults to github.com for GitHub Enterprise. Both the username
and token are percent-encoded, so a credential containing :, /, or @
cannot corrupt the URL.
Security note: a token embedded in a remote URL and written into
.git/configstays readable on disk for the lifetime of the checkout. On an ephemeral CI runner that is usually acceptable; anywhere else, prefer a git credential helper.
Logging
import { consoleLogger, runGit } from '@actionforge/git'
await runGit(['status'], { logger: consoleLogger })Logger is a structural interface (debug / info / warn / error), so
the global console satisfies it directly, as does @actions/core. The
default is silentLogger — a library should not print unless asked.
License
MIT © Andrew Cates
