diff --git a/apps/desktop/src/main/todo-agent/git-status.ts b/apps/desktop/src/main/todo-agent/git-status.ts index fb3cef7e805..6b24b0b505f 100644 --- a/apps/desktop/src/main/todo-agent/git-status.ts +++ b/apps/desktop/src/main/todo-agent/git-status.ts @@ -20,6 +20,23 @@ async function gitOut(args: string[], cwd: string): Promise { } } +/** + * Does `sha` resolve to a commit object in `cwd`'s git dir? Used to + * distinguish "no new commits" from "startHeadSha was orphaned by a + * reset/rebase" — otherwise both look identical in the sidebar. + */ +async function gitRevExists(sha: string, cwd: string): Promise { + try { + await execGitWithShellPath( + ["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], + { cwd }, + ); + return true; + } catch { + return false; + } +} + export async function getCurrentHeadSha(cwd: string): Promise { const out = (await gitOut(["rev-parse", "HEAD"], cwd)).trim(); return out || null; @@ -42,12 +59,34 @@ export interface SessionGitFile { code: string; } +export interface SessionGitChangedFile { + path: string; + /** First letter of git's name-status code: A / M / D / R / C / T */ + code: string; +} + export interface SessionGitSnapshot { branch: string | null; startHeadSha: string | null; currentHeadSha: string | null; commits: SessionGitCommit[]; workingTree: SessionGitFile[]; + /** + * Files whose contents differ between `startHeadSha` and HEAD + * (two-dot `git diff`). Populated regardless of whether HEAD is a + * descendant of startHeadSha, so branch switches / rebases still + * surface the cumulative session delta instead of silently + * rendering an empty sidebar. + */ + sessionFiles: SessionGitChangedFile[]; + /** + * True when `startHeadSha` is set but its commit object is no + * longer reachable (e.g. the branch was reset and the object was + * pruned, or a different repo was swapped in under the worktree). + * The UI uses this to show an explanatory message rather than a + * silently empty panel. + */ + startHeadUnreachable: boolean; ahead: number; behind: number; } @@ -68,32 +107,53 @@ export async function getSessionGitSnapshot(params: { const branch = branchOut.trim() || null; const currentHeadSha = currentOut.trim() || null; - // Commits produced since the session started. If start and current - // are the same (no new commits yet) this returns an empty list. + // Commits produced since the session started. Scoped to the range + // `startHeadSha..HEAD`; when HEAD is not a descendant of + // startHeadSha (branch switch / reset / rebase), this can validly + // return an empty list, and we surface cumulative file-level + // changes via `sessionFiles` below so the sidebar isn't empty. let commits: SessionGitCommit[] = []; + let sessionFiles: SessionGitChangedFile[] = []; + let startHeadUnreachable = false; if (startHeadSha && currentHeadSha && startHeadSha !== currentHeadSha) { - const logOut = await gitOut( - [ - "log", - `${startHeadSha}..${currentHeadSha}`, - `--format=${COMMIT_FORMAT}`, - ], - cwd, - ); - commits = logOut - .split("\n") - .filter((l) => l.length > 0) - .map((line) => { - const [sha, shortSha, subject, authorName, authorDate] = - line.split(COMMIT_DELIM); - return { - sha: sha ?? "", - shortSha: shortSha ?? "", - subject: subject ?? "", - authorName: authorName ?? "", - authorDate: authorDate ?? "", - }; - }); + const reachable = await gitRevExists(startHeadSha, cwd); + if (!reachable) { + startHeadUnreachable = true; + } else { + const logOut = await gitOut( + [ + "log", + `${startHeadSha}..${currentHeadSha}`, + `--format=${COMMIT_FORMAT}`, + ], + cwd, + ); + commits = logOut + .split("\n") + .filter((l) => l.length > 0) + .map((line) => { + const [sha, shortSha, subject, authorName, authorDate] = + line.split(COMMIT_DELIM); + return { + sha: sha ?? "", + shortSha: shortSha ?? "", + subject: subject ?? "", + authorName: authorName ?? "", + authorDate: authorDate ?? "", + }; + }); + + // `git diff --name-status -z A B` compares the two commits + // directly (two-dot in diff has no range semantics), so it + // works even when A and B are on divergent histories. This + // is what lets the sidebar show the real session delta + // when commits are zero but files were touched. + const diffOut = await gitOut( + ["diff", "--name-status", "-z", startHeadSha, currentHeadSha], + cwd, + ); + sessionFiles = parseNameStatusNul(diffOut); + } } // Working tree state via porcelain v1 for stable parsing. @@ -152,11 +212,45 @@ export async function getSessionGitSnapshot(params: { currentHeadSha, commits, workingTree, + sessionFiles, + startHeadUnreachable, ahead, behind, }; } +/** + * Parse `git diff --name-status -z` output. + * + * Standard entries are `\0\0`; rename/copy entries are + * `\0\0\0` — we keep only the new path + * and collapse the code to its first letter so the UI can render a + * single badge per file. + */ +function parseNameStatusNul(raw: string): SessionGitChangedFile[] { + const files: SessionGitChangedFile[] = []; + const parts = raw.split("\0"); + let i = 0; + while (i < parts.length) { + const token = parts[i]; + if (!token) { + i += 1; + continue; + } + const letter = token[0] ?? ""; + if (letter === "R" || letter === "C") { + const newPath = parts[i + 2]; + if (newPath) files.push({ path: newPath, code: letter }); + i += 3; + continue; + } + const p = parts[i + 1]; + if (p) files.push({ path: p, code: letter || token }); + i += 2; + } + return files; +} + export type SessionDiffScope = "session" | "staged" | "unstaged" | "commit"; export async function getSessionFileDiff(params: { diff --git a/apps/desktop/src/main/todo-agent/supervisor.ts b/apps/desktop/src/main/todo-agent/supervisor.ts index e3111c9c9e4..50b84c79bfd 100644 --- a/apps/desktop/src/main/todo-agent/supervisor.ts +++ b/apps/desktop/src/main/todo-agent/supervisor.ts @@ -224,12 +224,16 @@ class TodoSupervisor { // sidebar can show exactly what this session produced via // `git log ..HEAD` — user commits made before // the session are excluded from attribution. + // + // On resume (follow-up intervention), keep the ORIGINAL + // starting point. Overwriting it on every run moved the + // goalpost forward and hid earlier commits from the sidebar. if (worktreePath) { appendSetupEvent(sessionId, "worktree", worktreePath); } - const startHeadSha = worktreePath - ? await getCurrentHeadSha(worktreePath) - : null; + const startHeadSha = + session0.startHeadSha ?? + (worktreePath ? await getCurrentHeadSha(worktreePath) : null); if (startHeadSha) { appendSetupEvent( sessionId, diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/ChangesSidebar/ChangesSidebar.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoManager/ChangesSidebar/ChangesSidebar.tsx index 38ac9a5099d..d82a96bbcf0 100644 --- a/apps/desktop/src/renderer/features/todo-agent/TodoManager/ChangesSidebar/ChangesSidebar.tsx +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/ChangesSidebar/ChangesSidebar.tsx @@ -33,6 +33,7 @@ export function ChangesSidebar({ sessionId, active }: ChangesSidebarProps) { const [selected, setSelected] = useState(null); const [commitsOpen, setCommitsOpen] = useState(true); const [workingTreeOpen, setWorkingTreeOpen] = useState(true); + const [sessionFilesOpen, setSessionFilesOpen] = useState(true); const snapshot = electronTrpc.todoAgent.gitSnapshot.useQuery( { sessionId }, @@ -70,6 +71,8 @@ export function ChangesSidebar({ sessionId, active }: ChangesSidebarProps) { const data = snapshot.data; const commits = data?.commits ?? []; const workingTree = data?.workingTree ?? []; + const sessionFiles = data?.sessionFiles ?? []; + const startHeadUnreachable = data?.startHeadUnreachable ?? false; const stagedCount = useMemo( () => workingTree.filter((f) => f.stage === "staged").length, @@ -145,6 +148,82 @@ export function ChangesSidebar({ sessionId, active }: ChangesSidebarProps) { )} + {startHeadUnreachable && ( +
+ 開始時 HEAD + のコミットが見つかりません。ブランチがリセットされたか、 + オブジェクトが失われている可能性があります。 +
+ )} + + {/* Cumulative session delta (startHeadSha ↔ HEAD), shown + even when no new commits exist so branch switches / + rebases don't leave the sidebar looking empty. */} +
+ + {sessionFilesOpen && ( +
+ {!data?.startHeadSha ? ( +

+ 開始時 HEAD が未記録のため、差分を算出できません。 +

+ ) : sessionFiles.length === 0 ? ( +

+ 開始時からの差分はありません。 +

+ ) : ( + sessionFiles.map((file) => { + const key = `session:${file.path}`; + // Deletions ARE the diff at session scope — + // `git diff ..HEAD -- ` still emits + // a valid deletion patch, so keep every entry + // clickable. The working-tree section below + // rightly disables `D`, because there the file + // is already gone from the worktree. + return ( + + ); + }) + )} +
+ )} +
+ {/* Commits since session start */}