Commit

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

syntaqxcommitted Parentd19e4ceBrowse files
17 files+655−200/17 viewed
+7−0
858858 "g1t-kit",
859859 "serde",
860860 "serde_json",
861+ "similar",
861862 "worker",
862863 ]
863864
24892490 checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
24902491
24912492 [[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]]
24922499 name = "slab"
24932500 version = "0.4.12"
24942501 source = "registry+https://github.com/rust-lang/crates.io-index"
+166−0
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+}
+3−2
9191 - **Pull requests from branches.** Opening an attempt from a branch you
9292 pushed, the way a pull request works elsewhere.
9393 - **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.
+58−6
22 import {
33 Bot,
44 ChevronRight,
5+ FileDiff,
56 GitCommitHorizontal,
7+ MessagesSquare,
68 Rocket,
79 StickyNote,
810 User,
1113 import { useEffect } from "react";
1214 import { Form, Link, useRevalidator } from "react-router";
1315
14−import type { SessionEntry } from "@g1t/contracts";
16+import type { Comparison, SessionEntry } from "@g1t/contracts";
1517
1618 import type { Route } from "./+types/attempt";
19+import { DiffView } from "../../components/diff-view";
1720 import { Markdown } from "../../components/markdown";
1821 import {
1922 Button,
3336 } from "../../lib/session.server";
3437
3538 const REFRESH_MS = 4000;
39+const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
3640
3741 export function meta({ loaderData }: Route.MetaArgs) {
3842 return [
4448 ];
4549 }
4650
47−export async function loader({ params, context }: Route.LoaderArgs) {
51+export async function loader({ params, context, request }: Route.LoaderArgs) {
4852 const viewer = getViewer(context);
4953 const [found, session] = await Promise.all([
5054 env.WORK.getAttempt(params.id, viewer),
5256 ]);
5357 const detail = unwrap(found);
5458 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;
5567 return {
5668 ...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),
5771 session: unwrap(session),
5872 viewer,
5973 // Only the repository's owner can land an attempt.
8195 }
8296
8397 /** 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+
84123 function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) {
85124 const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
86125 const Icon =
160199 actionData,
161200 params,
162201 }: Route.ComponentProps) {
163− const { attempt, intent, session, viewer, canShip, defaultBranch } = loaderData;
202+ const { attempt, intent, session, viewer, canShip, defaultBranch, comparison } =
203+ loaderData;
164204 const base = `/${params.owner}/${params.repo}`;
205+ const here = base + "/attempts/" + attempt.id;
165206 const remote = `https://g1t.sh/${attempt.fork.namespace}/${attempt.fork.name}.git`;
166207 const mine = viewer?.id === attempt.startedBy.id;
167208 const active = attempt.status === "working" || attempt.status === "submitted";
215256 </section>
216257 )}
217258
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 ? (
221273 <EmptyState title="Nothing recorded yet">
222274 The agent's prompts, reasoning and tool calls appear here as it
223275 works.
+3−3
215215 <section className="rounded-xl border border-accent/30 bg-accent/5 p-4">
216216 <h3 className="flex items-center gap-2 text-sm font-medium">
217217 <Sparkles size={15} className="text-accent" />
218− Run hosted agents
218+ Run g1t agents
219219 </h3>
220220 <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.
223223 </p>
224224 <Form method="post" className="mt-3 space-y-2">
225225 <input type="hidden" name="intentId" value={intent.id} />
+74−1
110110 ReceivePack,
111111 }
112112
113−/// The commit `main` points to after an attempt has landed.
113+/// The result of landing an attempt.
114114 #[derive(Clone, Debug, Serialize, Deserialize)]
115115 pub struct Landed {
116+ /// The commit the branch points to now.
116117 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>,
117121 }
118122
119123 /// `get`. Returns `Outcome<Repo>`.
215219 pub fork_id: String,
216220 pub actor: User,
217221 }
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+}
+1−1
7272 &["-c", &auth, "clone", "--quiet", &remote, WORKDIR],
7373 )
7474 .context("could not clone the attempt's fork")?;
75− git(workdir, &["config", "user.name", "Claude Code"])?;
75+ git(workdir, &["config", "user.name", "g1t agent"])?;
7676 git(workdir, &["config", "user.email", "agent@g1t.sh"])?;
7777 let branch = git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
7878 let start = git(workdir, &["rev-parse", "HEAD"]).unwrap_or_default();
+1−0
7373 gitAccess: (path, viewer, service) =>
7474 call("git_access", { path, viewer, service }),
7575 land: (forkId, actor) => call("land", { forkId, actor }),
76+ compare: (repoId, viewer, base) => call("compare", { repoId, viewer, base }),
7677 };
7778 }
+39−1
9191 * head. Refused with "conflict" when the fork is behind, since that would
9292 * discard commits.
9393 */
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>>;
95102 }
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+};
+7−0
4141 /** The agent's own account of what it did, set on submit. */
4242 summary: string | null;
4343 fork: RepoPath;
44+ /** The fork's repository id. */
45+ forkRepoId: string;
4446 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;
4552 startedBy: User;
4653 createdAt: number;
4754 updatedAt: number;
+1−0
1414 serde.workspace = true
1515 serde_json.workspace = true
1616 worker.workspace = true
17+similar = "2"
+186−0
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+}
+99−3
55 //! `g1t_contracts::repos` for the methods and their arguments. Any other
66 //! request is treated as git's smart HTTP protocol.
77
8+mod diff;
89 mod git_http;
910 mod land;
1011 mod registry;
1617 FailureCode, Outcome, User, Viewer, is_valid_namespace, is_valid_repo_name, new_id,
1718 };
1819 use g1t_kit::{args, js, now_ms, reply, rpc_method};
19−use std::collections::{HashMap, HashSet};
20+use std::collections::{HashMap, HashSet, VecDeque};
2021
2122 use serde::Serialize;
2223 use worker::wasm_bindgen::JsValue;
8384 Ok(false)
8485 }
8586
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+
86119 struct Repos<S: GitStore> {
87120 registry: Registry,
88121 store: S,
427460 .map(|commit| commit.hash);
428461
429462 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+ }));
431467 }
432468 // Moving the branch to a commit that does not descend from its
433469 // current head would discard whatever landed in between.
455491 ));
456492 }
457493 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+ }))
459554 }
460555
461556 async fn publish_push(&self, repo: &Repo, after: &str, actor: Option<String>) -> Result<()> {
542637 "fork_for_attempt" => reply(&repos.fork_for_attempt(args(body)?).await?),
543638 "git_access" => reply(&repos.git_access(args(body)?).await?),
544639 "land" => reply(&repos.land(args(body)?).await?),
640+ "compare" => reply(&repos.compare(args(body)?).await?),
545641 _ => Response::error("Unknown method", 404),
546642 }
547643 }
+2−1
3232 const MAX_AGENTS_PER_RUN = 5;
3333 /** A run that takes longer than this has its token expire under it. */
3434 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";
3637
3738 type RunRequest = { actor: User; attemptId: string; envVars: Record<string, string> };
3839
+2−0
1+-- What the branch pointed to before a shipped attempt landed.
2+ALTER TABLE attempts ADD COLUMN landed_base TEXT;
+3−2
353353 await this.db.batch([
354354 this.db
355355 .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 = ?",
357357 )
358− .bind(landed.value.commit, now, attempt.id),
358+ .bind(landed.value.commit, landed.value.previous, now, attempt.id),
359359 this.db
360360 .prepare("UPDATE intents SET status = 'shipped' WHERE id = ?")
361361 .bind(intent.id),
385385 ...attempt,
386386 status: "shipped",
387387 headCommit: landed.value.commit,
388+ landedBase: landed.value.previous,
388389 updatedAt: now,
389390 });
390391 }
+3−0
3535 fork_namespace: string;
3636 fork_name: string;
3737 head_commit: string | null;
38+ landed_base: string | null;
3839 started_by_id: string;
3940 started_by_name: string;
4041 created_at: number;
7778 status: row.status,
7879 summary: row.summary,
7980 fork: { namespace: row.fork_namespace, name: row.fork_name },
81+ forkRepoId: row.fork_repo_id,
8082 headCommit: row.head_commit,
83+ landedBase: row.landed_base,
8184 startedBy: { id: row.started_by_id, username: row.started_by_name },
8285 createdAt: row.created_at,
8386 updatedAt: row.updated_at,