Skip to content
Merged
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
142 changes: 118 additions & 24 deletions apps/desktop/src/main/todo-agent/git-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ async function gitOut(args: string[], cwd: string): Promise<string> {
}
}

/**
* 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<boolean> {
try {
await execGitWithShellPath(
["rev-parse", "--verify", "--quiet", `${sha}^{commit}`],
{ cwd },
);
return true;
} catch {
return false;
}
}

export async function getCurrentHeadSha(cwd: string): Promise<string | null> {
const out = (await gitOut(["rev-parse", "HEAD"], cwd)).trim();
return out || null;
Expand All @@ -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;
}
Expand All @@ -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.
Expand Down Expand Up @@ -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 `<CODE>\0<path>\0`; rename/copy entries are
* `<CODE><score>\0<oldPath>\0<newPath>\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: {
Expand Down
10 changes: 7 additions & 3 deletions apps/desktop/src/main/todo-agent/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,12 +224,16 @@ class TodoSupervisor {
// sidebar can show exactly what this session produced via
// `git log <startHeadSha>..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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function ChangesSidebar({ sessionId, active }: ChangesSidebarProps) {
const [selected, setSelected] = useState<SelectedDiff | null>(null);
const [commitsOpen, setCommitsOpen] = useState(true);
const [workingTreeOpen, setWorkingTreeOpen] = useState(true);
const [sessionFilesOpen, setSessionFilesOpen] = useState(true);

const snapshot = electronTrpc.todoAgent.gitSnapshot.useQuery(
{ sessionId },
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -145,6 +148,82 @@ export function ChangesSidebar({ sessionId, active }: ChangesSidebarProps) {
</div>
)}

{startHeadUnreachable && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] text-amber-500">
開始時 HEAD
のコミットが見つかりません。ブランチがリセットされたか、
オブジェクトが失われている可能性があります。
</div>
)}

{/* Cumulative session delta (startHeadSha ↔ HEAD), shown
even when no new commits exist so branch switches /
rebases don't leave the sidebar looking empty. */}
<section>
<button
type="button"
onClick={() => setSessionFilesOpen((v) => !v)}
className="w-full flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground font-semibold mb-1 hover:text-foreground transition"
>
{sessionFilesOpen ? (
<HiMiniChevronDown className="size-3" />
) : (
<HiMiniChevronRight className="size-3" />
)}
セッション全体
<span className="ml-1 text-muted-foreground/70">
({sessionFiles.length})
</span>
</button>
{sessionFilesOpen && (
<div className="flex flex-col gap-0.5">
{!data?.startHeadSha ? (
<p className="text-[11px] text-muted-foreground px-1 py-2">
開始時 HEAD が未記録のため、差分を算出できません。
</p>
) : sessionFiles.length === 0 ? (
<p className="text-[11px] text-muted-foreground px-1 py-2">
開始時からの差分はありません。
</p>
) : (
sessionFiles.map((file) => {
const key = `session:${file.path}`;
// Deletions ARE the diff at session scope —
// `git diff <start>..HEAD -- <path>` 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 (
<button
key={key}
type="button"
onClick={() =>
setSelected({
key,
path: file.path,
scope: "session",
label: file.path,
})
}
className={cn(
"text-left rounded-md px-2 py-1 border border-transparent hover:bg-accent/50 hover:border-border/40 transition flex items-center gap-2",
selected?.key === key &&
"bg-accent border-primary/40",
)}
>
<StatusBadge code={file.code} stage="session" />
<span className="text-[11px] font-mono truncate flex-1">
{file.path}
</span>
</button>
);
})
)}
</div>
)}
</section>

{/* Commits since session start */}
<section>
<button
Expand Down Expand Up @@ -284,7 +363,7 @@ export function ChangesSidebar({ sessionId, active }: ChangesSidebarProps) {
<div className="text-[10px] uppercase tracking-wide text-muted-foreground font-semibold truncate">
{selected.scope === "commit"
? `コミット ${selected.label}`
: `${selected.scope === "staged" ? "staged" : "unstaged"} · ${selected.label}`}
: `${scopeLabel(selected.scope)} · ${selected.label}`}
</div>
<button
type="button"
Expand Down Expand Up @@ -394,3 +473,16 @@ function formatShortDate(iso: string): string {
const pad = (n: number) => n.toString().padStart(2, "0");
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

function scopeLabel(scope: DiffScope): string {
switch (scope) {
case "staged":
return "staged";
case "unstaged":
return "unstaged";
case "session":
return "セッション全体";
case "commit":
return "commit";
}
}
Loading