flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

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

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