diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ecff2b268..0c8f30962 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -22,6 +22,7 @@ - Fixed model resolution for commit message generation, title generation, memory consolidation, and image inspection when role strings use canonical ids instead of raw provider/model values - Fixed default-model updates so previously configured thinking levels were preserved when reassigning a role - Fixed model scope and selection handling in CLI/session startup paths that previously failed to resolve aliases consistently across features +- Fixed short-lived git subprocesses to disable `core.fsmonitor` and `core.untrackedCache`, avoiding unnecessary repository watchers and cache work during agent git operations ## [14.0.5] - 2026-04-11 ### Added @@ -6983,4 +6984,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections \ No newline at end of file +- HTML export with syntax highlighting and collapsible sections diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index 9d825a34a..531d43a59 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -146,6 +146,10 @@ const NO_OPTIONAL_LOCKS = "--no-optional-locks"; const HEAD_REF_PREFIX = "ref:"; const LOCAL_BRANCH_PREFIX = "refs/heads/"; const DEFAULT_BRANCH_REFS = ["refs/remotes/origin/HEAD", "refs/remotes/upstream/HEAD"] as const; +const SHORT_LIVED_GIT_CONFIG: readonly (readonly [key: string, value: string])[] = [ + ["core.fsmonitor", "false"], + ["core.untrackedCache", "false"], +]; interface CommandOptions { readonly env?: Record; @@ -183,7 +187,7 @@ async function runCommand( args: readonly string[], options: CommandOptions = {}, ): Promise { - const commandArgs = options.readOnly ? withNoOptionalLocks(args) : [...args]; + const commandArgs = withShortLivedGitConfig(options.readOnly ? withNoOptionalLocks(args) : [...args]); const child = Bun.spawn(["git", ...commandArgs], { cwd, env: options.env ? { ...process.env, ...options.env } : undefined, @@ -212,6 +216,25 @@ function withNoOptionalLocks(args: readonly string[]): string[] { return [NO_OPTIONAL_LOCKS, ...args]; } +function withShortLivedGitConfig(args: readonly string[]): string[] { + const prefix: string[] = []; + for (const [key, value] of SHORT_LIVED_GIT_CONFIG) { + if (hasGitConfig(args, key, value)) continue; + prefix.push("-c", `${key}=${value}`); + } + return [...prefix, ...args]; +} + +function hasGitConfig(args: readonly string[], key: string, value: string): boolean { + const expected = `${key}=${value}`; + for (let index = 0; index < args.length - 1; index += 1) { + if (args[index] === "-c" && args[index + 1] === expected) { + return true; + } + } + return false; +} + async function runChecked( cwd: string, args: readonly string[], diff --git a/packages/coding-agent/test/git-process-config.test.ts b/packages/coding-agent/test/git-process-config.test.ts new file mode 100644 index 000000000..1e31e9171 --- /dev/null +++ b/packages/coding-agent/test/git-process-config.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import type { Subprocess } from "bun"; +import * as git from "../src/utils/git"; + +type SpawnOptions = Bun.SpawnOptions.SpawnOptions< + Bun.SpawnOptions.Writable, + Bun.SpawnOptions.Readable, + Bun.SpawnOptions.Readable +>; + +type SpawnCall = { + cmd: string[]; + options: SpawnOptions; +}; + +function createTextStream(text: string): ReadableStream { + const body = new Response(text).body; + if (!body) { + throw new Error("Failed to create response stream."); + } + return body; +} + +function createFakeProcess(stdout = "", stderr = "", exitCode = 0): Subprocess { + return { + pid: 12345, + stdout: createTextStream(stdout), + stderr: createTextStream(stderr), + exited: Promise.resolve(exitCode), + } as Subprocess; +} + +function createSpawnMock(calls: SpawnCall[]) { + function mockSpawn(options: SpawnOptions & { cmd: string[] }): Subprocess; + function mockSpawn(cmd: string[], options?: SpawnOptions): Subprocess; + function mockSpawn(first: string[] | (SpawnOptions & { cmd: string[] }), second?: SpawnOptions): Subprocess { + if (Array.isArray(first)) { + calls.push({ cmd: first, options: second ?? ({} as SpawnOptions) }); + } else { + const { cmd, ...options } = first; + calls.push({ cmd, options }); + } + return createFakeProcess(); + } + + return mockSpawn; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("git subprocess config", () => { + it("disables fsmonitor and untracked cache for read-only commands", async () => { + const spawnCalls: SpawnCall[] = []; + vi.spyOn(Bun, "spawn").mockImplementation(createSpawnMock(spawnCalls)); + + expect(await git.status.summary("/work/pi")).toEqual({ staged: 0, unstaged: 0, untracked: 0 }); + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0]?.cmd).toEqual([ + "git", + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "--no-optional-locks", + "status", + "--porcelain", + ]); + }); + + it("disables fsmonitor and untracked cache for mutating commands", async () => { + const spawnCalls: SpawnCall[] = []; + vi.spyOn(Bun, "spawn").mockImplementation(createSpawnMock(spawnCalls)); + + await git.stage.files("/work/pi", ["tracked.txt"]); + + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0]?.cmd).toEqual([ + "git", + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "add", + "--", + "tracked.txt", + ]); + }); +}); diff --git a/scripts/release.ts b/scripts/release.ts index 5a4501e3d..8105b8143 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -15,12 +15,16 @@ const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const packageJsonGlob = new Glob("packages/*/package.json"); const cargoTomlGlob = new Glob("crates/*/Cargo.toml"); +function git(args: readonly string[]) { + return $`git -c core.fsmonitor=false -c core.untrackedCache=false ${args}`; +} + // ============================================================================= // Shared functions // ============================================================================= async function watchCI(): Promise { - const commitSha = (await $`git rev-parse HEAD`.text()).trim(); + const commitSha = (await git(["rev-parse", "HEAD"]).text()).trim(); console.log(` Commit: ${commitSha.slice(0, 8)}`); while (true) { @@ -185,14 +189,14 @@ async function cmdRelease(version: string): Promise { // 1. Pre-flight checks console.log("Pre-flight checks..."); - const branch = await $`git branch --show-current`.text(); + const branch = await git(["branch", "--show-current"]).text(); if (branch.trim() !== "main") { console.error(`Error: Must be on main branch (currently on '${branch.trim()}')`); process.exit(1); } console.log(" On main branch"); - const status = await $`git status --porcelain`.text(); + const status = await git(["status", "--porcelain"]).text(); if (status.trim()) { console.error("Error: Uncommitted changes detected. Commit or stash first."); console.error(status); @@ -200,7 +204,7 @@ async function cmdRelease(version: string): Promise { } console.log(" Working directory clean"); - const latestTag = (await $`git describe --tags --abbrev=0`.text()).trim(); + const latestTag = (await git(["describe", "--tags", "--abbrev=0"]).text()).trim(); if (compareVersions(version, latestTag) <= 0) { console.error(`Error: Version ${version} must be greater than latest tag ${latestTag}`); process.exit(1); @@ -274,15 +278,15 @@ async function cmdRelease(version: string): Promise { // 7. Commit and tag console.log("Committing and tagging..."); - await $`git add .`; - await $`git commit -m ${`chore: bump version to ${version}`}`; - await $`git tag ${`v${version}`}`; + await git(["add", "."]); + await git(["commit", "-m", `chore: bump version to ${version}`]); + await git(["tag", `v${version}`]); console.log(); // 8. Push console.log("Pushing to remote..."); - await $`git push origin main`; - await $`git push origin ${`v${version}`}`; + await git(["push", "origin", "main"]); + await git(["push", "origin", `v${version}`]); console.log(); // 9. Watch CI