Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/opencode/src/session/file-tracking.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<string>>()

/**
* 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<string> {
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
}
}
4 changes: 4 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
Expand All @@ -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(),
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export namespace SessionProcessor {
}) {
const toolcalls: Record<string, MessageV2.ToolPart> = {}
let snapshot: string | undefined
let gitHead: string | undefined
let blocked = false
let attempt = 0
let needsCompaction = false
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down
66 changes: 63 additions & 3 deletions packages/opencode/src/session/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand All @@ -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) => {
Expand Down Expand Up @@ -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<string[]> {
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
}
}
23 changes: 23 additions & 0 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
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<string[]> {
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)
}
}
76 changes: 76 additions & 0 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
// 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<string | undefined> {
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
Expand Down Expand Up @@ -88,6 +144,7 @@ export const BashTool = Tool.define("bash", async () => {
if (!Instance.containsPath(cwd)) directories.add(cwd)
const patterns = new Set<string>()
const always = new Set<string>()
const parsedCommands: string[][] = []

for (const node of tree.rootNode.descendantsOfType("command")) {
if (!node) continue
Expand All @@ -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)) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -244,6 +311,15 @@ export const BashTool = Tool.define("bash", async () => {
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
}

// 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: {
Expand Down
Loading