Skip to content

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

g1t.sh
276 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all1/**
2 * A first CI workflow for a repository that has none, worked out from what
3 * is at its root: one job per stack found (Node, Rust, Go, Python, Ruby,
4 * Java, .NET), each installing, linting where the project says how,
5 * building and testing as that stack usually does. Pure, so it is tested on
6 * its own; `add-ci.server.ts` reads the repository and opens the pull
7 * request.
8 *
9 * The workflow is named `CI`: that is the check it reports, and the name a
10 * branch requires. It runs on pull requests, on pushes to the default
11 * branch, and on the merge queue's states (`merge_group`), so that a
12 * required `CI` can pass everywhere a merge waits for it.
13 */
14
15/** Where the workflow goes. */
16export const STARTER_PATH = ".g1t/workflows/ci.yml";
17/** The check it reports. */
18export const STARTER_CHECK = "CI";
19
20export type Stack = "bun" | "pnpm" | "yarn" | "npm" | "rust" | "go" | "uv" | "python" | "ruby" | "maven" | "gradle" | "dotnet" | "make";
21
22/** What the root of a repository says about how it is built. */
23export type RootFacts = {
24 /** The names of the files and directories at the root. */
25 names: string[];
26 /** The text of `package.json`, when there is one and it could be read. */
27 packageJson?: string | null;
28};
29
30const LABELS: Record<Stack, string> = {
31 bun: "Bun",
32 pnpm: "Node (pnpm)",
33 yarn: "Node (Yarn)",
34 npm: "Node (npm)",
35 rust: "Rust",
36 go: "Go",
37 uv: "Python (uv)",
38 python: "Python",
39 ruby: "Ruby",
40 maven: "Java (Maven)",
41 gradle: "Java (Gradle)",
42 dotnet: ".NET",
43 make: "Make",
44};
45
46/** A stack's name, for people. */
47export function stackLabel(stack: Stack): string {
48 return LABELS[stack];
49}
50
51/** The stacks at a repository's root, most telling first; at most three. */
52export function detectStacks({ names }: RootFacts): Stack[] {
53 const has = (name: string) => names.includes(name);
54 const any = (test: (name: string) => boolean) => names.some(test);
55 const found: Stack[] = [];
56 if (has("package.json")) {
57 if (has("bun.lockb") || has("bun.lock")) found.push("bun");
58 else if (has("pnpm-lock.yaml")) found.push("pnpm");
59 else if (has("yarn.lock")) found.push("yarn");
60 else found.push("npm");
61 }
62 if (has("Cargo.toml")) found.push("rust");
63 if (has("go.mod")) found.push("go");
64 if (has("uv.lock")) found.push("uv");
65 else if (has("pyproject.toml") || has("requirements.txt") || has("setup.py")) found.push("python");
66 if (has("Gemfile")) found.push("ruby");
67 if (has("pom.xml")) found.push("maven");
68 else if (has("build.gradle") || has("build.gradle.kts")) found.push("gradle");
69 if (any((name) => name.endsWith(".sln") || name.endsWith(".csproj") || name.endsWith(".fsproj"))) found.push("dotnet");
70 if (found.length === 0 && (has("Makefile") || has("makefile"))) found.push("make");
71 return found.slice(0, 3);
72}
73
74/** The scripts a `package.json` names, or null when it cannot be read. */
75function scripts(packageJson: string | null | undefined): Set<string> | null {
76 if (!packageJson) return null;
77 try {
78 const parsed = JSON.parse(packageJson) as { scripts?: Record<string, unknown> };
79 return new Set(Object.keys(parsed.scripts ?? {}));
80 } catch {
81 return null;
82 }
83}
84
85/** One step, as YAML lines indented for a job's `steps:`. */
86type Step = string[];
87
88const uses = (action: string, name?: string, with_?: Record<string, string>): Step => [
89 ...(name ? [`- name: ${name}`, ` uses: ${action}`] : [`- uses: ${action}`]),
90 ...(with_ ? [" with:", ...Object.entries(with_).map(([key, value]) => ` ${key}: ${value}`)] : []),
91];
92const run = (command: string, name?: string): Step => (name ? [`- name: ${name}`, ` run: ${command}`] : [`- run: ${command}`]);
93
94/** The steps that lint, build and test a Node project, with the runner it uses. */
95function nodeSteps(stack: "bun" | "pnpm" | "yarn" | "npm", facts: RootFacts): Step[] {
96 const known = scripts(facts.packageJson);
97 // Only the scripts it has; when they cannot be read, the ones that exist run.
98 const wanted = ["lint", "typecheck", "build", "test"].filter((script) => !known || known.has(script));
99 const missing = known != null && wanted.length === 0;
100 const steps: Step[] = [];
101 if (stack === "bun") {
102 steps.push(uses("oven-sh/setup-bun@v2"));
103 steps.push(run("bun install --frozen-lockfile"));
104 for (const script of wanted) steps.push(run(`bun run ${script}`));
105 } else {
106 if (stack === "pnpm") steps.push(uses("pnpm/action-setup@v4"));
107 steps.push(uses("actions/setup-node@v5", undefined, { "node-version": "24", cache: stack }));
108 const install =
109 stack === "pnpm"
110 ? "pnpm install --frozen-lockfile"
111 : stack === "yarn"
112 ? "yarn install --frozen-lockfile"
113 : facts.names.includes("package-lock.json")
114 ? "npm ci"
115 : "npm install";
116 steps.push(run(install));
117 for (const script of wanted) {
118 steps.push(
119 run(
120 stack === "npm"
121 ? known
122 ? `npm run ${script}`
123 : `npm run ${script} --if-present`
124 : stack === "pnpm"
125 ? known
126 ? `pnpm run ${script}`
127 : `pnpm run --if-present ${script}`
128 : `yarn run ${script}`,
129 ),
130 );
131 }
132 }
133 if (missing) steps.push(run('echo "package.json has no lint, build or test script yet: add the ones this project needs."'));
134 return steps;
135}
136
137/** Each stack's job: its key, its name, and its steps after the checkout. */
138function job(stack: Stack, facts: RootFacts): { key: string; name: string; steps: Step[] } {
139 const has = (name: string) => facts.names.includes(name);
140 switch (stack) {
141 case "bun":
142 case "pnpm":
143 case "yarn":
144 case "npm":
145 return { key: "node", name: "Node", steps: nodeSteps(stack, facts) };
146 case "rust":
147 return {
148 key: "rust",
149 name: "Rust",
150 steps: [
151 uses("dtolnay/rust-toolchain@stable", undefined, { components: "clippy" }),
152 uses("Swatinem/rust-cache@v2"),
153 run("cargo clippy --all-targets"),
154 run("cargo test --all-targets"),
155 ],
156 };
157 case "go":
158 return {
159 key: "go",
160 name: "Go",
161 steps: [
162 uses("actions/setup-go@v5", undefined, { "go-version-file": "go.mod" }),
163 run("go vet ./..."),
164 run("go build ./..."),
165 run("go test ./..."),
166 ],
167 };
168 case "uv":
169 return {
170 key: "python",
171 name: "Python",
172 steps: [uses("astral-sh/setup-uv@v6"), run("uv sync"), run("uv run pytest")],
173 };
174 case "python": {
175 const install = has("requirements.txt")
176 ? "pip install -r requirements.txt pytest"
177 : "pip install -e . pytest";
178 return {
179 key: "python",
180 name: "Python",
181 steps: [uses("actions/setup-python@v5", undefined, { "python-version": '"3.12"', cache: "pip" }), run(install), run("pytest")],
182 };
183 }
184 case "ruby":
185 return {
186 key: "ruby",
187 name: "Ruby",
188 steps: [
189 uses("ruby/setup-ruby@v1", undefined, { "bundler-cache": "true" }),
190 run(has("Rakefile") ? "bundle exec rake" : "bundle exec rspec"),
191 ],
192 };
193 case "maven":
194 return {
195 key: "java",
196 name: "Java",
197 steps: [uses("actions/setup-java@v4", undefined, { distribution: "temurin", "java-version": '"21"', cache: "maven" }), run("mvn -B verify")],
198 };
199 case "gradle":
200 return {
201 key: "java",
202 name: "Java",
203 steps: [
204 uses("actions/setup-java@v4", undefined, { distribution: "temurin", "java-version": '"21"' }),
205 uses("gradle/actions/setup-gradle@v4"),
206 run(has("gradlew") ? "./gradlew build" : "gradle build"),
207 ],
208 };
209 case "dotnet":
210 return {
211 key: "dotnet",
212 name: ".NET",
213 steps: [
214 uses("actions/setup-dotnet@v4", undefined, { "dotnet-version": "8.0.x" }),
215 run("dotnet restore"),
216 run("dotnet build --no-restore"),
217 run("dotnet test --no-build"),
218 ],
219 };
220 case "make":
221 return { key: "build", name: "Build", steps: [run("make"), run("make test")] };
222 }
223}
224
225/** The starter workflow for a repository, and the stacks it was made for. */
226export function starterWorkflow(facts: RootFacts, defaultBranch: string): { yaml: string; stacks: Stack[] } {
227 const stacks = detectStacks(facts);
228 const jobs = stacks.map((stack) => job(stack, facts));
229 const lines = [
230 `# ${STARTER_CHECK}: what g1t checks on every pull request before it can merge.`,
231 "# Generated for this repository; change the steps to match how you build and test.",
232 `# Require it in Settings > Branches and merging once it has run.`,
233 `name: ${STARTER_CHECK}`,
234 "",
235 "on:",
236 " pull_request:",
237 " push:",
238 ` branches: [${JSON.stringify(defaultBranch)}]`,
239 " merge_group:",
240 "",
241 "jobs:",
242 ];
243 if (jobs.length === 0) {
244 lines.push(
245 " check:",
246 " runs-on: ubuntu-latest",
247 " steps:",
248 " - uses: actions/checkout@v5",
249 " # g1t could not tell how this project is built. Replace this step",
250 " # with the commands that build and test it.",
251 ' - run: echo "Add the commands that build and test this project." && exit 1',
252 );
253 }
254 for (const { key, name, steps } of jobs) {
255 lines.push(` ${key}:`, ` name: ${name}`, " runs-on: ubuntu-latest", " steps:", " - uses: actions/checkout@v5");
256 for (const step of steps) for (const line of step) lines.push(` ${line}`);
257 }
258 return { yaml: `${lines.join("\n")}\n`, stacks };
259}
260
261/** What the pull request that adds it says. */
262export function starterDescription(stacks: Stack[], defaultBranch: string): string {
263 const found = stacks.length > 0 ? `g1t found ${stacks.map(stackLabel).join(", ")} at the root and wrote a job for each.` : "g1t could not tell how this project is built, so the job is a placeholder: replace its last step with the commands that build and test it.";
264 return [
265 `Adds \`${STARTER_PATH}\`, a workflow named **${STARTER_CHECK}** that runs on every pull request, on pushes to \`${defaultBranch}\`, and on the merge queue. ${found}`,
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.266 "Its runs are this repository's checks: they show on every pull request, whoever or whatever opened it.",
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all267 `Once this has run, require **${STARTER_CHECK}** in Settings, Branches and merging, so that nothing merges into \`${defaultBranch}\` unless it passes.`,
268 ].join("\n\n");
269}
270
271/** A branch name for the pull request that is not one of `taken`. */
272export function freeBranch(taken: string[], base = "add-ci"): string {
273 const names = new Set(taken);
274 if (!names.has(base)) return base;
275 for (let n = 2; ; n += 1) if (!names.has(`${base}-${n}`)) return `${base}-${n}`;
276}

This file's history is long; its oldest lines are credited to the oldest commit read.