Skip to content

Commit

Merge site-audit: SEO routes, analytics with consent first in Europe, security page, founder and X account, docs header; the tour opens on the pull request and leads with Code, on the real shell, with Agents and Docs working today

syntaqxcommitted Parents1cbf9c92dcfbc4Browse files
36 files+1273−3170/36 viewed
+1−1
46674667 "expires_at": "2026-10-12T17:00:00.000Z"
46684668 }
46694669 },
4670− "notes": "`invitee` is a username or an email address. A member of the workspace answers `{\"result\": \"granted\", \"collaborator\": {…}}` with the role already given, shown as list_collaborators shows a person. Anyone else answers `{\"result\": \"invited\", \"invitation\": {…}}`: the invitation is emailed and waits 7 days, and the role is theirs once they accept it. An email address without an account gets an invitation with `email` set and `invitee` null, and an invite that makes the account and accepts in one step. Refused with `404` when no account has that username, `409` when they already have a role of their own or a pending invitation (change it with update_collaborator), and `403` without the Admin role, from an agent's or a workspace's token, or when the person does not meet what the workspace asks of everyone with access. collaborator_added` webhook event is sent once they have the role. See [Access and roles](/guides/access-and-roles/). On a free workspace, inviting anyone who is not a member answers `402` with `payment_required` until it starts the g1t plan."
4670+ "notes": "`invitee` is a username or an email address. A member of the workspace answers `{\"result\": \"granted\", \"collaborator\": {…}}` with the role already given, shown as list_collaborators shows a person. Anyone else answers `{\"result\": \"invited\", \"invitation\": {…}}`: the invitation is emailed and waits 7 days, and the role is theirs once they accept it. An email address without an account gets an invitation with `email` set and `invitee` null, and an invite that makes the account and accepts in one step. Refused with `404` when no account has that username, `409` when they already have a role of their own or a pending invitation (change it with update_collaborator), and `403` without the Admin role, from an agent's or a workspace's token, or when the person does not meet what the workspace asks of everyone with access. A `collaborator_added` webhook event is sent once they have the role. See [Access and roles](/guides/access-and-roles/). On a free workspace, inviting anyone who is not a member answers `402` with `payment_required` until it starts the g1t plan."
46714671 },
46724672 "update_collaborator": {
46734673 "params": {
+63−6
11 ---
2−// Starlight's header, with the tabs that divide the docs beneath it.
3−import Default from '@astrojs/starlight/components/Header.astro';
2+// The docs header: the wordmark, search in the middle and links on the
3+// right, with the tabs that divide the docs beneath it.
4+//
5+// Its own layout, not Starlight's: Starlight lines the search up with the
6+// page's text column, which moves when a page has an "On this page" list
7+// and when it does not. Here the row is three columns, the outer two always
8+// the same width, so the search sits at the header's centre on every page.
9+import Search from '@astrojs/starlight/components/Search.astro';
410
511 import { TABS, tabFor } from '../tabs';
12+import SiteTitle from './SiteTitle.astro';
13+import SocialIcons from './SocialIcons.astro';
614
715 const current = tabFor(Astro.locals.starlightRoute.id);
816 ---
917
1018 <div class="g1t-header">
11− <div class="g1t-header-top"><Default /></div>
19+ <div class="g1t-header-top">
20+ <div class="g1t-header-title"><SiteTitle /></div>
21+ <div class="g1t-header-search print:hidden"><Search /></div>
22+ <div class="g1t-header-links print:hidden"><SocialIcons /></div>
23+ </div>
1224 <nav class="g1t-tabs" aria-label="Sections">
1325 {
1426 TABS.map((tab) => (
2840 }
2941 /* The same bar as the app's: 3.5rem, and a line under it. */
3042 .g1t-header-top {
43+ /* Each side column is at least as wide as the widest thing that goes
44+ in either, so the two stay equal and the search stays centred. */
45+ --g1t-header-side: 13.5rem;
3146 box-sizing: border-box;
47+ display: grid;
48+ grid-template-columns:
49+ minmax(var(--g1t-header-side), 1fr)
50+ minmax(0, 26rem)
51+ minmax(var(--g1t-header-side), 1fr);
52+ align-items: center;
53+ gap: 1rem;
3254 height: 3.5rem;
3355 flex-shrink: 0;
3456 padding: 0 var(--sl-nav-pad-x);
3557 border-bottom: 1px solid var(--g1t-line);
3658 }
37− /* On phones the menu button sits at the top row's end: leave it room. */
59+ .g1t-header-title {
60+ display: flex;
61+ min-width: 0;
62+ /* Room for the link's focus ring. */
63+ padding: 0.25rem;
64+ margin: -0.25rem;
65+ }
66+ .g1t-header-search {
67+ display: flex;
68+ justify-content: center;
69+ min-width: 0;
70+ }
71+ .g1t-header-links {
72+ display: flex;
73+ justify-content: flex-end;
74+ min-width: 0;
75+ }
76+ /* Tablets: the sidebar is a menu, and its button (fixed in place by
77+ Starlight) takes the row's end. The right column makes room for it
78+ inside itself, so the two columns stay equal and the search centred. */
3879 @media (max-width: 49.99rem) {
39− :global([data-has-sidebar]) .g1t-header-top {
40− padding-inline-end: calc(var(--sl-nav-pad-x) + var(--sl-menu-button-size) + var(--sl-nav-gap));
80+ .g1t-header-top {
81+ --g1t-header-side: 13rem;
82+ }
83+ :global([data-has-sidebar]) .g1t-header-links {
84+ padding-inline-end: calc(var(--sl-menu-button-size) + 0.5rem);
4185 }
4286 }
87+ /* Phones: the wordmark on the left; search, the links and the menu
88+ button together on the right. */
89+ @media (max-width: 44.99rem) {
90+ .g1t-header-top {
91+ grid-template-columns: minmax(0, 1fr) auto auto;
92+ gap: 0.25rem;
93+ }
94+ }
4395 .g1t-tabs {
4496 display: flex;
4597 gap: 1.5rem;
4698 height: 2.75rem;
4799 padding: 0 var(--sl-nav-pad-x);
48100 overflow-x: auto;
101+ overscroll-behavior-x: contain;
49102 scrollbar-width: none;
50103 }
104+ .g1t-tabs::-webkit-scrollbar {
105+ display: none;
106+ }
51107 .g1t-tabs a {
52108 display: flex;
109+ flex-shrink: 0;
53110 align-items: center;
54111 border-bottom: 1.5px solid transparent;
55112 font-size: 0.875rem;
+37−2
1414 <div class="g1t-page-title">
1515 <div class="g1t-page-title-row">
1616 <h1 id="_top">{title}</h1>
17− <div class="g1t-copy not-content">
17+ <div class="g1t-copy not-content" data-pagefind-ignore>
1818 <button type="button" class="g1t-copy-main" data-markdown={markdown}>
1919 <Copy />
2020 <span>Copy page</span>
224224 font-size: 0.75rem;
225225 color: var(--g1t-faint);
226226 }
227+ /* Phones: the group keeps its place beside the title, as two square
228+ buttons a finger can hit; the copy button shows only its icon, and
229+ its label stays for screen readers. */
227230 @media (max-width: 40rem) {
231+ .g1t-page-title-row {
232+ gap: 0.75rem;
233+ }
234+ h1 {
235+ min-width: 0;
236+ font-size: 1.75rem;
237+ overflow-wrap: anywhere;
238+ }
228239 .g1t-copy {
229− display: none;
240+ height: 2.5rem;
241+ margin-top: 0;
242+ }
243+ .g1t-copy-main span {
244+ position: absolute;
245+ width: 1px;
246+ height: 1px;
247+ overflow: hidden;
248+ clip-path: inset(50%);
249+ white-space: nowrap;
250+ }
251+ .g1t-copy-main,
252+ .g1t-copy-more summary {
253+ width: 2.5rem;
254+ padding: 0;
255+ }
256+ .g1t-copy :global(svg) {
257+ width: 1rem;
258+ height: 1rem;
259+ }
260+ .g1t-copy-menu {
261+ width: min(16.5rem, calc(100vw - 2rem));
262+ }
263+ .g1t-copy-menu a {
264+ padding-block: 0.65rem;
230265 }
231266 }
232267 </style>
+39−11
11 ---
2−// The header's right side: where people go from the docs, then the source.
2+// The header's right side: where people go from the docs, then g1t on X and the source.
33 import { GitBranch } from '@lucide/astro';
44 ---
55
66 <nav class="g1t-nav" aria-label="g1t">
77 <a href="https://g1t.sh/">g1t.sh</a>
88 <a href="https://g1t.sh/register" class="g1t-nav-cta">Sign up</a>
9+ <a href="https://x.com/g1t_sh" aria-label="g1t on X" title="g1t on X" class="g1t-nav-icon">
10+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z" /></svg>
11+ </a>
912 <a href="https://g1t.sh/flagon-io/g1t" aria-label="Source on g1t" title="Source on g1t" class="g1t-nav-icon">
1013 <GitBranch />
1114 </a>
1720 align-items: center;
1821 gap: 0.25rem;
1922 }
20− a {
23+ /* nav.g1t-nav: the menu's own link rule must not win where these sit
24+ at the foot of the phone menu. */
25+ nav.g1t-nav a {
26+ display: inline-flex;
27+ align-items: center;
28+ height: 2rem;
2129 border-radius: 0.4rem;
22− padding: 0.3rem 0.6rem;
30+ padding: 0 0.6rem;
2331 font-size: 0.85rem;
32+ white-space: nowrap;
2433 color: var(--g1t-muted);
2534 text-decoration: none;
2635 transition: color 0.15s, background 0.15s;
2736 }
28− a:hover {
37+ nav.g1t-nav a:hover {
2938 background: var(--g1t-raised);
3039 color: var(--g1t-fg);
3140 }
32− .g1t-nav-cta {
41+ nav.g1t-nav .g1t-nav-cta {
3342 margin-left: 0.25rem;
3443 background: var(--g1t-fg);
3544 color: var(--g1t-bg);
3645 font-weight: 500;
3746 }
38− .g1t-nav-cta:hover {
47+ nav.g1t-nav .g1t-nav-cta:hover {
3948 background: #fff;
4049 color: var(--g1t-bg);
4150 }
42− .g1t-nav-icon {
43− display: inline-flex;
44− padding: 0.4rem;
51+ nav.g1t-nav .g1t-nav-icon {
52+ justify-content: center;
53+ width: 2rem;
54+ padding: 0;
4555 }
4656 .g1t-nav-icon :global(svg) {
4757 width: 1rem;
4858 height: 1rem;
4959 }
50− @media (max-width: 50rem) {
51− a:not(.g1t-nav-icon):not(.g1t-nav-cta) {
60+ /* Tablets and phones: in the header g1t.sh's link gives way (the menu
61+ keeps it), and the icons grow to a size a finger can hit. */
62+ @media (max-width: 49.99rem) {
63+ :global(.g1t-header) nav.g1t-nav a:not(.g1t-nav-icon):not(.g1t-nav-cta) {
64+ display: none;
65+ }
66+ nav.g1t-nav .g1t-nav-icon {
67+ width: 2.5rem;
68+ height: 2.5rem;
69+ }
70+ :global(.sidebar-content) nav.g1t-nav a {
71+ height: 2.5rem;
72+ }
73+ }
74+ /* Phones: Sign up leaves the header for the menu, where it has room. */
75+ @media (max-width: 44.99rem) {
76+ :global(.g1t-header) .g1t-nav {
77+ gap: 0;
78+ }
79+ :global(.g1t-header) nav.g1t-nav .g1t-nav-cta {
5280 display: none;
5381 }
5482 }
+1−0
3131 | Context hub search | Off |
3232 | Deployments on `g1t.page` | Off |
3333 | Billing | Off. Nothing is charged, and no usage limit stops work. |
34+| Site analytics | Off. Only g1t.sh sends page analytics (to HeyCatch, as its [privacy policy](https://g1t.sh/policies/privacy#how-the-site-is-used) describes); your installation sends none. |
3435 | Git over SSH and the `g1t` CLI | Not available yet |
3536 | Scheduled jobs | Run on their schedules inside the g1t container: webhook retries, purging deleted repositories, the packages sweep, security sweeps, audit log retention and access request summaries. Actions schedules (`on: schedule`) are not run. |
3637
+0−0

Binary or large file; its contents are not shown.

+73−5
4141 beneath it. */
4242 --sl-nav-height: 6.25rem;
4343 --sl-sidebar-width: 17rem;
44+ /* Big enough to tap. */
45+ --sl-menu-button-size: 2.5rem;
4446
4547 color-scheme: dark;
4648 }
6264 backdrop-filter: blur(10px);
6365 border-bottom: 1px solid var(--g1t-line);
6466 }
67+/* The search box fills the header's middle column; Header.astro centres
68+ that column in the header, the same on every page. */
6569 site-search {
70+ display: flex;
71+ justify-content: center;
6672 width: 100%;
67− max-width: 30rem;
68−}
69−/* On phones the menu button sits in the header's top row, not its middle. */
70−.sl-menu-button {
71− top: calc((3.5rem - var(--sl-menu-button-size)) / 2) !important;
7273 }
7374 site-search button[data-open-modal] {
7475 border-radius: 0.5rem;
8081 border-color: var(--g1t-line-strong);
8182 color: var(--g1t-muted);
8283 }
84+@media (min-width: 50rem) {
85+ site-search button[data-open-modal] {
86+ max-width: none;
87+ }
88+}
89+/* Tablets: the full search box, as on desktop, since there is room. */
90+@media (min-width: 45rem) and (max-width: 49.99rem) {
91+ site-search button[data-open-modal] {
92+ width: 100%;
93+ border: 1px solid var(--g1t-line);
94+ padding-inline: 0.75rem 0.5rem;
95+ font-size: var(--sl-text-sm);
96+ }
97+ site-search button[data-open-modal] > span.sl-hidden {
98+ display: block;
99+ }
100+ site-search button[data-open-modal] > kbd.sl-hidden {
101+ display: flex;
102+ margin-inline-start: auto;
103+ }
104+}
105+/* Phones: a search icon, as big as a fingertip. */
106+@media (max-width: 44.99rem) {
107+ site-search button[data-open-modal] {
108+ justify-content: center;
109+ width: 2.5rem;
110+ height: 2.5rem;
111+ padding: 0;
112+ border: 0;
113+ background: transparent;
114+ color: var(--g1t-muted);
115+ font-size: 1.125rem;
116+ }
117+}
118+/* The menu button sits in the header's top row, at the end, drawn like
119+ the header's other icon buttons rather than as a floating disc. */
120+.sl-menu-button {
121+ top: calc((3.5rem - var(--sl-menu-button-size)) / 2) !important;
122+ align-items: center;
123+ justify-content: center;
124+ border-radius: 0.5rem;
125+ padding: 0;
126+ background: transparent;
127+ box-shadow: none;
128+ color: var(--g1t-fg-soft);
129+ font-size: 1.25rem;
130+}
131+.sl-menu-button:hover,
132+.sl-menu-button:has(~ :popover-open) {
133+ background: var(--g1t-raised);
134+ color: var(--g1t-fg);
135+}
83136
84137 /* --- Sidebar -------------------------------------------------------------- */
85138
217270 overflow-wrap: normal;
218271 word-break: normal;
219272 }
273+ /* The last column usually holds the sentences. Kept readable, it
274+ scrolls the table sideways instead of folding to a word a line. */
275+ .sl-markdown-content :is(td, th):last-child:not(:first-child) {
276+ min-width: 12rem;
277+ }
220278 }
221279 .sl-markdown-content th {
222280 border-bottom: 1px solid var(--g1t-line-strong);
397455 white-space: nowrap;
398456 color: var(--g1t-fg);
399457 }
458+/* On a phone a long path wraps onto a second line rather than hiding its end. */
459+@media (max-width: 40rem) {
460+ .sl-markdown-content .g1t-endpoint {
461+ align-items: flex-start;
462+ }
463+ .sl-markdown-content .g1t-endpoint code {
464+ white-space: normal;
465+ overflow-wrap: anywhere;
466+ }
467+}
400468 /* Methods: the same small mono tag in the sidebar and on the page. */
401469 .g1t-method {
402470 --method: var(--g1t-muted);
+53−0
1+/**
2+ * Asking a visitor from the EU, EEA, UK or Switzerland before site
3+ * analytics runs (lib/analytics-consent.ts): both answers are one click,
4+ * and nothing is fetched or stored for analytics until they agree. The
5+ * footer's "Cookie choices" asks again (components/footer.tsx).
6+ */
7+import { useEffect, useState, useSyncExternalStore } from "react";
8+import { Link } from "react-router";
9+
10+import { analyticsHere, choice, choose, consentRequired, onChoice } from "../lib/analytics-consent";
11+
12+/** Whether this visitor is asked at all, known only once the page is in the browser. */
13+export function useAsksFirst(): boolean {
14+ const [asks, setAsks] = useState(false);
15+ useEffect(() => setAsks(analyticsHere() && consentRequired()), []);
16+ return asks;
17+}
18+
19+export function AnalyticsConsent() {
20+ const asks = useAsksFirst();
21+ const chosen = useSyncExternalStore(onChoice, choice, () => null);
22+ if (!asks || chosen) return null;
23+ return (
24+ <section
25+ aria-label="Analytics cookie"
26+ className="fixed inset-x-3 bottom-3 z-[60] mx-auto max-w-lg rounded-2xl bg-surface p-4 text-sm shadow-2xl shadow-black/50 ring-1 ring-line-strong sm:inset-x-auto sm:right-4 sm:bottom-4"
27+ >
28+ <p className="leading-6 text-fg-soft">
29+ May g1t count how its pages are used? It sets one analytics cookie, and names inside your workspaces are
30+ removed before anything is sent.{" "}
31+ <Link to="/policies/privacy#how-the-site-is-used" className="text-accent hover:underline">
32+ What is sent
33+ </Link>
34+ </p>
35+ <div className="mt-3 flex gap-2">
36+ <button
37+ type="button"
38+ onClick={() => choose("yes")}
39+ className="flex-1 rounded-lg bg-fg px-3 py-2 font-medium text-bg transition-colors hover:bg-white"
40+ >
41+ Allow
42+ </button>
43+ <button
44+ type="button"
45+ onClick={() => choose("no")}
46+ className="flex-1 rounded-lg px-3 py-2 font-medium text-fg ring-1 ring-line-strong transition-colors hover:bg-raised"
47+ >
48+ No thanks
49+ </button>
50+ </div>
51+ </section>
52+ );
53+}
+14−2
99
1010 import type { User } from "@g1t/contracts";
1111
12+import { useAsksFirst } from "./analytics-consent";
1213 import { Mark } from "./logo";
13−import { COMPANY, MAKER_PRODUCTS, copyright, listed } from "../lib/legal";
14+import { choose } from "../lib/analytics-consent";
15+import { COMPANY, MAKER_PRODUCTS, SOCIAL, copyright, listed } from "../lib/legal";
1416 import { type OverallState, STATUS_JSON_URL, STATUS_URL, STATUS_WORDS, type StatusReport, dotClass } from "../lib/status";
1517
1618 /** How long one fetched report is reused across the pages of one visit. */
9496 title: "Company",
9597 links: [
9698 ["Flagon, Inc.", COMPANY.url],
99+ ...SOCIAL.map((account): [string, string] => [account.label, account.url]),
97100 ["Support", "/support"],
98101 ["Security", "/security"],
99102 ["Status", STATUS_URL],
167170 <FlagonMark className="mr-1 inline-block size-[0.95em] -translate-y-px align-middle" />
168171 {COMPANY.name}
169172 </a>
170− {products.length > 0 ? `, the people behind ${listed(products)}.` : `, ${COMPANY.about}.`}
173+ {products.length > 0 ? `, the people behind ${listed(products)}.` : `, ${COMPANY.about} founded by ${COMPANY.founder}.`}
171174 </p>
172175 );
173176 }
180183 */
181184 export function LegalRow({ user }: { user: User | null | undefined }) {
182185 const status = useSiteStatus();
186+ const asksFirst = useAsksFirst();
183187 const links = [
184188 <a key="status" href={STATUS_URL} className={`inline-flex items-center gap-1.5 ${ROW_LINK}`}>
185189 <StatusDot state={status?.overall.state ?? null} />
190194 {label}
191195 </Link>
192196 )),
197+ // Where the visitor was asked about the analytics cookie: ask again.
198+ ...(asksFirst
199+ ? [
200+ <button key="consent" type="button" onClick={() => choose(null)} className={ROW_LINK}>
201+ Cookie choices
202+ </button>,
203+ ]
204+ : []),
193205 ];
194206 const account = [
195207 user ? (
+179−130
163163 /** The four modes of a workspace. */
164164 const MODES: { icon: ReactNode; name: string; about: string; soon?: boolean; to: string }[] = [
165165 {
166+ icon: <Code2 size={18} />,
167+ name: "Code",
168+ about: "Repositories, issues, pull requests, checks, a merge queue and deployments. For the people who build.",
169+ to: `${DOCS}/concepts/overview/`,
170+ },
171+ {
166172 icon: <MessagesSquare size={18} />,
167173 name: "Chat",
168174 about: "Channels, direct messages and threads, live. People and agents are members alike.",
180186 about: "Specs, runbooks and decisions, written together. Agents read them and keep them current.",
181187 soon: true,
182188 to: `${DOCS}/guides/docs/`,
183− },
184− {
185− icon: <Code2 size={18} />,
186− name: "Code",
187− about: "Repositories, issues, pull requests, checks, a merge queue and deployments. For the people who build.",
188− to: `${DOCS}/concepts/overview/`,
189189 },
190190 ];
191191
353353 to={`${DOCS}/guides/chat/`}
354354 className="inline-flex animate-fade-up items-center gap-2 rounded-full bg-bg/50 px-3 py-1 text-xs text-fg-soft ring-1 ring-white/10 backdrop-blur transition-colors hover:bg-bg/70"
355355 >
356− <span className="size-1.5 rounded-full bg-accent" />
357− Chat, agents, docs and code in one workspace
358− <ArrowRight size={12} />
356+ <span className="size-1.5 shrink-0 rounded-full bg-accent" />
357+ For engineering teams running AI coding agents: chat, agents, docs and code in one workspace
358+ <ArrowRight size={12} className="shrink-0" />
359359 </Link>
360360 <h1 className="mx-auto mt-7 max-w-4xl animate-fade-up text-[2.75rem] leading-[1.04] font-semibold tracking-tight text-balance sm:text-7xl">
361361 Your team and its agents,{" "}
433433 </div>
434434 </section>
435435
436− {/* Chat first. */}
437− <section className="mx-auto grid max-w-6xl items-start gap-12 px-4 pt-24 lg:grid-cols-[1.1fr_1fr] lg:gap-16">
436+ {/* Code. */}
437+ <section className="mx-auto max-w-6xl px-4 pt-24">
438+ <div className="max-w-3xl">
439+ <Eyebrow>Code</Eyebrow>
440+ <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-5xl">
441+ A forge built for many agents at once.
442+ </h2>
443+ <p className="mt-5 max-w-2xl text-lg leading-8 text-muted">
444+ Underneath the conversation is plain git. Every change an agent makes is a pull request in its own fork,
445+ checked by g1t, reviewed, and landed through a queue that keeps main passing.
446+ </p>
447+ </div>
448+
449+ <div className="mt-6 divide-y divide-line">
450+ <Pillar
451+ eyebrow="Outcomes"
452+ title="Hand off an outcome, not just a task"
453+ art={<PlanArt className="w-full" />}
454+ points={[
455+ "A brief planned into issues with dependencies",
456+ "Your workflows' required checks on every change",
457+ "Work starts as each dependency lands",
458+ "The plan as a live graph, with its cost",
459+ ]}
460+ more={["Hand off an outcome", `${DOCS}/guides/outcomes/`]}
461+ >
462+ Write what should be true. A planner turns it into issues, each saying what done looks like, and the order
463+ they depend on. Agents take each issue as it unblocks, and you watch the whole outcome converge.
464+ </Pillar>
465+
466+ <Pillar
467+ flip
468+ eyebrow="Ship safely"
469+ title="Main only moves to what passed"
470+ art={<QueueArt className="w-full" />}
471+ points={[
472+ "Checks run by g1t in a clean sandbox",
473+ "Workflows from .g1t/workflows",
474+ "A merge queue that tests changes together",
475+ "Conflicts found on every push, before a merge",
476+ "Catch up with main in seconds",
477+ "Reviews by people and by agents",
478+ ]}
479+ more={["The merge queue", `${DOCS}/guides/merge-queue/`]}
480+ >
481+ Checks are run by g1t, never by the agent being checked. The queue tests each change together with what
482+ lands ahead of it; one that breaks goes back to its author with what failed, and main never sees it.
483+ </Pillar>
484+
485+ <Pillar
486+ eyebrow="Context"
487+ title="Every line knows why it is there"
488+ art={<WhyArt className="w-full" />}
489+ points={[
490+ "Sessions recorded onto pull requests",
491+ "Why-blame on any line",
492+ "A context hub agents search before they start",
493+ "Search across code, issues and people",
494+ ]}
495+ more={["Sessions and why-blame", `${DOCS}/guides/why-blame/`]}
496+ >
497+ Pick any line. g1t shows the commit that changed it, the pull request and issue it came from, and the
498+ agent&apos;s own session: what it read, ran and decided. The reasoning stays with the code, for people and
499+ for the next agent.
500+ </Pillar>
501+
502+ <Pillar
503+ flip
504+ eyebrow="Run it"
505+ title="Every change, live on the edge"
506+ art={<DeployArt className="w-full" />}
507+ points={[
508+ "A live preview for every pull request",
509+ "Production on every merge to main",
510+ "Custom domains, with certificates",
511+ "Projects that depend on each other",
512+ ]}
513+ more={["Deployments", `${DOCS}/guides/deployments/`]}
514+ >
515+ Turn on deployments and every pull request gets its own address on g1t.page; merging ships production.
516+ Reviewers, and the agents reviewing for you, click through a change instead of reading a diff. An app nobody
517+ visits runs nothing and costs nothing.
518+ </Pillar>
519+ </div>
520+ </section>
521+
522+ {/* The forge, for people. */}
523+ <section className="mx-auto max-w-6xl px-4 py-24">
524+ <div className="grid gap-10 lg:grid-cols-[1fr_1.2fr] lg:gap-16">
525+ <div>
526+ <Eyebrow>Collaborate</Eyebrow>
527+ <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
528+ A complete forge for the people who build
529+ </h2>
530+ <p className="mt-4 max-w-md leading-7 text-muted">
531+ Issues, branches, pull requests and reviews, the way your team already works, with agents as members
532+ alongside you. Work by hand, hand work off, or both on the same issue.
533+ </p>
534+ <More to={`${DOCS}/concepts/overview/`}>How g1t works</More>
535+ </div>
536+ <div className="rounded-3xl bg-surface p-7 ring-1 ring-line">
537+ <p className="text-sm font-medium">Included in every workspace</p>
538+ <Tags items={FORGE} />
539+ </div>
540+ </div>
541+ </section>
542+
543+ {/* Chat, where work is asked for. */}
544+ <section className="mx-auto grid max-w-6xl items-start gap-12 px-4 lg:grid-cols-[1.1fr_1fr] lg:gap-16">
438545 <div>
439546 <Eyebrow>Chat</Eyebrow>
440547 <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
613720 </div>
614721 </section>
615722
616− {/* Coming next: coordination and Docs. */}
723+ {/* Switching: what moving in costs, and what holds if g1t goes away. */}
724+ <section id="switching" className="mx-auto max-w-6xl scroll-mt-20 px-4 pb-24">
725+ <div className="grid gap-10 rounded-3xl bg-surface p-8 ring-1 ring-line sm:p-10 lg:grid-cols-[1fr_1.1fr] lg:gap-16">
726+ <div>
727+ <Eyebrow>Switching</Eyebrow>
728+ <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
729+ Moving in is an import. Leaving is a git clone.
730+ </h2>
731+ <p className="mt-4 max-w-xl leading-7 text-muted">
732+ Bring repositories across from GitHub in a few clicks: every branch and tag with full history, and their
733+ issues if you want them. Import a copy, keep a mirror that follows GitHub while you try g1t, or move and
734+ let g1t push back to GitHub so people still working there see every change.
735+ </p>
736+ <p className="mt-4 max-w-xl leading-7 text-muted">
737+ If g1t went away tomorrow, your code is plain git, the source is MIT licensed, and the core forge runs on
738+ your own machine with Docker Compose.
739+ </p>
740+ <More to={`${DOCS}/guides/github/#import-mirror-or-move-a-repository`}>Import from GitHub</More>
741+ </div>
742+ <div className="space-y-6">
743+ <div>
744+ <p className="text-sm font-medium">What keeps working</p>
745+ <Points
746+ columns={false}
747+ points={[
748+ "git over HTTPS, with your history as it is",
749+ "GitHub Actions workflows: rename .github to .g1t and push",
750+ "The coding agents you use today, through MCP",
751+ "Leaving: git clone, and the REST API for the rest",
752+ ]}
753+ />
754+ </div>
755+ <div className="rounded-xl border border-dashed border-line-strong px-4 py-3.5 text-sm leading-6 text-muted">
756+ <p className="font-medium text-fg-soft">Not a fit yet if you need</p>
757+ <p className="mt-1.5">
758+ Single sign-on, git over SSH, or agents, chat and deployments on your own machines: those run only on
759+ g1t.sh today. Open pull requests, issue comments, releases and wikis stay behind on import.{" "}
760+ <Link to={`${DOCS}/about/limitations/`} className="text-fg-soft underline-offset-4 hover:text-accent hover:underline">
761+ What g1t can&apos;t do yet
762+ </Link>
763+ </p>
764+ </div>
765+ </div>
766+ </div>
767+ </section>
768+
769+ {/* Coordination, as it works today; and Docs, coming next. */}
617770 <section className="mx-auto grid max-w-6xl gap-4 px-4 pb-24 lg:grid-cols-2">
618771 <div className="rounded-3xl bg-surface p-8 ring-1 ring-line">
619− <div className="flex items-center gap-3">
620− <Eyebrow>Coordination</Eyebrow>
621− <Soon />
622− </div>
772+ <Eyebrow>Coordination</Eyebrow>
623773 <h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance">Many agents, no collisions</h3>
624774 <p className="mt-3 text-sm leading-6 text-muted">
625− Before an agent touches an issue, a branch, an environment or a set of paths, it claims it. When two
626− agents&apos; work would overlap, they agree who goes first in a thread you can read. Chains of agents stop
627− and ask a person after a few hops, and work done for another agent is charged to the task that asked.
775+ Every agent already sees what the others are changing and can ask them, through the forge. Each change is a
776+ pull request in the agent&apos;s own fork, conflicts are found on every push, and the merge queue tests
777+ changes together with what lands ahead of them. Five agents can open pull requests the same afternoon and
778+ main still only moves to what passed.
628779 </p>
629− <p className="mt-3 text-sm leading-6 text-muted">
630− Today, every agent already sees what the others are changing and can ask them, through the forge.
780+ <p className="mt-4 flex flex-wrap items-center gap-x-2.5 gap-y-1.5 text-sm leading-6 text-muted">
781+ <Soon />
782+ <span>
783+ Claims and handoffs: an agent claims an issue, branch or set of paths before it starts, and overlapping
784+ agents agree who goes first in a thread you can read.
785+ </span>
631786 </p>
787+ <More to={`${DOCS}/guides/merge-queue/`}>The merge queue</More>
632788 </div>
633789 <div className="rounded-3xl bg-surface p-8 ring-1 ring-line">
634790 <div className="flex items-center gap-3">
689845 </div>
690846 </section>
691847
692− {/* Code. */}
693− <section className="mx-auto max-w-6xl px-4 pt-24">
694− <div className="max-w-3xl">
695− <Eyebrow>Code</Eyebrow>
696− <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-5xl">
697− A forge built for many agents at once.
698− </h2>
699− <p className="mt-5 max-w-2xl text-lg leading-8 text-muted">
700− Underneath the conversation is plain git. Every change an agent makes is a pull request in its own fork,
701− checked by g1t, reviewed, and landed through a queue that keeps main passing.
702− </p>
703− </div>
704−
705− <div className="mt-6 divide-y divide-line">
706− <Pillar
707− eyebrow="Outcomes"
708− title="Hand off an outcome, not just a task"
709− art={<PlanArt className="w-full" />}
710− points={[
711− "A brief planned into issues with dependencies",
712− "Your workflows' required checks on every change",
713− "Work starts as each dependency lands",
714− "The plan as a live graph, with its cost",
715− ]}
716− more={["Hand off an outcome", `${DOCS}/guides/outcomes/`]}
717− >
718− Write what should be true. A planner turns it into issues, each saying what done looks like, and the order
719− they depend on. Agents take each issue as it unblocks, and you watch the whole outcome converge.
720− </Pillar>
721−
722− <Pillar
723− flip
724− eyebrow="Ship safely"
725− title="Main only moves to what passed"
726− art={<QueueArt className="w-full" />}
727− points={[
728− "Checks run by g1t in a clean sandbox",
729− "Workflows from .g1t/workflows",
730− "A merge queue that tests changes together",
731− "Conflicts found on every push, before a merge",
732− "Catch up with main in seconds",
733− "Reviews by people and by agents",
734− ]}
735− more={["The merge queue", `${DOCS}/guides/merge-queue/`]}
736− >
737− Checks are run by g1t, never by the agent being checked. The queue tests each change together with what
738− lands ahead of it; one that breaks goes back to its author with what failed, and main never sees it.
739− </Pillar>
740−
741− <Pillar
742− eyebrow="Context"
743− title="Every line knows why it is there"
744− art={<WhyArt className="w-full" />}
745− points={[
746− "Sessions recorded onto pull requests",
747− "Why-blame on any line",
748− "A context hub agents search before they start",
749− "Search across code, issues and people",
750− ]}
751− more={["Sessions and why-blame", `${DOCS}/guides/why-blame/`]}
752− >
753− Pick any line. g1t shows the commit that changed it, the pull request and issue it came from, and the
754− agent&apos;s own session: what it read, ran and decided. The reasoning stays with the code, for people and
755− for the next agent.
756− </Pillar>
757−
758− <Pillar
759− flip
760− eyebrow="Run it"
761− title="Every change, live on the edge"
762− art={<DeployArt className="w-full" />}
763− points={[
764− "A live preview for every pull request",
765− "Production on every merge to main",
766− "Custom domains, with certificates",
767− "Projects that depend on each other",
768− ]}
769− more={["Deployments", `${DOCS}/guides/deployments/`]}
770− >
771− Turn on deployments and every pull request gets its own address on g1t.page; merging ships production.
772− Reviewers, and the agents reviewing for you, click through a change instead of reading a diff. An app nobody
773− visits runs nothing and costs nothing.
774− </Pillar>
775− </div>
776− </section>
777−
778− {/* The forge, for people. */}
848+ {/* Secure by default */}
779849 <section className="mx-auto max-w-6xl px-4 py-24">
780− <div className="grid gap-10 lg:grid-cols-[1fr_1.2fr] lg:gap-16">
781− <div>
782− <Eyebrow>Collaborate</Eyebrow>
783− <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance">
784− A complete forge for the people who build
785− </h2>
786− <p className="mt-4 max-w-md leading-7 text-muted">
787− Issues, branches, pull requests and reviews, the way your team already works, with agents as members
788− alongside you. Work by hand, hand work off, or both on the same issue.
789− </p>
790− <More to={`${DOCS}/concepts/overview/`}>How g1t works</More>
791− </div>
792− <div className="rounded-3xl bg-surface p-7 ring-1 ring-line">
793− <p className="text-sm font-medium">Included in every workspace</p>
794− <Tags items={FORGE} />
795− </div>
796− </div>
797− </section>
798−
799− {/* Secure by default */}
800− <section className="mx-auto max-w-6xl px-4 pb-24">
801850 <Eyebrow>Secure by default</Eyebrow>
802851 <h2 className="mt-3 max-w-2xl text-3xl font-semibold tracking-tight text-balance">
803852 Safe to hand the work to, and healthy without anyone watching
915964 </li>
916965 ))}
917966 </ul>
918− <p className="text-sm leading-6 text-muted">g1t is made by Flagon, Inc., a small, independent software company.</p>
967+ <p className="text-sm leading-6 text-muted">g1t is made by Flagon, Inc., a small, independent software company founded by Chase Pierce.</p>
919968 </div>
920969 </div>
921970 </section>
+7−9
11 /**
22 * The landing page's product tour: g1t browsing itself. A faithful app
33 * frame (rail, sidebars, the real colours) plays one story on a loop, with
4− * a soft cursor that moves, clicks and types: a request in #web, an agent's
5− * desk, the pull request going green, the ship note, the docs page.
4+ * a soft cursor that moves and clicks: the pull request going green and
5+ * merging, the request in #web it came from and the ship note, the agent's
6+ * sessions, the docs page.
67 *
78 * The frame is a pure function of time (lib/tour.ts). One rAF loop drives
89 * the clock and re-renders only when the frame changes; the step pills'
182183
183184 /** The pill a frame belongs to, from what it shows (the clock's time is not React state). */
184185 function pillOf(frame: Frame): number {
185− if (frame.scene === "docs") return 3;
186− if (frame.scene === "code" || frame.merged) return 2;
187− if (frame.scene === "agents") return 1;
188− return 0;
186+ return Math.max(0, PILLS.findIndex((pill) => pill.scene === frame.scene));
189187 }
190188
191189 /** What the line under the frame says about the moment: working today, or a preview. */
12211219 observer.observe(element);
12221220 return () => observer.disconnect();
12231221 }, []);
1224− const scenes: Scene[] = ["chat", "agents", "code", "docs"];
1222+ const scenes: Scene[] = ["code", "chat", "agents", "docs"];
12251223 const mains: Record<Scene, ReactNode> = {
12261224 chat: <ChatMain frame={frame} />,
12271225 agents: <AgentsMain frame={frame} />,
12861284 ];
12871285
12881286 function Phone({ frame }: { frame: Frame }) {
1289− const scenes: Scene[] = ["chat", "agents", "code", "docs"];
1287+ const scenes: Scene[] = ["code", "chat", "agents", "docs"];
12901288 return (
12911289 <div className="relative flex h-[34rem] w-full flex-col overflow-hidden rounded-2xl bg-bg text-left shadow-2xl shadow-black/50 ring-1 ring-line">
12921290 <div className="flex h-12 shrink-0 items-center gap-2 border-b border-line px-3">
14101408 <p className="flex items-center gap-2 text-sm font-semibold text-fg">
14111409 Otto <AgentTag />
14121410 </p>
1413− <p className="text-[11px] text-muted">Working · ${cost.toFixed(2)} of $5</p>
1411+ <p className="text-[11px] text-muted">{frame.merged ? "Shipped #431" : "Working"} · ${cost.toFixed(2)} of $5</p>
14141412 </div>
14151413 </div>
14161414 <div className="rounded-xl border border-line bg-surface">
+20−5
11 This policy explains what information g1t collects, why, where it goes, how long we keep it, and what you can do about it. It covers g1t.sh, api.g1t.sh, mcp.g1t.sh, docs.g1t.sh, g1t.page and the agents and sandboxes g1t runs. g1t is run by Flagon, Inc. ("Flagon", "we"), which is responsible for your information.
22
3−The short version: we collect what we need to run a git platform for you and your agents, we don't sell it, we don't advertise, we don't use your private content to train AI models, and there are no third-party trackers on g1t.
3+The short version: we collect what we need to run a git platform for you and your agents, we don't sell it, we don't advertise, we don't use your private content to train AI models, and the only analytics on g1t.sh counts how pages are used without reading what is in your workspaces.
44
55 ## What we collect
66
3838
3939 Payments are handled by Stripe. **Card numbers never reach g1t.** From Stripe we keep the workspace's customer reference, the card's brand, last four digits and expiry date, whether it is a prepaid card, and a fingerprint of the card that Stripe gives us, so that each card gets only one trial. Your billing email, address and tax ID are held by Stripe. We keep the workspace's statement: usage, charges, credits, payments and invoices.
4040
41+### How the site is used
42+
43+On g1t.sh, our analytics provider HeyCatch, Inc. records page views, clicks and the page each happened on, with your browser and device type, your IP address (which it uses to estimate your country and city) and the site that sent you. We use it to learn which pages help people and where they get stuck.
44+
45+- **On the public pages** (the home page, pricing, Explore, signing up and signing in, support, security and these policies) it receives the page's address and the text of what you click.
46+- **Everywhere else** your browser removes names before anything is sent: an address such as `g1t.sh/acme/web/pull/12` is sent as `/:name/:name/pull/:n`, and page titles and the text, links and attributes of what you click are left out. Nothing from your repositories, issues, pull requests or chat is sent.
47+- **If you're signed in**, it receives your account's internal id, so your visits count as one person. Not your username, email address or name.
48+- **Never**: recordings of your screen or session, what you type, or error reports.
49+- **Query strings** are removed, except campaign tags (`utm_…`).
50+
51+**If you're in the EU, the EEA, the UK or Switzerland, we ask first.** Until you choose **Allow**, nothing is loaded from HeyCatch, no analytics cookie is set and nothing is sent. Your answer is kept in your browser's local storage (`g1t_analytics`), and **Cookie choices** at the foot of any page asks again. Elsewhere, analytics runs without asking. If your browser sends Do Not Track, nothing is recorded anywhere. A g1t you run yourself sends nothing to HeyCatch.
52+
53+HeyCatch stores these events with PostHog in the United States, under its [data processing addendum](https://heycatch.ai/dpa), which includes the EU Standard Contractual Clauses.
54+
4155 ### When you write to us
4256
4357 If you email support, security, privacy or billing, we keep the conversation, so we can help you and remember what we said.
4862
4963 ## Cookies and browser storage
5064
51−g1t uses only the cookies it needs to work. There are **no analytics, advertising or third-party tracking cookies**.
65+g1t uses the cookies it needs to work, and one analytics cookie. There are **no advertising or cross-site tracking cookies**.
5266
5367 | Cookie | What it's for | How long |
5468 | --- | --- | --- |
5670 | `g1t_ws` | Remembers which workspace you last chose, so the sidebar opens on it. | 1 year |
5771 | `g1t_seen` | Remembers when you last looked at mission control, so it can show what's new since. | 1 year |
5872 | `g1t_tz` | Your browser's time zone, so mission control's greeting and days fit your day. | 1 year |
73+| `ph_…_posthog` | Set by HeyCatch's analytics on g1t.sh: a random id for your browser, so its visits count as one visitor ([above](#how-the-site-is-used)). In the EU, the EEA, the UK and Switzerland, only after you allow it. | 1 year |
5974
6075 Cloudflare may set its own security cookies (such as `__cf_bm`) to tell people from bots when g1t is under attack.
6176
62−The site also keeps a few preferences in your browser's local storage, which never leave your device: which coding agent you set up with, how you like diffs shown, and checklist items you've dismissed.
77+The site also keeps a few preferences in your browser's local storage, which never leave your device: which coding agent you set up with, how you like diffs shown, and checklist items you've dismissed. HeyCatch keeps a copy of its analytics id there too, which is sent with each analytics event.
6378
6479 **Fonts.** g1t.sh and docs.g1t.sh serve their typefaces themselves, so loading a page asks no one else for them.
6580
7287 - bill workspaces and keep the records the law requires;
7388 - send you the email g1t needs to: confirming your address, resetting your password, billing alerts and receipts, answers to requests you made, and important changes to g1t or these policies. **We don't send marketing email**;
7489 - answer you when you write to us;
75−- improve g1t, using our own records of how it is used. We don't use your private content to train AI models.
90+- improve g1t, using our own records of how it is used and the site analytics [above](#how-the-site-is-used). We don't use your private content to train AI models.
7691
7792 If you're in the European Economic Area or the United Kingdom, our legal bases are: **performing our contract with you** (running the service you signed up for), **our legitimate interests** (keeping g1t secure, preventing abuse, and improving it, balanced against your rights), and **legal obligations** (such as keeping tax records).
7893
8297
8398 We share information only to run g1t, at your direction, or when the law requires it.
8499
85−- **Service providers ("subprocessors")** that run parts of g1t for us, under contracts that limit them to doing so: Cloudflare (hosting, storage, databases, sandboxes, email, search and the AI gateway), Stripe (payments) and Anthropic (the model behind hosted agents). The [subprocessors](/policies/subprocessors) page lists them and what each receives.
100+- **Service providers ("subprocessors")** that run parts of g1t for us, under contracts that limit them to doing so: Cloudflare (hosting, storage, databases, sandboxes, email, search and the AI gateway), Stripe (payments), Anthropic (the model behind hosted agents) and HeyCatch (site analytics). The [subprocessors](/policies/subprocessors) page lists them and what each receives.
86101 - **Services you connect.** When you connect your own model provider, an issue tracker, error tracking or a webhook, or let your sandboxes reach a host, we send them what that connection needs, because you asked us to.
87102 - **Other people on g1t**, as your settings allow: your workspace's members, and everyone for public projects and your public profile.
88103 - **Vulnerability databases.** To find vulnerable dependencies, we send the names and versions of packages in your lockfiles to OSV.dev, an open database run by Google. Nothing else about you or your repository is sent.
+3−0
66 | --- | --- | --- | --- |
77 | **Cloudflare, Inc.** | Hosting and the network g1t runs on (Workers), databases (D1), storage for repositories, avatars and screenshots (Artifacts, KV and R2), queues, the sandboxes agents and checks run in (Containers), sending email (Email Service), search embeddings (Workers AI and Vectorize), the gateway hosted agents reach their model through (AI Gateway), and screenshots of deployed apps (Browser Rendering) | Everything stored on g1t, and every request to it | Global |
88 | **Stripe, Inc.** | Payments, cards, invoices and receipts | Workspace owners' billing details, card details (entered on Stripe's page, never on g1t), and what each invoice charges | United States |
9+| **HeyCatch, Inc.** | Product analytics on g1t.sh: page views and clicks. Visitors in the EU, EEA, UK and Switzerland are asked first | The page's address (names replaced outside the public pages), the text of what is clicked on the public pages, browser and device type, IP address, the referring site, an analytics id, and a signed-in person's internal account id. Never repository content, chat, page titles inside the app, what you type, or recordings of your session | United States |
910 | **Anthropic, PBC** | The AI model behind g1t's hosted agents | What an agent run needs: the issue or request, the relevant code and files, the conversation so far, and tool results. Not your account details. Only when a hosted agent runs | United States |
1011
1112 **AI Gateway logs.** Each hosted agent's model request passes through Cloudflare AI Gateway, which records it with the workspace, repository and pull request it was for, so we can bill it accurately.
1213
1314 **Training.** Anthropic does not train its models on what g1t sends through its commercial API.
1415
16+**HeyCatch** stores analytics events with PostHog (United States) and lists its own subprocessors at [heycatch.ai/subprocessors](https://heycatch.ai/subprocessors). Its [data processing addendum](https://heycatch.ai/dpa) covers what it does for g1t, including the EU Standard Contractual Clauses for transfers to the United States.
17+
1518 ## Other services g1t calls
1619
1720 - **OSV.dev**, run by Google: the names and versions of packages in your lockfiles, to look up known vulnerabilities. Nothing that identifies you or your repository.
+10−2
6161
6262 ## Not built yet
6363
64−We'd rather tell you than have you assume. **Two-factor authentication and single sign-on are not available yet.** Until they are, use a long, unique password, and review your access tokens and authorized apps in your settings from time to time.
64+We'd rather tell you than have you assume. **Single sign-on is not available yet.** Until it is, turn on [two-factor authentication](/settings/two-factor), and review your access tokens and authorized apps in your settings from time to time.
6565
6666 ## Responsible disclosure
6767
100100 - We'll aim to fix critical problems within 30 days.
101101 - We'll credit you when we publish the fix, if you'd like.
102102
103−g1t doesn't have a paid bug bounty yet.
103+### No bug bounty, yet
104+
105+g1t doesn't pay bug bounties right now. We want to, and we will once g1t is making money to pay them from. Until then, we can offer the credit above and our thanks.
106+
107+### Fixing it yourself
108+
109+g1t is open source, so you can also send the fix. [Its source](/flagon-io/g1t) is on g1t, and changes land through pull requests like anyone else's: see [contributing](/flagon-io/g1t/blob/main/CONTRIBUTING.md).
110+
111+For a vulnerability that isn't fixed yet, **write to us first**: a public pull request shows the problem to everyone before the fix is live. We'll agree with you when to open it. Hardening that doesn't point at an open hole, such as stricter headers, tighter checks or better tests, is welcome as a pull request straight away.
104112
105113 ### Safe harbour
106114
+3−0
22 import { hydrateRoot } from "react-dom/client";
33 import { HydratedRouter } from "react-router/dom";
44
5+// Product analytics, started before the app (lib/analytics.client.ts).
6+import "./lib/analytics.client";
7+
58 startTransition(() => {
69 hydrateRoot(
710 document,
+19−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { asksFirst, choice, consentRequired } from "./analytics-consent.ts";
5+
6+test("visitors from the EU, EEA, UK and Switzerland are asked first", () => {
7+ for (const country of ["DE", "fr", "IE", "NO", "IS", "GB", "CH"]) assert.equal(asksFirst(country), true, country);
8+});
9+
10+test("others are not, nor is a visitor whose country is unknown", () => {
11+ for (const country of ["US", "CA", "AU", "JP", "BR"]) assert.equal(asksFirst(country), false, country);
12+ assert.equal(asksFirst(null), false);
13+ assert.equal(asksFirst(""), false);
14+});
15+
16+test("on the server nothing is chosen or required", () => {
17+ assert.equal(choice(), null);
18+ assert.equal(consentRequired(), false);
19+});
+83−0
1+/**
2+ * Whether a visitor has agreed to site analytics, where the law asks
3+ * first: the EU and EEA, the UK and Switzerland. The server decides from
4+ * the visitor's country and marks the page (`<meta name="g1t-analytics"
5+ * content="consent">`); the choice is kept in this browser's local
6+ * storage. Safe to import on the server, where nothing is chosen.
7+ */
8+
9+/** Countries whose visitors are asked before analytics runs. */
10+const ASK_FIRST = new Set([
11+ // The EU.
12+ "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU",
13+ "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE",
14+ // The rest of the EEA, the UK and Switzerland.
15+ "IS", "LI", "NO", "GB", "CH",
16+]);
17+
18+/** Whether a visitor from `country` (ISO 3166 alpha-2, as Cloudflare gives it) is asked first. */
19+export function asksFirst(country: string | null | undefined): boolean {
20+ return !!country && ASK_FIRST.has(country.toUpperCase());
21+}
22+
23+/**
24+ * Whether the visitor making `request` is asked first. Cloudflare sets the
25+ * country header (replacing any the visitor sent); `cf` is the same answer.
26+ */
27+export function visitorAsksFirst(request: Request): boolean {
28+ return asksFirst(request.headers.get("cf-ipcountry") ?? (request as Request & { cf?: { country?: string } }).cf?.country);
29+}
30+
31+/** The installation analytics runs on; anywhere else nothing is sent or asked. */
32+export const ANALYTICS_HOST = "g1t.sh";
33+
34+export function analyticsHere(): boolean {
35+ return typeof window !== "undefined" && window.location.hostname === ANALYTICS_HOST;
36+}
37+
38+/** Whether this page was marked as one whose visitor is asked first. */
39+export function consentRequired(): boolean {
40+ return typeof document !== "undefined" && document.querySelector('meta[name="g1t-analytics"][content="consent"]') !== null;
41+}
42+
43+export type Choice = "yes" | "no" | null;
44+
45+const KEY = "g1t_analytics";
46+let current: Choice | undefined;
47+const listeners = new Set<() => void>();
48+
49+/** What this browser chose; null until it has. */
50+export function choice(): Choice {
51+ if (typeof window === "undefined") return null;
52+ if (current === undefined) {
53+ try {
54+ const stored = window.localStorage.getItem(KEY);
55+ current = stored === "yes" || stored === "no" ? stored : null;
56+ } catch {
57+ current = null;
58+ }
59+ }
60+ return current;
61+}
62+
63+/** Record a choice, or null to ask again. */
64+export function choose(next: Choice) {
65+ current = next;
66+ try {
67+ if (next) window.localStorage.setItem(KEY, next);
68+ else window.localStorage.removeItem(KEY);
69+ } catch {
70+ // No storage: the choice holds for this page only.
71+ }
72+ for (const listener of listeners) listener();
73+}
74+
75+export function onChoice(listener: () => void): () => void {
76+ listeners.add(listener);
77+ return () => listeners.delete(listener);
78+}
79+
80+/** Whether analytics may run in this browser now. */
81+export function allowed(): boolean {
82+ return analyticsHere() && (!consentRequired() || choice() === "yes");
83+}
+79−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { isPublicPath, scrubEvent, scrubPath, scrubUrl } from "./analytics-scrub.ts";
5+
6+const ORIGIN = "https://g1t.sh";
7+
8+test("the front door is sent as it is", () => {
9+ for (const path of ["/", "/pricing", "/explore", "/register", "/policies/privacy", "/pricing/"]) {
10+ assert.equal(isPublicPath(path), true, path);
11+ assert.equal(scrubPath(path), path);
12+ }
13+});
14+
15+test("names in other addresses are replaced, and g1t's own words kept", () => {
16+ assert.equal(scrubPath("/acme/web/pull/12/files"), "/:name/:name/pull/:n/files");
17+ assert.equal(scrubPath("/acme/-/chat/secret-launch"), "/:name/-/chat/:name");
18+ assert.equal(scrubPath("/u/sam"), "/u/:name");
19+ assert.equal(scrubPath("/invite/abc123"), "/invite/:name");
20+ assert.equal(scrubPath("/acme/web/blob/main/src/keys.ts"), "/:name/:name/blob/:name/:name/:name");
21+});
22+
23+test("a URL on the site keeps only campaign parameters; another site's is left alone", () => {
24+ assert.equal(scrubUrl("https://g1t.sh/reset?token=s3cret&utm_source=x", ORIGIN), "https://g1t.sh/reset?utm_source=x");
25+ assert.equal(scrubUrl("https://g1t.sh/search?q=private", ORIGIN), "https://g1t.sh/search");
26+ assert.equal(scrubUrl("https://g1t.sh/acme/web#L4", ORIGIN), "https://g1t.sh/:name/:name");
27+ assert.equal(scrubUrl("https://news.example/acme?x=1", ORIGIN), "https://news.example/acme?x=1");
28+});
29+
30+test("a click inside the app goes without its text, link, attributes or title", () => {
31+ const event = scrubEvent(
32+ {
33+ event: "$autocapture",
34+ properties: {
35+ $current_url: "https://g1t.sh/acme/secret/issues/3",
36+ $pathname: "/acme/secret/issues/3",
37+ $title: "Rotate the prod key · acme/secret · g1t",
38+ $el_text: "Rotate the prod key",
39+ $elements_chain: 'a.link:text="Rotate the prod key"href="/acme/secret/issues/3"nth-child="1"attr__class="link"',
40+ $elements: [{ tag_name: "a", $el_text: "Rotate", href: "/acme/secret", attr__title: "x", nth_child: 1 }],
41+ $set_once: { $initial_current_url: "https://g1t.sh/acme/secret" },
42+ },
43+ },
44+ "/acme/secret/issues/3",
45+ ORIGIN,
46+ );
47+ assert.deepEqual(event?.properties, {
48+ $current_url: "https://g1t.sh/:name/:name/issues/:n",
49+ $pathname: "/:name/:name/issues/:n",
50+ $title: "g1t",
51+ $elements_chain: 'a.link:nth-child="1"',
52+ $elements: [{ tag_name: "a", nth_child: 1 }],
53+ $set_once: { $initial_current_url: "https://g1t.sh/:name/:name" },
54+ });
55+});
56+
57+test("on the front door a click keeps its text", () => {
58+ const event = scrubEvent(
59+ { event: "$autocapture", properties: { $el_text: "Start for free", $title: "g1t · Your team", $pathname: "/" } },
60+ "/",
61+ ORIGIN,
62+ );
63+ assert.deepEqual(event?.properties, { $el_text: "Start for free", $title: "g1t · Your team", $pathname: "/" });
64+});
65+
66+test("recordings and error reports are never sent, and heatmaps only from the front door", () => {
67+ assert.equal(scrubEvent({ event: "$snapshot", properties: {} }, "/", ORIGIN), null);
68+ assert.equal(scrubEvent({ event: "$exception", properties: {} }, "/", ORIGIN), null);
69+ assert.equal(scrubEvent({ event: "$$heatmap", properties: {} }, "/acme/web", ORIGIN), null);
70+ const heatmap = scrubEvent({ event: "$$heatmap", properties: { $heatmap_data: { "https://g1t.sh/pricing?ref=mail": [1] } } }, "/pricing", ORIGIN);
71+ assert.deepEqual(heatmap?.properties, { $heatmap_data: { "https://g1t.sh/pricing": [1] } });
72+});
73+
74+test("pages after signing in keep their address but not their text, which can show an email address", () => {
75+ assert.equal(scrubPath("/confirm-email"), "/confirm-email");
76+ assert.equal(scrubPath("/login/two-factor"), "/login/two-factor");
77+ const event = scrubEvent({ event: "$autocapture", properties: { $el_text: "Resend to sam@example.com" } }, "/confirm-email", ORIGIN);
78+ assert.deepEqual(event?.properties, {});
79+});
+122−0
1+/**
2+ * What product analytics may learn from a page, decided before an event
3+ * leaves the browser (lib/analytics.client.ts).
4+ *
5+ * The public front door (the home page, pricing, Explore, signing up and
6+ * in, support, security and the policies) is sent as it is. Everywhere else, names are replaced
7+ * with placeholders: `/acme/web/pull/12` is sent as
8+ * `/:name/:name/pull/:n`, the page title as "g1t", and a clicked element
9+ * without its text, link or attributes. Query strings keep only `utm_*`.
10+ * Session recordings and error reports are never sent, and click heatmaps
11+ * only from the front door. No Workers or browser imports, so it is tested
12+ * under Node.
13+ */
14+
15+/** Paths sent as they are. */
16+const PUBLIC = new Set([
17+ "/",
18+ "/pricing",
19+ "/explore",
20+ "/register",
21+ "/login",
22+ "/security",
23+ "/support",
24+ "/status",
25+ "/policies",
26+]);
27+
28+/**
29+ * Words of g1t's own addresses, kept in a scrubbed path so it still says
30+ * which kind of page it was. Anything else is a name, and is replaced.
31+ */
32+const ROUTE_WORDS = new Set([
33+ "-", "about.json", "account", "actions", "activity", "agents", "applications", "archive", "audit", "billing",
34+ "blob", "branches", "browse", "bypass-requests", "chat", "checks", "code", "code-access", "commit", "commits",
35+ "compare", "confirm-email", "context", "deployments", "dm", "docs", "emails", "emoji", "entries", "explore", "files", "forgot", "gateway",
36+ "github", "guardrails", "home", "inbox", "insights", "integrations", "invitations", "invite", "invites", "issues",
37+ "jobs", "keys", "labels", "login", "members", "memory", "merge-queue", "milestones", "new", "notifications", "overview",
38+ "packages", "patterns", "people", "personal-access-tokens", "pins", "policies", "profile", "projects", "pull",
39+ "pulls", "queue", "releases", "repositories", "reset", "rules", "runners", "runs", "search", "secrets", "security",
40+ "security-log", "settings", "soon", "spend", "tags", "teams", "tokens", "tree", "two-factor", "u", "usage",
41+ "verify", "webhooks", "workspace", "workspaces",
42+]);
43+
44+export function isPublicPath(pathname: string): boolean {
45+ const path = pathname.replace(/\/+$/, "") || "/";
46+ return PUBLIC.has(path) || path.startsWith("/policies/");
47+}
48+
49+/** A path as analytics may see it. */
50+export function scrubPath(pathname: string): string {
51+ if (isPublicPath(pathname)) return pathname;
52+ return pathname
53+ .split("/")
54+ .map((segment) => {
55+ if (segment === "" || ROUTE_WORDS.has(segment)) return segment;
56+ return /^\d+$/.test(segment) ? ":n" : ":name";
57+ })
58+ .join("/");
59+}
60+
61+/** Only campaign parameters survive; everything else in a query can name something. */
62+function scrubSearch(search: URLSearchParams): string {
63+ const kept = new URLSearchParams();
64+ for (const [key, value] of search) if (key.startsWith("utm_")) kept.append(key, value);
65+ const query = kept.toString();
66+ return query ? `?${query}` : "";
67+}
68+
69+/** A URL on this site as analytics may see it; another site's is left alone. */
70+export function scrubUrl(value: string, origin: string): string {
71+ let url: URL;
72+ try {
73+ url = new URL(value);
74+ } catch {
75+ return value;
76+ }
77+ if (url.origin !== origin) return value;
78+ return `${url.origin}${scrubPath(url.pathname)}${scrubSearch(url.searchParams)}`;
79+}
80+
81+/** Element text, links and attributes in autocapture's chain string. */
82+const CHAIN_DETAIL = /(?:text|href|attr__[\w-]+)="(?:[^"\\]|\\.)*"/g;
83+
84+function scrubValue(key: string, value: unknown, origin: string, publicPage: boolean): unknown {
85+ if (typeof value === "string") {
86+ if (!publicPage && (key === "$title" || key === "title")) return "g1t";
87+ if (!publicPage && key === "$el_text") return undefined;
88+ if (!publicPage && key === "$elements_chain") return value.replace(CHAIN_DETAIL, "");
89+ if (/pathname$/i.test(key) && value.startsWith("/")) return scrubPath(value);
90+ return value.startsWith(origin) ? scrubUrl(value, origin) : value;
91+ }
92+ if (Array.isArray(value)) return value.map((item) => scrubValue(key, item, origin, publicPage));
93+ if (value && typeof value === "object") {
94+ const out: Record<string, unknown> = {};
95+ for (const [inner, innerValue] of Object.entries(value)) {
96+ if (!publicPage && (inner === "$el_text" || inner === "href" || inner.startsWith("attr__"))) continue;
97+ const scrubbed = scrubValue(inner, innerValue, origin, publicPage);
98+ // Some objects are keyed by address, such as a heatmap's pages.
99+ if (scrubbed !== undefined) out[inner.startsWith(origin) ? scrubUrl(inner, origin) : inner] = scrubbed;
100+ }
101+ return out;
102+ }
103+ return value;
104+}
105+
106+/** Events that are never sent: session recordings and error reports, which carry page content. */
107+const NEVER = new Set(["$snapshot", "$snapshot_items", "$exception"]);
108+
109+export type CapturedEvent = { event: string; properties: Record<string, unknown>; [key: string]: unknown };
110+
111+/**
112+ * The event as analytics may see it, or null to drop it. `pathname` is the
113+ * page the event happened on; `origin` is this site's.
114+ */
115+export function scrubEvent<T extends CapturedEvent>(event: T | null, pathname: string, origin: string): T | null {
116+ if (!event || NEVER.has(event.event)) return null;
117+ const publicPage = isPublicPath(pathname);
118+ // A heatmap is keyed by the page's address, and holds where on it people clicked.
119+ if (event.event === "$$heatmap" && !publicPage) return null;
120+ const properties = scrubValue("", event.properties ?? {}, origin, publicPage) as Record<string, unknown>;
121+ return { ...event, properties };
122+}
+62−0
1+/**
2+ * Product analytics (HeyCatch), on g1t.sh only: pageviews, clicks and
3+ * route changes. Every event passes through lib/analytics-scrub.ts first,
4+ * so inside the app names, text and titles never leave the browser, and
5+ * recordings are never sent. A signed-in person is known by their account
6+ * id alone (`identify` below).
7+ *
8+ * The SDK is fetched once the page has loaded and the browser is idle, so
9+ * it never slows a page down; where a visitor is asked first
10+ * (lib/analytics-consent.ts) it is not fetched at all until they agree.
11+ * Imported by entry.client.tsx. A self-hosted g1t sends nothing.
12+ */
13+import type { analytics as Analytics } from "@heycatch/sdk";
14+
15+import { allowed, analyticsHere, choice, consentRequired, onChoice } from "./analytics-consent";
16+import { scrubEvent } from "./analytics-scrub";
17+
18+let sdk: Promise<typeof Analytics> | null = null;
19+/** Who is signed in, kept for when the SDK arrives. */
20+let signedIn: string | null = null;
21+
22+function load(): Promise<typeof Analytics> {
23+ sdk ??= import("@heycatch/sdk").then(({ analytics }) => {
24+ analytics.init({
25+ projectKey: "hck_pk_EsF6nrWNZ0tOM4cmDKA6tKBzdTr-GEW3",
26+ install: { framework: "react", frameworkVersion: "19", agent: "claude-code" },
27+ respectDnt: true,
28+ beforeSend: (event) => scrubEvent(event, window.location.pathname, window.location.origin),
29+ });
30+ if (signedIn) analytics.setIdentity(signedIn);
31+ return analytics;
32+ });
33+ return sdk;
34+}
35+
36+/** After the page has loaded, when the browser has a moment. */
37+function whenIdle(run: () => void) {
38+ // Safari has no requestIdleCallback.
39+ const idle = () => (typeof window.requestIdleCallback === "function" ? window.requestIdleCallback(run, { timeout: 4000 }) : setTimeout(run, 1500));
40+ if (document.readyState === "complete") idle();
41+ else window.addEventListener("load", idle, { once: true });
42+}
43+
44+if (analyticsHere()) {
45+ if (allowed()) whenIdle(() => void load());
46+ // A choice made on the banner, or changed from the footer.
47+ onChoice(() => {
48+ if (!consentRequired()) return;
49+ if (choice() === "yes") void load().then((analytics) => analytics.optInCapturing());
50+ else if (sdk) void sdk.then((analytics) => analytics.optOutCapturing());
51+ });
52+}
53+
54+/** Who is signed in, by account id only; null after signing out. */
55+export function identify(userId: string | null, previous: string | null) {
56+ signedIn = userId;
57+ if (!sdk) return;
58+ void sdk.then((analytics) => {
59+ if (userId) analytics.setIdentity(userId);
60+ else if (previous) analytics.resetIdentity();
61+ });
62+}
+11−1
1010 url: "https://www.flagon.io",
1111 /** How the footer describes it. */
1212 about: "a small, independent software company",
13+ /** Who founded it, named wherever the company is described. */
14+ founder: "Chase Pierce",
1315 };
1416
17+/** g1t's own accounts elsewhere. */
18+export const SOCIAL: { label: string; url: string }[] = [{ label: "X", url: "https://x.com/g1t_sh" }];
19+
1520 /**
1621 * Flagon's other launched products, which the footer's maker line names
1722 * ("…, the people behind X and Y"). None yet: add each here when it ships.
8489 ];
8590
8691 /** When any policy last changed, `YYYY-MM-DD`. */
87−export const POLICIES_UPDATED = "2026-10-06";
92+export const POLICIES_UPDATED = "2026-10-09";
8893
8994 /** Every change to the policies, newest first. */
9095 export const POLICY_HISTORY: { date: string; change: string }[] = [
9196 {
97+ date: "2026-10-09",
98+ change:
99+ "Privacy Policy: g1t.sh uses HeyCatch for site analytics, with names removed outside the public pages, and one analytics cookie, set in the EU, EEA, UK and Switzerland only after you allow it. Subprocessors: HeyCatch, Inc. added.",
100+ },
101+ {
92102 date: "2026-10-06",
93103 change:
94104 "Privacy Policy: request logs are kept 7 days, and database history 30 days. Subprocessors: GitHub listed among the integrations you choose, in place of Slack, which g1t does not connect to.",
+7−5
77 * - No other site may put g1t's pages in a frame.
88 * - A page runs only the scripts the site served it: its own files, and the
99 * inline scripts React and React Router write, each carrying the page's
10− * nonce. Styles may be inline (highlighting and layout set them); images
11− * may come from any HTTPS address (pictures in a README); requests may go
12− * to any HTTPS address (the status page's summary).
10+ * nonce, plus Cloudflare's and HeyCatch's analytics scripts. Styles may
11+ * be inline (highlighting and layout set them); images may come from any
12+ * HTTPS address (pictures in a README); requests may go to any HTTPS
13+ * address (the status page's summary, analytics events).
1314 */
1415
1516 /** A fresh nonce for one page: 128 random bits, base64. */
2728 const files = usercontent && /^http:/.test(usercontent) ? ` ${new URL(usercontent).origin}` : "";
2829 return [
2930 "default-src 'self'",
30− // Cloudflare's Web Analytics beacon, when the zone turns it on.
31− `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com`,
31+ // Cloudflare's Web Analytics beacon, when the zone turns it on, and
32+ // HeyCatch's helper for product analytics (lib/analytics.client.ts).
33+ `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com https://in.heycatch.ai`,
3234 "style-src 'self' 'unsafe-inline'",
3335 `img-src 'self' https: data: blob:${files}`,
3436 `media-src 'self' https:${files}`,
+75−0
1+/**
2+ * Schema.org JSON-LD for the pages that say what g1t is and what it costs,
3+ * so search engines and AI assistants read it without parsing the prose.
4+ * Each is a `script:ld+json` meta descriptor, added after `page(…)`.
5+ */
6+import type { MetaDescriptor } from "react-router";
7+
8+import { COMPANY, SOCIAL } from "./legal";
9+import { DESCRIPTION, SITE } from "./meta";
10+
11+export function organization(): MetaDescriptor {
12+ return {
13+ "script:ld+json": {
14+ "@context": "https://schema.org",
15+ "@type": "Organization",
16+ name: COMPANY.name,
17+ url: COMPANY.url,
18+ founder: { "@type": "Person", name: COMPANY.founder },
19+ brand: { "@type": "Brand", name: "g1t", url: SITE, sameAs: SOCIAL.map((account) => account.url) },
20+ },
21+ };
22+}
23+
24+/** g1t as an application, with the plan's monthly price per workspace. */
25+export function application(monthlyDollars: number): MetaDescriptor {
26+ return {
27+ "script:ld+json": {
28+ "@context": "https://schema.org",
29+ "@type": "SoftwareApplication",
30+ name: "g1t",
31+ url: SITE,
32+ applicationCategory: "DeveloperApplication",
33+ operatingSystem: "Web",
34+ description: DESCRIPTION,
35+ publisher: { "@type": "Organization", name: COMPANY.name, url: COMPANY.url },
36+ license: "https://opensource.org/licenses/MIT",
37+ offers: [
38+ {
39+ "@type": "Offer",
40+ name: "Free",
41+ price: "0",
42+ priceCurrency: "USD",
43+ description: "Chat and the forge, with no card.",
44+ },
45+ {
46+ "@type": "Offer",
47+ name: "The g1t plan",
48+ price: String(monthlyDollars),
49+ priceCurrency: "USD",
50+ description: "A month per workspace, never per seat, with $10 of usage included.",
51+ priceSpecification: {
52+ "@type": "UnitPriceSpecification",
53+ price: String(monthlyDollars),
54+ priceCurrency: "USD",
55+ unitText: "month per workspace",
56+ },
57+ },
58+ ],
59+ },
60+ };
61+}
62+
63+export function faqPage(questions: { q: string; a: string }[]): MetaDescriptor {
64+ return {
65+ "script:ld+json": {
66+ "@context": "https://schema.org",
67+ "@type": "FAQPage",
68+ mainEntity: questions.map((item) => ({
69+ "@type": "Question",
70+ name: item.q,
71+ acceptedAnswer: { "@type": "Answer", text: item.a },
72+ })),
73+ },
74+ };
75+}
+24−33
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { ASK, BEAT, CHECKS, LOOP_MS, PILLS, STEPS, STILLS, frameAt, pillAt, pillProgress, sameFrame } from "./tour.ts";
4+import { BEAT, CHECKS, LOOP_MS, PILLS, STEPS, STILLS, frameAt, pillAt, pillProgress, sameFrame } from "./tour.ts";
55
6−test("the first frame is the empty channel, the same on the server and the client", () => {
6+test("the first frame is the pull request before its checks, the same on the server and the client", () => {
77 const frame = frameAt(0);
8− assert.equal(frame.scene, "chat");
9− assert.equal(frame.typed, 0);
10− assert.equal(frame.sent, false);
8+ assert.equal(frame.scene, "code");
9+ assert.equal(frame.checks, 0);
10+ assert.equal(frame.merged, false);
1111 assert.equal(frame.cursorShown, false);
1212 assert.ok(sameFrame(frame, frameAt(0)));
1313 });
1414
15−test("the message is typed in full before it is sent, then the composer empties", () => {
16− assert.equal(frameAt(BEAT.typed).typed, ASK.length);
17− assert.equal(frameAt(BEAT.typed - 1).typed, ASK.length - 1);
18− const sent = frameAt(BEAT.sent);
19− assert.equal(sent.sent, true);
20− assert.equal(sent.typed, 0);
21−});
22−
23−test("typing only ever moves forward", () => {
24− let last = 0;
25− for (let t = 0; t <= BEAT.typed; t += 17) {
26− const typed = frameAt(t).typed;
27− assert.ok(typed >= last);
28− last = typed;
29− }
30−});
31−
32−test("the scenes come in the story's order", () => {
15+test("the scenes come in the story's order: Code first", () => {
3316 const order: string[] = [];
3417 for (let t = 0; t < LOOP_MS; t += 100) {
3518 const scene = frameAt(t).scene;
3619 if (order[order.length - 1] !== scene) order.push(scene);
3720 }
38− assert.deepEqual(order, ["chat", "agents", "code", "chat", "docs"]);
21+ assert.deepEqual(order, ["code", "chat", "agents", "docs"]);
3922 });
4023
41−test("steps and checks finish before the cursor leaves them", () => {
42− assert.equal(frameAt(BEAT.pullClick).steps, STEPS.length);
24+test("checks pass before review, and the queue merges only after approval", () => {
4325 assert.equal(frameAt(BEAT.approved).checks, CHECKS.length);
44− assert.equal(frameAt(BEAT.pullClick).cursor, "task-pull");
26+ assert.equal(frameAt(BEAT.merged - 1).merged, false);
27+ assert.equal(frameAt(BEAT.merged).approved, true);
28+});
29+
30+test("the cursor leaves the code only once it has merged", () => {
31+ assert.equal(frameAt(BEAT.merged - 1).cursorShown, false);
32+ assert.equal(frameAt(BEAT.railChatClick).cursor, "rail-chat");
33+ assert.equal(frameAt(BEAT.railChatClick).click, true);
34+});
35+
36+test("Otto's work is done before the story opens", () => {
37+ for (const t of [0, BEAT.chat, BEAT.agents]) assert.equal(frameAt(t).steps, STEPS.length);
4538 });
4639
4740 test("the loop wraps", () => {
5043 });
5144
5245 test("pills start where their scene does, and progress runs 0 to 1", () => {
46+ assert.equal(PILLS[0].scene, "code");
5347 PILLS.forEach((pill, index) => {
5448 assert.equal(frameAt(pill.start + 1).scene, pill.scene);
5549 assert.equal(pillAt(pill.start + 1), index);
5650 });
57− assert.equal(pillAt(BEAT.chatAgain + 10), 2);
5851 assert.ok(pillProgress(PILLS[1].start) < 0.01);
5952 assert.ok(pillProgress(PILLS[2].start - 1) > 0.99);
6053 });
6154
6255 test("each still shows its step's point", () => {
63− assert.equal(frameAt(STILLS.chat).handoff, true);
64− assert.equal(frameAt(STILLS.agents).steps, STEPS.length);
6556 assert.equal(frameAt(STILLS.code).approved, true);
57+ assert.equal(frameAt(STILLS.chat).izzy, true);
58+ assert.equal(frameAt(STILLS.agents).scene, "agents");
6659 assert.equal(frameAt(STILLS.docs).docUpdated, true);
6760 });
6861
69−test("Izzy tells #support after g1t says it shipped, and the consult shows while Otto works", () => {
62+test("Izzy tells #support after g1t says it shipped", () => {
7063 assert.equal(frameAt(BEAT.izzy - 1).izzy, false);
7164 assert.equal(frameAt(BEAT.izzy).shipped, true);
72− assert.equal(frameAt(BEAT.agents).consult, false);
73− assert.equal(frameAt(BEAT.pullClick).consult, true);
7465 });
+43−88
44 * paused, resumed, jumped to a step, or shown still, and the server and
55 * the first client render agree (t = 0).
66 *
7− * The story: a person asks g1t in #web to fix the CSV export; g1t hands it
8− * to @otto and the review to @margo (agents the workspace hired from role
9− * templates; only g1t is built in); Otto works it in a session, bringing
10− * Margo in on the way; the pull request goes green and merges; g1t says
11− * it shipped and Izzy tells #support; Inky updates the docs page.
7+ * The story opens on the code: Otto's pull request #431 runs its checks,
8+ * is approved and is merged by the queue. Then where it came from: the
9+ * ask in #web, handed to @otto by g1t, now merged, and Izzy telling
10+ * #support. Then Otto's sessions, the record of what he did and spent, and
11+ * last Inky's docs page picking up the change. Otto, Margo, Izzy and Inky
12+ * are agents this workspace hired from role templates; only g1t is built in.
1213 */
1314
1415 export type Scene = "chat" | "agents" | "code" | "docs";
7374 ];
7475
7576 export const CHECKS = ["build", "test", "lint", "preview"];
76−
77−/** When the person starts typing, and how long each character takes, in ms. */
78−const TYPE_START = 1500;
79−
80−/**
81− * A human typing pace: about 45 ms a character, a little longer after a
82− * space, and a short hesitation after punctuation. Seeded, so every loop
83− * and every render types the same way.
84− */
85−function typingTimes(text: string): number[] {
86− let seed = 7;
87− const random = () => {
88− seed = (seed * 16807) % 2147483647;
89− return (seed - 1) / 2147483646;
90− };
91− const times: number[] = [];
92− let at = TYPE_START;
93− for (const char of text) {
94− at += 32 + random() * 34;
95− if (char === " ") at += random() * 30;
96− times.push(Math.round(at));
97− if (char === "." || char === "?" || char === ",") at += 220 + random() * 160;
98− }
99− return times;
100−}
10177
102−const TYPED_AT = typingTimes(ASK);
103−const TYPED_END = TYPED_AT[TYPED_AT.length - 1];
104−
10578 /** The beats of the story, in ms from the start of the loop. */
10679 export const BEAT = {
107− composerClick: 1100,
108− typed: TYPED_END,
109− sendClick: TYPED_END + 700,
110− sent: TYPED_END + 800,
111− g1tTyping: TYPED_END + 1300,
112− handoff: TYPED_END + 2500,
113− railAgentsClick: TYPED_END + 4300,
114− agents: TYPED_END + 4450,
115− step0: TYPED_END + 5400,
116− stepGap: 1150,
117− pullClick: TYPED_END + 11900,
118− code: TYPED_END + 12050,
119− check0: TYPED_END + 12900,
80+ code: 0,
81+ check0: 900,
12082 checkGap: 650,
121− diff: TYPED_END + 13300,
122− approved: TYPED_END + 15900,
123− merged: TYPED_END + 17000,
124− railChatClick: TYPED_END + 18300,
125− chatAgain: TYPED_END + 18450,
126− cardMerged: TYPED_END + 19100,
127− shipped: TYPED_END + 20400,
128− izzy: TYPED_END + 21600,
129− railDocsClick: TYPED_END + 23000,
130− docs: TYPED_END + 23150,
131− docUpdated: TYPED_END + 24300,
132− end: TYPED_END + 29000,
83+ diff: 1300,
84+ approved: 3900,
85+ merged: 5000,
86+ railChatClick: 6300,
87+ chat: 6450,
88+ cardMerged: 7100,
89+ shipped: 8400,
90+ izzy: 9600,
91+ railAgentsClick: 11000,
92+ agents: 11150,
93+ railDocsClick: 15000,
94+ docs: 15150,
95+ docUpdated: 16300,
96+ end: 21000,
13397 } as const;
13498
13599 /** The whole loop, in ms. */
137101
138102 /** The step pills under the frame: where each starts, for jumping to it. */
139103 export const PILLS: { scene: Scene; label: string; start: number; soon: boolean }[] = [
140− { scene: "chat", label: "Chat", start: 0, soon: false },
104+ { scene: "code", label: "Code", start: 0, soon: false },
105+ { scene: "chat", label: "Chat", start: BEAT.chat, soon: false },
141106 // Sessions, colleagues brought in, spend against a cap: built.
142107 { scene: "agents", label: "Agents", start: BEAT.agents, soon: false },
143− { scene: "code", label: "Code", start: BEAT.code, soon: false },
144108 // Docs is built; an agent keeping a page current after a merge on its
145109 // own is not yet, and the line under the frame says so.
146110 { scene: "docs", label: "Docs", start: BEAT.docs, soon: false },
147111 ];
148112
149−/** Which pill a time falls under. Back in chat after the merge is still Code's story. */
113+/** Which pill a time falls under. */
150114 export function pillAt(t: number): number {
151115 let index = 0;
152116 PILLS.forEach((pill, i) => {
172136 function cursorAt(t: number): { cursor: CursorTarget; click: boolean } {
173137 const press = (at: number) => t >= at && t < at + 180;
174138 const moves: [number, CursorTarget][] = [
175− [300, "composer"],
176− [TYPED_END + 150, "send"],
177− [BEAT.handoff + 700, "rail-agents"],
178− [BEAT.step0 + 4 * BEAT.stepGap + 500, "task-pull"],
179139 [BEAT.merged + 300, "rail-chat"],
180− [BEAT.izzy + 500, "rail-docs"],
140+ [BEAT.izzy + 500, "rail-agents"],
141+ [BEAT.agents + 2600, "rail-docs"],
181142 [BEAT.docs + 500, "idle"],
182143 ];
183144 let cursor: CursorTarget = "idle";
184145 for (const [at, target] of moves) if (t >= at) cursor = target;
185− const click =
186− press(BEAT.composerClick) ||
187− press(BEAT.sendClick) ||
188− press(BEAT.railAgentsClick) ||
189− press(BEAT.pullClick) ||
190− press(BEAT.railChatClick) ||
191− press(BEAT.railDocsClick);
146+ const click = press(BEAT.railChatClick) || press(BEAT.railAgentsClick) || press(BEAT.railDocsClick);
192147 return { cursor, click };
193148 }
194149
195−/** The frame at time t (ms into the loop). */
150+/**
151+ * The frame at time t (ms into the loop). The ask, g1t's handoff and
152+ * Otto's work all happened before the pull request the story opens on, so
153+ * they are shown done throughout.
154+ */
196155 export function frameAt(time: number): Frame {
197156 const t = ((time % LOOP_MS) + LOOP_MS) % LOOP_MS;
198− const scene: Scene =
199− t >= BEAT.docs ? "docs" : t >= BEAT.chatAgain ? "chat" : t >= BEAT.code ? "code" : t >= BEAT.agents ? "agents" : "chat";
200− let typed = 0;
201− while (typed < TYPED_AT.length && TYPED_AT[typed] <= t) typed++;
202− const sent = t >= BEAT.sent;
157+ const scene: Scene = t >= BEAT.docs ? "docs" : t >= BEAT.agents ? "agents" : t >= BEAT.chat ? "chat" : "code";
203158 const { cursor, click } = cursorAt(t);
204159 return {
205160 scene,
206− typed: sent ? 0 : typed,
207− sent,
208− g1tTyping: t >= BEAT.g1tTyping && t < BEAT.handoff,
209− handoff: t >= BEAT.handoff,
210− steps: count(t, BEAT.step0, BEAT.stepGap, STEPS.length),
161+ typed: 0,
162+ sent: true,
163+ g1tTyping: false,
164+ handoff: true,
165+ steps: STEPS.length,
211166 checks: count(t, BEAT.check0, BEAT.checkGap, CHECKS.length),
212167 diff: t >= BEAT.diff,
213168 approved: t >= BEAT.approved,
215170 cardMerged: t >= BEAT.cardMerged,
216171 shipped: t >= BEAT.shipped,
217172 izzy: t >= BEAT.izzy,
218− consult: t >= BEAT.step0 + 2 * BEAT.stepGap + 500,
173+ consult: true,
219174 docUpdated: t >= BEAT.docUpdated,
220175 cursor,
221176 click,
222− cursorShown: t >= 200 && t < BEAT.end - 600,
177+ cursorShown: t >= BEAT.merged && t < BEAT.end - 600,
223178 };
224179 }
225180
226181 /** A still frame for each pill, for reduced motion: the moment the step makes its point. */
227182 export const STILLS: Record<Scene, number> = {
228− chat: BEAT.handoff + 400,
229− agents: BEAT.step0 + 4 * BEAT.stepGap + 200,
230183 code: BEAT.approved + 200,
184+ chat: BEAT.izzy + 400,
185+ agents: BEAT.agents + 400,
231186 docs: BEAT.docUpdated + 400,
232187 };
233188
+21−2
1111 Search,
1212 Settings,
1313 } from "lucide-react";
14−import { useEffect, useState } from "react";
14+import { useEffect, useRef, useState } from "react";
1515 import {
1616 Form,
1717 isRouteErrorResponse,
5151 import { SiteFooter } from "./components/footer";
5252 import { SpikeBanner } from "./components/spike-banner";
5353 import { PolicyNotice } from "./components/policy-notice";
54+import { identify } from "./lib/analytics.client";
55+import { visitorAsksFirst } from "./lib/analytics-consent";
56+import { AnalyticsConsent } from "./components/analytics-consent";
5457 import { readCookie } from "./lib/mission";
5558 import { WORKSPACE_COOKIE, workspaceFor } from "./lib/workspace-choice";
5659 import { PageMain } from "./components/landmark";
109112 registrationMode(),
110113 ]);
111114 // Where this g1t lives, for clone lines, agent setup and link previews.
112− return { user, shell, inviteOnly: mode !== "open", addresses: addresses() };
115+ return {
116+ user,
117+ shell,
118+ inviteOnly: mode !== "open",
119+ addresses: addresses(),
120+ // Visitors from where the law asks first are asked before analytics runs.
121+ analyticsConsent: visitorAsksFirst(request),
122+ };
113123 }
114124
115125 /**
593603 {/* The stylesheet before everything React Router preloads, so a slow
594604 connection paints sooner (docs/research/css-shipping.md). */}
595605 <link rel="stylesheet" href={appCss} precedence="default" />
606+ {root?.analyticsConsent && <meta name="g1t-analytics" content="consent" />}
596607 <Meta />
597608 <Links nonce={nonce} />
598609 </head>
623634 {user && !awaitsConfirmation(user) && (
624635 <LiveNotifications workspace={root?.shell?.workspace?.slug ?? null} inbox={root?.shell?.inbox?.unread ?? null} />
625636 )}
637+ <AnalyticsConsent />
626638 <ScrollRestoration nonce={nonce} />
627639 <Scripts nonce={nonce} />
628640 </body>
631643 }
632644
633645 export default function App() {
646+ const userId = useRouteLoaderData<typeof loader>("root")?.user?.id ?? null;
647+ // Tell analytics who is signed in, once per change; signing out forgets them.
648+ const lastUserId = useRef<string | null>(null);
649+ useEffect(() => {
650+ identify(userId, lastUserId.current);
651+ lastUserId.current = userId;
652+ }, [userId]);
634653 return <Outlet />;
635654 }
636655
+3−0
7878 route("status", "routes/status.tsx"),
7979 route("status.json", "routes/status-json.ts"),
8080 route(".well-known/security.txt", "routes/security-txt.ts"),
81+ // For search engines: what may be crawled, and a map of the public pages.
82+ route("robots.txt", "routes/robots-txt.ts"),
83+ route("sitemap.xml", "routes/sitemap-xml.ts"),
8184 // Releases of the self-hosted runner and the g1t CLI, from R2.
8285 route("downloads/:tool/*", "routes/downloads-runner.ts"),
8386 // A workspace's own pages sit under `-`, which no repository can be named.
+10−5
33
44 import type { Route } from "./+types/home";
55 import { page } from "../lib/meta";
6+import { application, organization } from "../lib/structured-data";
67 import { WORKSPACE_COOKIE, chosenWorkspace } from "../lib/workspace-choice";
78 import { readCookie } from "../lib/mission";
89 import { type NotStarted, chosenRepo, delegateForm, issuePath, notStarted } from "../lib/delegate";
1213 import { homePath } from "../lib/workspace-nav";
1314
1415 export function meta(args: Route.MetaArgs) {
15− return page(args, {
16− title: "g1t · Your team and its agents, working in one place",
17− description:
18− "Chat with your team and your agents in channels and DMs. Agents are teammates with a role, a personality and a budget, and their code lands through pull requests, checks and a merge queue. Chat is free on every plan, never per seat.",
19− });
16+ return [
17+ ...page(args, {
18+ title: "g1t · Your team and its agents, working in one place",
19+ description:
20+ "Chat with your team and your agents in channels and DMs. Agents are teammates with a role, a personality and a budget, and their code lands through pull requests, checks and a merge queue. Chat is free on every plan, never per seat.",
21+ }),
22+ organization(),
23+ application(20),
24+ ];
2025 }
2126
2227 /**
+26−6
55 import type { Route } from "./+types/pricing";
66 import { wholeDollars } from "../lib/billing";
77 import { page } from "../lib/meta";
8+import { application, faqPage } from "../lib/structured-data";
89 import { TimeAgo } from "../components/ui";
910 import { billing } from "../lib/services.server";
1011
1112 export function meta(args: Route.MetaArgs) {
12− return page(args, {
13− title: "Pricing · g1t",
14− description:
15− "People chat free on every plan, and the forge is free. One plan, $20 a month per workspace with $10 of usage included. Agents pay the model's price plus a flat agent rate, from each agent's budget. No seats, ever.",
16− });
13+ const plan = args.loaderData?.book?.plans?.find((p) => p.feature === "plan") ?? DEFAULT_PLAN;
14+ return [
15+ ...page(args, {
16+ title: `g1t pricing - $${plan.monthlyCents / 100} per workspace, never per seat, cost-plus AI`,
17+ description:
18+ "People chat free on every plan, and the forge is free. One plan, $20 a month per workspace with $10 of usage included. Agents pay the model's price plus a flat agent rate, from each agent's budget. No seats, ever.",
19+ }),
20+ application(plan.monthlyCents / 100),
21+ faqPage(FAQ),
22+ ];
1723 }
1824
1925 export async function loader() {
148154 a: "No. There are no seats. People who only chat, and ask agents questions, cost nothing until their questions use an agent; then the agent's reply is charged like any other.",
149155 },
150156 {
157+ q: "What does moving from GitHub cost, and can we leave again?",
158+ a: "Moving in is an import: bring repositories across from GitHub with every branch, tag and their issues, or keep a mirror that follows GitHub while you try g1t. Your code is plain git and g1t is MIT licensed, so leaving is a git clone, and the core forge runs on your own machine with Docker Compose.",
159+ },
160+ {
151161 q: "Is Docs included?",
152162 a: "Docs is coming soon. When it ships, pages, editing and history are included on every plan, like chat. Files are on the storage meter.",
153163 },
312322
313323 <section className="mt-10 rounded-xl border border-accent/40 bg-surface p-6">
314324 <div className="flex flex-wrap items-baseline justify-between gap-3">
315− <h2 className="text-xl font-semibold tracking-tight">{plan.title}</h2>
325+ <h2 className="flex flex-wrap items-center gap-2 text-xl font-semibold tracking-tight">
326+ {plan.title}
327+ <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs font-medium tracking-normal text-accent">Recommended</span>
328+ </h2>
316329 <p>
317330 <span className="text-3xl font-semibold tabular-nums">${price}</span>{" "}
318331 <span className="text-sm text-muted">a month per workspace</span>
321334 </span>
322335 </p>
323336 </div>
337+ <p className="mt-2 text-sm text-muted">
338+ The plan for a team whose agents do real work: unlimited members, agents, checks, the merge queue and
339+ deployments.
340+ </p>
341+ <p className="mt-3 text-sm text-fg-soft">
342+ One price for the whole team: a 10-person team pays ${price} a month, not ${price} a person.
343+ </p>
324344 <ul className="mt-4 space-y-1.5 text-sm text-muted">
325345 {plan.includes.map((line) => (
326346 <li key={line} className="flex gap-2">
+20−0
1+/**
2+ * /robots.txt: everything may be crawled, and where the site's maps are.
3+ * Served from the request's own origin, so a self-hosted g1t names itself.
4+ */
5+import type { Route } from "./+types/robots-txt";
6+
7+/** g1t.sh's docs keep their own map; a self-hosted g1t has no docs site. */
8+const DOCS_SITEMAP = "https://docs.g1t.sh/sitemap-index.xml";
9+
10+export function loader({ request }: Route.LoaderArgs) {
11+ const site = new URL(request.url).origin;
12+ const lines = ["User-agent: *", "Allow: /", "", `Sitemap: ${site}/sitemap.xml`];
13+ if (site === "https://g1t.sh") lines.push(`Sitemap: ${DOCS_SITEMAP}`);
14+ return new Response(`${lines.join("\n")}\n`, {
15+ headers: {
16+ "content-type": "text/plain; charset=utf-8",
17+ "cache-control": "public, max-age=86400",
18+ },
19+ });
20+}
+45−0
1+/**
2+ * /sitemap.xml: the public pages, then the public projects Explore lists,
3+ * newest first. Explore being unreachable leaves just the pages.
4+ */
5+import type { Route } from "./+types/sitemap-xml";
6+import { search } from "../lib/services.server";
7+
8+/** Pages anyone can read, signed in or not. */
9+const PAGES = ["/", "/pricing", "/explore", "/support", "/security", "/policies", "/policies/privacy"];
10+
11+/** How many of Explore's pages to list; each holds one page of projects. */
12+const EXPLORE_PAGES = 10;
13+
14+function escape(text: string): string {
15+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
16+}
17+
18+export async function loader({ request }: Route.LoaderArgs) {
19+ const site = new URL(request.url).origin;
20+ const entries: { loc: string; lastmod?: string }[] = PAGES.map((path) => ({ loc: `${site}${path}` }));
21+ for (let pageNumber = 1; pageNumber <= EXPLORE_PAGES; pageNumber++) {
22+ const explore = await search.explore(null, { sort: "new", page: pageNumber }).catch(() => null);
23+ if (!explore) break;
24+ for (const repo of explore.repos) {
25+ const lastmod = (repo.pushedAt ?? repo.createdAt)?.slice(0, 10);
26+ entries.push({ loc: `${site}/${repo.namespace}/${repo.name}`, lastmod });
27+ }
28+ if (!explore.more) break;
29+ }
30+ const body = [
31+ '<?xml version="1.0" encoding="UTF-8"?>',
32+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
33+ ...entries.map(
34+ (entry) => ` <url><loc>${escape(entry.loc)}</loc>${entry.lastmod ? `<lastmod>${entry.lastmod}</lastmod>` : ""}</url>`,
35+ ),
36+ "</urlset>",
37+ "",
38+ ].join("\n");
39+ return new Response(body, {
40+ headers: {
41+ "content-type": "application/xml; charset=utf-8",
42+ "cache-control": "public, max-age=3600",
43+ },
44+ });
45+}
+1−0
1919 "@blocknote/shadcn": "^0.55.0",
2020 "@g1t/contracts": "*",
2121 "@g1t/theme": "*",
22+ "@heycatch/sdk": "0.8.3",
2223 "class-variance-authority": "^0.7.1",
2324 "clsx": "^2.1.1",
2425 "cmdk": "^1.1.1",
+0−2
1−User-agent: *
2−Allow: /
+5−1
33 import { identityClient, isNamespaceShaped } from "@g1t/contracts";
44
55 import { addressesFor } from "../app/lib/addresses";
6+import { visitorAsksFirst } from "../app/lib/analytics-consent";
67 import { hardenRegistryHeaders } from "../app/lib/content-safety";
78 import { withSiteHeaders } from "../app/lib/page-headers";
89 import { finishResponse, withRequestPerf } from "../app/lib/perf.server";
128129
129130 async function servePublic(env: Env, request: Request, ctx: ExecutionContext, render: () => Promise<Response>): Promise<Response> {
130131 const cache = (caches as unknown as { default: Cache }).default;
131− const key = new Request(request.url, { method: "GET" });
132+ // Visitors asked about analytics first get a page that says so, kept apart.
133+ const keyUrl = new URL(request.url);
134+ if (visitorAsksFirst(request)) keyUrl.searchParams.set("_g1t_consent", "1");
135+ const key = new Request(keyUrl, { method: "GET" });
132136 const repository = PUBLIC_PROJECT.test(new URL(request.url).pathname) ? repositoryOfPage(new URL(request.url).pathname) : null;
133137 // Asked alongside the cache, so a hit waits for one indexed read at most.
134138 const [cached, visible] = await Promise.all([cache.match(key), repository ? isStillPublic(env, repository) : Promise.resolve(true)]);
+1−1
185185 | --- | --- | --- | --- |
186186 | Static assets (`/assets/*`) | browser and edge | a year, immutable | hashed file names |
187187 | Avatars | edge cache | a year, immutable | by content hash |
188−| Public pages for people signed out | the data centre's cache (`workers/app.ts`, `servePublic`) | fresh 30 s, then served once more while a new copy is made, up to 5 min | GET, no `g1t_session` cookie, an allowlisted path (home, pricing, explore, policies, a project's pages), status 200 or 404, no `Set-Cookie`, nothing private. Reserved first segments and workspace pages (`-`) are never kept. A project's kept page is served only after repos' `visibility` says the repository is still there and public (one indexed read, alongside the cache lookup); a repository made private or deleted is never served from any data centre's copy, and the copy is dropped. The answer says `server-timing: cache;desc="hit, Ns old"`. |
188+| Public pages for people signed out | the data centre's cache (`workers/app.ts`, `servePublic`) | fresh 30 s, then served once more while a new copy is made, up to 5 min | GET, no `g1t_session` cookie, an allowlisted path (home, pricing, explore, policies, a project's pages), status 200 or 404, no `Set-Cookie`, nothing private. Reserved first segments and workspace pages (`-`) are never kept. A project's kept page is served only after repos' `visibility` says the repository is still there and public (one indexed read, alongside the cache lookup); a repository made private or deleted is never served from any data centre's copy, and the copy is dropped. Visitors from the EU, EEA, UK and Switzerland, whose page asks about the analytics cookie, get a copy of their own (the cache key gains `_g1t_consent=1`, `lib/analytics-consent.ts`). The answer says `server-timing: cache;desc="hit, Ns old"`. |
189189 | Sidebar data (projects, spend, limit, entitlements) | per isolate (`lib/cache.server.ts`) | 15 s, per person and workspace | skipped during a write and for 30 s after the person's last one; failures not kept; only settled answers kept |
190190 | Registration mode | per isolate | 60 s | |
191191 | A commit's log by hash | repos' data-centre cache | for good | history from a commit never changes. One of 100 commits or more is put together from a 16-commit read and the history kept from any of those commits, when there is one (`store.rs` `spliced_log`): a default branch that moved by a merge costs 16 commits, not 120 or 1,000 |
+113−0
7979 "@blocknote/shadcn": "^0.55.0",
8080 "@g1t/contracts": "*",
8181 "@g1t/theme": "*",
82+ "@heycatch/sdk": "0.8.3",
8283 "class-variance-authority": "^0.7.1",
8384 "clsx": "^2.1.1",
8485 "cmdk": "^1.1.1",
20372038 "prosemirror-view": "^1.0.0"
20382039 }
20392040 },
2041+ "node_modules/@heycatch/sdk": {
2042+ "version": "0.8.3",
2043+ "resolved": "https://registry.npmjs.org/@heycatch/sdk/-/sdk-0.8.3.tgz",
2044+ "integrity": "sha512-7knqXfptgctAIHu5IXwd/WnivY+wwYAws2Xr0ZuARottBnbQteFbxZP7TyEDWCyz6A5+PcGlzlPxeTdq5uyGoA==",
2045+ "license": "MIT",
2046+ "dependencies": {
2047+ "posthog-react-native": "^4.61.1"
2048+ },
2049+ "engines": {
2050+ "node": ">=22"
2051+ },
2052+ "peerDependencies": {
2053+ "expo-router": "*",
2054+ "react": "^18.2.0 || ^19.0.0"
2055+ },
2056+ "peerDependenciesMeta": {
2057+ "expo-router": {
2058+ "optional": true
2059+ },
2060+ "react": {
2061+ "optional": true
2062+ }
2063+ }
2064+ },
20402065 "node_modules/@iconify/types": {
20412066 "version": "2.0.0",
20422067 "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
28562881 "dev": true,
28572882 "license": "MIT"
28582883 },
2884+ "node_modules/@posthog/core": {
2885+ "version": "1.57.3",
2886+ "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.57.3.tgz",
2887+ "integrity": "sha512-lag+QZE61kcYSjYN9lKwri6P4/DACOtyRsA41MSI6tdKHbSIyrPfQPkDQiPbq413AMbTXFjcN/N6bqxPhei93Q==",
2888+ "license": "MIT",
2889+ "dependencies": {
2890+ "@posthog/types": "^1.415.2"
2891+ }
2892+ },
2893+ "node_modules/@posthog/types": {
2894+ "version": "1.415.2",
2895+ "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.415.2.tgz",
2896+ "integrity": "sha512-BIIHPyXcPZuNTImrR1qLqLKFw7j9o8co6LG/9OpLtpWJ3RT0y89Lx6VCtd37N9/dkr/qW7y0uPjJxAxjsALHtw==",
2897+ "license": "MIT"
2898+ },
28592899 "node_modules/@puppeteer/browsers": {
28602900 "version": "2.2.4",
28612901 "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.2.4.tgz",
1063910679 "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
1064010680 "license": "MIT"
1064110681 },
10682+ "node_modules/posthog-react-native": {
10683+ "version": "4.80.0",
10684+ "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.80.0.tgz",
10685+ "integrity": "sha512-aSCTguolb/6R01czT8MVW/lHSSdGoHHVomUE2MjQUub/+pIdw64ScwzNLUSZ9x5TU2sLAD7C1RqpGwnB6RidRw==",
10686+ "license": "MIT",
10687+ "dependencies": {
10688+ "@posthog/core": "^1.57.3",
10689+ "@posthog/types": "^1.415.2"
10690+ },
10691+ "bin": {
10692+ "posthog-react-native-xcode": "tooling/posthog-xcode.sh"
10693+ },
10694+ "peerDependencies": {
10695+ "@posthog/react-native-plugin": ">= 2.9.3",
10696+ "@react-native-async-storage/async-storage": ">=1.0.0",
10697+ "@react-navigation/native": ">= 5.0.0",
10698+ "expo-application": ">= 4.0.0",
10699+ "expo-device": ">= 4.0.0",
10700+ "expo-file-system": ">= 13.0.0",
10701+ "expo-localization": ">= 11.0.0",
10702+ "expo-updates": ">= 0.25.0",
10703+ "posthog-react-native-session-replay": ">= 1.6.0",
10704+ "react-native-device-info": ">= 10.0.0",
10705+ "react-native-localize": ">= 3.0.0",
10706+ "react-native-navigation": ">= 6.0.0",
10707+ "react-native-safe-area-context": ">= 4.0.0",
10708+ "react-native-svg": ">= 15.0.0"
10709+ },
10710+ "peerDependenciesMeta": {
10711+ "@posthog/react-native-plugin": {
10712+ "optional": true
10713+ },
10714+ "@react-native-async-storage/async-storage": {
10715+ "optional": true
10716+ },
10717+ "@react-navigation/native": {
10718+ "optional": true
10719+ },
10720+ "expo-application": {
10721+ "optional": true
10722+ },
10723+ "expo-device": {
10724+ "optional": true
10725+ },
10726+ "expo-file-system": {
10727+ "optional": true
10728+ },
10729+ "expo-localization": {
10730+ "optional": true
10731+ },
10732+ "expo-updates": {
10733+ "optional": true
10734+ },
10735+ "posthog-react-native-session-replay": {
10736+ "optional": true
10737+ },
10738+ "react-native-device-info": {
10739+ "optional": true
10740+ },
10741+ "react-native-localize": {
10742+ "optional": true
10743+ },
10744+ "react-native-navigation": {
10745+ "optional": true
10746+ },
10747+ "react-native-safe-area-context": {
10748+ "optional": true
10749+ },
10750+ "react-native-svg": {
10751+ "optional": true
10752+ }
10753+ }
10754+ },
1064210755 "node_modules/prismjs": {
1064310756 "version": "1.30.0",
1064410757 "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",