From f1a1f18cf8bdae61c627bcff13be753e2bc9bb8f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:55:50 -0700 Subject: [PATCH] Attribution (#1049) --- .../attribution/terminal-attribution.test.ts | 492 +++++++++++++ src/main/attribution/terminal-attribution.ts | 688 ++++++++++++++++++ .../runtime-home-service.test.ts | 1 + src/main/codex-accounts/service.test.ts | 1 + src/main/ipc/pty.test.ts | 34 +- src/main/ipc/pty.ts | 10 + src/main/providers/provider-dispatch.test.ts | 11 +- .../src/components/settings/GitPane.tsx | 38 + .../src/components/settings/git-search.ts | 5 + src/shared/constants.ts | 1 + src/shared/types.ts | 1 + 11 files changed, 1276 insertions(+), 6 deletions(-) create mode 100644 src/main/attribution/terminal-attribution.test.ts create mode 100644 src/main/attribution/terminal-attribution.ts diff --git a/src/main/attribution/terminal-attribution.test.ts b/src/main/attribution/terminal-attribution.test.ts new file mode 100644 index 00000000000..80e224a7187 --- /dev/null +++ b/src/main/attribution/terminal-attribution.test.ts @@ -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 { + 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 ' + ) + }) + + 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 ' + ) + }) + + 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 ' + ) + }) + + 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"') + }) +}) diff --git a/src/main/attribution/terminal-attribution.ts b/src/main/attribution/terminal-attribution.ts new file mode 100644 index 00000000000..dba36a18b7d --- /dev/null +++ b/src/main/attribution/terminal-attribution.ts @@ -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 ' +const ORCA_GH_FOOTER = `Made with [Orca](${ORCA_PRODUCT_URL}) 🐋` +const SHELL_DOLLAR = '$' +const POWERSHELL_TICK = '`' + +const writtenRoots = new Set() + +type AttributionShimPaths = { + posixDir: string + win32Dir: string +} + +export function applyTerminalAttributionEnv( + baseEnv: Record, + options: { enabled: boolean; userDataPath: string } +): Record { + 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 }" +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 ' } + +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 +` diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 1608ad72da7..2a5bac41118 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -89,6 +89,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalTerminalDaemonNoticeShown: false, terminalForceHyperlink: true, terminalWindowsShell: 'powershell.exe', + enableGitHubAttribution: true, ...overrides } } diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index ef08b7d4448..63775e32c54 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -83,6 +83,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalTerminalDaemonNoticeShown: false, terminalForceHyperlink: true, terminalWindowsShell: 'powershell.exe', + enableGitHubAttribution: true, ...overrides } } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 4d5f1d121b3..2c449553404 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -196,7 +196,8 @@ describe('registerPtyHandlers', () => { async function spawnAndGetEnv( argsEnv?: Record, processEnvOverrides?: Record, - getSelectedCodexHomePath?: () => string | null + getSelectedCodexHomePath?: () => string | null, + getSettings?: () => { enableGitHubAttribution: boolean } ): Promise> { const savedEnv: Record = {} 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 ') + 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, diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 76db54831d5..b6f6088d535 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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), diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index eac8cefc081..0881a612aff 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -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 } })) diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index 491f827a99e..a15f51cef40 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -109,6 +109,44 @@ export function GitPane({ /> + ) : 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'] + }) ? ( + +
+ +

+ Add Orca attribution to commits, PRs, and issues. +

+
+ +
) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/git-search.ts b/src/renderer/src/components/settings/git-search.ts index 48adb3f020a..cb66e34ee00 100644 --- a/src/renderer/src/components/settings/git-search.ts +++ b/src/renderer/src/components/settings/git-search.ts @@ -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'] } ] diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0bbf7ae1307..c7fd2e8f2a5 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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, diff --git a/src/shared/types.ts b/src/shared/types.ts index 936a8ed7527..dec6343e332 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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