Previews build from any branch; Agent in place of Ask AI; pin from the sidebar
- Deploy builds fetch the commit before checking it out (clone::ensure), and ensure falls back to fetching every branch when the store will not fetch a commit by name: a preview of a branch other than the default failed with "reference is not a tree". A test reproduces it. - The new-team route imported a server-only helper it did not use, which failed the production build. - "Ask AI" is "Agent": the top bar button and mission control's composer (still disabled, "Coming later"). - The sidebar's Pinned heading shows only when something is pinned; every project row has a pin to pin or unpin it in place (always shown on touch screens). - Runner tests' scratch repositories never use the machine's commit signing.
10 files+116−400/10 viewed
| 11 | 11 | */ | |
| 12 | 12 | export function AskComposer({ children }: { children?: ReactNode }) { | |
| 13 | 13 | return ( | |
| 14 | − | <section aria-label="Ask g1t" className="space-y-3"> | |
| 14 | + | <section aria-label="Agent" className="space-y-3"> | |
| 15 | 15 | <div aria-disabled="true" className="rounded-xl border border-line bg-surface opacity-80"> | |
| 16 | + | <p className="flex items-center gap-2 px-4 pt-3 text-sm font-medium text-fg"> | |
| 17 | + | <Sparkles size={14} className="text-merged" aria-hidden="true" /> | |
| 18 | + | Agent | |
| 19 | + | <span className="rounded-full border border-line px-1.5 text-[0.6875rem] font-normal text-muted">Coming later</span> | |
| 20 | + | </p> | |
| 16 | 21 | <label htmlFor="ask-g1t" className="sr-only"> | |
| 17 | − | Ask g1t (coming later) | |
| 22 | + | Ask the agent (coming later) | |
| 18 | 23 | </label> | |
| 19 | 24 | <textarea | |
| 20 | 25 | id="ask-g1t" | |
| 36 | 41 | <Soon label="Mention" icon> | |
| 37 | 42 | <AtSign size={14} /> | |
| 38 | 43 | </Soon> | |
| 39 | − | <span className="ml-auto inline-flex items-center gap-1.5 text-xs text-faint"> | |
| 40 | − | <Sparkles size={13} className="text-merged" aria-hidden="true" /> | |
| 41 | − | Coming later | |
| 42 | − | </span> | |
| 44 | + | <span className="ml-auto" /> | |
| 43 | 45 | <Soon label="Send" icon> | |
| 44 | 46 | <ArrowUp size={15} /> | |
| 45 | 47 | </Soon> | |
| 66 | 68 | </button> | |
| 67 | 69 | </span> | |
| 68 | 70 | </TooltipTrigger> | |
| 69 | − | <TooltipContent>Asking g1t is coming later</TooltipContent> | |
| 71 | + | <TooltipContent>Agent is coming later</TooltipContent> | |
| 70 | 72 | </Tooltip> | |
| 71 | 73 | ); | |
| 72 | 74 | } |
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | 341 | /** | |
| 342 | − | * Ask AI: here to say it is coming, and not yet usable. A disabled button | |
| 342 | + | * Agent: g1t's agent to talk to, here to say it is coming, and not yet usable. A disabled button | |
| 343 | 343 | * gets no pointer events, so the tooltip hangs on a span around it. | |
| 344 | 344 | */ | |
| 345 | − | export function AskAi() { | |
| 345 | + | export function AgentButton() { | |
| 346 | 346 | return ( | |
| 347 | 347 | <Tooltip> | |
| 348 | 348 | <TooltipTrigger asChild> | |
| 350 | 350 | <button | |
| 351 | 351 | type="button" | |
| 352 | 352 | disabled | |
| 353 | − | aria-label="Ask AI" | |
| 353 | + | aria-label="Agent" | |
| 354 | 354 | className="pointer-events-none flex h-9 items-center gap-1.5 rounded-md border border-line px-2.5 text-sm text-muted opacity-60 sm:px-3" | |
| 355 | 355 | > | |
| 356 | 356 | <Sparkles size={15} className="text-merged" /> | |
| 357 | − | <span className="hidden sm:inline">Ask AI</span> | |
| 357 | + | <span className="hidden sm:inline">Agent</span> | |
| 358 | 358 | </button> | |
| 359 | 359 | </span> | |
| 360 | 360 | </TooltipTrigger> | |
| 361 | − | <TooltipContent>Ask AI is coming later</TooltipContent> | |
| 361 | + | <TooltipContent>Agent is coming later</TooltipContent> | |
| 362 | 362 | </Tooltip> | |
| 363 | 363 | ); | |
| 364 | 364 | } |
| 870 | 870 | </div> | |
| 871 | 871 | </header> | |
| 872 | 872 | ||
| 873 | − | {/* Asking g1t is not on yet; the actions under it do the work today. */} | |
| 873 | + | {/* The agent is not on yet; the actions under it do the work today. */} | |
| 874 | 874 | <AskComposer> | |
| 875 | 875 | {newIssue} | |
| 876 | 876 | {repos.length > 0 && ( |
| 5 | 5 | import type { Abilities, InboxCounts, Membership, Spike, User } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./command-palette"; | |
| 8 | − | import { AskAi, InboxBell } from "./inbox"; | |
| 8 | + | import { AgentButton, InboxBell } from "./inbox"; | |
| 9 | + | import { PinButton } from "./pin-button"; | |
| 9 | 10 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 10 | 11 | import { StatusDot, useSiteStatus } from "./footer"; | |
| 11 | 12 | import { Logo, Mark } from "./logo"; | |
| 604 | 605 | for (const project of next) form.append("slug", project.name); | |
| 605 | 606 | reorder.submit(form, { method: "post", action: `/${slug}/-/pins` }); | |
| 606 | 607 | }; | |
| 607 | − | const row = (project: ShortcutProject) => ( | |
| 608 | − | <SidebarLink to={`/${project.namespace}/${project.name}`} icon={project.isPrivate ? <Lock size={15} /> : <Box size={15} />} drill="hover"> | |
| 609 | − | {project.title ?? project.name} | |
| 610 | − | </SidebarLink> | |
| 608 | + | // Each row pins or unpins in place: the pin shows on hover or focus, and | |
| 609 | + | // stays shown on a pinned row's hover so it reads as "unpin". | |
| 610 | + | const row = (project: ShortcutProject, isPinned: boolean) => ( | |
| 611 | + | <div className="group/row relative"> | |
| 612 | + | <SidebarLink to={`/${project.namespace}/${project.name}`} icon={project.isPrivate ? <Lock size={15} /> : <Box size={15} />} drill="hover"> | |
| 613 | + | <span className="block truncate pr-6">{project.title ?? project.name}</span> | |
| 614 | + | </SidebarLink> | |
| 615 | + | <PinButton | |
| 616 | + | workspace={slug} | |
| 617 | + | slug={project.name} | |
| 618 | + | name={project.title ?? project.name} | |
| 619 | + | pinned={isPinned} | |
| 620 | + | small | |
| 621 | + | className="absolute top-1/2 right-6 -translate-y-1/2 opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100" | |
| 622 | + | /> | |
| 623 | + | </div> | |
| 611 | 624 | ); | |
| 612 | 625 | return ( | |
| 613 | 626 | <SidebarGroup | |
| 618 | 631 | </Link> | |
| 619 | 632 | } | |
| 620 | 633 | > | |
| 621 | − | <SidebarSubhead>Pinned</SidebarSubhead> | |
| 622 | − | {pinned.length === 0 ? ( | |
| 623 | − | <p className="px-2 pb-1 text-xs text-faint">Pin projects to keep them here.</p> | |
| 624 | − | ) : ( | |
| 634 | + | {/* Only there once something is pinned: each row below has its own pin. */} | |
| 635 | + | {pinned.length > 0 && ( | |
| 636 | + | <> | |
| 637 | + | <SidebarSubhead>Pinned</SidebarSubhead> | |
| 625 | 638 | <ul aria-label="Pinned projects" className="space-y-px"> | |
| 626 | 639 | {pinned.map((project, index) => ( | |
| 627 | 640 | <li | |
| 648 | 661 | title={pinned.length > 1 ? "Drag, or Alt and an arrow key, to reorder" : undefined} | |
| 649 | 662 | className={`group/pin relative ${dragging === index ? "opacity-50" : ""}`} | |
| 650 | 663 | > | |
| 651 | − | {row(project)} | |
| 664 | + | {row(project, true)} | |
| 652 | 665 | {pinned.length > 1 && ( | |
| 653 | 666 | <GripVertical | |
| 654 | 667 | size={12} | |
| 659 | 672 | </li> | |
| 660 | 673 | ))} | |
| 661 | 674 | </ul> | |
| 675 | + | </> | |
| 662 | 676 | )} | |
| 663 | 677 | {recent.length > 0 && ( | |
| 664 | 678 | <> | |
| 665 | 679 | <SidebarSubhead>Recent</SidebarSubhead> | |
| 666 | 680 | {recent.map((project) => ( | |
| 667 | − | <div key={`${project.namespace}/${project.name}`}>{row(project)}</div> | |
| 681 | + | <div key={`${project.namespace}/${project.name}`}>{row(project, false)}</div> | |
| 668 | 682 | ))} | |
| 669 | 683 | </> | |
| 670 | 684 | )} | |
| 1695 | 1709 | </a> | |
| 1696 | 1710 | {user && ( | |
| 1697 | 1711 | <> | |
| 1698 | − | <AskAi /> | |
| 1712 | + | <AgentButton /> | |
| 1699 | 1713 | <InboxBell counts={shell.inbox ?? null} /> | |
| 1700 | 1714 | </> | |
| 1701 | 1715 | )} |
| 12 | 12 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 13 | 13 | import { newTeamFromForm, parentChoices, teamPath } from "../../lib/teams"; | |
| 14 | 14 | import { identity } from "../../lib/services.server"; | |
| 15 | − | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 15 | + | import { assertSameOrigin, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 16 | 16 | ||
| 17 | 17 | /** The parent select's value for "no parent": Radix selects have no empty value. */ | |
| 18 | 18 | const NO_PARENT = "-"; |
| 366 | 366 | fn commit(dir: &Path, file: &str, text: &str) { | |
| 367 | 367 | std::fs::write(dir.join(file), text).unwrap(); | |
| 368 | 368 | git_in(dir, &["add", "--all"], None).unwrap(); | |
| 369 | − | git_in(dir, &["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "--quiet", "-m", text], None).unwrap(); | |
| 369 | + | git_in(dir, &["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", text], None).unwrap(); | |
| 370 | 370 | } | |
| 371 | 371 | ||
| 372 | 372 | fn mirror_of(origin: &Path, into: &Path) { |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | 120 | /// Makes sure `commit` is in the clone: fetched by name, which servers | |
| 121 | − | /// allow for commits on their branches, else the whole of `branch`. | |
| 121 | + | /// allow for commits on their branches, else the whole of `branch`, else | |
| 122 | + | /// every branch (a preview's commit can be on a branch the clone never | |
| 123 | + | /// had, from a server that will not fetch a commit by name). | |
| 122 | 124 | pub(crate) fn ensure(dir: &Path, auth: &str, remote: &str, branch: &str, commit: &str) -> Result<()> { | |
| 123 | 125 | if has(dir, commit) { | |
| 124 | 126 | return Ok(()); | |
| 129 | 131 | } | |
| 130 | 132 | if is_shallow(dir) { | |
| 131 | 133 | let _ = git(dir, &["-c", auth, "fetch", "--quiet", "--unshallow", remote, branch]); | |
| 134 | + | if has(dir, commit) { | |
| 135 | + | return Ok(()); | |
| 136 | + | } | |
| 132 | 137 | } | |
| 138 | + | let shallow = if is_shallow(dir) { "--unshallow" } else { "--quiet" }; | |
| 139 | + | let _ = git(dir, &["-c", auth, "fetch", "--quiet", shallow, remote, "+refs/heads/*:refs/remotes/everything/*"]); | |
| 133 | 140 | Ok(()) | |
| 134 | 141 | } | |
| 135 | 142 | ||
| 162 | 169 | run(&origin, &["init", "--quiet", "-b", "main"]); | |
| 163 | 170 | run(&origin, &["config", "user.name", "t"]); | |
| 164 | 171 | run(&origin, &["config", "user.email", "t@example.com"]); | |
| 172 | + | // A test repository of its own: never the machine's commit signing. | |
| 173 | + | run(&origin, &["config", "commit.gpgsign", "false"]); | |
| 165 | 174 | run(&origin, &["config", "uploadpack.allowReachableSHA1InWant", "true"]); | |
| 166 | 175 | for i in 0..12 { | |
| 167 | 176 | std::fs::write(origin.join("f.txt"), format!("{i}\n")).unwrap(); | |
| 189 | 198 | assert_eq!(git(&work, &["log", "-1", "--format=%s", "FETCH_HEAD"]).unwrap(), "side"); | |
| 190 | 199 | let _ = std::fs::remove_dir_all(&root); | |
| 191 | 200 | } | |
| 201 | + | ||
| 202 | + | /// A build of a commit that is only on another branch, from a server | |
| 203 | + | /// that will not fetch a commit by name: a shallow clone of the default | |
| 204 | + | /// branch, then `ensure` finds it (a preview of a branch, 2026-10-08: | |
| 205 | + | /// "reference is not a tree"). | |
| 206 | + | #[test] | |
| 207 | + | fn a_commit_only_on_another_branch_is_fetched_for_a_build() { | |
| 208 | + | if Command::new("git").arg("--version").output().is_err() { | |
| 209 | + | return; | |
| 210 | + | } | |
| 211 | + | let root = std::env::temp_dir().join(format!("g1t-ensure-test-{}", std::process::id())); | |
| 212 | + | let _ = std::fs::remove_dir_all(&root); | |
| 213 | + | let origin = root.join("origin"); | |
| 214 | + | std::fs::create_dir_all(&origin).unwrap(); | |
| 215 | + | let run = |dir: &Path, args: &[&str]| git(dir, args).unwrap(); | |
| 216 | + | run(&origin, &["init", "--quiet", "-b", "main"]); | |
| 217 | + | run(&origin, &["config", "user.name", "t"]); | |
| 218 | + | run(&origin, &["config", "user.email", "t@example.com"]); | |
| 219 | + | // A test repository of its own: never the machine's commit signing. | |
| 220 | + | run(&origin, &["config", "commit.gpgsign", "false"]); | |
| 221 | + | run(&origin, &["config", "uploadpack.allowReachableSHA1InWant", "false"]); | |
| 222 | + | run(&origin, &["config", "uploadpack.allowAnySHA1InWant", "false"]); | |
| 223 | + | for i in 0..3 { | |
| 224 | + | std::fs::write(origin.join("f.txt"), format!("{i}\n")).unwrap(); | |
| 225 | + | run(&origin, &["add", "f.txt"]); | |
| 226 | + | run(&origin, &["commit", "--quiet", "-m", &format!("c{i}")]); | |
| 227 | + | } | |
| 228 | + | run(&origin, &["checkout", "--quiet", "-b", "v2"]); | |
| 229 | + | std::fs::write(origin.join("g.txt"), "v2\n").unwrap(); | |
| 230 | + | run(&origin, &["add", "g.txt"]); | |
| 231 | + | run(&origin, &["commit", "--quiet", "-m", "v2"]); | |
| 232 | + | let preview = run(&origin, &["rev-parse", "HEAD"]); | |
| 233 | + | run(&origin, &["checkout", "--quiet", "main"]); | |
| 234 | + | ||
| 235 | + | let url = format!("file://{}", origin.display().to_string().replace('\\', "/")); | |
| 236 | + | let auth = "http.extraHeader=X-Test: 1"; | |
| 237 | + | clone(&root, auth, &[], &url, "work").unwrap(); | |
| 238 | + | let work = root.join("work"); | |
| 239 | + | assert!(!has(&work, &preview)); | |
| 240 | + | ensure(&work, auth, "origin", "main", &preview).unwrap(); | |
| 241 | + | assert!(has(&work, &preview)); | |
| 242 | + | git(&work, &["-c", "advice.detachedHead=false", "checkout", "--quiet", &preview]).unwrap(); | |
| 243 | + | assert_eq!(std::fs::read_to_string(work.join("g.txt")).unwrap().trim(), "v2"); | |
| 244 | + | let _ = std::fs::remove_dir_all(&root); | |
| 245 | + | } | |
| 192 | 246 | } |
| 571 | 571 | let commit = env("GIT_COMMIT")?; | |
| 572 | 572 | let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?); | |
| 573 | 573 | std::fs::create_dir_all("/work")?; | |
| 574 | − | let cloned = crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR).and_then(|_| { | |
| 575 | − | git( | |
| 576 | − | Path::new(WORKDIR), | |
| 577 | − | &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit], | |
| 578 | − | ) | |
| 579 | − | }); | |
| 574 | + | let workdir = Path::new(WORKDIR); | |
| 575 | + | // The clone is shallow and of the default branch; a preview's commit is | |
| 576 | + | // often on another one, so it is fetched before checking out. | |
| 577 | + | let cloned = crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR) | |
| 578 | + | .and_then(|_| git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"])) | |
| 579 | + | .and_then(|branch| crate::clone::ensure(workdir, &auth, "origin", &branch, &commit)) | |
| 580 | + | .and_then(|_| git(workdir, &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit])); | |
| 580 | 581 | if let Err(error) = cloned { | |
| 581 | 582 | bail!("{}", redact(&format!("{error:#}"), secrets)); | |
| 582 | 583 | } |
| 1130 | 1130 | Save, Snooze and Mark all read; `/inbox` with Saved, Done and a reason | |
| 1131 | 1131 | filter; reasons and update counts on each card; a Notifications box on | |
| 1132 | 1132 | issue and pull request pages; a Watch menu in the repository header; | |
| 1133 | − | Settings → Notifications; a Needs you card on mission control. Ask AI sits | |
| 1133 | + | Settings → Notifications; a Needs you card on mission control. Agent sits | |
| 1134 | 1134 | beside the bell, disabled. Still to come: security alerts (the security | |
| 1135 | − | service publishes no event yet), email digests and push, Ask AI, and | |
| 1135 | + | service publishes no event yet), email digests and push, Agent, and | |
| 1136 | 1136 | channels. | |
| 1137 | 1137 | ||
| 1138 | 1138 | **Channels** (working name): workspace channels, direct messages and |