flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/components/merge-box.tsx

801 lines31,187 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1/**
2 * The parts of a pull request's merge box that say what stands between it
3 * and a merge: every check, with what a failed one printed, and whether
4 * the change merges cleanly into its target, with what to do when not.
5 */
6import {
7 Check,
8 ChevronRight,
9 CircleAlert,
10 CircleCheck,
11 CircleX,
12 Copy,
13 GitCommitHorizontal,
14 History,
15 LoaderCircle,
16 RotateCw,
17 Sparkles,
18 Terminal,
19 TriangleAlert,
20} from "lucide-react";
Catching up with main takes seconds when the two sides touched different files21import { useEffect, useMemo, useState } from "react";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains22import { Form, Link } from "react-router";
23
Catching up with main takes seconds when the two sides touched different files24import type { CheckResult, CheckRun, CommitStatus, Job, Mergeable, PullBranchUpdate, Pull } from "@g1t/contracts";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains25
Catching up with main takes seconds when the two sides touched different files26import { catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "../lib/catch-up";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains27import { duration } from "./actions";
Catching up with main takes seconds when the two sides touched different files28import { Elapsed, useRuns } from "./agents";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains29import { Button, CopyLine, ErrorText, TimeAgo } from "./ui";
30import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
31
32// --- Output ---------------------------------------------------------------
33
34/** What the sandbox puts first when it kept only the end of the output. */
35const CUT_SHORT = "… (earlier output not shown)";
36
37/** One run of text in one style, from a line with ANSI colour codes. */
38type Segment = { text: string; className: string };
39
40const ANSI_COLOURS: Record<number, string> = {
41 30: "text-faint",
42 31: "text-danger",
43 32: "text-accent",
44 33: "text-warn",
45 34: "text-info",
46 35: "text-merged",
47 36: "text-info",
48 37: "text-fg",
49 90: "text-faint",
50 91: "text-danger",
51 92: "text-accent",
52 93: "text-warn",
53 94: "text-info",
54 95: "text-merged",
55 96: "text-info",
56 97: "text-fg",
57};
58
59/**
60 * A line's text split where its colour changes. Colours and bold are kept;
61 * every other escape sequence (cursor movement, erasing, links) is dropped.
62 */
63export function ansiSegments(line: string): Segment[] {
64 const segments: Segment[] = [];
65 let colour = "";
66 let bold = false;
67 // SGR sequences are kept apart; anything else that starts with ESC goes.
68 const pattern = /\x1b\[([0-9;]*)m|\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g;
69 let at = 0;
70 const push = (text: string) => {
71 if (text) segments.push({ text, className: [colour, bold ? "font-semibold" : ""].filter(Boolean).join(" ") });
72 };
73 for (const match of line.matchAll(pattern)) {
74 push(line.slice(at, match.index));
75 at = match.index + match[0].length;
76 if (match[1] === undefined) continue;
77 const codes = match[1] === "" ? [0] : match[1].split(";").map(Number);
78 for (const code of codes) {
79 if (code === 0) {
80 colour = "";
81 bold = false;
82 } else if (code === 1) bold = true;
83 else if (code === 22) bold = false;
84 else if (code === 39) colour = "";
85 else if (ANSI_COLOURS[code]) colour = ANSI_COLOURS[code];
86 }
87 }
88 push(line.slice(at));
89 return segments;
90}
91
92/** Text without any ANSI escape sequences, for copying. */
93export function stripAnsi(text: string): string {
94 return ansiSegments(text.replace(/\r\n/g, "\n"))
95 .map((segment) => segment.text)
96 .join("");
97}
98
99/** Lines as a terminal would show them: a carriage return starts the line over. */
100function terminalLines(text: string): string[] {
101 return text
102 .replace(/\r\n/g, "\n")
103 .split("\n")
104 .map((line) => {
105 const parts = line.split("\r");
106 return parts[parts.length - 1] || parts.filter(Boolean).pop() || "";
107 });
108}
109
110/**
111 * A command's output as a log: numbered lines, colours rendered, a copy
112 * button, and a note when only the end of it was kept.
113 */
114export function LogViewer({ text }: { text: string }) {
115 const cut = text.startsWith(CUT_SHORT);
116 const body = cut ? text.slice(CUT_SHORT.length).replace(/^\n/, "") : text;
117 const lines = useMemo(() => {
118 const all = terminalLines(body);
119 while (all.length > 0 && all[all.length - 1].trim() === "") all.pop();
120 return all;
121 }, [body]);
122 const [copied, setCopied] = useState(false);
123 if (lines.length === 0) {
124 return <p className="px-3 py-2 font-mono text-xs text-faint">No output.</p>;
125 }
126 return (
127 <div className="relative">
128 <button
129 type="button"
130 aria-label="Copy the output"
131 title="Copy the output"
132 className="absolute top-1.5 right-3 z-10 rounded-md border border-line bg-surface p-1.5 text-faint transition-colors hover:text-fg"
133 onClick={() => {
134 void navigator.clipboard.writeText(stripAnsi(body));
135 setCopied(true);
136 setTimeout(() => setCopied(false), 1500);
137 }}
138 >
139 {copied ? <Check size={13} className="text-accent" /> : <Copy size={13} />}
140 </button>
141 {cut && (
142 <p className="border-b border-line px-3 py-1.5 text-xs text-faint">
143 Showing the end of the output. Earlier lines were not kept.
144 </p>
145 )}
146 <div className="max-h-96 overflow-auto py-1.5 font-mono text-xs leading-5">
147 {lines.map((line, index) => (
148 <div key={index} className="flex gap-3 pr-12 pl-2 hover:bg-raised/40">
149 <span className="w-9 shrink-0 text-right text-faint/70 select-none">{index + 1}</span>
150 <span className="min-w-0 break-all whitespace-pre-wrap text-fg/85">
151 {ansiSegments(line).map((segment, at) =>
152 segment.className ? (
153 <span key={at} className={segment.className}>
154 {segment.text}
155 </span>
156 ) : (
157 segment.text
158 ),
159 )}
160 </span>
161 </div>
162 ))}
163 </div>
164 </div>
165 );
166}
167
168// --- Checks ---------------------------------------------------------------
169
170function seconds(ms: number): string {
171 if (ms < 1000) return `${ms}ms`;
172 const s = Math.round(ms / 1000);
173 return s < 60 ? `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
174}
175
176type Standing = "passed" | "failed" | "running" | "queued" | "skipped";
177
178function StandingIcon({ standing, size = 15 }: { standing: Standing; size?: number }) {
179 if (standing === "passed") return <CircleCheck size={size} className="shrink-0 text-accent" aria-label="Passed" />;
180 if (standing === "failed") return <CircleX size={size} className="shrink-0 text-danger" aria-label="Failed" />;
181 if (standing === "running")
182 return <LoaderCircle size={size} className="shrink-0 animate-spin text-warn" aria-label="Running" />;
183 if (standing === "skipped") return <CircleAlert size={size} className="shrink-0 text-faint" aria-label="Skipped" />;
184 return <LoaderCircle size={size} className="shrink-0 text-faint" aria-label="Queued" />;
185}
186
187/** The id of the workflow run a status links to, if it links to one. */
188export function runIdOf(status: CommitStatus): string | null {
189 return /\/actions\/runs\/([^/?#]+)/.exec(status.targetUrl ?? "")?.[1] ?? null;
190}
191
192/** Where a job stands, in the box's terms. */
193function jobStanding(job: Job): Standing {
194 if (job.status !== "completed") return job.status === "in_progress" || job.status === "calling" ? "running" : "queued";
195 if (job.conclusion === "success") return "passed";
196 if (job.conclusion === "skipped" || job.conclusion === "cancelled") return "skipped";
197 return "failed";
198}
199
200function statusStanding(status: CommitStatus): Standing {
201 if (status.state === "pending") return "running";
202 return status.state === "success" ? "passed" : "failed";
203}
204
205/** One row of the checks list: an icon, a name, how long, and a way in. */
206function CheckLine({
207 standing,
208 name,
209 detail,
210 time,
211 to,
212}: {
213 standing: Standing;
214 name: React.ReactNode;
215 detail?: React.ReactNode;
216 time?: string;
217 to?: string;
218}) {
219 return (
220 <div className="flex items-center gap-2.5 px-4 py-2 text-sm">
221 <StandingIcon standing={standing} />
222 <span className="min-w-0 grow truncate">
223 {name}
224 {detail && <span className="text-muted"> — {detail}</span>}
225 </span>
226 {time && <span className="shrink-0 font-mono text-xs text-faint">{time}</span>}
227 {to && (
228 <Link to={to} className="shrink-0 text-xs text-muted hover:text-fg hover:underline">
229 Details
230 </Link>
231 )}
232 </div>
233 );
234}
235
236/** An acceptance check: its command, and, open when it failed, what it printed. */
237function AcceptanceCheck({ result }: { result: CheckResult }) {
238 const timedOut = result.exitCode == null && !result.passed;
239 return (
240 <details className="group" open={!result.passed}>
241 <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40">
242 <StandingIcon standing={result.passed ? "passed" : "failed"} />
243 <span className="min-w-0 grow truncate">
244 <code className="font-mono text-xs">{result.command}</code>
245 {!result.passed && (
246 <span className="text-muted">
247 {" — "}
248 {timedOut ? "stopped for taking too long" : `exited with code ${result.exitCode}`}
249 </span>
250 )}
251 </span>
252 <span className="shrink-0 font-mono text-xs text-faint">{seconds(result.durationMs)}</span>
253 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
254 </summary>
255 <div className="mx-4 mb-3 overflow-hidden rounded-lg border border-line bg-bg">
256 <LogViewer text={result.output} />
257 </div>
258 </details>
259 );
260}
261
262/**
263 * Every check on a pull request, at a glance and in detail: its issue's
264 * acceptance checks, run in a clean sandbox, and the workflows run on its
265 * head, job by job.
266 */
267export function ChecksSection({
268 run,
269 commands,
270 statuses,
271 jobs,
272 pull,
273 base,
274 earlier,
275 canRerun,
276 canRerunWorkflows,
277 error,
278}: {
279 run: CheckRun | null;
280 /** The issue's acceptance checks, shown before a run has results. */
281 commands: string[];
282 statuses: CommitStatus[];
283 /** Each workflow run's jobs, by run id, where they could be read. */
284 jobs: Record<string, Job[]>;
285 pull: Pull;
286 /** The repository's path, `/<owner>/<repo>`. */
287 base: string;
288 earlier: CheckRun[];
289 canRerun: boolean;
290 canRerunWorkflows: boolean;
291 error?: string | null;
292}) {
293 if (!run && statuses.length === 0) return null;
294
295 // Every row, so they can be counted the same way they are shown.
296 const acceptance: Standing[] = run
297 ? run.results.length > 0
298 ? run.results.map((result) => (result.passed ? "passed" : "failed"))
299 : run.status === "queued" || run.status === "running"
300 ? commands.map(() => (run.status === "running" ? "running" : "queued"))
301 : run.status === "errored"
302 ? ["failed"]
303 : []
304 : [];
305 const workflow: Standing[] = statuses.flatMap((status) => {
306 const id = runIdOf(status);
307 const theirs = id ? jobs[id] : undefined;
308 return theirs && theirs.length > 0 ? theirs.map(jobStanding) : [statusStanding(status)];
309 });
310 const all = [...acceptance, ...workflow];
311 const failed = all.filter((standing) => standing === "failed").length;
312 const pending = all.filter((standing) => standing === "running" || standing === "queued").length;
313 const passed = all.filter((standing) => standing === "passed").length;
314 const total = all.length;
315
316 const headline =
317 failed > 0
318 ? `${failed} of ${total} ${total === 1 ? "check" : "checks"} failed`
319 : pending > 0
320 ? `${pending} of ${total} ${total === 1 ? "check is" : "checks are"} still running`
321 : total > 0
322 ? "All checks have passed"
323 : "Checks";
324 const sub = [
325 failed > 0 && pending > 0 && `${pending} still running`,
326 passed > 0 && `${passed} passed`,
327 all.filter((standing) => standing === "skipped").length > 0 &&
328 `${all.filter((standing) => standing === "skipped").length} skipped or cancelled`,
329 ]
330 .filter(Boolean)
331 .join(", ");
332
333 const pending_ = run?.status === "queued" || run?.status === "running";
334 const commitPath = (sha: string) =>
335 pull.fork ? `/${pull.fork.namespace}/${pull.fork.name}/commit/${sha}` : `${base}/commit/${sha}`;
336 const older = run != null && pull.headCommit != null && run.headCommit !== pull.headCommit;
337 const failedRuns = statuses.filter((status) => statusStanding(status) === "failed" && runIdOf(status));
338
339 return (
340 <div>
341 <div className="flex items-start gap-3 px-4 py-3">
342 <span className="mt-0.5 shrink-0">
343 {failed > 0 ? (
344 <CircleX size={16} className="text-danger" />
345 ) : pending > 0 ? (
346 <LoaderCircle size={16} className="animate-spin text-warn" />
347 ) : (
348 <CircleCheck size={16} className="text-accent" />
349 )}
350 </span>
351 <div className="min-w-0 grow">
352 <p className="text-sm font-medium">{headline}</p>
353 {sub && <p className="mt-0.5 text-xs text-muted">{sub}</p>}
354 </div>
355 <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
356 {canRerun && run && !pending_ && (
357 <Form method="post">
358 <input type="hidden" name="action" value="recheck" />
359 <Button variant="quiet" type="submit">
360 <RotateCw size={13} />
361 Re-run checks
362 </Button>
363 </Form>
364 )}
365 {canRerunWorkflows &&
366 failedRuns.map((status) => (
367 <Form method="post" key={status.context}>
368 <input type="hidden" name="action" value="rerun-workflow" />
369 <input type="hidden" name="run" value={runIdOf(status) ?? ""} />
370 <Button variant="quiet" type="submit" title={`Re-run the failed jobs of ${status.context}`}>
371 <RotateCw size={13} />
372 Re-run failed jobs{failedRuns.length > 1 ? ` of ${status.context}` : ""}
373 </Button>
374 </Form>
375 ))}
376 </div>
377 </div>
378 {error && (
379 <div className="px-4 pb-2">
380 <ErrorText>{error}</ErrorText>
381 </div>
382 )}
383
384 <div className="divide-y divide-line border-t border-line">
385 {run && (
386 <section aria-label="Acceptance checks" className="py-1">
387 <p className="flex flex-wrap items-center gap-x-1.5 px-4 pt-1.5 pb-1 text-xs text-faint">
388 <span className="font-medium text-muted">Acceptance checks</span>
389 <span>· in a clean sandbox, on</span>
390 <Link to={commitPath(run.headCommit)} className="inline-flex items-center gap-1 font-mono hover:text-fg">
391 <GitCommitHorizontal size={12} />
392 {run.headCommit.slice(0, 7)}
393 </Link>
394 {run.finishedAt ? (
395 <span>
396 · finished <TimeAgo at={run.finishedAt} />
397 </span>
398 ) : (
399 <span>
400 · started <TimeAgo at={run.createdAt} />
401 </span>
402 )}
403 </p>
404 {older && (
405 <p className="mx-4 my-1.5 flex items-start gap-2 rounded-md bg-warn/10 px-2.5 py-1.5 text-xs text-warn">
406 <TriangleAlert size={13} className="mt-0.5 shrink-0" />
407 These checks ran on an older commit. The latest push,{" "}
408 <span className="font-mono">{pull.headCommit?.slice(0, 7)}</span>, has not been checked yet.
409 </p>
410 )}
411 {run.status === "errored" && (
412 <div className="mx-4 my-1.5 rounded-lg border border-danger/40 bg-danger/5 px-3 py-2 text-sm">
413 <p className="flex items-center gap-2 font-medium text-danger">
414 <TriangleAlert size={14} />
415 The checks could not be run
416 </p>
417 {run.error && <p className="mt-1 text-muted">{run.error}</p>}
418 </div>
419 )}
420 {run.status === "failed" && run.error && (
421 <p className="mx-4 my-1.5 rounded-lg border border-line bg-bg px-3 py-2 text-sm text-muted">{run.error}</p>
422 )}
423 {run.results.length > 0
424 ? run.results.map((result, index) => <AcceptanceCheck key={`${result.command}-${index}`} result={result} />)
425 : pending_ &&
426 commands.map((command) => (
427 <CheckLine
428 key={command}
429 standing={run.status === "running" ? "running" : "queued"}
430 name={<code className="font-mono text-xs">{command}</code>}
431 detail={run.status === "running" ? "running" : "waiting for a sandbox"}
432 />
433 ))}
434 </section>
435 )}
436
437 {statuses.length > 0 && (
438 <section aria-label="Workflows" className="py-1">
439 <p className="px-4 pt-1.5 pb-1 text-xs font-medium text-muted">Workflows</p>
440 {statuses.map((status) => {
441 const id = runIdOf(status);
442 const runPath = id ? `${base}/actions/runs/${id}` : undefined;
443 const theirs = id ? jobs[id] : undefined;
444 if (!theirs || theirs.length === 0) {
445 return (
446 <CheckLine
447 key={status.context}
448 standing={statusStanding(status)}
449 name={status.context}
450 detail={status.description ?? undefined}
451 to={runPath}
452 />
453 );
454 }
455 return theirs.map((job) => (
456 <CheckLine
457 key={`${status.context}-${job.id}`}
458 standing={jobStanding(job)}
459 name={
460 <>
461 <span className="text-muted">{status.context} / </span>
462 {job.name}
463 </>
464 }
465 detail={job.reason ?? (jobStanding(job) === "failed" ? "failed" : undefined)}
466 time={duration(job.startedAt, job.finishedAt) || undefined}
467 to={`${runPath}?job=${job.id}`}
468 />
469 ));
470 })}
471 </section>
472 )}
473
474 {earlier.length > 0 && (
475 <details className="group">
476 <summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-2 text-xs text-muted hover:text-fg">
477 <History size={13} />
478 Earlier runs of the acceptance checks ({earlier.length})
479 <ChevronRight size={13} className="transition-transform group-open:rotate-90" />
480 </summary>
481 <ul className="space-y-1 px-4 pb-3 text-xs">
482 {earlier.map((before) => (
483 <li key={before.id} className="flex items-center gap-2 text-muted">
484 <StandingIcon
485 size={13}
486 standing={
487 before.status === "passed"
488 ? "passed"
489 : before.status === "queued" || before.status === "running"
490 ? "queued"
491 : "failed"
492 }
493 />
494 <Link to={commitPath(before.headCommit)} className="font-mono hover:text-fg">
495 {before.headCommit.slice(0, 7)}
496 </Link>
497 <span className="min-w-0 truncate">
498 {before.status === "errored"
499 ? `could not run${before.error ? `: ${before.error}` : ""}`
500 : before.results.length > 0
501 ? `${before.results.filter((result) => result.passed).length} of ${before.results.length} passed`
502 : before.status}
503 </span>
504 <span className="ml-auto shrink-0 text-faint">
505 <TimeAgo at={before.createdAt} />
506 </span>
507 </li>
508 ))}
509 </ul>
510 </details>
511 )}
512 </div>
513 </div>
514 );
515}
516
517// --- Mergeability ---------------------------------------------------------
518
519/** How to resolve the conflicts by hand, step by step, for a branch or a fork. */
520export function commandLineSteps(pull: Pull, owner: string, repo: string, defaultBranch: string, files: string[]): string[] {
521 const upstream = `https://g1t.sh/${owner}/${repo}.git`;
522 const add = files.length > 0 && files.length <= 6 ? `git add ${files.join(" ")}` : "git add -A";
523 if (pull.branch) {
524 return [
525 `git fetch origin`,
526 `git checkout ${pull.branch}`,
527 `git merge origin/${defaultBranch}`,
528 `${add} && git commit --no-edit`,
529 `git push origin ${pull.branch}`,
530 ];
531 }
532 const fork = pull.fork!;
533 return [
534 `git clone https://g1t.sh/${fork.namespace}/${fork.name}.git && cd ${fork.name}`,
535 `git pull --no-rebase ${upstream} ${defaultBranch}`,
536 `${add} && git commit --no-edit`,
537 `git push`,
538 ];
539}
540
541/**
542 * The branch conflicts with its target: which files, and three ways out —
543 * the g1t agent, the browser (soon), or the command line.
544 */
545export function ConflictsSection({
546 conflicts,
547 pull,
548 owner,
549 repo,
550 defaultBranch,
551 changesUrl,
552 canResolve,
553 resolving,
554 error,
555}: {
556 conflicts: string[];
557 pull: Pull;
558 owner: string;
559 repo: string;
560 defaultBranch: string;
561 /** Where the pull request's changes are shown, to link each file. */
562 changesUrl: string;
563 /** Whether the viewer may have the g1t agent resolve them. */
564 canResolve: boolean;
Catching up with main takes seconds when the two sides touched different files565 /** Whether asking for it is on its way. */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains566 resolving: boolean;
567 error?: string | null;
568}) {
569 const steps = commandLineSteps(pull, owner, repo, defaultBranch, conflicts);
570 return (
571 <div className="bg-danger/5">
572 <div className="flex gap-3 px-4 py-3">
573 <CircleAlert size={16} className="mt-0.5 shrink-0 text-danger" />
574 <div className="min-w-0 grow">
575 <p className="text-sm font-medium">This branch has conflicts that must be resolved</p>
576 <p className="mt-0.5 text-sm text-muted">
577 Merging <span className="font-mono">{defaultBranch}</span> into it stops on{" "}
578 {conflicts.length === 1 ? "one file" : `${conflicts.length} files`}. It cannot merge until they are resolved.
579 </p>
580 {conflicts.length > 0 && (
581 <>
582 <p className="mt-3 text-xs font-medium text-muted">Conflicting files</p>
583 <ul className="mt-1.5 space-y-1">
584 {conflicts.map((path) => (
585 <li key={path}>
586 <Link
587 to={`${changesUrl}#file-${path}`}
588 className="font-mono text-xs text-fg hover:text-danger hover:underline"
589 >
590 {path}
591 </Link>
592 </li>
593 ))}
594 </ul>
595 </>
596 )}
597 <div className="mt-3 flex flex-wrap items-center gap-2">
598 {canResolve && (
Catching up with main takes seconds when the two sides touched different files599 <Form method="post">
Agents and memory, checks and conflicts, profiles, slug renames, custom domains600 <input type="hidden" name="action" value="update" />
601 <Button variant="primary" type="submit" disabled={resolving}>
Catching up with main takes seconds when the two sides touched different files602 {resolving ? <LoaderCircle size={14} className="animate-spin" /> : <Sparkles size={14} />}
603 {resolving ? "Starting g1t-agent…" : "Resolve with g1t agent"}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains604 </Button>
605 </Form>
606 )}
607 <Tooltip>
608 <TooltipTrigger asChild>
609 {/* A disabled button gets no pointer events; the span carries the tooltip. */}
610 <span tabIndex={0} className="inline-flex">
611 <Button variant="quiet" type="button" disabled aria-disabled="true">
612 Resolve in the browser
613 <span className="rounded-full border border-line px-1.5 text-[0.625rem] text-faint">Soon</span>
614 </Button>
615 </span>
616 </TooltipTrigger>
617 <TooltipContent>
618 An editor for conflicts in the browser is coming. Until then, have the g1t agent resolve them, or use the
619 command line.
620 </TooltipContent>
621 </Tooltip>
622 </div>
623 {error && (
624 <div className="mt-2">
625 <ErrorText>{error}</ErrorText>
626 </div>
627 )}
628 <details className="group mt-3">
629 <summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs text-muted hover:text-fg">
630 <Terminal size={13} />
631 Resolve on the command line
632 <ChevronRight size={13} className="transition-transform group-open:rotate-90" />
633 </summary>
634 <ol className="mt-2 space-y-2 text-xs text-muted">
635 {steps.map((step, index) => (
636 <li key={step}>
637 <p className="mb-1">
638 {index + 1}.{" "}
639 {step.startsWith("git add")
640 ? "Fix the conflicts in each file, removing the conflict markers, then commit the merge:"
641 : step.startsWith("git push")
642 ? "Push it. The pull request updates, and its checks run again:"
643 : step.includes("merge") || step.includes("pull")
644 ? `Merge ${defaultBranch} in. Git stops on the conflicting files:`
645 : step.startsWith("git clone")
646 ? "Clone the pull request's fork:"
647 : step.startsWith("git checkout")
648 ? "Check out the pull request's branch:"
649 : "Get the latest of both:"}
650 </p>
651 <CopyLine text={step} prompt />
652 </li>
653 ))}
654 </ol>
655 {pull.fork && (
656 <p className="mt-2 text-xs text-faint">
657 A pull request's fork takes pushes from whoever opened it.
658 </p>
659 )}
660 </details>
661 </div>
662 </div>
663 </div>
664 );
665}
666
667/** Whether the change merges cleanly, when that is not a conflict. */
668export function MergeabilityRow({
669 mergeable,
670 defaultBranch,
671}: {
672 mergeable: Mergeable;
673 defaultBranch: string;
674}) {
675 if (mergeable === "checking") {
676 return (
677 <div className="flex gap-3 px-4 py-3 text-sm">
678 <LoaderCircle size={16} className="mt-0.5 shrink-0 animate-spin text-faint" />
679 <div>
680 <p className="font-medium">Checking whether this merges cleanly…</p>
681 <p className="mt-0.5 text-muted">
682 It and <span className="font-mono">{defaultBranch}</span> changed some of the same files, so g1t is merging
683 the two in a sandbox to see.
684 </p>
685 </div>
686 </div>
687 );
688 }
689 if (mergeable === "clean") {
690 return (
691 <div className="flex gap-3 px-4 py-3 text-sm">
692 <CircleCheck size={16} className="mt-0.5 shrink-0 text-accent" />
693 <p className="font-medium">
694 This branch has no conflicts with <span className="font-mono">{defaultBranch}</span>
695 </p>
696 </div>
697 );
698 }
699 return null;
700}
Catching up with main takes seconds when the two sides touched different files701
702/**
703 * A catch-up handed to a sandbox, while it lasts: who is doing what, the
704 * run's live step, and how long it has taken. It ends with the pull request
705 * up to date (and this disappears), or with a plain failure and a way to
706 * try again. It never spins on with no end.
707 */
708export function CatchUpProgress({
709 owner,
710 repo,
711 number,
712 defaultBranch,
713 behind,
714 update,
715 startedAt,
716 retrying,
717}: {
718 owner: string;
719 repo: string;
720 number: number;
721 defaultBranch: string;
722 behind: boolean;
723 update: Extract<PullBranchUpdate, { outcome: "needs_agent" }>;
724 /** When it was asked for, in ms since the epoch. */
725 startedAt: number;
726 /** Whether a retry is on its way. */
727 retrying: boolean;
728}) {
729 // The page revalidates while this is working, which reloads the runs too.
730 const data = useRuns(owner, repo, { number: String(number), limit: "5" });
731 const [now, setNow] = useState(() => Date.now());
732 useEffect(() => {
733 const timer = setInterval(() => setNow(Date.now()), 5000);
734 return () => clearInterval(timer);
735 }, []);
736 const run = catchUpRun(data?.runs ?? [], startedAt);
737 const phase = catchUpPhase({ behind, run, startedAt, now });
738 if (phase === "done") return null;
739 const session = `/${owner}/${repo}/sessions/${number}`;
740 if (phase !== "working") {
741 return (
742 <div className="flex gap-3 px-4 py-3 text-sm">
743 <TriangleAlert size={16} className={`mt-0.5 shrink-0 ${phase === "failed" ? "text-danger" : "text-warn"}`} />
744 <div className="min-w-0">
745 <p className="font-medium">
746 {phase === "failed"
747 ? `Catching up with ${defaultBranch} failed`
748 : `Catching up with ${defaultBranch} is taking longer than it should`}
749 </p>
750 <p className="mt-0.5 text-muted">
751 {phase === "failed"
752 ? `${run?.error ? `${run.error} ` : ""}Nothing was pushed, so the pull request is as it was.`
753 : "It usually takes about a minute. It may still finish; this page updates if it does."}{" "}
754 <Link to={session} className="text-fg hover:underline">
755 See the session
756 </Link>
757 .
758 </p>
759 <Form method="post" className="mt-2">
760 <input type="hidden" name="action" value="update" />
761 <Button variant="quiet" type="submit" disabled={retrying}>
762 {retrying ? <LoaderCircle size={14} className="animate-spin" /> : <RotateCw size={14} />}
763 {retrying ? "Trying again…" : "Try again"}
764 </Button>
765 </Form>
766 </div>
767 </div>
768 );
769 }
770 return (
771 <div className="flex gap-3 px-4 py-3 text-sm">
772 <LoaderCircle size={16} className="mt-0.5 shrink-0 animate-spin text-merged" />
773 <div className="min-w-0 grow">
774 <p className="font-medium">{catchUpTitle(update.reason, defaultBranch)}</p>
775 <p className="mt-0.5 text-muted">
776 {catchUpWhy(update, defaultBranch)} This usually takes about a minute; the pull request updates here when it
777 is pushed.
778 </p>
779 {update.paths.length > 0 && (
780 <p className="mt-1 truncate font-mono text-xs text-faint" title={update.paths.join(", ")}>
781 {update.paths.slice(0, 5).join(", ")}
782 {update.paths.length > 5 && ` and ${update.paths.length - 5} more`}
783 </p>
784 )}
785 <p
786 className="mt-2 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line"
787 title={run?.step ?? undefined}
788 >
789 <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />
790 {run?.step ?? (run ? "Starting a sandbox…" : "Waiting for a sandbox…")}
791 </p>
792 <p className="mt-2 flex flex-wrap items-center gap-x-3 text-xs text-muted">
793 <Elapsed from={new Date(startedAt).toISOString()} />
794 <Link to={session} className="hover:text-fg">
795 Watch the session
796 </Link>
797 </p>
798 </div>
799 </div>
800 );
801}