mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
Attribution (#1049)
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
/* eslint-disable max-lines -- Why: these tests exercise generated shell wrapper
|
||||
scripts end-to-end, and keeping the regression fixtures adjacent makes the
|
||||
attribution safety cases easier to audit. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { applyTerminalAttributionEnv } from './terminal-attribution'
|
||||
|
||||
describe('applyTerminalAttributionEnv', () => {
|
||||
let tmpRoot: string | null = null
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpRoot) {
|
||||
rmSync(tmpRoot, { force: true, recursive: true })
|
||||
tmpRoot = null
|
||||
}
|
||||
})
|
||||
|
||||
function makeTmpRoot(): string {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-'))
|
||||
return tmpRoot
|
||||
}
|
||||
|
||||
function runGit(repo: string, args: string[], env?: Record<string, string>): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...env }
|
||||
})
|
||||
}
|
||||
|
||||
it('does not amend HEAD when git commit --dry-run exits successfully', () => {
|
||||
const root = makeTmpRoot()
|
||||
const repo = join(root, 'repo')
|
||||
mkdirSync(repo)
|
||||
runGit(repo, ['init'])
|
||||
runGit(repo, ['config', 'user.name', 'Orca Test'])
|
||||
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
|
||||
writeFileSync(join(repo, 'README.md'), 'initial\n')
|
||||
runGit(repo, ['add', 'README.md'])
|
||||
runGit(repo, ['commit', '-m', 'initial'])
|
||||
|
||||
const attributionEnv = applyTerminalAttributionEnv(
|
||||
{ PATH: process.env.PATH ?? '' },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
const beforeHead = runGit(repo, ['rev-parse', 'HEAD']).trim()
|
||||
writeFileSync(join(repo, 'second.txt'), 'second\n')
|
||||
runGit(repo, ['add', 'second.txt'])
|
||||
|
||||
// Why: dry-run reports what would be committed but must not rewrite the
|
||||
// existing HEAD just because the real git command returns success.
|
||||
runGit(repo, ['commit', '--dry-run', '-m', 'second'], attributionEnv)
|
||||
|
||||
expect(runGit(repo, ['rev-parse', 'HEAD']).trim()).toBe(beforeHead)
|
||||
expect(runGit(repo, ['log', '-1', '--format=%B'])).not.toContain('Co-authored-by: Orca')
|
||||
|
||||
runGit(repo, ['commit', '-m', 'second'], attributionEnv)
|
||||
expect(runGit(repo, ['rev-parse', 'HEAD']).trim()).not.toBe(beforeHead)
|
||||
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
|
||||
'Co-authored-by: Orca <help@stably.ai>'
|
||||
)
|
||||
})
|
||||
|
||||
it('still adds the trailer when git commit uses --no-verify shorthand', () => {
|
||||
const root = makeTmpRoot()
|
||||
const repo = join(root, 'repo')
|
||||
mkdirSync(repo)
|
||||
runGit(repo, ['init'])
|
||||
runGit(repo, ['config', 'user.name', 'Orca Test'])
|
||||
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
|
||||
writeFileSync(join(repo, 'README.md'), 'initial\n')
|
||||
runGit(repo, ['add', 'README.md'])
|
||||
|
||||
const attributionEnv = applyTerminalAttributionEnv(
|
||||
{ PATH: process.env.PATH ?? '' },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
|
||||
runGit(repo, ['commit', '-n', '-m', 'initial'], attributionEnv)
|
||||
|
||||
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
|
||||
'Co-authored-by: Orca <help@stably.ai>'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not rerun git hooks for the attribution-only amend', () => {
|
||||
const root = makeTmpRoot()
|
||||
const repo = join(root, 'repo')
|
||||
mkdirSync(repo)
|
||||
runGit(repo, ['init'])
|
||||
runGit(repo, ['config', 'user.name', 'Orca Test'])
|
||||
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
|
||||
const hookPath = join(repo, '.git', 'hooks', 'commit-msg')
|
||||
const hookCounterPath = join(repo, 'hook-count')
|
||||
writeFileSync(
|
||||
hookPath,
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
count=0
|
||||
if [[ -f "${hookCounterPath}" ]]; then
|
||||
count="$(cat "${hookCounterPath}")"
|
||||
fi
|
||||
printf '%s\\n' "$((count + 1))" >"${hookCounterPath}"
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(hookPath, 0o755)
|
||||
writeFileSync(join(repo, 'README.md'), 'initial\n')
|
||||
runGit(repo, ['add', 'README.md'])
|
||||
|
||||
const attributionEnv = applyTerminalAttributionEnv(
|
||||
{ PATH: process.env.PATH ?? '' },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
|
||||
runGit(repo, ['commit', '-m', 'initial'], attributionEnv)
|
||||
|
||||
expect(readFileSync(hookCounterPath, 'utf8').trim()).toBe('1')
|
||||
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
|
||||
'Co-authored-by: Orca <help@stably.ai>'
|
||||
)
|
||||
})
|
||||
|
||||
it('skips git attribution when commit signing is enabled', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const commitPath = join(root, 'commit-called')
|
||||
const amendPath = join(root, 'amend-called')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'git'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2 $3" == "config --bool commit.gpgsign" ]]; then
|
||||
printf '%s\\n' 'true'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1" == "commit" ]]; then
|
||||
if [[ "\${2:-}" == "--amend" ]]; then
|
||||
touch "${amendPath}"
|
||||
else
|
||||
touch "${commitPath}"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'git'), 0o755)
|
||||
|
||||
const attributionEnv = applyTerminalAttributionEnv(
|
||||
{ PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
|
||||
// Why: attribution uses an amend; signed commits can prompt or fail during
|
||||
// that second commit, so the wrapper skips attribution instead.
|
||||
execFileSync('git', ['commit', '-m', 'signed commit'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...attributionEnv }
|
||||
})
|
||||
|
||||
expect(existsSync(commitPath)).toBe(true)
|
||||
expect(existsSync(amendPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves interactive gh pr create without guessing which PR to edit', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const markerPath = join(root, 'gh-edit-called')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2" == "pr create" ]]; then
|
||||
printf '%s\\n' 'interactive create complete'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "pr view --json url" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
|
||||
printf '%s\\n' 'Existing body'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
|
||||
touch "${markerPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = applyTerminalAttributionEnv(
|
||||
{ PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
|
||||
const output = execFileSync('gh', ['pr', 'create'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...attributionEnv }
|
||||
})
|
||||
|
||||
expect(output).toBe('interactive create complete\n')
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('adds gh attribution for noninteractive create output URLs', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const prMarkerPath = join(root, 'pr-edit-called')
|
||||
const issueMarkerPath = join(root, 'issue-edit-called')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2" == "pr create" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "issue create" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/issues/456'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
|
||||
printf '%s\\n' 'PR body'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "api repos/stablyai/orca/issues/456" && "\${3:-}" == "--jq" ]]; then
|
||||
printf '%s\\n' 'Issue body'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
|
||||
touch "${prMarkerPath}"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/issues/456" ]]; then
|
||||
touch "${issueMarkerPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = applyTerminalAttributionEnv(
|
||||
{ PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
|
||||
expect(
|
||||
execFileSync('gh', ['pr', 'create', '--fill'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...attributionEnv }
|
||||
})
|
||||
).toBe('https://github.com/stablyai/orca/pull/123\n')
|
||||
expect(
|
||||
execFileSync('gh', ['issue', 'create', '--title', 'Issue', '--body', 'Body'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...attributionEnv }
|
||||
})
|
||||
).toBe('https://github.com/stablyai/orca/issues/456\n')
|
||||
|
||||
expect(existsSync(prMarkerPath)).toBe(true)
|
||||
expect(existsSync(issueMarkerPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('passes gh create help through without editing existing PRs or issues', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const markerPath = join(root, 'gh-edit-called')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2 $3" == "pr create --help" ]]; then
|
||||
printf '%s\\n' 'pr help'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3" == "issue create --help" ]]; then
|
||||
printf '%s\\n' 'issue help'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "pr view --json url" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "issue list" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/issues/456'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
|
||||
touch "${markerPath}"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/issues/456" ]]; then
|
||||
touch "${markerPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = applyTerminalAttributionEnv(
|
||||
{ PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
|
||||
const output = execFileSync('gh', ['pr', 'create', '--help'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...attributionEnv }
|
||||
})
|
||||
|
||||
expect(output).toBe('pr help\n')
|
||||
const issueOutput = execFileSync('gh', ['issue', 'create', '--help'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...attributionEnv }
|
||||
})
|
||||
|
||||
expect(issueOutput).toBe('issue help\n')
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves interactive gh issue create without guessing which issue to edit', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const markerPath = join(root, 'gh-edit-called')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2" == "issue create" ]]; then
|
||||
printf '%s\\n' 'interactive issue create complete'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "issue list" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/issues/456'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/issues/456" ]]; then
|
||||
touch "${markerPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = applyTerminalAttributionEnv(
|
||||
{ PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
|
||||
const output = execFileSync('gh', ['issue', 'create'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...attributionEnv }
|
||||
})
|
||||
|
||||
expect(output).toBe('interactive issue create complete\n')
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('skips gh attribution edits when viewing the created item fails', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const markerPath = join(root, 'gh-edit-called')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2" == "pr create" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
|
||||
exit 7
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
|
||||
touch "${markerPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = applyTerminalAttributionEnv(
|
||||
{ PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
|
||||
const output = execFileSync('gh', ['pr', 'create', '--fill'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...attributionEnv }
|
||||
})
|
||||
|
||||
expect(output).toBe('https://github.com/stablyai/orca/pull/123\n')
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps gh create successful when the attribution edit fails', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2" == "pr create" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
|
||||
printf '%s\\n' 'Existing body'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
|
||||
exit 9
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = applyTerminalAttributionEnv(
|
||||
{ PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
|
||||
const output = execFileSync('gh', ['pr', 'create', '--fill'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...attributionEnv }
|
||||
})
|
||||
|
||||
expect(output).toBe('https://github.com/stablyai/orca/pull/123\n')
|
||||
})
|
||||
|
||||
it('fails open when shim files cannot be written', () => {
|
||||
const root = makeTmpRoot()
|
||||
const blockedUserDataPath = join(root, 'not-a-directory')
|
||||
writeFileSync(blockedUserDataPath, 'blocked\n')
|
||||
const baseEnv = { PATH: '/usr/bin' }
|
||||
|
||||
const env = applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: true,
|
||||
userDataPath: blockedUserDataPath
|
||||
})
|
||||
|
||||
expect(env).toBe(baseEnv)
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('writes PowerShell wrappers without raw-template backslash escapes', () => {
|
||||
const root = makeTmpRoot()
|
||||
applyTerminalAttributionEnv(
|
||||
{ PATH: process.env.PATH ?? '' },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
|
||||
const shimDir = join(root, 'user-data', 'orca-terminal-attribution', 'win32')
|
||||
const gitWrapper = readFileSync(join(shimDir, 'git-wrapper.ps1'), 'utf8')
|
||||
const ghWrapper = readFileSync(join(shimDir, 'gh-wrapper.ps1'), 'utf8')
|
||||
|
||||
expect(gitWrapper).toContain('$message.TrimEnd("`r", "`n")')
|
||||
expect(gitWrapper).toContain('"`r`n`r`n"')
|
||||
expect(ghWrapper).toContain('$body.TrimEnd("`r", "`n")')
|
||||
expect(ghWrapper).toContain('"`r`n`r`n"')
|
||||
expect(gitWrapper).not.toContain('"\\`r"')
|
||||
expect(ghWrapper).not.toContain('"\\`r"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,688 @@
|
||||
/* eslint-disable max-lines -- Why: this module owns the generated git/gh wrapper
|
||||
scripts for both POSIX shells and Windows shells. Keeping the scripts adjacent
|
||||
to the env injection code makes the attribution behavior auditable as one unit
|
||||
instead of scattering generated shell fragments across files. */
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const ATTRIBUTION_ROOT_DIR = 'orca-terminal-attribution'
|
||||
const ATTRIBUTION_SHIM_VERSION = '5'
|
||||
const ORCA_PRODUCT_URL = 'https://github.com/orca-ide'
|
||||
const ORCA_GIT_COMMIT_TRAILER = 'Co-authored-by: Orca <help@stably.ai>'
|
||||
const ORCA_GH_FOOTER = `Made with [Orca](${ORCA_PRODUCT_URL}) 🐋`
|
||||
const SHELL_DOLLAR = '$'
|
||||
const POWERSHELL_TICK = '`'
|
||||
|
||||
const writtenRoots = new Set<string>()
|
||||
|
||||
type AttributionShimPaths = {
|
||||
posixDir: string
|
||||
win32Dir: string
|
||||
}
|
||||
|
||||
export function applyTerminalAttributionEnv(
|
||||
baseEnv: Record<string, string>,
|
||||
options: { enabled: boolean; userDataPath: string }
|
||||
): Record<string, string> {
|
||||
if (!options.enabled) {
|
||||
return baseEnv
|
||||
}
|
||||
|
||||
let shimPaths: AttributionShimPaths
|
||||
try {
|
||||
shimPaths = ensureAttributionShims(options.userDataPath)
|
||||
} catch {
|
||||
return baseEnv
|
||||
}
|
||||
|
||||
const pathDelimiter = process.platform === 'win32' ? ';' : ':'
|
||||
const basePath = baseEnv.PATH ?? process.env.PATH ?? ''
|
||||
// Why: resolve real Windows commands before prepending shims so cmd wrappers
|
||||
// cannot recursively point ORCA_REAL_* at themselves.
|
||||
const resolvedGit =
|
||||
process.platform === 'win32' ? resolveWindowsExecutable('git', basePath) : null
|
||||
const resolvedGh = process.platform === 'win32' ? resolveWindowsExecutable('gh', basePath) : null
|
||||
const { posixDir, win32Dir } = shimPaths
|
||||
// Why: Windows terminals may be cmd/PowerShell or Git Bash. Include both shim
|
||||
// families; native shells ignore extensionless POSIX files, Git Bash can use them.
|
||||
const prependDirs = process.platform === 'win32' ? [posixDir, win32Dir] : [posixDir]
|
||||
|
||||
// Why: these wrappers should affect only Orca-managed PTYs. Prepending the
|
||||
// shim directory here keeps the attribution behavior scoped to Orca's live
|
||||
// terminal environment instead of mutating global git/gh config or the
|
||||
// user's external shell PATH.
|
||||
baseEnv.PATH = [...prependDirs, basePath].filter(Boolean).join(pathDelimiter)
|
||||
baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION = '1'
|
||||
baseEnv.ORCA_GIT_COMMIT_TRAILER = ORCA_GIT_COMMIT_TRAILER
|
||||
baseEnv.ORCA_GH_PR_FOOTER = ORCA_GH_FOOTER
|
||||
baseEnv.ORCA_GH_ISSUE_FOOTER = ORCA_GH_FOOTER
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (resolvedGit) {
|
||||
baseEnv.ORCA_REAL_GIT = resolvedGit
|
||||
}
|
||||
if (resolvedGh) {
|
||||
baseEnv.ORCA_REAL_GH = resolvedGh
|
||||
}
|
||||
}
|
||||
|
||||
return baseEnv
|
||||
}
|
||||
|
||||
function ensureAttributionShims(userDataPath: string): AttributionShimPaths {
|
||||
const rootDir = join(userDataPath, ATTRIBUTION_ROOT_DIR)
|
||||
const posixDir = join(rootDir, 'posix')
|
||||
const win32Dir = join(rootDir, 'win32')
|
||||
const versionFile = join(rootDir, 'VERSION')
|
||||
|
||||
if (writtenRoots.has(rootDir)) {
|
||||
return { posixDir, win32Dir }
|
||||
}
|
||||
|
||||
if (readShimVersion(versionFile) === ATTRIBUTION_SHIM_VERSION) {
|
||||
writtenRoots.add(rootDir)
|
||||
return { posixDir, win32Dir }
|
||||
}
|
||||
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
writeExecutable(join(posixDir, 'git'), POSIX_GIT_WRAPPER)
|
||||
writeExecutable(join(posixDir, 'gh'), POSIX_GH_WRAPPER)
|
||||
|
||||
writeExecutable(join(win32Dir, 'git.cmd'), WIN32_GIT_CMD_WRAPPER)
|
||||
writeExecutable(join(win32Dir, 'gh.cmd'), WIN32_GH_CMD_WRAPPER)
|
||||
writeExecutable(join(win32Dir, 'git-wrapper.ps1'), WIN32_GIT_PS_WRAPPER)
|
||||
writeExecutable(join(win32Dir, 'gh-wrapper.ps1'), WIN32_GH_PS_WRAPPER)
|
||||
writeFileSync(versionFile, `${ATTRIBUTION_SHIM_VERSION}\n`, 'utf8')
|
||||
|
||||
writtenRoots.add(rootDir)
|
||||
|
||||
return { posixDir, win32Dir }
|
||||
}
|
||||
|
||||
function readShimVersion(versionFile: string): string | null {
|
||||
try {
|
||||
return readFileSync(versionFile, 'utf8').trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeExecutable(filePath: string, contents: string): void {
|
||||
writeFileSync(filePath, contents, 'utf8')
|
||||
chmodSync(filePath, 0o755)
|
||||
}
|
||||
|
||||
function resolveWindowsExecutable(command: string, pathValue: string): string | null {
|
||||
const pathExt = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
|
||||
.split(';')
|
||||
.map((ext) => ext.toLowerCase())
|
||||
const searchDirs = pathValue.split(';').filter(Boolean)
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
for (const ext of pathExt) {
|
||||
const candidate = join(dir, `${command}${ext}`)
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
const bareCandidate = join(dir, command)
|
||||
if (existsSync(bareCandidate)) {
|
||||
return bareCandidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const POSIX_COMMON = String.raw`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
clean_path() {
|
||||
local current_path="${SHELL_DOLLAR}{PATH:-}"
|
||||
local script_dir
|
||||
script_dir="$(cd -- "$(dirname "${SHELL_DOLLAR}{BASH_SOURCE[0]}")" && pwd)"
|
||||
local cleaned=()
|
||||
local entry
|
||||
IFS=':' read -r -a entries <<<"$current_path"
|
||||
for entry in "${SHELL_DOLLAR}{entries[@]}"; do
|
||||
case "$entry" in
|
||||
"$script_dir"|*/orca-terminal-attribution/posix|*/orca-terminal-attribution/win32|*\\orca-terminal-attribution\\posix|*\\orca-terminal-attribution\\win32)
|
||||
;;
|
||||
*)
|
||||
cleaned+=("$entry")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
(IFS=':'; printf '%s' "${SHELL_DOLLAR}{cleaned[*]:-}")
|
||||
}
|
||||
`
|
||||
|
||||
const POSIX_GIT_WRAPPER = `${POSIX_COMMON}
|
||||
real_path="$(clean_path)"
|
||||
real_git="$(PATH="$real_path" command -v git || true)"
|
||||
if [[ -z "$real_git" ]]; then
|
||||
echo "Orca attribution wrapper could not locate git on PATH." >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
if [[ "\${ORCA_ENABLE_GIT_ATTRIBUTION:-0}" != "1" || "\${ORCA_ATTRIBUTION_BYPASS:-0}" == "1" || "\${1:-}" != "commit" ]]; then
|
||||
PATH="$real_path" exec "$real_git" "$@"
|
||||
fi
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run)
|
||||
PATH="$real_path" exec "$real_git" "$@"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
should_skip_signed_commit_attribution() {
|
||||
local saw_no_gpg_sign=0
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-gpg-sign)
|
||||
saw_no_gpg_sign=1
|
||||
;;
|
||||
--gpg-sign|--gpg-sign=*|-S|-S*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [[ $saw_no_gpg_sign -eq 1 ]]; then
|
||||
return 1
|
||||
fi
|
||||
[[ "$(PATH="$real_path" "$real_git" config --bool commit.gpgsign 2>/dev/null || true)" == "true" ]]
|
||||
}
|
||||
|
||||
if should_skip_signed_commit_attribution "$@"; then
|
||||
PATH="$real_path" exec "$real_git" "$@"
|
||||
fi
|
||||
|
||||
before_head="$(
|
||||
PATH="$real_path" "$real_git" rev-parse --verify HEAD 2>/dev/null || true
|
||||
)"
|
||||
|
||||
PATH="$real_path" "$real_git" "$@"
|
||||
status=$?
|
||||
if [[ $status -ne 0 ]]; then
|
||||
exit $status
|
||||
fi
|
||||
|
||||
after_head="$(
|
||||
PATH="$real_path" "$real_git" rev-parse --verify HEAD 2>/dev/null || true
|
||||
)"
|
||||
if [[ -z "$after_head" || "$before_head" == "$after_head" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
message="$(
|
||||
PATH="$real_path" "$real_git" log -1 --format=%B 2>/dev/null || true
|
||||
)"
|
||||
trailer="\${ORCA_GIT_COMMIT_TRAILER:-Co-authored-by: Orca <help@stably.ai>}"
|
||||
if grep -Fqi "$trailer" <<<"$message"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp_file="$(mktemp)"
|
||||
cleanup() {
|
||||
rm -f "$tmp_file"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ -n "$message" ]]; then
|
||||
printf '%s\n\n%s\n' "$message" "$trailer" >"$tmp_file"
|
||||
else
|
||||
printf '%s\n' "$trailer" >"$tmp_file"
|
||||
fi
|
||||
|
||||
# Why: git commit has no generic "post-success message transformer" hook. The
|
||||
# wrapper amends only the just-created commit so Orca can add attribution
|
||||
# without mutating repo config or installing hooks into the user's checkout. The
|
||||
# amend is best-effort so attribution cannot turn a successful user commit into
|
||||
# a failed terminal command.
|
||||
ORCA_ATTRIBUTION_BYPASS=1 PATH="$real_path" "$real_git" commit --amend --no-verify -F "$tmp_file" >/dev/null 2>/dev/null || true
|
||||
`
|
||||
|
||||
const POSIX_GH_WRAPPER = `${POSIX_COMMON}
|
||||
real_path="$(clean_path)"
|
||||
real_gh="$(PATH="$real_path" command -v gh || true)"
|
||||
if [[ -z "$real_gh" ]]; then
|
||||
echo "Orca attribution wrapper could not locate gh on PATH." >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
append_footer() {
|
||||
local kind="$1"
|
||||
local url_pattern="$2"
|
||||
local footer="$3"
|
||||
local stdout_capture="$4"
|
||||
local stderr_capture="$5"
|
||||
local url=""
|
||||
|
||||
url="$(printf '%s\n%s\n' "$stdout_capture" "$stderr_capture" | grep -Eo "$url_pattern" | tail -n 1 || true)"
|
||||
append_footer_url "$kind" "$footer" "$url"
|
||||
}
|
||||
|
||||
append_footer_url() {
|
||||
local kind="$1"
|
||||
local footer="$2"
|
||||
local url="$3"
|
||||
|
||||
if [[ -z "$url" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local api_path
|
||||
api_path="$(github_api_path "$kind" "$url" || true)"
|
||||
if [[ -z "$api_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local body
|
||||
if ! body="$(PATH="$real_path" "$real_gh" api "$api_path" --jq '.body // ""' 2>/dev/null)"; then
|
||||
return 0
|
||||
fi
|
||||
if grep -Fqi "$footer" <<<"$body"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local tmp_file
|
||||
tmp_file="$(mktemp)"
|
||||
if [[ -n "$body" ]]; then
|
||||
printf '%s\n\n%s\n' "$body" "$footer" >"$tmp_file"
|
||||
else
|
||||
printf '%s\n' "$footer" >"$tmp_file"
|
||||
fi
|
||||
# Why: gh exposes create output as a URL, but does not provide a transactional
|
||||
# body append. Use REST instead of gh pr/issue edit because those commands can
|
||||
# hit unrelated GraphQL fields, while the URL maps directly to one REST item.
|
||||
PATH="$real_path" "$real_gh" api -X PATCH "$api_path" -f "body=$(cat "$tmp_file")" >/dev/null || true
|
||||
rm -f "$tmp_file"
|
||||
}
|
||||
|
||||
github_api_path() {
|
||||
local kind="$1"
|
||||
local url="$2"
|
||||
if [[ "$kind" == "pr" && "$url" =~ ^https://github[.]com/([^/]+)/([^/]+)/pull/([0-9]+) ]]; then
|
||||
printf 'repos/%s/%s/pulls/%s' "${SHELL_DOLLAR}{BASH_REMATCH[1]}" "${SHELL_DOLLAR}{BASH_REMATCH[2]}" "${SHELL_DOLLAR}{BASH_REMATCH[3]}"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$kind" == "issue" && "$url" =~ ^https://github[.]com/([^/]+)/([^/]+)/issues/([0-9]+) ]]; then
|
||||
printf 'repos/%s/%s/issues/%s' "${SHELL_DOLLAR}{BASH_REMATCH[1]}" "${SHELL_DOLLAR}{BASH_REMATCH[2]}" "${SHELL_DOLLAR}{BASH_REMATCH[3]}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
has_noninteractive_create_args() {
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--title|-t|--title=*|--body|-b|--body=*|--body-file|-F|--body-file=*|--fill|--fill-first|--fill-verbose|--template|-T|--template=*|--recover|--recover=*|--web)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_passthrough_create_args() {
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--help|-h|--version)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "\${ORCA_ENABLE_GIT_ATTRIBUTION:-0}" != "1" || "\${ORCA_ATTRIBUTION_BYPASS:-0}" == "1" ]]; then
|
||||
PATH="$real_path" exec "$real_gh" "$@"
|
||||
fi
|
||||
|
||||
if [[ "\${1:-}" == "pr" && "\${2:-}" == "create" ]]; then
|
||||
footer="\${ORCA_GH_PR_FOOTER:-Made with [Orca](https://github.com/orca-ide) 🐋}"
|
||||
if has_passthrough_create_args "$@"; then
|
||||
PATH="$real_path" exec "$real_gh" "$@"
|
||||
fi
|
||||
if ! has_noninteractive_create_args "$@"; then
|
||||
# Why: gh switches off interactive prompts when stdout/stderr are redirected,
|
||||
# and post-create "pr view" can select the wrong PR in fork/multi-PR cases.
|
||||
# Preserve interactive UX and skip attribution rather than guessing.
|
||||
PATH="$real_path" exec "$real_gh" "$@"
|
||||
fi
|
||||
stdout_file="$(mktemp)"
|
||||
stderr_file="$(mktemp)"
|
||||
cleanup_capture() {
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
}
|
||||
trap cleanup_capture EXIT
|
||||
if PATH="$real_path" "$real_gh" "$@" >"$stdout_file" 2>"$stderr_file"; then
|
||||
status=0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
stdout_capture="$(cat "$stdout_file")"
|
||||
stderr_capture="$(cat "$stderr_file")"
|
||||
cat "$stderr_file" >&2
|
||||
cat "$stdout_file"
|
||||
if [[ $status -eq 0 ]]; then
|
||||
append_footer "pr" 'https://github.com/[^[:space:]]+/pull/[0-9]+' "$footer" "$stdout_capture" "$stderr_capture"
|
||||
fi
|
||||
cleanup_capture
|
||||
trap - EXIT
|
||||
exit $status
|
||||
fi
|
||||
|
||||
if [[ "\${1:-}" == "issue" && "\${2:-}" == "create" ]]; then
|
||||
footer="\${ORCA_GH_ISSUE_FOOTER:-Made with [Orca](https://github.com/orca-ide) 🐋}"
|
||||
if has_passthrough_create_args "$@"; then
|
||||
PATH="$real_path" exec "$real_gh" "$@"
|
||||
fi
|
||||
if ! has_noninteractive_create_args "$@"; then
|
||||
# Why: gh issue create also requires a live TTY for prompts, but gh has no
|
||||
# current-issue lookup equivalent to "pr view". Do not guess with issue list:
|
||||
# that can edit an unrelated issue if the command printed no URL.
|
||||
PATH="$real_path" exec "$real_gh" "$@"
|
||||
fi
|
||||
stdout_file="$(mktemp)"
|
||||
stderr_file="$(mktemp)"
|
||||
cleanup_capture() {
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
}
|
||||
trap cleanup_capture EXIT
|
||||
if PATH="$real_path" "$real_gh" "$@" >"$stdout_file" 2>"$stderr_file"; then
|
||||
status=0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
stdout_capture="$(cat "$stdout_file")"
|
||||
stderr_capture="$(cat "$stderr_file")"
|
||||
cat "$stderr_file" >&2
|
||||
cat "$stdout_file"
|
||||
if [[ $status -eq 0 ]]; then
|
||||
append_footer "issue" 'https://github.com/[^[:space:]]+/issues/[0-9]+' "$footer" "$stdout_capture" "$stderr_capture"
|
||||
fi
|
||||
cleanup_capture
|
||||
trap - EXIT
|
||||
exit $status
|
||||
fi
|
||||
|
||||
PATH="$real_path" exec "$real_gh" "$@"
|
||||
`
|
||||
|
||||
const WIN32_GIT_CMD_WRAPPER = String.raw`@echo off
|
||||
setlocal
|
||||
if not "%ORCA_ENABLE_GIT_ATTRIBUTION%"=="1" goto run
|
||||
if "%ORCA_ATTRIBUTION_BYPASS%"=="1" goto run
|
||||
if /I not "%~1"=="commit" goto run
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0git-wrapper.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
:run
|
||||
if defined ORCA_REAL_GIT (
|
||||
"%ORCA_REAL_GIT%" %*
|
||||
) else (
|
||||
echo Orca attribution wrapper could not locate git on PATH. 1>&2
|
||||
exit /b 127
|
||||
)
|
||||
exit /b %ERRORLEVEL%
|
||||
`
|
||||
|
||||
const WIN32_GH_CMD_WRAPPER = String.raw`@echo off
|
||||
setlocal
|
||||
if not "%ORCA_ENABLE_GIT_ATTRIBUTION%"=="1" goto run
|
||||
if "%ORCA_ATTRIBUTION_BYPASS%"=="1" goto run
|
||||
if /I "%~1"=="pr" if /I "%~2"=="create" goto wrap
|
||||
if /I "%~1"=="issue" if /I "%~2"=="create" goto wrap
|
||||
goto run
|
||||
:wrap
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0gh-wrapper.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
:run
|
||||
if defined ORCA_REAL_GH (
|
||||
"%ORCA_REAL_GH%" %*
|
||||
) else (
|
||||
echo Orca attribution wrapper could not locate gh on PATH. 1>&2
|
||||
exit /b 127
|
||||
)
|
||||
exit /b %ERRORLEVEL%
|
||||
`
|
||||
|
||||
const WIN32_GIT_PS_WRAPPER = String.raw`$ErrorActionPreference = 'Stop'
|
||||
$realGit = if ($env:ORCA_REAL_GIT) { $env:ORCA_REAL_GIT } else { 'git' }
|
||||
$trailer = if ($env:ORCA_GIT_COMMIT_TRAILER) { $env:ORCA_GIT_COMMIT_TRAILER } else { 'Co-authored-by: Orca <help@stably.ai>' }
|
||||
|
||||
if ($args -contains '--dry-run') {
|
||||
& $realGit @args
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
function Test-SignedCommitAttributionSkip {
|
||||
$sawNoGpgSign = $false
|
||||
foreach ($arg in $args) {
|
||||
if ($arg -eq '--no-gpg-sign') {
|
||||
$sawNoGpgSign = $true
|
||||
} elseif ($arg -eq '--gpg-sign' -or $arg.StartsWith('--gpg-sign=') -or $arg -eq '-S' -or $arg.StartsWith('-S')) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
if ($sawNoGpgSign) {
|
||||
return $false
|
||||
}
|
||||
$gpgSign = (& $realGit config --bool commit.gpgsign 2>$null)
|
||||
return $LASTEXITCODE -eq 0 -and $gpgSign -eq 'true'
|
||||
}
|
||||
|
||||
if (Test-SignedCommitAttributionSkip) {
|
||||
& $realGit @args
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$beforeHead = (& $realGit rev-parse --verify HEAD 2>$null)
|
||||
& $realGit @args
|
||||
$status = $LASTEXITCODE
|
||||
if ($status -ne 0) {
|
||||
exit $status
|
||||
}
|
||||
|
||||
$afterHead = (& $realGit rev-parse --verify HEAD 2>$null)
|
||||
if ([string]::IsNullOrWhiteSpace($afterHead) -or $beforeHead -eq $afterHead) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
$message = (& $realGit log -1 --format=%B 2>$null) | Out-String
|
||||
if ($message -match [Regex]::Escape($trailer)) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
$tmpFile = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$trimmed = $message.TrimEnd("${POWERSHELL_TICK}r", "${POWERSHELL_TICK}n")
|
||||
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
||||
Set-Content -LiteralPath $tmpFile -Value $trailer -NoNewline
|
||||
} else {
|
||||
Set-Content -LiteralPath $tmpFile -Value ($trimmed + "${POWERSHELL_TICK}r${POWERSHELL_TICK}n${POWERSHELL_TICK}r${POWERSHELL_TICK}n" + $trailer) -NoNewline
|
||||
}
|
||||
$env:ORCA_ATTRIBUTION_BYPASS = '1'
|
||||
& $realGit commit --amend --no-verify -F $tmpFile 2>$null | Out-Null
|
||||
exit 0
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $tmpFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
`
|
||||
|
||||
const WIN32_GH_PS_WRAPPER = String.raw`$ErrorActionPreference = 'Stop'
|
||||
$realGh = if ($env:ORCA_REAL_GH) { $env:ORCA_REAL_GH } else { 'gh' }
|
||||
|
||||
function Test-NonInteractiveCreateArgs {
|
||||
param([string[]]$CommandArgs)
|
||||
foreach ($arg in $CommandArgs) {
|
||||
if ($arg -match '^(--title|-t|--body|-b|--body-file|-F|--fill|--fill-first|--fill-verbose|--template|-T|--recover|--web)(=|$)') {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-PassthroughCreateArgs {
|
||||
param([string[]]$CommandArgs)
|
||||
foreach ($arg in $CommandArgs) {
|
||||
if ($arg -eq '--help' -or $arg -eq '-h' -or $arg -eq '--version') {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Add-Footer {
|
||||
param([string]$Kind, [string]$CreatedUrl, [string]$Footer)
|
||||
if (-not $CreatedUrl) {
|
||||
return
|
||||
}
|
||||
$apiPath = Get-GitHubApiPath $Kind $CreatedUrl
|
||||
if (-not $apiPath) {
|
||||
return
|
||||
}
|
||||
$body = (& $realGh api $apiPath --jq '.body // ""' 2>$null) | Out-String
|
||||
if ($LASTEXITCODE -ne 0 -or $body -match [Regex]::Escape($Footer)) {
|
||||
return
|
||||
}
|
||||
$tmpFile = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$trimmed = $body.TrimEnd("${POWERSHELL_TICK}r", "${POWERSHELL_TICK}n")
|
||||
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
||||
Set-Content -LiteralPath $tmpFile -Value $Footer -NoNewline
|
||||
} else {
|
||||
Set-Content -LiteralPath $tmpFile -Value ($trimmed + "${POWERSHELL_TICK}r${POWERSHELL_TICK}n${POWERSHELL_TICK}r${POWERSHELL_TICK}n" + $Footer) -NoNewline
|
||||
}
|
||||
try {
|
||||
& $realGh api -X PATCH $apiPath -f "body=$(Get-Content -LiteralPath $tmpFile -Raw)" | Out-Null
|
||||
} catch {
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $tmpFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GitHubApiPath {
|
||||
param([string]$Kind, [string]$CreatedUrl)
|
||||
if ($Kind -eq 'pr' -and $CreatedUrl -match '^https://github\.com/([^/]+)/([^/]+)/pull/([0-9]+)') {
|
||||
return "repos/$($Matches[1])/$($Matches[2])/pulls/$($Matches[3])"
|
||||
}
|
||||
if ($Kind -eq 'issue' -and $CreatedUrl -match '^https://github\.com/([^/]+)/([^/]+)/issues/([0-9]+)') {
|
||||
return "repos/$($Matches[1])/$($Matches[2])/issues/$($Matches[3])"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$commandText = ($args -join ' ').ToLowerInvariant()
|
||||
if (($commandText.StartsWith('pr create') -or $commandText.StartsWith('issue create')) -and (Test-PassthroughCreateArgs $args)) {
|
||||
& $realGh @args
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
if (($commandText.StartsWith('pr create') -or $commandText.StartsWith('issue create')) -and -not (Test-NonInteractiveCreateArgs $args)) {
|
||||
& $realGh @args
|
||||
$status = $LASTEXITCODE
|
||||
if ($status -ne 0) {
|
||||
exit $status
|
||||
}
|
||||
if ($commandText.StartsWith('pr create')) {
|
||||
exit 0
|
||||
} else {
|
||||
exit 0
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
$stdoutFile = [System.IO.Path]::GetTempFileName()
|
||||
$stderrFile = [System.IO.Path]::GetTempFileName()
|
||||
& $realGh @args > $stdoutFile 2> $stderrFile
|
||||
$status = $LASTEXITCODE
|
||||
$stdoutCapture = if (Test-Path -LiteralPath $stdoutFile) { Get-Content -LiteralPath $stdoutFile -Raw } else { '' }
|
||||
$stderrCapture = if (Test-Path -LiteralPath $stderrFile) { Get-Content -LiteralPath $stderrFile -Raw } else { '' }
|
||||
if ($stderrCapture) {
|
||||
[Console]::Error.Write($stderrCapture)
|
||||
}
|
||||
if ($status -ne 0) {
|
||||
if ($stdoutCapture) {
|
||||
[Console]::Out.Write($stdoutCapture)
|
||||
}
|
||||
Remove-Item -LiteralPath $stdoutFile, $stderrFile -Force -ErrorAction SilentlyContinue
|
||||
exit $status
|
||||
}
|
||||
if ($stdoutCapture) {
|
||||
[Console]::Out.Write($stdoutCapture)
|
||||
}
|
||||
|
||||
if ($commandText.StartsWith('pr create')) {
|
||||
$createdUrl = ([regex]::Matches(($stdoutCapture + [Environment]::NewLine + $stderrCapture), 'https://github.com/\S+/pull/\d+') | Select-Object -Last 1).Value
|
||||
if ($createdUrl) {
|
||||
$apiPath = Get-GitHubApiPath 'pr' $createdUrl
|
||||
$body = if ($apiPath) { (& $realGh api $apiPath --jq '.body // ""' 2>$null) | Out-String } else { $null }
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$body = $null
|
||||
}
|
||||
$footer = if ($env:ORCA_GH_PR_FOOTER) { $env:ORCA_GH_PR_FOOTER } else { 'Made with [Orca](https://github.com/orca-ide) 🐋' }
|
||||
if ($null -ne $body -and $body -notmatch [Regex]::Escape($footer)) {
|
||||
$tmpFile = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$trimmed = $body.TrimEnd("${POWERSHELL_TICK}r", "${POWERSHELL_TICK}n")
|
||||
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
||||
Set-Content -LiteralPath $tmpFile -Value $footer -NoNewline
|
||||
} else {
|
||||
Set-Content -LiteralPath $tmpFile -Value ($trimmed + "${POWERSHELL_TICK}r${POWERSHELL_TICK}n${POWERSHELL_TICK}r${POWERSHELL_TICK}n" + $footer) -NoNewline
|
||||
}
|
||||
# Why: gh has no transactional body append for newly-created PRs. This
|
||||
# immediate REST patch keeps attribution scoped to the URL gh returned.
|
||||
try {
|
||||
& $realGh api -X PATCH $apiPath -f "body=$(Get-Content -LiteralPath $tmpFile -Raw)" | Out-Null
|
||||
} catch {
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $tmpFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($commandText.StartsWith('issue create')) {
|
||||
$createdUrl = ([regex]::Matches(($stdoutCapture + [Environment]::NewLine + $stderrCapture), 'https://github.com/\S+/issues/\d+') | Select-Object -Last 1).Value
|
||||
if ($createdUrl) {
|
||||
$apiPath = Get-GitHubApiPath 'issue' $createdUrl
|
||||
$body = if ($apiPath) { (& $realGh api $apiPath --jq '.body // ""' 2>$null) | Out-String } else { $null }
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$body = $null
|
||||
}
|
||||
$footer = if ($env:ORCA_GH_ISSUE_FOOTER) { $env:ORCA_GH_ISSUE_FOOTER } else { 'Made with [Orca](https://github.com/orca-ide) 🐋' }
|
||||
if ($null -ne $body -and $body -notmatch [Regex]::Escape($footer)) {
|
||||
$tmpFile = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$trimmed = $body.TrimEnd("${POWERSHELL_TICK}r", "${POWERSHELL_TICK}n")
|
||||
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
||||
Set-Content -LiteralPath $tmpFile -Value $footer -NoNewline
|
||||
} else {
|
||||
Set-Content -LiteralPath $tmpFile -Value ($trimmed + "${POWERSHELL_TICK}r${POWERSHELL_TICK}n${POWERSHELL_TICK}r${POWERSHELL_TICK}n" + $footer) -NoNewline
|
||||
}
|
||||
# Why: gh has no transactional body append for newly-created issues.
|
||||
# This immediate REST patch keeps attribution scoped to the URL gh returned.
|
||||
try {
|
||||
& $realGh api -X PATCH $apiPath -f "body=$(Get-Content -LiteralPath $tmpFile -Raw)" | Out-Null
|
||||
} catch {
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $tmpFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $stdoutFile, $stderrFile -Force -ErrorAction SilentlyContinue
|
||||
exit 0
|
||||
`
|
||||
@@ -89,6 +89,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
experimentalTerminalDaemonNoticeShown: false,
|
||||
terminalForceHyperlink: true,
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
enableGitHubAttribution: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
experimentalTerminalDaemonNoticeShown: false,
|
||||
terminalForceHyperlink: true,
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
enableGitHubAttribution: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,8 @@ describe('registerPtyHandlers', () => {
|
||||
async function spawnAndGetEnv(
|
||||
argsEnv?: Record<string, string>,
|
||||
processEnvOverrides?: Record<string, string | undefined>,
|
||||
getSelectedCodexHomePath?: () => string | null
|
||||
getSelectedCodexHomePath?: () => string | null,
|
||||
getSettings?: () => { enableGitHubAttribution: boolean }
|
||||
): Promise<Record<string, string>> {
|
||||
const savedEnv: Record<string, string | undefined> = {}
|
||||
if (processEnvOverrides) {
|
||||
@@ -214,7 +215,12 @@ describe('registerPtyHandlers', () => {
|
||||
// Clear previously registered handlers so re-registration doesn't
|
||||
// accumulate stale state across calls within one test.
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, getSelectedCodexHomePath)
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
undefined,
|
||||
getSelectedCodexHomePath,
|
||||
getSettings as never
|
||||
)
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
@@ -320,6 +326,30 @@ describe('registerPtyHandlers', () => {
|
||||
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
|
||||
})
|
||||
|
||||
it('prepends local git/gh attribution shims when attribution is enabled', async () => {
|
||||
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
|
||||
enableGitHubAttribution: true
|
||||
}))
|
||||
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
|
||||
expect(env.ORCA_GIT_COMMIT_TRAILER).toBe('Co-authored-by: Orca <help@stably.ai>')
|
||||
expect(env.ORCA_GH_PR_FOOTER).toBe('Made with [Orca](https://github.com/orca-ide) 🐋')
|
||||
expect(env.ORCA_GH_ISSUE_FOOTER).toBe('Made with [Orca](https://github.com/orca-ide) 🐋')
|
||||
expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix')
|
||||
})
|
||||
|
||||
it('skips git/gh attribution shims when attribution is disabled', async () => {
|
||||
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
|
||||
enableGitHubAttribution: false
|
||||
}))
|
||||
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
|
||||
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
|
||||
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
|
||||
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
|
||||
expect(env.PATH ?? '').not.toContain('/tmp/orca-user-data/orca-terminal-attribution/posix')
|
||||
})
|
||||
|
||||
it('leaves ambient CODEX_HOME untouched when system default is selected', async () => {
|
||||
const env = await spawnAndGetEnv(
|
||||
undefined,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
markClaudePtyExited,
|
||||
markClaudePtySpawned
|
||||
} from '../claude-accounts/live-pty-gate'
|
||||
import { applyTerminalAttributionEnv } from '../attribution/terminal-attribution'
|
||||
|
||||
// ─── Provider Registry ──────────────────────────────────────────────
|
||||
// Routes PTY operations by connectionId. null = local provider.
|
||||
@@ -240,6 +241,15 @@ export function registerPtyHandlers(
|
||||
baseEnv.PATH = baseEnv.PATH ? `${devCliBin}${delimiter}${baseEnv.PATH}` : devCliBin
|
||||
}
|
||||
|
||||
// Why: GitHub attribution should only affect commands launched from
|
||||
// Orca's own PTYs. Injecting lightweight PATH shims at spawn-time keeps
|
||||
// the behavior local to Orca instead of rewriting user git config or
|
||||
// touching external shells.
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: getSettings?.()?.enableGitHubAttribution ?? true,
|
||||
userDataPath: app.getPath('userData')
|
||||
})
|
||||
|
||||
return baseEnv
|
||||
},
|
||||
onSpawned: (id) => runtime?.onPtySpawned(id),
|
||||
|
||||
@@ -7,15 +7,15 @@ const { handleMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: true,
|
||||
getPath: vi.fn().mockReturnValue('/tmp/orca-test-userdata')
|
||||
},
|
||||
ipcMain: {
|
||||
handle: handleMock,
|
||||
on: vi.fn(),
|
||||
removeHandler: removeHandlerMock,
|
||||
removeAllListeners: removeAllListenersMock
|
||||
},
|
||||
app: {
|
||||
isPackaged: true,
|
||||
getPath: vi.fn().mockReturnValue('/tmp/orca-test-userdata')
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -23,6 +23,9 @@ vi.mock('fs', () => ({
|
||||
existsSync: () => true,
|
||||
statSync: () => ({ isDirectory: () => true, mode: 0o755 }),
|
||||
accessSync: () => undefined,
|
||||
mkdirSync: vi.fn(),
|
||||
readFileSync: vi.fn(() => ''),
|
||||
writeFileSync: vi.fn(),
|
||||
chmodSync: vi.fn(),
|
||||
constants: { X_OK: 1 }
|
||||
}))
|
||||
|
||||
@@ -109,6 +109,44 @@ export function GitPane({
|
||||
/>
|
||||
</button>
|
||||
</SearchableSetting>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, {
|
||||
title: 'Orca Attribution',
|
||||
description: 'Add Orca attribution to commits, PRs, and issues.',
|
||||
keywords: ['github', 'gh', 'pr', 'issue', 'co-author', 'coauthored', 'attribution', 'orca']
|
||||
}) ? (
|
||||
<SearchableSetting
|
||||
key="github-attribution"
|
||||
title="Orca Attribution"
|
||||
description="Add Orca attribution."
|
||||
keywords={['github', 'gh', 'pr', 'issue', 'co-author', 'coauthored', 'attribution', 'orca']}
|
||||
className="flex items-center justify-between gap-4 px-1 py-2"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label>Orca Attribution</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add Orca attribution to commits, PRs, and issues.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.enableGitHubAttribution}
|
||||
onClick={() =>
|
||||
updateSettings({
|
||||
enableGitHubAttribution: !settings.enableGitHubAttribution
|
||||
})
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
settings.enableGitHubAttribution ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
settings.enableGitHubAttribution ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</SearchableSetting>
|
||||
) : null
|
||||
].filter(Boolean)
|
||||
|
||||
|
||||
@@ -10,5 +10,10 @@ export const GIT_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
title: 'Refresh Local Base Ref',
|
||||
description: 'Optionally fast-forward local main or master when creating worktrees.',
|
||||
keywords: ['main', 'master', 'origin/main', 'git diff', 'base ref', 'worktree']
|
||||
},
|
||||
{
|
||||
title: 'Orca Attribution',
|
||||
description: 'Add Orca attribution to commits, PRs, and issues.',
|
||||
keywords: ['github', 'gh', 'pr', 'issue', 'co-author', 'coauthored', 'attribution', 'orca']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -106,6 +106,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'git-username',
|
||||
branchPrefixCustom: '',
|
||||
enableGitHubAttribution: true,
|
||||
theme: 'system',
|
||||
editorAutoSave: false,
|
||||
editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS,
|
||||
|
||||
@@ -725,6 +725,7 @@ export type GlobalSettings = {
|
||||
refreshLocalBaseRefOnWorktreeCreate: boolean
|
||||
branchPrefix: 'git-username' | 'custom' | 'none'
|
||||
branchPrefixCustom: string
|
||||
enableGitHubAttribution: boolean
|
||||
theme: 'system' | 'dark' | 'light'
|
||||
editorAutoSave: boolean
|
||||
editorAutoSaveDelayMs: number
|
||||
|
||||
Reference in New Issue
Block a user