Skip to content

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

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