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

184 lines6,494 bytesCodeBlame
1//! 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")?;
62 git(
63 Path::new("/work"),
64 &[
65 "-c", &auth, "clone", "--quiet", "--branch", &branch, &remote, WORKDIR,
66 ],
67 )
68 .context("could not clone the pull request")?;
69 git(workdir, &["config", "user.name", "g1t agent"])?;
70 git(workdir, &["config", "user.email", "agent@g1t.sh"])?;
71 git(
72 workdir,
73 &["-c", &auth, "fetch", "--quiet", &upstream, &upstream_branch],
74 )
75 .with_context(|| format!("could not fetch {upstream_branch}"))?;
76 let theirs = git(workdir, &["rev-parse", "FETCH_HEAD"])?;
77
78 if git_ok(
79 workdir,
80 &["merge-base", "--is-ancestor", "FETCH_HEAD", "HEAD"],
81 ) {
82 reporter.record(Entry::new(
83 "note",
84 &format!("Already up to date with {upstream_branch}."),
85 ));
86 return Ok(());
87 }
88 reporter.record(Entry::new(
89 "note",
90 &format!(
91 "{upstream_branch} has moved to {}. Merging it into this pull request.",
92 short(&theirs)
93 ),
94 ));
95 reporter.flush();
96
97 let message = format!("Catch up with {upstream_branch}");
98 if !git_ok(
99 workdir,
100 &["merge", "--no-edit", "-m", &message, "FETCH_HEAD"],
101 ) {
102 let files = conflicted(workdir)?;
103 if files.is_empty() {
104 bail!("the merge failed for a reason other than a conflict");
105 }
106 reporter.record(Entry::new(
107 "note",
108 &format!("The merge conflicts in: {}.", files.join(", ")),
109 ));
110 let prompt = format!(
111 "You are a coding agent working in the git repository checked out in the current directory.\n\n\
112 A merge of `{upstream_branch}` into this pull request's branch has stopped with conflicts in:\n{}\n\n\
113 What this pull request is for:\n\n{}\n\n\
114 Resolve every conflict so that the result keeps what `{upstream_branch}` changed and what this pull request set out to do. \
115 Read both sides before choosing; do not simply take one. Remove all conflict markers. \
116 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. \
117 Finish with one or two sentences on how you resolved each conflict.",
118 files
119 .iter()
120 .map(|file| format!("- {file}"))
121 .collect::<Vec<_>>()
122 .join("\n"),
123 std::env::var("PROMPT").unwrap_or_default(),
124 );
125 reporter.record(Entry::new("prompt", &prompt));
126 reporter.flush();
127 let summary = harness::run_claude(workdir, &prompt, reporter)?;
128 reporter.record(Entry::new("message", &summary));
129
130 git(workdir, &["add", "--all"])?;
131 let unresolved = conflicted(workdir)?;
132 if !unresolved.is_empty() || files.iter().any(|file| has_markers(workdir, file)) {
133 bail!("conflicts remain after the agent's attempt to resolve them");
134 }
135 git(workdir, &["commit", "--quiet", "--no-edit"])
136 .context("could not conclude the merge")?;
137 reporter.record(Entry::new(
138 "note",
139 &format!("Resolved the conflicts in {}.", files.join(", ")),
140 ));
141 }
142
143 let head = git(workdir, &["rev-parse", "HEAD"])?;
144 git(
145 workdir,
146 &[
147 "-c",
148 &auth,
149 "push",
150 "--quiet",
151 "origin",
152 &format!("HEAD:{branch}"),
153 ],
154 )
155 .context("could not push the updated pull request")?;
156 reporter.record(Entry::new(
157 "note",
158 &format!(
159 "Pushed {}. This pull request now contains {upstream_branch}.",
160 short(&head)
161 ),
162 ));
163 Ok(())
164}
165
166pub fn main() -> i32 {
167 let mut reporter = match Reporter::from_env() {
168 Ok(reporter) => reporter,
169 Err(error) => {
170 eprintln!("g1t-runner: {error:#}");
171 return 2;
172 }
173 };
174 let outcome = update(&mut reporter);
175 if let Err(error) = &outcome {
176 eprintln!("g1t-runner: {error:#}");
177 reporter.record(Entry::new(
178 "note",
179 &format!("Catching up failed: {error:#}. Nothing was pushed."),
180 ));
181 }
182 reporter.flush();
183 i32::from(outcome.is_err())
184}