diff --git a/packages/opencode/src/session/file-tracking.ts b/packages/opencode/src/session/file-tracking.ts new file mode 100644 index 00000000000..4e5993d8cd6 --- /dev/null +++ b/packages/opencode/src/session/file-tracking.ts @@ -0,0 +1,48 @@ +import { Log } from "@/util/log" + +export namespace FileTracking { + const log = Log.create({ service: "file-tracking" }) + + // Session-scoped tracking of files modified by git operations + // These files should be excluded from the session diff + const gitModifiedFiles = new Map>() + + /** + * Register files that were modified by a git operation (pull, merge, checkout, etc.) + * These files will be excluded from the session diff calculation + */ + export function addGitModified(sessionID: string, files: string[]) { + if (!files.length) return + let set = gitModifiedFiles.get(sessionID) + if (!set) { + set = new Set() + gitModifiedFiles.set(sessionID, set) + } + for (const file of files) { + set.add(file) + } + log.info("git modified files added", { sessionID, count: files.length, files: files.slice(0, 5) }) + } + + /** + * Get all files modified by git operations in a session + */ + export function getGitModified(sessionID: string): Set { + return gitModifiedFiles.get(sessionID) ?? new Set() + } + + /** + * Clear git-modified files for a session (called when session is cleaned up) + */ + export function clear(sessionID: string) { + gitModifiedFiles.delete(sessionID) + log.info("cleared file tracking", { sessionID }) + } + + /** + * Check if a file was modified by a git operation + */ + export function isGitModified(sessionID: string, file: string): boolean { + return gitModifiedFiles.get(sessionID)?.has(file) ?? false + } +} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index c1d4015f6d3..1c84dc20de6 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -187,6 +187,8 @@ export namespace MessageV2 { export const StepStartPart = PartBase.extend({ type: z.literal("step-start"), snapshot: z.string().optional(), + /** The project's git HEAD at step start, used to detect external git operations */ + gitHead: z.string().optional(), }).meta({ ref: "StepStartPart", }) @@ -196,6 +198,8 @@ export namespace MessageV2 { type: z.literal("step-finish"), reason: z.string(), snapshot: z.string().optional(), + /** The project's git HEAD at step finish, used to detect external git operations */ + gitHead: z.string().optional(), cost: z.number(), tokens: z.object({ input: z.number(), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 71db7f13677..7291563cb9c 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -31,6 +31,7 @@ export namespace SessionProcessor { }) { const toolcalls: Record = {} let snapshot: string | undefined + let gitHead: string | undefined let blocked = false let attempt = 0 let needsCompaction = false @@ -224,11 +225,13 @@ export namespace SessionProcessor { case "start-step": snapshot = await Snapshot.track() + gitHead = await Snapshot.getProjectHead() await Session.updatePart({ id: Identifier.ascending("part"), messageID: input.assistantMessage.id, sessionID: input.sessionID, snapshot, + gitHead, type: "step-start", }) break @@ -242,10 +245,12 @@ export namespace SessionProcessor { input.assistantMessage.finish = value.finishReason input.assistantMessage.cost += usage.cost input.assistantMessage.tokens = usage.tokens + const finishGitHead = await Snapshot.getProjectHead() await Session.updatePart({ id: Identifier.ascending("part"), reason: value.finishReason, snapshot: await Snapshot.track(), + gitHead: finishGitHead, messageID: input.assistantMessage.id, sessionID: input.assistantMessage.sessionID, type: "step-finish", diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 2bd1b0da601..451cb737edd 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -16,6 +16,7 @@ import { Bus } from "@/bus" import { LLM } from "./llm" import { Agent } from "@/agent/agent" +import { FileTracking } from "./file-tracking" export namespace SessionSummary { const log = Log.create({ service: "session.summary" }) @@ -35,16 +36,40 @@ export namespace SessionSummary { ) async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) { - const files = new Set( + // Get files modified by patch operations (edit/write/patch tools) + const patchedFiles = new Set( input.messages .flatMap((x) => x.parts) .filter((x) => x.type === "patch") .flatMap((x) => x.files) .map((x) => path.relative(Instance.worktree, x)), ) + + // Compute files modified by git operations by looking at gitHead changes between steps + // This detects both internal (via bash tool) and external (user's terminal) git operations + const gitModifiedFromHeads = await computeGitModifiedFiles({ messages: input.messages }) + + // Also include files tracked via the bash tool (for immediate detection during the session) + const gitModifiedFromTracking = FileTracking.getGitModified(input.sessionID) + + // Combine both sources of git-modified files + const gitModifiedRelative = new Set([ + ...gitModifiedFromHeads, + ...Array.from(gitModifiedFromTracking).map((x) => path.relative(Instance.worktree, x)), + ]) + const diffs = await computeDiff({ messages: input.messages }).then((x) => - x.filter((x) => { - return files.has(x.file) + x.filter((diff) => { + // Include file if it was patched by a tool AND not modified by a git operation + // This allows user external edits to show (they appear in snapshot diff but NOT in gitModifiedFiles) + // while excluding files pulled in by git operations + if (gitModifiedRelative.has(diff.file)) { + // File was modified by git - only include if also explicitly patched by a tool + // This handles the case where user does `git pull` then edits a file that was in the pull + return patchedFiles.has(diff.file) + } + // File was not modified by git - include if it was in a patch + return patchedFiles.has(diff.file) }), ) await Session.update(input.sessionID, (draft) => { @@ -146,4 +171,39 @@ export namespace SessionSummary { if (from && to) return Snapshot.diffFull(from, to) return [] } + + /** + * Compute files that were modified by git operations (pull, merge, checkout, etc.) + * by comparing gitHead values between step-start and step-finish parts. + * This detects both internal (via bash tool) and external (user's terminal) git operations. + */ + async function computeGitModifiedFiles(input: { messages: MessageV2.WithParts[] }): Promise { + const allGitModified: string[] = [] + + // Find pairs of step-start and step-finish to detect git HEAD changes + for (const msg of input.messages) { + let stepStartHead: string | undefined + + for (const part of msg.parts) { + if (part.type === "step-start" && part.gitHead) { + stepStartHead = part.gitHead + } + if (part.type === "step-finish" && part.gitHead && stepStartHead) { + // If HEAD changed between step-start and step-finish, get the changed files + if (stepStartHead !== part.gitHead) { + const files = await Snapshot.getProjectChangedFiles(stepStartHead, part.gitHead) + allGitModified.push(...files) + log.info("detected git HEAD change", { + from: stepStartHead.slice(0, 8), + to: part.gitHead.slice(0, 8), + files: files.length, + }) + } + stepStartHead = undefined + } + } + } + + return allGitModified + } } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 69f2abc7903..1f7e5923ef7 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -196,4 +196,27 @@ export namespace Snapshot { const project = Instance.project return path.join(Global.Path.data, "snapshot", project.id) } + + /** + * Get the current HEAD commit of the project's actual git repository. + * This is used to detect when external git operations (pull, merge, etc.) + * modify the working tree. + */ + export async function getProjectHead(): Promise { + if (Instance.project.vcs !== "git") return undefined + const result = await $`git rev-parse HEAD`.cwd(Instance.worktree).quiet().nothrow().text() + return result.trim() || undefined + } + + /** + * Get the list of files that changed between two commits in the project's git repo. + * Used to identify files modified by git operations (pull, merge, checkout, etc.) + */ + export async function getProjectChangedFiles(fromHead: string, toHead: string): Promise { + if (fromHead === toHead) return [] + const result = await $`git diff --name-only ${fromHead} ${toHead}`.cwd(Instance.worktree).quiet().nothrow().text() + + if (!result.trim()) return [] + return result.trim().split("\n").filter(Boolean) + } } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f3a1b04d431..7884fd5fbbb 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -16,12 +16,68 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" +import { FileTracking } from "@/session/file-tracking" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 export const log = Log.create({ service: "bash-tool" }) +// Git commands that can modify the working tree by pulling in external changes +const GIT_WORKTREE_MODIFYING_COMMANDS = [ + "pull", + "merge", + "checkout", + "rebase", + "reset", + "stash", + "cherry-pick", + "revert", + "switch", + "restore", +] + +/** + * Detect if a command is a git operation that modifies the working tree + */ +function detectGitWorktreeOperation(commands: string[][]): boolean { + for (const cmd of commands) { + if (cmd[0] !== "git") continue + // Find the git subcommand (skip flags like -C) + for (let i = 1; i < cmd.length; i++) { + const arg = cmd[i] + if (arg.startsWith("-")) continue + if (GIT_WORKTREE_MODIFYING_COMMANDS.includes(arg)) return true + break + } + } + return false +} + +/** + * Get list of files that changed between two git states + */ +async function getGitChangedFiles(cwd: string, beforeHead: string): Promise { + // Get files that changed between the old HEAD and current state + const result = await $`git diff --name-only ${beforeHead} HEAD`.cwd(cwd).quiet().nothrow().text() + + if (!result.trim()) return [] + + return result + .trim() + .split("\n") + .filter(Boolean) + .map((f) => path.join(cwd, f)) +} + +/** + * Get current HEAD commit hash + */ +async function getGitHead(cwd: string): Promise { + const result = await $`git rev-parse HEAD`.cwd(cwd).quiet().nothrow().text() + return result.trim() || undefined +} + const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset @@ -88,6 +144,7 @@ export const BashTool = Tool.define("bash", async () => { if (!Instance.containsPath(cwd)) directories.add(cwd) const patterns = new Set() const always = new Set() + const parsedCommands: string[][] = [] for (const node of tree.rootNode.descendantsOfType("command")) { if (!node) continue @@ -107,6 +164,8 @@ export const BashTool = Tool.define("bash", async () => { command.push(child.text) } + if (command.length) parsedCommands.push(command) + // not an exhaustive list, but covers most common cases if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"].includes(command[0])) { for (const arg of command.slice(1)) { @@ -136,6 +195,14 @@ export const BashTool = Tool.define("bash", async () => { } } + // Detect if this is a git operation that modifies the working tree + const isGitWorktreeOp = detectGitWorktreeOperation(parsedCommands) + let beforeHead: string | undefined + if (isGitWorktreeOp) { + beforeHead = await getGitHead(cwd) + log.info("detected git worktree operation", { beforeHead }) + } + if (directories.size > 0) { await ctx.ask({ permission: "external_directory", @@ -244,6 +311,15 @@ export const BashTool = Tool.define("bash", async () => { output += "\n\n\n" + resultMetadata.join("\n") + "\n" } + // Track files modified by git operations so they can be excluded from session diffs + if (isGitWorktreeOp && beforeHead && proc.exitCode === 0) { + const changedFiles = await getGitChangedFiles(cwd, beforeHead) + if (changedFiles.length) { + FileTracking.addGitModified(ctx.sessionID, changedFiles) + log.info("tracked git-modified files", { count: changedFiles.length }) + } + } + return { title: params.description, metadata: { diff --git a/packages/opencode/test/session/file-tracking.test.ts b/packages/opencode/test/session/file-tracking.test.ts new file mode 100644 index 00000000000..13207a7e24b --- /dev/null +++ b/packages/opencode/test/session/file-tracking.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { FileTracking } from "../../src/session/file-tracking" + +describe("FileTracking", () => { + beforeEach(() => { + // Clear any existing tracking data + FileTracking.clear("test-session") + }) + + test("addGitModified adds files to tracking", () => { + FileTracking.addGitModified("test-session", ["/path/to/file1.ts", "/path/to/file2.ts"]) + + expect(FileTracking.isGitModified("test-session", "/path/to/file1.ts")).toBe(true) + expect(FileTracking.isGitModified("test-session", "/path/to/file2.ts")).toBe(true) + expect(FileTracking.isGitModified("test-session", "/path/to/file3.ts")).toBe(false) + }) + + test("addGitModified handles empty array", () => { + FileTracking.addGitModified("test-session", []) + expect(FileTracking.getGitModified("test-session").size).toBe(0) + }) + + test("getGitModified returns empty set for unknown session", () => { + const result = FileTracking.getGitModified("unknown-session") + expect(result.size).toBe(0) + }) + + test("isGitModified returns false for unknown session", () => { + expect(FileTracking.isGitModified("unknown-session", "/path/to/file.ts")).toBe(false) + }) + + test("clear removes all tracked files for session", () => { + FileTracking.addGitModified("test-session", ["/path/to/file1.ts"]) + expect(FileTracking.isGitModified("test-session", "/path/to/file1.ts")).toBe(true) + + FileTracking.clear("test-session") + expect(FileTracking.isGitModified("test-session", "/path/to/file1.ts")).toBe(false) + }) + + test("multiple sessions are isolated", () => { + FileTracking.addGitModified("session-1", ["/path/to/file1.ts"]) + FileTracking.addGitModified("session-2", ["/path/to/file2.ts"]) + + expect(FileTracking.isGitModified("session-1", "/path/to/file1.ts")).toBe(true) + expect(FileTracking.isGitModified("session-1", "/path/to/file2.ts")).toBe(false) + expect(FileTracking.isGitModified("session-2", "/path/to/file1.ts")).toBe(false) + expect(FileTracking.isGitModified("session-2", "/path/to/file2.ts")).toBe(true) + }) + + test("addGitModified accumulates files across multiple calls", () => { + FileTracking.addGitModified("test-session", ["/path/to/file1.ts"]) + FileTracking.addGitModified("test-session", ["/path/to/file2.ts"]) + FileTracking.addGitModified("test-session", ["/path/to/file3.ts"]) + + const tracked = FileTracking.getGitModified("test-session") + expect(tracked.size).toBe(3) + expect(tracked.has("/path/to/file1.ts")).toBe(true) + expect(tracked.has("/path/to/file2.ts")).toBe(true) + expect(tracked.has("/path/to/file3.ts")).toBe(true) + }) + + test("addGitModified deduplicates files", () => { + FileTracking.addGitModified("test-session", ["/path/to/file1.ts", "/path/to/file1.ts"]) + FileTracking.addGitModified("test-session", ["/path/to/file1.ts"]) + + const tracked = FileTracking.getGitModified("test-session") + expect(tracked.size).toBe(1) + }) +}) diff --git a/packages/opencode/test/snapshot/git-head.test.ts b/packages/opencode/test/snapshot/git-head.test.ts new file mode 100644 index 00000000000..30d4e5114db --- /dev/null +++ b/packages/opencode/test/snapshot/git-head.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from "bun:test" +import { $ } from "bun" +import { Snapshot } from "../../src/snapshot" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +async function bootstrap() { + return tmpdir({ + git: true, + init: async (dir) => { + const unique = Math.random().toString(36).slice(2) + await Bun.write(`${dir}/a.txt`, `A${unique}`) + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m "initial commit"`.cwd(dir).quiet() + return { unique } + }, + }) +} + +describe("Snapshot.getProjectHead", () => { + test("returns current HEAD commit hash", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const head = await Snapshot.getProjectHead() + expect(head).toBeTruthy() + expect(head!.length).toBe(40) // SHA-1 hash length + }, + }) + }) + + test("returns undefined for non-git directory", async () => { + await using tmp = await tmpdir({ git: false }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const head = await Snapshot.getProjectHead() + expect(head).toBeUndefined() + }, + }) + }) + + test("HEAD changes after commit", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const headBefore = await Snapshot.getProjectHead() + + await Bun.write(`${tmp.path}/new.txt`, "new content") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add new file"`.cwd(tmp.path).quiet() + + const headAfter = await Snapshot.getProjectHead() + + expect(headBefore).toBeTruthy() + expect(headAfter).toBeTruthy() + expect(headBefore).not.toBe(headAfter) + }, + }) + }) +}) + +describe("Snapshot.getProjectChangedFiles", () => { + test("returns empty array for same commits", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const head = await Snapshot.getProjectHead() + const files = await Snapshot.getProjectChangedFiles(head!, head!) + expect(files).toEqual([]) + }, + }) + }) + + test("returns changed files between commits", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const headBefore = await Snapshot.getProjectHead() + + await Bun.write(`${tmp.path}/new.txt`, "new content") + await Bun.write(`${tmp.path}/another.txt`, "another file") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add new files"`.cwd(tmp.path).quiet() + + const headAfter = await Snapshot.getProjectHead() + const files = await Snapshot.getProjectChangedFiles(headBefore!, headAfter!) + + expect(files).toContain("new.txt") + expect(files).toContain("another.txt") + expect(files.length).toBe(2) + }, + }) + }) + + test("detects modified files", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const headBefore = await Snapshot.getProjectHead() + + await Bun.write(`${tmp.path}/a.txt`, "modified content") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "modify a.txt"`.cwd(tmp.path).quiet() + + const headAfter = await Snapshot.getProjectHead() + const files = await Snapshot.getProjectChangedFiles(headBefore!, headAfter!) + + expect(files).toContain("a.txt") + expect(files.length).toBe(1) + }, + }) + }) + + test("detects deleted files", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const headBefore = await Snapshot.getProjectHead() + + await $`git rm a.txt`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "delete a.txt"`.cwd(tmp.path).quiet() + + const headAfter = await Snapshot.getProjectHead() + const files = await Snapshot.getProjectChangedFiles(headBefore!, headAfter!) + + expect(files).toContain("a.txt") + expect(files.length).toBe(1) + }, + }) + }) + + test("handles multiple commits between HEAD changes", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const headBefore = await Snapshot.getProjectHead() + + // First commit + await Bun.write(`${tmp.path}/file1.txt`, "content1") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file1"`.cwd(tmp.path).quiet() + + // Second commit + await Bun.write(`${tmp.path}/file2.txt`, "content2") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file2"`.cwd(tmp.path).quiet() + + // Third commit + await Bun.write(`${tmp.path}/file3.txt`, "content3") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file3"`.cwd(tmp.path).quiet() + + const headAfter = await Snapshot.getProjectHead() + const files = await Snapshot.getProjectChangedFiles(headBefore!, headAfter!) + + expect(files).toContain("file1.txt") + expect(files).toContain("file2.txt") + expect(files).toContain("file3.txt") + expect(files.length).toBe(3) + }, + }) + }) +}) + +describe("git pull simulation", () => { + test("detects files changed by simulated git pull", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Create a "remote" branch with changes + await $`git checkout -b feature`.cwd(tmp.path).quiet() + await Bun.write(`${tmp.path}/pulled-file1.txt`, "pulled content 1") + await Bun.write(`${tmp.path}/pulled-file2.txt`, "pulled content 2") + await Bun.write(`${tmp.path}/a.txt`, "modified by pull") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "feature changes"`.cwd(tmp.path).quiet() + + // Go back to main and record HEAD + await $`git checkout -`.cwd(tmp.path).quiet() + const headBefore = await Snapshot.getProjectHead() + + // Simulate merge (like git pull would do) + await $`git merge feature --no-edit`.cwd(tmp.path).quiet() + const headAfter = await Snapshot.getProjectHead() + + // Get files changed by the "pull" + const files = await Snapshot.getProjectChangedFiles(headBefore!, headAfter!) + + expect(files).toContain("pulled-file1.txt") + expect(files).toContain("pulled-file2.txt") + expect(files).toContain("a.txt") + expect(files.length).toBe(3) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/bash-git-tracking.test.ts b/packages/opencode/test/tool/bash-git-tracking.test.ts new file mode 100644 index 00000000000..11b2019f529 --- /dev/null +++ b/packages/opencode/test/tool/bash-git-tracking.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { $ } from "bun" +import path from "path" +import { BashTool } from "../../src/tool/bash" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { FileTracking } from "../../src/session/file-tracking" + +const sessionID = "test-git-tracking-session" + +const ctx = { + sessionID, + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + metadata: () => {}, + ask: async () => {}, +} + +async function bootstrapWithRemote() { + // Create a "remote" repo first + const remote = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(`${dir}/remote-file.txt`, "remote content") + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m "remote commit"`.cwd(dir).quiet() + return {} + }, + }) + + // Create a local repo that clones from the remote + const local = await tmpdir({ + git: false, + init: async (dir) => { + await $`git clone ${remote.path} .`.cwd(dir).quiet() + return { remotePath: remote.path } + }, + }) + + return { local, remote } +} + +describe("bash tool git operation tracking", () => { + beforeEach(() => { + FileTracking.clear(sessionID) + }) + + test("tracks files changed by git pull", async () => { + const { local, remote } = await bootstrapWithRemote() + + try { + // Add a new commit to the remote + await Bun.write(`${remote.path}/new-remote-file.txt`, "new remote content") + await $`git add .`.cwd(remote.path).quiet() + await $`git commit --no-gpg-sign -m "add new remote file"`.cwd(remote.path).quiet() + + await Instance.provide({ + directory: local.path, + fn: async () => { + const bash = await BashTool.init() + + // Run git pull + await bash.execute( + { + command: "git pull", + description: "Pull from remote", + }, + ctx, + ) + + // Check that the new file is tracked as git-modified + const tracked = FileTracking.getGitModified(sessionID) + expect(tracked.has(path.join(local.path, "new-remote-file.txt"))).toBe(true) + }, + }) + } finally { + await local[Symbol.asyncDispose]() + await remote[Symbol.asyncDispose]() + } + }) + + test("tracks files changed by git merge", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(`${dir}/main-file.txt`, "main content") + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m "main commit"`.cwd(dir).quiet() + + // Create feature branch with changes + await $`git checkout -b feature`.cwd(dir).quiet() + await Bun.write(`${dir}/feature-file.txt`, "feature content") + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m "feature commit"`.cwd(dir).quiet() + + // Go back to main + await $`git checkout master || git checkout main`.cwd(dir).quiet().nothrow() + return {} + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + + // Run git merge + await bash.execute( + { + command: "git merge feature --no-edit", + description: "Merge feature branch", + }, + ctx, + ) + + // Check that the merged file is tracked + const tracked = FileTracking.getGitModified(sessionID) + expect(tracked.has(path.join(tmp.path, "feature-file.txt"))).toBe(true) + }, + }) + }) + + test("tracks files changed by git checkout", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(`${dir}/main-file.txt`, "main content") + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m "main commit"`.cwd(dir).quiet() + + // Create feature branch with different content + await $`git checkout -b feature`.cwd(dir).quiet() + await Bun.write(`${dir}/main-file.txt`, "feature modified content") + await Bun.write(`${dir}/feature-only.txt`, "feature only") + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m "feature changes"`.cwd(dir).quiet() + + // Go back to main + await $`git checkout master || git checkout main`.cwd(dir).quiet().nothrow() + return {} + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + + // Checkout to feature branch + await bash.execute( + { + command: "git checkout feature", + description: "Checkout feature branch", + }, + ctx, + ) + + // Check that changed files are tracked + const tracked = FileTracking.getGitModified(sessionID) + expect(tracked.has(path.join(tmp.path, "main-file.txt"))).toBe(true) + expect(tracked.has(path.join(tmp.path, "feature-only.txt"))).toBe(true) + }, + }) + }) + + test("does not track files for non-git commands", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + + await bash.execute( + { + command: "echo hello", + description: "Echo hello", + }, + ctx, + ) + + const tracked = FileTracking.getGitModified(sessionID) + expect(tracked.size).toBe(0) + }, + }) + }) + + test("does not track files for git status (read-only)", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + + await bash.execute( + { + command: "git status", + description: "Git status", + }, + ctx, + ) + + const tracked = FileTracking.getGitModified(sessionID) + expect(tracked.size).toBe(0) + }, + }) + }) + + test("does not track files for git log (read-only)", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + + await bash.execute( + { + command: "git log --oneline -5", + description: "Git log", + }, + ctx, + ) + + const tracked = FileTracking.getGitModified(sessionID) + expect(tracked.size).toBe(0) + }, + }) + }) + + test("does not track files when git command fails", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + + // Try to pull from non-existent remote (will fail) + await bash.execute( + { + command: "git pull origin nonexistent", + description: "Pull from nonexistent", + }, + ctx, + ) + + const tracked = FileTracking.getGitModified(sessionID) + expect(tracked.size).toBe(0) + }, + }) + }) +})