Diffs on attempts; hosted agent presented as the g1t agent
- repos: compare, a tree walk that skips identical subtrees plus line diffs, against the commit a fork last shared with its source - work: records what main pointed to before an attempt landed - site: Session and Changes tabs on attempts with a pull-request style diff - the hosted agent is labelled g1t-agent; what runs behind it is g1t's choice
17 files+655−200/17 viewed
| 858 | 858 | "g1t-kit", | |
| 859 | 859 | "serde", | |
| 860 | 860 | "serde_json", | |
| 861 | + | "similar", | |
| 861 | 862 | "worker", | |
| 862 | 863 | ] | |
| 863 | 864 | ||
| 2489 | 2490 | checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" | |
| 2490 | 2491 | ||
| 2491 | 2492 | [[package]] | |
| 2493 | + | name = "similar" | |
| 2494 | + | version = "2.7.0" | |
| 2495 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2496 | + | checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" | |
| 2497 | + | ||
| 2498 | + | [[package]] | |
| 2492 | 2499 | name = "slab" | |
| 2493 | 2500 | version = "0.4.12" | |
| 2494 | 2501 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1 | + | import { FileDiff as FileIcon, FileMinus, FilePlus } from "lucide-react"; | |
| 2 | + | ||
| 3 | + | import type { Comparison, DiffLine, FileDiff } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import { EmptyState } from "./ui"; | |
| 6 | + | ||
| 7 | + | const ROW_STYLES: Record<DiffLine["kind"], string> = { | |
| 8 | + | context: "", | |
| 9 | + | add: "bg-accent/10", | |
| 10 | + | delete: "bg-danger/10", | |
| 11 | + | }; | |
| 12 | + | ||
| 13 | + | const MARKERS: Record<DiffLine["kind"], string> = { | |
| 14 | + | context: " ", | |
| 15 | + | add: "+", | |
| 16 | + | delete: "-", | |
| 17 | + | }; | |
| 18 | + | ||
| 19 | + | /** `+12 −3` with a five-block bar, like the summary on a pull request. */ | |
| 20 | + | function Stat({ additions, deletions }: { additions: number; deletions: number }) { | |
| 21 | + | const total = additions + deletions; | |
| 22 | + | const green = total === 0 ? 0 : Math.round((additions / total) * 5); | |
| 23 | + | return ( | |
| 24 | + | <span className="flex shrink-0 items-center gap-2 font-mono text-xs"> | |
| 25 | + | <span className="text-accent">+{additions}</span> | |
| 26 | + | <span className="text-danger">−{deletions}</span> | |
| 27 | + | <span className="flex gap-px" aria-hidden="true"> | |
| 28 | + | {Array.from({ length: 5 }, (_, i) => ( | |
| 29 | + | <span | |
| 30 | + | key={i} | |
| 31 | + | className={`size-2 rounded-[2px] ${ | |
| 32 | + | total === 0 ? "bg-line" : i < green ? "bg-accent" : "bg-danger" | |
| 33 | + | }`} | |
| 34 | + | /> | |
| 35 | + | ))} | |
| 36 | + | </span> | |
| 37 | + | </span> | |
| 38 | + | ); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | function File({ file }: { file: FileDiff }) { | |
| 42 | + | const Icon = | |
| 43 | + | file.status === "added" ? FilePlus : file.status === "deleted" ? FileMinus : FileIcon; | |
| 44 | + | return ( | |
| 45 | + | <section | |
| 46 | + | id={`file-${file.path}`} | |
| 47 | + | className="scroll-mt-20 overflow-hidden rounded-xl border border-line" | |
| 48 | + | > | |
| 49 | + | <header className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5"> | |
| 50 | + | <Icon | |
| 51 | + | size={15} | |
| 52 | + | className={ | |
| 53 | + | file.status === "added" | |
| 54 | + | ? "text-accent" | |
| 55 | + | : file.status === "deleted" | |
| 56 | + | ? "text-danger" | |
| 57 | + | : "text-faint" | |
| 58 | + | } | |
| 59 | + | /> | |
| 60 | + | <span className="min-w-0 grow truncate font-mono text-[0.8125rem]"> | |
| 61 | + | {file.path} | |
| 62 | + | </span> | |
| 63 | + | <Stat additions={file.additions} deletions={file.deletions} /> | |
| 64 | + | </header> | |
| 65 | + | {file.binary ? ( | |
| 66 | + | <p className="px-4 py-6 text-sm text-muted"> | |
| 67 | + | Binary or large file; its contents are not shown. | |
| 68 | + | </p> | |
| 69 | + | ) : file.hunks.length === 0 ? ( | |
| 70 | + | <p className="px-4 py-6 text-sm text-muted">No line changes.</p> | |
| 71 | + | ) : ( | |
| 72 | + | <div className="overflow-x-auto"> | |
| 73 | + | <table className="w-full border-collapse font-mono text-xs leading-5"> | |
| 74 | + | <tbody> | |
| 75 | + | {file.hunks.map((hunk, index) => ( | |
| 76 | + | <HunkRows key={index} lines={hunk.lines} first={index === 0} /> | |
| 77 | + | ))} | |
| 78 | + | </tbody> | |
| 79 | + | </table> | |
| 80 | + | </div> | |
| 81 | + | )} | |
| 82 | + | </section> | |
| 83 | + | ); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | function HunkRows({ lines, first }: { lines: DiffLine[]; first: boolean }) { | |
| 87 | + | return ( | |
| 88 | + | <> | |
| 89 | + | {!first && ( | |
| 90 | + | <tr aria-hidden="true"> | |
| 91 | + | <td colSpan={4} className="border-y border-line bg-surface py-1 text-center text-faint"> | |
| 92 | + | ⋯ | |
| 93 | + | </td> | |
| 94 | + | </tr> | |
| 95 | + | )} | |
| 96 | + | {lines.map((line, index) => ( | |
| 97 | + | <tr key={index} className={ROW_STYLES[line.kind]}> | |
| 98 | + | <td className="w-10 px-2 text-right text-faint select-none">{line.old}</td> | |
| 99 | + | <td className="w-10 px-2 text-right text-faint select-none">{line.new}</td> | |
| 100 | + | <td | |
| 101 | + | className={`w-5 text-center select-none ${ | |
| 102 | + | line.kind === "add" | |
| 103 | + | ? "text-accent" | |
| 104 | + | : line.kind === "delete" | |
| 105 | + | ? "text-danger" | |
| 106 | + | : "text-faint" | |
| 107 | + | }`} | |
| 108 | + | > | |
| 109 | + | {MARKERS[line.kind]} | |
| 110 | + | </td> | |
| 111 | + | <td className="pr-4 whitespace-pre">{line.text}</td> | |
| 112 | + | </tr> | |
| 113 | + | ))} | |
| 114 | + | </> | |
| 115 | + | ); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | export function DiffView({ comparison }: { comparison: Comparison }) { | |
| 119 | + | const { files, truncated } = comparison; | |
| 120 | + | if (files.length === 0) { | |
| 121 | + | return ( | |
| 122 | + | <EmptyState title="No changes yet"> | |
| 123 | + | Nothing has been pushed to this attempt's fork, or it matches the | |
| 124 | + | repository it came from. | |
| 125 | + | </EmptyState> | |
| 126 | + | ); | |
| 127 | + | } | |
| 128 | + | const additions = files.reduce((sum, file) => sum + file.additions, 0); | |
| 129 | + | const deletions = files.reduce((sum, file) => sum + file.deletions, 0); | |
| 130 | + | return ( | |
| 131 | + | <div className="space-y-4"> | |
| 132 | + | <div className="flex items-center gap-3 text-sm text-muted"> | |
| 133 | + | <span> | |
| 134 | + | <span className="font-medium text-fg">{files.length}</span>{" "} | |
| 135 | + | {files.length === 1 ? "file" : "files"} changed | |
| 136 | + | </span> | |
| 137 | + | <Stat additions={additions} deletions={deletions} /> | |
| 138 | + | </div> | |
| 139 | + | {files.length > 1 && ( | |
| 140 | + | <ul className="rounded-xl border border-line bg-surface p-2 text-sm"> | |
| 141 | + | {files.map((file) => ( | |
| 142 | + | <li key={file.path}> | |
| 143 | + | <a | |
| 144 | + | href={`#file-${file.path}`} | |
| 145 | + | className="flex items-center gap-3 rounded-md px-2 py-1 hover:bg-raised" | |
| 146 | + | > | |
| 147 | + | <span className="min-w-0 grow truncate font-mono text-[0.8125rem]"> | |
| 148 | + | {file.path} | |
| 149 | + | </span> | |
| 150 | + | <Stat additions={file.additions} deletions={file.deletions} /> | |
| 151 | + | </a> | |
| 152 | + | </li> | |
| 153 | + | ))} | |
| 154 | + | </ul> | |
| 155 | + | )} | |
| 156 | + | {files.map((file) => ( | |
| 157 | + | <File key={file.path} file={file} /> | |
| 158 | + | ))} | |
| 159 | + | {truncated && ( | |
| 160 | + | <p className="text-center text-sm text-muted"> | |
| 161 | + | This change is too large to show in full. | |
| 162 | + | </p> | |
| 163 | + | )} | |
| 164 | + | </div> | |
| 165 | + | ); | |
| 166 | + | } |
| 91 | 91 | - **Pull requests from branches.** Opening an attempt from a branch you | |
| 92 | 92 | pushed, the way a pull request works elsewhere. | |
| 93 | 93 | - **Checks.** Running an intent's acceptance checks automatically. | |
| 94 | − | - **Hosted agents.** Starting agents on g1t's own sandboxes. Today you bring | |
| 95 | − | your own agent. | |
| 94 | + | - **g1t agents for everyone.** g1t can run its own agents on an intent, each | |
| 95 | + | in a sandbox. This is in preview and limited to selected accounts; anyone | |
| 96 | + | can bring their own agent today. |
| 2 | 2 | import { | |
| 3 | 3 | Bot, | |
| 4 | 4 | ChevronRight, | |
| 5 | + | FileDiff, | |
| 5 | 6 | GitCommitHorizontal, | |
| 7 | + | MessagesSquare, | |
| 6 | 8 | Rocket, | |
| 7 | 9 | StickyNote, | |
| 8 | 10 | User, | |
| 11 | 13 | import { useEffect } from "react"; | |
| 12 | 14 | import { Form, Link, useRevalidator } from "react-router"; | |
| 13 | 15 | ||
| 14 | − | import type { SessionEntry } from "@g1t/contracts"; | |
| 16 | + | import type { Comparison, SessionEntry } from "@g1t/contracts"; | |
| 15 | 17 | ||
| 16 | 18 | import type { Route } from "./+types/attempt"; | |
| 19 | + | import { DiffView } from "../../components/diff-view"; | |
| 17 | 20 | import { Markdown } from "../../components/markdown"; | |
| 18 | 21 | import { | |
| 19 | 22 | Button, | |
| 33 | 36 | } from "../../lib/session.server"; | |
| 34 | 37 | ||
| 35 | 38 | const REFRESH_MS = 4000; | |
| 39 | + | const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false }; | |
| 36 | 40 | ||
| 37 | 41 | export function meta({ loaderData }: Route.MetaArgs) { | |
| 38 | 42 | return [ | |
| 44 | 48 | ]; | |
| 45 | 49 | } | |
| 46 | 50 | ||
| 47 | − | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 51 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 48 | 52 | const viewer = getViewer(context); | |
| 49 | 53 | const [found, session] = await Promise.all([ | |
| 50 | 54 | env.WORK.getAttempt(params.id, viewer), | |
| 52 | 56 | ]); | |
| 53 | 57 | const detail = unwrap(found); | |
| 54 | 58 | const repo = await repos.getById(detail.attempt.repoId, viewer); | |
| 59 | + | const showChanges = new URL(request.url).searchParams.get("tab") === "changes"; | |
| 60 | + | const comparison = showChanges | |
| 61 | + | ? await repos.compare( | |
| 62 | + | detail.attempt.forkRepoId, | |
| 63 | + | viewer, | |
| 64 | + | detail.attempt.landedBase, | |
| 65 | + | ) | |
| 66 | + | : null; | |
| 55 | 67 | return { | |
| 56 | 68 | ...detail, | |
| 69 | + | // Null on the session tab; an empty comparison if it could not be made. | |
| 70 | + | comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON), | |
| 57 | 71 | session: unwrap(session), | |
| 58 | 72 | viewer, | |
| 59 | 73 | // Only the repository's owner can land an attempt. | |
| 81 | 95 | } | |
| 82 | 96 | ||
| 83 | 97 | /** One step of the session, on the timeline's rail. */ | |
| 98 | + | function TabLink({ | |
| 99 | + | to, | |
| 100 | + | active, | |
| 101 | + | children, | |
| 102 | + | }: { | |
| 103 | + | to: string; | |
| 104 | + | active: boolean; | |
| 105 | + | children: React.ReactNode; | |
| 106 | + | }) { | |
| 107 | + | return ( | |
| 108 | + | <Link | |
| 109 | + | to={to} | |
| 110 | + | preventScrollReset | |
| 111 | + | className={ | |
| 112 | + | "-mb-px flex items-center gap-2 border-b-2 px-1 pb-2.5 text-sm transition-colors " + | |
| 113 | + | (active | |
| 114 | + | ? "border-accent font-medium text-fg" | |
| 115 | + | : "border-transparent text-muted hover:text-fg") | |
| 116 | + | } | |
| 117 | + | > | |
| 118 | + | {children} | |
| 119 | + | </Link> | |
| 120 | + | ); | |
| 121 | + | } | |
| 122 | + | ||
| 84 | 123 | function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) { | |
| 85 | 124 | const isTool = entry.kind === "tool_call" || entry.kind === "tool_result"; | |
| 86 | 125 | const Icon = | |
| 160 | 199 | actionData, | |
| 161 | 200 | params, | |
| 162 | 201 | }: Route.ComponentProps) { | |
| 163 | − | const { attempt, intent, session, viewer, canShip, defaultBranch } = loaderData; | |
| 202 | + | const { attempt, intent, session, viewer, canShip, defaultBranch, comparison } = | |
| 203 | + | loaderData; | |
| 164 | 204 | const base = `/${params.owner}/${params.repo}`; | |
| 205 | + | const here = base + "/attempts/" + attempt.id; | |
| 165 | 206 | const remote = `https://g1t.sh/${attempt.fork.namespace}/${attempt.fork.name}.git`; | |
| 166 | 207 | const mine = viewer?.id === attempt.startedBy.id; | |
| 167 | 208 | const active = attempt.status === "working" || attempt.status === "submitted"; | |
| 215 | 256 | </section> | |
| 216 | 257 | )} | |
| 217 | 258 | ||
| 218 | − | <h3 className="mt-10 font-semibold tracking-tight">Session</h3> | |
| 219 | − | <div className="mt-4"> | |
| 220 | − | {session.length === 0 ? ( | |
| 259 | + | <nav className="mt-10 flex gap-6 border-b border-line"> | |
| 260 | + | <TabLink to={here} active={!comparison}> | |
| 261 | + | <MessagesSquare size={15} /> | |
| 262 | + | Session | |
| 263 | + | </TabLink> | |
| 264 | + | <TabLink to={here + "?tab=changes"} active={Boolean(comparison)}> | |
| 265 | + | <FileDiff size={15} /> | |
| 266 | + | Changes | |
| 267 | + | </TabLink> | |
| 268 | + | </nav> | |
| 269 | + | <div className="mt-5"> | |
| 270 | + | {comparison ? ( | |
| 271 | + | <DiffView comparison={comparison} /> | |
| 272 | + | ) : session.length === 0 ? ( | |
| 221 | 273 | <EmptyState title="Nothing recorded yet"> | |
| 222 | 274 | The agent's prompts, reasoning and tool calls appear here as it | |
| 223 | 275 | works. |
| 215 | 215 | <section className="rounded-xl border border-accent/30 bg-accent/5 p-4"> | |
| 216 | 216 | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 217 | 217 | <Sparkles size={15} className="text-accent" /> | |
| 218 | − | Run hosted agents | |
| 218 | + | Run g1t agents | |
| 219 | 219 | </h3> | |
| 220 | 220 | <p className="mt-1 text-xs text-muted"> | |
| 221 | − | g1t starts Claude Code in a sandbox for each attempt. They work | |
| 222 | − | in parallel, each in its own fork. | |
| 221 | + | Each agent works in its own sandbox and its own fork, in | |
| 222 | + | parallel. Compare what they produce and ship the best. | |
| 223 | 223 | </p> | |
| 224 | 224 | <Form method="post" className="mt-3 space-y-2"> | |
| 225 | 225 | <input type="hidden" name="intentId" value={intent.id} /> |
| 110 | 110 | ReceivePack, | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | − | /// The commit `main` points to after an attempt has landed. | |
| 113 | + | /// The result of landing an attempt. | |
| 114 | 114 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 115 | 115 | pub struct Landed { | |
| 116 | + | /// The commit the branch points to now. | |
| 116 | 117 | pub commit: String, | |
| 118 | + | /// The commit it pointed to before, if it had one. Comparing against | |
| 119 | + | /// this shows what the attempt changed. | |
| 120 | + | pub previous: Option<String>, | |
| 117 | 121 | } | |
| 118 | 122 | ||
| 119 | 123 | /// `get`. Returns `Outcome<Repo>`. | |
| 215 | 219 | pub fork_id: String, | |
| 216 | 220 | pub actor: User, | |
| 217 | 221 | } | |
| 222 | + | ||
| 223 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 224 | + | #[serde(rename_all = "lowercase")] | |
| 225 | + | pub enum FileStatus { | |
| 226 | + | Added, | |
| 227 | + | Modified, | |
| 228 | + | Deleted, | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 232 | + | #[serde(rename_all = "lowercase")] | |
| 233 | + | pub enum LineKind { | |
| 234 | + | /// Unchanged, shown for context. | |
| 235 | + | Context, | |
| 236 | + | Add, | |
| 237 | + | Delete, | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 241 | + | pub struct DiffLine { | |
| 242 | + | pub kind: LineKind, | |
| 243 | + | /// Line number in the old file; absent for added lines. | |
| 244 | + | pub old: Option<u32>, | |
| 245 | + | /// Line number in the new file; absent for deleted lines. | |
| 246 | + | pub new: Option<u32>, | |
| 247 | + | pub text: String, | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | /// A run of changed lines with their surrounding context. | |
| 251 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 252 | + | pub struct Hunk { | |
| 253 | + | pub lines: Vec<DiffLine>, | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 257 | + | pub struct FileDiff { | |
| 258 | + | pub path: String, | |
| 259 | + | pub status: FileStatus, | |
| 260 | + | pub additions: u32, | |
| 261 | + | pub deletions: u32, | |
| 262 | + | /// True when the file is binary or too large, so no lines are shown. | |
| 263 | + | pub binary: bool, | |
| 264 | + | pub hunks: Vec<Hunk>, | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | /// What changed between two commits. | |
| 268 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 269 | + | pub struct Comparison { | |
| 270 | + | /// Null when the head has no earlier commit to compare against. | |
| 271 | + | pub base: Option<String>, | |
| 272 | + | pub head: String, | |
| 273 | + | pub files: Vec<FileDiff>, | |
| 274 | + | /// True when the change was too large to return in full. | |
| 275 | + | pub truncated: bool, | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | /// `compare`: what a repository's head changes. | |
| 279 | + | /// | |
| 280 | + | /// With no `base`, an attempt's fork is compared against the point where it | |
| 281 | + | /// and the repository it came from last agreed, and any other repository | |
| 282 | + | /// against its head's parent. Returns `Outcome<Comparison>`. | |
| 283 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 284 | + | #[serde(rename_all = "camelCase")] | |
| 285 | + | pub struct CompareArgs { | |
| 286 | + | pub repo_id: String, | |
| 287 | + | pub viewer: Viewer, | |
| 288 | + | #[serde(default)] | |
| 289 | + | pub base: Option<String>, | |
| 290 | + | } |
| 72 | 72 | &["-c", &auth, "clone", "--quiet", &remote, WORKDIR], | |
| 73 | 73 | ) | |
| 74 | 74 | .context("could not clone the attempt's fork")?; | |
| 75 | − | git(workdir, &["config", "user.name", "Claude Code"])?; | |
| 75 | + | git(workdir, &["config", "user.name", "g1t agent"])?; | |
| 76 | 76 | git(workdir, &["config", "user.email", "agent@g1t.sh"])?; | |
| 77 | 77 | let branch = git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"])?; | |
| 78 | 78 | let start = git(workdir, &["rev-parse", "HEAD"]).unwrap_or_default(); |
| 73 | 73 | gitAccess: (path, viewer, service) => | |
| 74 | 74 | call("git_access", { path, viewer, service }), | |
| 75 | 75 | land: (forkId, actor) => call("land", { forkId, actor }), | |
| 76 | + | compare: (repoId, viewer, base) => call("compare", { repoId, viewer, base }), | |
| 76 | 77 | }; | |
| 77 | 78 | } |
| 91 | 91 | * head. Refused with "conflict" when the fork is behind, since that would | |
| 92 | 92 | * discard commits. | |
| 93 | 93 | */ | |
| 94 | − | land(forkId: string, actor: User): Promise<Result<{ commit: string }>>; | |
| 94 | + | land(forkId: string, actor: User): Promise<Result<{ commit: string; previous: string | null }>>; | |
| 95 | + | ||
| 96 | + | /** | |
| 97 | + | * What a repository's head changes. An attempt's fork is compared with the | |
| 98 | + | * last commit it shares with the repository it came from, unless `base` | |
| 99 | + | * says otherwise. | |
| 100 | + | */ | |
| 101 | + | compare(repoId: string, viewer: Viewer, base?: string | null): Promise<Result<Comparison>>; | |
| 95 | 102 | } | |
| 103 | + | ||
| 104 | + | export type DiffLine = { | |
| 105 | + | kind: "context" | "add" | "delete"; | |
| 106 | + | /** Line number in the old file; null for added lines. */ | |
| 107 | + | old: number | null; | |
| 108 | + | /** Line number in the new file; null for deleted lines. */ | |
| 109 | + | new: number | null; | |
| 110 | + | text: string; | |
| 111 | + | }; | |
| 112 | + | ||
| 113 | + | /** A run of changed lines with their surrounding context. */ | |
| 114 | + | export type Hunk = { lines: DiffLine[] }; | |
| 115 | + | ||
| 116 | + | export type FileDiff = { | |
| 117 | + | path: string; | |
| 118 | + | status: "added" | "modified" | "deleted"; | |
| 119 | + | additions: number; | |
| 120 | + | deletions: number; | |
| 121 | + | /** True when the file is binary or too large, so no lines are shown. */ | |
| 122 | + | binary: boolean; | |
| 123 | + | hunks: Hunk[]; | |
| 124 | + | }; | |
| 125 | + | ||
| 126 | + | /** What changed between two commits. */ | |
| 127 | + | export type Comparison = { | |
| 128 | + | base: string | null; | |
| 129 | + | head: string; | |
| 130 | + | files: FileDiff[]; | |
| 131 | + | /** True when the change was too large to return in full. */ | |
| 132 | + | truncated: boolean; | |
| 133 | + | }; |
| 41 | 41 | /** The agent's own account of what it did, set on submit. */ | |
| 42 | 42 | summary: string | null; | |
| 43 | 43 | fork: RepoPath; | |
| 44 | + | /** The fork's repository id. */ | |
| 45 | + | forkRepoId: string; | |
| 44 | 46 | headCommit: string | null; | |
| 47 | + | /** | |
| 48 | + | * For a shipped attempt, what the branch pointed to before it landed. | |
| 49 | + | * Comparing against it shows what the attempt changed. | |
| 50 | + | */ | |
| 51 | + | landedBase: string | null; | |
| 45 | 52 | startedBy: User; | |
| 46 | 53 | createdAt: number; | |
| 47 | 54 | updatedAt: number; |
| 14 | 14 | serde.workspace = true | |
| 15 | 15 | serde_json.workspace = true | |
| 16 | 16 | worker.workspace = true | |
| 17 | + | similar = "2" |
| 1 | + | //! Comparing two commits: which files changed, and how. | |
| 2 | + | //! | |
| 3 | + | //! Trees are walked together and identical subtrees are skipped by hash, so | |
| 4 | + | //! the cost follows the size of the change rather than the repository. | |
| 5 | + | ||
| 6 | + | use std::collections::BTreeMap; | |
| 7 | + | ||
| 8 | + | use g1t_contracts::repos::{DiffLine, EntryKind, FileDiff, FileStatus, Hunk, LineKind, TreeEntry}; | |
| 9 | + | use similar::{ChangeTag, TextDiff}; | |
| 10 | + | use worker::Result; | |
| 11 | + | ||
| 12 | + | use crate::store::GitRepo; | |
| 13 | + | ||
| 14 | + | /// Beyond these the comparison is cut short and marked truncated. | |
| 15 | + | const MAX_FILES: usize = 300; | |
| 16 | + | const MAX_LINES: usize = 20_000; | |
| 17 | + | /// Files larger than this are listed without their lines. | |
| 18 | + | const MAX_FILE_BYTES: usize = 512 * 1024; | |
| 19 | + | const CONTEXT_LINES: usize = 3; | |
| 20 | + | ||
| 21 | + | /// A file that differs between the two trees. | |
| 22 | + | struct Change { | |
| 23 | + | path: String, | |
| 24 | + | old: Option<String>, | |
| 25 | + | new: Option<String>, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | async fn entries<R: GitRepo>(repo: &R, tree: Option<&str>) -> Result<BTreeMap<String, TreeEntry>> { | |
| 29 | + | let Some(tree) = tree else { | |
| 30 | + | return Ok(BTreeMap::new()); | |
| 31 | + | }; | |
| 32 | + | Ok(repo | |
| 33 | + | .read_tree(tree) | |
| 34 | + | .await? | |
| 35 | + | .unwrap_or_default() | |
| 36 | + | .into_iter() | |
| 37 | + | .map(|entry| (entry.name.clone(), entry)) | |
| 38 | + | .collect()) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Collects changed files under `prefix`, depth first. Trees are compared | |
| 42 | + | /// with an explicit stack, since async functions cannot recurse directly. | |
| 43 | + | async fn changed_files<R: GitRepo>( | |
| 44 | + | repo: &R, | |
| 45 | + | old_root: Option<&str>, | |
| 46 | + | new_root: &str, | |
| 47 | + | ) -> Result<(Vec<Change>, bool)> { | |
| 48 | + | let mut changes = Vec::new(); | |
| 49 | + | let mut stack = vec![( | |
| 50 | + | String::new(), | |
| 51 | + | old_root.map(str::to_owned), | |
| 52 | + | Some(new_root.to_owned()), | |
| 53 | + | )]; | |
| 54 | + | while let Some((prefix, old_tree, new_tree)) = stack.pop() { | |
| 55 | + | let old = entries(repo, old_tree.as_deref()).await?; | |
| 56 | + | let new = entries(repo, new_tree.as_deref()).await?; | |
| 57 | + | let names: std::collections::BTreeSet<&String> = old.keys().chain(new.keys()).collect(); | |
| 58 | + | for name in names { | |
| 59 | + | let (before, after) = (old.get(name), new.get(name)); | |
| 60 | + | if before.map(|e| &e.hash) == after.map(|e| &e.hash) { | |
| 61 | + | continue; | |
| 62 | + | } | |
| 63 | + | let path = format!("{prefix}{name}"); | |
| 64 | + | let subtree = |entry: Option<&TreeEntry>| { | |
| 65 | + | entry | |
| 66 | + | .filter(|entry| entry.kind == EntryKind::Tree) | |
| 67 | + | .map(|entry| entry.hash.clone()) | |
| 68 | + | }; | |
| 69 | + | let file = |entry: Option<&TreeEntry>| { | |
| 70 | + | entry | |
| 71 | + | .filter(|entry| entry.kind != EntryKind::Tree) | |
| 72 | + | .map(|entry| entry.hash.clone()) | |
| 73 | + | }; | |
| 74 | + | let (old_dir, new_dir) = (subtree(before), subtree(after)); | |
| 75 | + | if old_dir.is_some() || new_dir.is_some() { | |
| 76 | + | stack.push((format!("{path}/"), old_dir, new_dir)); | |
| 77 | + | } | |
| 78 | + | let (old_file, new_file) = (file(before), file(after)); | |
| 79 | + | if old_file.is_some() || new_file.is_some() { | |
| 80 | + | if changes.len() >= MAX_FILES { | |
| 81 | + | return Ok((changes, true)); | |
| 82 | + | } | |
| 83 | + | changes.push(Change { | |
| 84 | + | path, | |
| 85 | + | old: old_file, | |
| 86 | + | new: new_file, | |
| 87 | + | }); | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | changes.sort_by(|a, b| a.path.cmp(&b.path)); | |
| 92 | + | Ok((changes, false)) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// The text of a blob, or `None` if it is binary, too large or missing. | |
| 96 | + | async fn text<R: GitRepo>(repo: &R, hash: Option<&str>) -> Result<Option<String>> { | |
| 97 | + | let Some(hash) = hash else { | |
| 98 | + | return Ok(Some(String::new())); | |
| 99 | + | }; | |
| 100 | + | let Some(bytes) = repo.read_blob(hash).await? else { | |
| 101 | + | return Ok(None); | |
| 102 | + | }; | |
| 103 | + | if bytes.len() > MAX_FILE_BYTES || bytes.contains(&0) { | |
| 104 | + | return Ok(None); | |
| 105 | + | } | |
| 106 | + | Ok(String::from_utf8(bytes).ok()) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | fn line_diff(old: &str, new: &str) -> (Vec<Hunk>, u32, u32) { | |
| 110 | + | let diff = TextDiff::from_lines(old, new); | |
| 111 | + | let (mut additions, mut deletions) = (0, 0); | |
| 112 | + | let hunks = diff | |
| 113 | + | .grouped_ops(CONTEXT_LINES) | |
| 114 | + | .iter() | |
| 115 | + | .map(|group| Hunk { | |
| 116 | + | lines: group | |
| 117 | + | .iter() | |
| 118 | + | .flat_map(|op| diff.iter_changes(op)) | |
| 119 | + | .map(|change| { | |
| 120 | + | let kind = match change.tag() { | |
| 121 | + | ChangeTag::Equal => LineKind::Context, | |
| 122 | + | ChangeTag::Insert => { | |
| 123 | + | additions += 1; | |
| 124 | + | LineKind::Add | |
| 125 | + | } | |
| 126 | + | ChangeTag::Delete => { | |
| 127 | + | deletions += 1; | |
| 128 | + | LineKind::Delete | |
| 129 | + | } | |
| 130 | + | }; | |
| 131 | + | DiffLine { | |
| 132 | + | kind, | |
| 133 | + | old: change.old_index().map(|index| index as u32 + 1), | |
| 134 | + | new: change.new_index().map(|index| index as u32 + 1), | |
| 135 | + | text: change.value().trim_end_matches(['\r', '\n']).to_owned(), | |
| 136 | + | } | |
| 137 | + | }) | |
| 138 | + | .collect(), | |
| 139 | + | }) | |
| 140 | + | .collect(); | |
| 141 | + | (hunks, additions, deletions) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /// The files that differ between two trees, with their line changes. | |
| 145 | + | /// Returns the files and whether the result was cut short. | |
| 146 | + | pub async fn compare_trees<R: GitRepo>( | |
| 147 | + | repo: &R, | |
| 148 | + | old_tree: Option<&str>, | |
| 149 | + | new_tree: &str, | |
| 150 | + | ) -> Result<(Vec<FileDiff>, bool)> { | |
| 151 | + | let (changes, mut truncated) = changed_files(repo, old_tree, new_tree).await?; | |
| 152 | + | let mut files = Vec::with_capacity(changes.len()); | |
| 153 | + | let mut lines = 0; | |
| 154 | + | for change in changes { | |
| 155 | + | let status = match (&change.old, &change.new) { | |
| 156 | + | (None, _) => FileStatus::Added, | |
| 157 | + | (_, None) => FileStatus::Deleted, | |
| 158 | + | _ => FileStatus::Modified, | |
| 159 | + | }; | |
| 160 | + | let texts = if lines >= MAX_LINES { | |
| 161 | + | truncated = true; | |
| 162 | + | None | |
| 163 | + | } else { | |
| 164 | + | let old = text(repo, change.old.as_deref()).await?; | |
| 165 | + | let new = text(repo, change.new.as_deref()).await?; | |
| 166 | + | old.zip(new) | |
| 167 | + | }; | |
| 168 | + | let (hunks, additions, deletions, binary) = match texts { | |
| 169 | + | Some((old, new)) => { | |
| 170 | + | let (hunks, additions, deletions) = line_diff(&old, &new); | |
| 171 | + | (hunks, additions, deletions, false) | |
| 172 | + | } | |
| 173 | + | None => (Vec::new(), 0, 0, true), | |
| 174 | + | }; | |
| 175 | + | lines += hunks.iter().map(|hunk| hunk.lines.len()).sum::<usize>(); | |
| 176 | + | files.push(FileDiff { | |
| 177 | + | path: change.path, | |
| 178 | + | status, | |
| 179 | + | additions, | |
| 180 | + | deletions, | |
| 181 | + | binary, | |
| 182 | + | hunks, | |
| 183 | + | }); | |
| 184 | + | } | |
| 185 | + | Ok((files, truncated)) | |
| 186 | + | } |
| 5 | 5 | //! `g1t_contracts::repos` for the methods and their arguments. Any other | |
| 6 | 6 | //! request is treated as git's smart HTTP protocol. | |
| 7 | 7 | ||
| 8 | + | mod diff; | |
| 8 | 9 | mod git_http; | |
| 9 | 10 | mod land; | |
| 10 | 11 | mod registry; | |
| 16 | 17 | FailureCode, Outcome, User, Viewer, is_valid_namespace, is_valid_repo_name, new_id, | |
| 17 | 18 | }; | |
| 18 | 19 | use g1t_kit::{args, js, now_ms, reply, rpc_method}; | |
| 19 | − | use std::collections::{HashMap, HashSet}; | |
| 20 | + | use std::collections::{HashMap, HashSet, VecDeque}; | |
| 20 | 21 | ||
| 21 | 22 | use serde::Serialize; | |
| 22 | 23 | use worker::wasm_bindgen::JsValue; | |
| 83 | 84 | Ok(false) | |
| 84 | 85 | } | |
| 85 | 86 | ||
| 87 | + | /// The commit closest to the newest in `history` that is also in `shared`: | |
| 88 | + | /// where a fork and the repository it came from last agreed. | |
| 89 | + | async fn nearest_ancestor_in<R: GitRepo>( | |
| 90 | + | repo: &R, | |
| 91 | + | history: &[Commit], | |
| 92 | + | shared: &HashSet<String>, | |
| 93 | + | ) -> Result<Option<String>> { | |
| 94 | + | let known: HashMap<&str, &[String]> = history | |
| 95 | + | .iter() | |
| 96 | + | .map(|commit| (commit.hash.as_str(), commit.parents.as_slice())) | |
| 97 | + | .collect(); | |
| 98 | + | let mut seen = HashSet::new(); | |
| 99 | + | let mut queue: VecDeque<String> = history | |
| 100 | + | .first() | |
| 101 | + | .map(|c| c.hash.clone()) | |
| 102 | + | .into_iter() | |
| 103 | + | .collect(); | |
| 104 | + | while let Some(hash) = queue.pop_front() { | |
| 105 | + | if shared.contains(&hash) { | |
| 106 | + | return Ok(Some(hash)); | |
| 107 | + | } | |
| 108 | + | if !seen.insert(hash.clone()) || seen.len() > MAX_ANCESTRY as usize { | |
| 109 | + | continue; | |
| 110 | + | } | |
| 111 | + | match known.get(hash.as_str()) { | |
| 112 | + | Some(parents) => queue.extend(parents.iter().cloned()), | |
| 113 | + | None => queue.extend(repo.parents(&hash).await?.unwrap_or_default()), | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | Ok(None) | |
| 117 | + | } | |
| 118 | + | ||
| 86 | 119 | struct Repos<S: GitStore> { | |
| 87 | 120 | registry: Registry, | |
| 88 | 121 | store: S, | |
| 427 | 460 | .map(|commit| commit.hash); | |
| 428 | 461 | ||
| 429 | 462 | if old.as_deref() == Some(new.as_str()) { | |
| 430 | − | return Ok(Outcome::Ok(Landed { commit: new })); | |
| 463 | + | return Ok(Outcome::Ok(Landed { | |
| 464 | + | commit: new, | |
| 465 | + | previous: None, | |
| 466 | + | })); | |
| 431 | 467 | } | |
| 432 | 468 | // Moving the branch to a commit that does not descend from its | |
| 433 | 469 | // current head would discard whatever landed in between. | |
| 455 | 491 | )); | |
| 456 | 492 | } | |
| 457 | 493 | self.publish_push(&target, &new, Some(a.actor.id)).await?; | |
| 458 | − | Ok(Outcome::Ok(Landed { commit: new })) | |
| 494 | + | Ok(Outcome::Ok(Landed { | |
| 495 | + | commit: new, | |
| 496 | + | previous: old, | |
| 497 | + | })) | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | async fn compare(&self, a: CompareArgs) -> Result<Outcome<Comparison>> { | |
| 501 | + | let Some(repo) = self | |
| 502 | + | .registry | |
| 503 | + | .by_id(&a.repo_id) | |
| 504 | + | .await? | |
| 505 | + | .filter(|repo| can_read(repo, &a.viewer)) | |
| 506 | + | else { | |
| 507 | + | return Ok(not_found()); | |
| 508 | + | }; | |
| 509 | + | let git = self.store.open(&store_key(&repo)).await?; | |
| 510 | + | let history = git.log(&repo.default_branch, MAX_ANCESTRY).await?; | |
| 511 | + | let Some(head) = history.first() else { | |
| 512 | + | return Ok(Outcome::fail( | |
| 513 | + | FailureCode::Conflict, | |
| 514 | + | "This repository has no commits yet.", | |
| 515 | + | )); | |
| 516 | + | }; | |
| 517 | + | ||
| 518 | + | let base = match (a.base, &repo.fork_of) { | |
| 519 | + | (Some(base), _) => Some(base), | |
| 520 | + | // A fork is compared with the last commit it shares with the | |
| 521 | + | // repository it came from. | |
| 522 | + | (None, Some(target_id)) => match self.registry.by_id(target_id).await? { | |
| 523 | + | Some(target) => { | |
| 524 | + | let target_git = self.store.open(&store_key(&target)).await?; | |
| 525 | + | let shared: HashSet<String> = target_git | |
| 526 | + | .log(&target.default_branch, MAX_ANCESTRY) | |
| 527 | + | .await? | |
| 528 | + | .into_iter() | |
| 529 | + | .map(|commit| commit.hash) | |
| 530 | + | .collect(); | |
| 531 | + | nearest_ancestor_in(&git, &history, &shared).await? | |
| 532 | + | } | |
| 533 | + | None => None, | |
| 534 | + | }, | |
| 535 | + | (None, None) => head.parents.first().cloned(), | |
| 536 | + | }; | |
| 537 | + | let base_tree = match &base { | |
| 538 | + | Some(base) => git | |
| 539 | + | .log(base, 1) | |
| 540 | + | .await? | |
| 541 | + | .into_iter() | |
| 542 | + | .next() | |
| 543 | + | .map(|commit| commit.tree_hash), | |
| 544 | + | None => None, | |
| 545 | + | }; | |
| 546 | + | let (files, truncated) = | |
| 547 | + | diff::compare_trees(&git, base_tree.as_deref(), &head.tree_hash).await?; | |
| 548 | + | Ok(Outcome::Ok(Comparison { | |
| 549 | + | base, | |
| 550 | + | head: head.hash.clone(), | |
| 551 | + | files, | |
| 552 | + | truncated, | |
| 553 | + | })) | |
| 459 | 554 | } | |
| 460 | 555 | ||
| 461 | 556 | async fn publish_push(&self, repo: &Repo, after: &str, actor: Option<String>) -> Result<()> { | |
| 542 | 637 | "fork_for_attempt" => reply(&repos.fork_for_attempt(args(body)?).await?), | |
| 543 | 638 | "git_access" => reply(&repos.git_access(args(body)?).await?), | |
| 544 | 639 | "land" => reply(&repos.land(args(body)?).await?), | |
| 640 | + | "compare" => reply(&repos.compare(args(body)?).await?), | |
| 545 | 641 | _ => Response::error("Unknown method", 404), | |
| 546 | 642 | } | |
| 547 | 643 | } |
| 32 | 32 | const MAX_AGENTS_PER_RUN = 5; | |
| 33 | 33 | /** A run that takes longer than this has its token expire under it. */ | |
| 34 | 34 | const TOKEN_TTL_SECONDS = 2 * 60 * 60; | |
| 35 | − | const AGENT = "claude-code"; | |
| 35 | + | /** How g1t's own agent is labelled. What runs behind it is g1t's choice. */ | |
| 36 | + | const AGENT = "g1t-agent"; | |
| 36 | 37 | ||
| 37 | 38 | type RunRequest = { actor: User; attemptId: string; envVars: Record<string, string> }; | |
| 38 | 39 |
| 1 | + | -- What the branch pointed to before a shipped attempt landed. | |
| 2 | + | ALTER TABLE attempts ADD COLUMN landed_base TEXT; |
| 353 | 353 | await this.db.batch([ | |
| 354 | 354 | this.db | |
| 355 | 355 | .prepare( | |
| 356 | − | "UPDATE attempts SET status = 'shipped', head_commit = ?, updated_at = ? WHERE id = ?", | |
| 356 | + | "UPDATE attempts SET status = 'shipped', head_commit = ?, landed_base = ?, updated_at = ? WHERE id = ?", | |
| 357 | 357 | ) | |
| 358 | − | .bind(landed.value.commit, now, attempt.id), | |
| 358 | + | .bind(landed.value.commit, landed.value.previous, now, attempt.id), | |
| 359 | 359 | this.db | |
| 360 | 360 | .prepare("UPDATE intents SET status = 'shipped' WHERE id = ?") | |
| 361 | 361 | .bind(intent.id), | |
| 385 | 385 | ...attempt, | |
| 386 | 386 | status: "shipped", | |
| 387 | 387 | headCommit: landed.value.commit, | |
| 388 | + | landedBase: landed.value.previous, | |
| 388 | 389 | updatedAt: now, | |
| 389 | 390 | }); | |
| 390 | 391 | } |
| 35 | 35 | fork_namespace: string; | |
| 36 | 36 | fork_name: string; | |
| 37 | 37 | head_commit: string | null; | |
| 38 | + | landed_base: string | null; | |
| 38 | 39 | started_by_id: string; | |
| 39 | 40 | started_by_name: string; | |
| 40 | 41 | created_at: number; | |
| 77 | 78 | status: row.status, | |
| 78 | 79 | summary: row.summary, | |
| 79 | 80 | fork: { namespace: row.fork_namespace, name: row.fork_name }, | |
| 81 | + | forkRepoId: row.fork_repo_id, | |
| 80 | 82 | headCommit: row.head_commit, | |
| 83 | + | landedBase: row.landed_base, | |
| 81 | 84 | startedBy: { id: row.started_by_id, username: row.started_by_name }, | |
| 82 | 85 | createdAt: row.created_at, | |
| 83 | 86 | updatedAt: row.updated_at, |