/** * A git config file per (user, project), for the one surface that outlives a * single command: the terminal. * * ── The problem this solves ── * `GIT_CONFIG_*` is read from the environment, and a process's environment is * fixed when it is spawned. That is fine for the Git panel (one process per * command) and for an engine turn, but a shell lives for hours. PtyKit's * `createSession` is idempotent and Clopen's session ids are deterministic * (`-terminal-1`), so a tab opened before an account was chosen — or * before it was changed — keeps the environment it started with, silently, for * as long as it stays open. A user who switches accounts, sees the Git panel * update, and then commits from the terminal gets the OLD identity with nothing * to indicate it. * * A file is re-read by git on every single invocation, so pointing * `GIT_CONFIG_GLOBAL` at one makes a change take effect in tabs that are already * open. The file is rewritten whenever the resolution could have changed. * * ── Why the user's own global config is not lost ── * `GIT_CONFIG_GLOBAL` REPLACES `~/.gitconfig` rather than adding to it, which * would take the user's aliases, diff tools and everything else with it. So the * generated file `[include]`s the real ones FIRST, then sets the identity — git * applies later values last, so ours win while everything else survives. * * ── Precedence, stated plainly ── * A global file ranks BELOW a repository's own `.git/config`. So in a terminal, a * repo that hardcodes `user.email` keeps winning, where the Git panel's * `GIT_CONFIG_*` would override it. That is a real difference and it is the * deliberate trade: a stale identity is silent and wrong, while a repo-local * override is something the user wrote on purpose. */ import { mkdir, rm, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { getClopenDir } from '$backend/utils/paths'; import { debug } from '$shared/utils/logger'; import { buildGitConfigPairs } from './env'; import { helperCommand, pickCredentialIdentity, resolveIdentity, toEnvIdentity } from './resolve'; /** Where the generated per-scope config files live. */ function scopeConfigDir(): string { return join(getClopenDir(), 'git-identities', 'scopes'); } /** * Path of the config file for one (user, project). * * Both ids are in the name because the identity is chosen per pair — two people * in one project must not share a file. */ export function scopeConfigPath(userId: string, projectId: string): string { return join(scopeConfigDir(), `${userId}__${projectId}.gitconfig`); } /** The config files git would normally read as "global", in the order it does. */ function realGlobalConfigPaths(): string[] { const home = homedir(); const xdg = process.env.XDG_CONFIG_HOME?.trim() || join(home, '.config'); // Git reads the XDG path first and `~/.gitconfig` second, so a value in the // latter wins. Preserving that order keeps the user's own precedence intact. return [join(xdg, 'git', 'config'), join(home, '.gitconfig')].filter((p) => existsSync(p)); } /** Escape a value for a git config file's `key = value` form. */ function configValue(value: string): string { // Quoted, with backslashes and quotes escaped: git's own parser rule. Without // it an ssh command containing a quoted path would truncate the value. return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; } /** * Render the file contents for a set of config pairs. * * Written as flat `section.key` lines under a single `[include]` plus one * section per key rather than grouping, because the pairs arrive as dotted keys * and splitting them into sections by hand is where a subkey like * `credential.https://host.username` gets mangled. */ export function renderScopeConfig(pairs: Array<[string, string]>): string { const lines = [ '# Generated by Clopen — do not edit.', '# Identity for one (user, project); regenerated whenever it changes.', '' ]; const includes = realGlobalConfigPaths(); if (includes.length > 0) { // First, so the user's own settings are the base and ours override them. lines.push('[include]'); for (const path of includes) lines.push(`\tpath = ${configValue(path)}`); lines.push(''); } for (const [key, value] of pairs) { // `section.subsection.key` — the subsection is everything between, and it // is the part that must stay verbatim (it can contain dots and slashes). const first = key.indexOf('.'); const last = key.lastIndexOf('.'); if (first <= 0) continue; const section = key.slice(0, first); const leaf = key.slice(last + 1); const subsection = last > first ? key.slice(first + 1, last) : null; lines.push(subsection ? `[${section} ${configValue(subsection)}]` : `[${section}]`); lines.push(`\t${leaf} = ${configValue(value)}`); } return `${lines.join('\n')}\n`; } /** * Write (or remove) the config file for one (user, project). * * Returns the path when a file now exists, and `null` when there is nothing to * write. The caller must only set `GIT_CONFIG_GLOBAL` on a non-null result: * pointing it at a missing file would make git ignore the user's real global * config entirely, which is a far worse outcome than having no identity. */ export async function writeScopeConfig( userId: string, projectId: string ): Promise { try { const { row } = resolveIdentity(projectId, userId); const path = scopeConfigPath(userId, projectId); if (!row) { // No identity — remove any file from a previous one, so a cleared // binding does not keep applying. await rm(path, { force: true }); return null; } // No remote URL here: a terminal is not one command against one remote, so // the credential comes from the identity's own host claims. const credential = pickCredentialIdentity(userId, null, row); const pairs = buildGitConfigPairs(toEnvIdentity(row, credential), helperCommand()); await mkdir(scopeConfigDir(), { recursive: true, mode: 0o700 }); await writeFile(path, renderScopeConfig(pairs), { mode: 0o600 }); debug.log('git', `Wrote git scope config for ${userId} in project ${projectId}`); return path; } catch (error) { // A terminal with the machine's identity is a working terminal; failing to // open one because a config file could not be written would not be. debug.warn('git', 'Could not write the git scope config:', error); return null; } } /** Drop the file for one (user, project). */ export async function removeScopeConfig(userId: string, projectId: string): Promise { await rm(scopeConfigPath(userId, projectId), { force: true }).catch(() => {}); } /** * Refresh every file that could be affected by a change to `identityId`. * * Called after a create/update/delete/default change, so a shell that is already * open picks the new identity up on its next git command. Bindings naming the * identity are refreshed, and so is every project the user's default reaches — * which is why the project list is consulted rather than only the bindings. */ export async function refreshScopeConfigsForUser(userId: string): Promise { try { const { projectQueries } = await import('$backend/database/queries'); const projects = projectQueries.getAllForUser(userId); await Promise.all(projects.map((project) => writeScopeConfig(userId, project.id))); } catch (error) { debug.warn('git', 'Could not refresh git scope configs:', error); } }