flagon-io/g1t

public

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/crates/runner/src/update.rs

176 lines6,627 bytesCodeBlame

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.

Agents as a team: lifecycle, merge queue, billing and a new shell1//! Brings a pull request up to date with the branch it would merge into.
2//!
3//! When that branch has moved, the pull request cannot merge until it holds
4//! the new commits. This merges them in. If the merge is clean that is all;
5//! if it conflicts, the agent is given the conflicted files and what the
6//! pull request is for, and resolves them. Either way the result is pushed
7//! and everything done is recorded in the pull request's session.
8//!
9//! Configuration, beyond what a session needs (see `main`):
10//!
11//! - `GIT_REMOTE`, `GIT_BRANCH`: the pull request's source.
12//! - `UPSTREAM_REMOTE`, `UPSTREAM_BRANCH`: what it would merge into.
13//! - `PROMPT`: what the pull request is for, given to the agent on a conflict.
14
15use std::path::Path;
16use std::process::Command;
17
18use anyhow::{Context, Result, bail};
19
20use crate::report::{Entry, Reporter};
21use crate::{WORKDIR, auth_option, env, git, harness};
22
23/// Runs git and says whether it succeeded, for commands whose failure is
24/// an answer and not an error.
25fn git_ok(dir: &Path, args: &[&str]) -> bool {
26 Command::new("git")
27 .current_dir(dir)
28 .args(args)
29 .output()
30 .is_ok_and(|output| output.status.success())
31}
32
33fn short(commit: &str) -> &str {
34 &commit[..commit.len().min(12)]
35}
36
37/// The files a stopped merge left in conflict.
38fn conflicted(workdir: &Path) -> Result<Vec<String>> {
39 Ok(git(workdir, &["diff", "--name-only", "--diff-filter=U"])?
40 .lines()
41 .map(str::to_owned)
42 .collect())
43}
44
45/// Whether a file still holds the markers git writes around a conflict.
46fn has_markers(workdir: &Path, file: &str) -> bool {
47 std::fs::read_to_string(workdir.join(file)).is_ok_and(|text| {
48 text.lines()
49 .any(|line| line.starts_with("<<<<<<< ") || line.starts_with(">>>>>>> "))
50 })
51}
52
53fn update(reporter: &mut Reporter) -> Result<()> {
54 let remote = env("GIT_REMOTE")?;
55 let branch = env("GIT_BRANCH")?;
56 let upstream = env("UPSTREAM_REMOTE")?;
57 let upstream_branch = env("UPSTREAM_BRANCH")?;
58 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
59 let workdir = Path::new(WORKDIR);
60
61 std::fs::create_dir_all("/work")?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents62 crate::clone::clone(Path::new("/work"), &auth, &["--branch", &branch], &remote, WORKDIR).context("could not clone the pull request")?;
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent63 git(workdir, &["config", "user.name", crate::AUTHOR_NAME])?;
64 git(workdir, &["config", "user.email", crate::AUTHOR_EMAIL])?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents65 crate::clone::fetch(workdir, &auth, &upstream, &upstream_branch).with_context(|| format!("could not fetch {upstream_branch}"))?;
66 // Shallow: deep enough to tell whether it is behind, and to merge.
67 crate::clone::share_history(workdir, &auth, &[("origin", branch.as_str()), (upstream.as_str(), upstream_branch.as_str())], "HEAD", "FETCH_HEAD")?;
Agents as a team: lifecycle, merge queue, billing and a new shell68 let theirs = git(workdir, &["rev-parse", "FETCH_HEAD"])?;
69
70 if git_ok(
71 workdir,
72 &["merge-base", "--is-ancestor", "FETCH_HEAD", "HEAD"],
73 ) {
74 reporter.record(Entry::new(
75 "note",
76 &format!("Already up to date with {upstream_branch}."),
77 ));
78 return Ok(());
79 }
80 reporter.record(Entry::new(
81 "note",
82 &format!(
83 "{upstream_branch} has moved to {}. Merging it into this pull request.",
84 short(&theirs)
85 ),
86 ));
87 reporter.flush();
88
89 let message = format!("Catch up with {upstream_branch}");
90 if !git_ok(
91 workdir,
92 &["merge", "--no-edit", "-m", &message, "FETCH_HEAD"],
93 ) {
94 let files = conflicted(workdir)?;
95 if files.is_empty() {
96 bail!("the merge failed for a reason other than a conflict");
97 }
98 reporter.record(Entry::new(
99 "note",
100 &format!("The merge conflicts in: {}.", files.join(", ")),
101 ));
102 let prompt = format!(
103 "You are a coding agent working in the git repository checked out in the current directory.\n\n\
104 A merge of `{upstream_branch}` into this pull request's branch has stopped with conflicts in:\n{}\n\n\
105 What this pull request is for:\n\n{}\n\n\
106 Resolve every conflict so that the result keeps what `{upstream_branch}` changed and what this pull request set out to do. \
107 Read both sides before choosing; do not simply take one. Remove all conflict markers. \
108 If the project has tests, run them. Then stage the files with `git add`. Do not commit and do not push; that is done for you. \
109 Finish with one or two sentences on how you resolved each conflict.",
110 files
111 .iter()
112 .map(|file| format!("- {file}"))
113 .collect::<Vec<_>>()
114 .join("\n"),
115 std::env::var("PROMPT").unwrap_or_default(),
116 );
117 reporter.record(Entry::new("prompt", &prompt));
118 reporter.flush();
119 let summary = harness::run_claude(workdir, &prompt, reporter)?;
120 reporter.record(Entry::new("message", &summary));
121
122 git(workdir, &["add", "--all"])?;
123 let unresolved = conflicted(workdir)?;
124 if !unresolved.is_empty() || files.iter().any(|file| has_markers(workdir, file)) {
125 bail!("conflicts remain after the agent's attempt to resolve them");
126 }
127 git(workdir, &["commit", "--quiet", "--no-edit"])
128 .context("could not conclude the merge")?;
129 reporter.record(Entry::new(
130 "note",
131 &format!("Resolved the conflicts in {}.", files.join(", ")),
132 ));
133 }
134
135 let head = git(workdir, &["rev-parse", "HEAD"])?;
136 git(
137 workdir,
138 &[
139 "-c",
140 &auth,
141 "push",
142 "--quiet",
143 "origin",
144 &format!("HEAD:{branch}"),
145 ],
146 )
147 .context("could not push the updated pull request")?;
148 reporter.record(Entry::new(
149 "note",
150 &format!(
151 "Pushed {}. This pull request now contains {upstream_branch}.",
152 short(&head)
153 ),
154 ));
155 Ok(())
156}
157
158pub fn main() -> i32 {
159 let mut reporter = match Reporter::from_env() {
160 Ok(reporter) => reporter,
161 Err(error) => {
162 eprintln!("g1t-runner: {error:#}");
163 return 2;
164 }
165 };
166 let outcome = update(&mut reporter);
167 if let Err(error) = &outcome {
168 eprintln!("g1t-runner: {error:#}");
169 reporter.record(Entry::new(
170 "note",
171 &format!("Catching up failed: {error:#}. Nothing was pushed."),
172 ));
173 }
174 reporter.flush();
175 i32::from(outcome.is_err())
176}