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