1use crate::source_repo::read_optional_config;
2use crate::{
3 BranchName, GitInvoker, Outpost, OutpostError, OutpostResult, Reporter, SourceRepo, StepKind,
4};
5
6pub struct PushOptions;
7
8pub struct PushReport {
9 pub outpost_to_source: StepResult,
10 pub source_to_origin: StepResult,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum StepResult {
15 Pushed { commits: u32 },
16}
17
18pub fn run(
19 outpost: &Outpost,
20 _opts: PushOptions,
21 reporter: &mut dyn Reporter,
22) -> OutpostResult<PushReport> {
23 let branch = outpost.current_branch().map_err(|err| match err {
24 OutpostError::BranchNotFound { .. } => OutpostError::NoUpstreamTracking {
25 branch: "HEAD".to_owned(),
26 },
27 other => other,
28 })?;
29 let source = outpost.source_repo()?;
30
31 check_checked_out_source_policy(&source, &branch)?;
32 if !source.branch_exists(&branch)? {
33 return Err(OutpostError::AmbiguousBranchCreation {
34 branch: branch.as_str().to_owned(),
35 });
36 }
37
38 check_source_fast_forwardable(outpost, &branch)?;
39 let origin_before = remote_origin_oid(&source, &branch)?;
40 check_origin_fast_forwardable(outpost, &source, &branch, origin_before.as_deref())?;
41
42 let outpost_before = source
43 .git()
44 .run_capture(["rev-parse", &source_branch_ref(branch.as_str())])?;
45 reporter.step(
46 StepKind::OutpostPush,
47 &format!(
48 "pushing outpost {} branch {} -> source {}",
49 outpost.work_tree().display(),
50 branch.as_str(),
51 source.work_tree().display()
52 ),
53 );
54 let outpost_refspec = branch_refspec(branch.as_str());
55 outpost.git().run_check([
56 "push",
57 outpost.metadata().remote_name.as_str(),
58 &outpost_refspec,
59 ])?;
60 let outpost_after = source
61 .git()
62 .run_capture(["rev-parse", &source_branch_ref(branch.as_str())])?;
63
64 let source_to_origin_commits =
65 source_to_origin_commit_count(source.git(), origin_before.as_deref(), &outpost_after)?;
66 reporter.step(
67 StepKind::SourcePush,
68 &format!(
69 "pushing source {} branch {} -> origin/{}",
70 source.work_tree().display(),
71 branch.as_str(),
72 branch.as_str()
73 ),
74 );
75 let source_refspec = branch_refspec(branch.as_str());
76 source
77 .git()
78 .run_check(["push", "--set-upstream", "origin", &source_refspec])?;
79 let origin_after =
80 remote_origin_oid(&source, &branch)?.ok_or_else(|| OutpostError::BranchNotFound {
81 branch: branch.as_str().to_owned(),
82 repo: source.work_tree().to_path_buf(),
83 })?;
84 if origin_after != outpost_after {
85 return Err(OutpostError::Divergence {
86 branch: branch.as_str().to_owned(),
87 });
88 }
89
90 Ok(PushReport {
91 outpost_to_source: StepResult::Pushed {
92 commits: pushed_commit_count(outpost, &outpost_before, &outpost_after)?,
93 },
94 source_to_origin: StepResult::Pushed {
95 commits: source_to_origin_commits,
96 },
97 })
98}
99
100fn check_checked_out_source_policy(source: &SourceRepo, branch: &BranchName) -> OutpostResult<()> {
101 if read_optional_config(source.git(), "receive.denyCurrentBranch")?.as_deref()
102 == Some("updateInstead")
103 {
104 return Ok(());
105 }
106
107 if source
108 .checked_out_branches()?
109 .iter()
110 .any(|checked_out| checked_out == branch)
111 {
112 Err(OutpostError::PushIntoCheckedOutBranch {
113 r#source: source.work_tree().to_path_buf(),
114 branch: branch.as_str().to_owned(),
115 })
116 } else {
117 Ok(())
118 }
119}
120
121fn check_source_fast_forwardable(outpost: &Outpost, branch: &BranchName) -> OutpostResult<()> {
122 let remote = outpost.metadata().remote_name.as_str();
123 let remote_tracking_ref = format!("refs/remotes/{remote}/{}", branch.as_str());
124 let fetch_refspec = format!("{}:{remote_tracking_ref}", branch.as_str());
125 outpost.git().run_check(["fetch", remote, &fetch_refspec])?;
126
127 let local_ref = source_branch_ref(branch.as_str());
128 let range = format!("{local_ref}...{remote_tracking_ref}");
129 let (_, behind) = ahead_behind(outpost.git(), &range)?;
130 if behind > 0 {
131 Err(OutpostError::Divergence {
132 branch: branch.as_str().to_owned(),
133 })
134 } else {
135 Ok(())
136 }
137}
138
139fn check_origin_fast_forwardable(
140 outpost: &Outpost,
141 source: &SourceRepo,
142 branch: &BranchName,
143 origin_oid: Option<&str>,
144) -> OutpostResult<()> {
145 let Some(origin_oid) = origin_oid else {
146 return Ok(());
147 };
148
149 let origin_url = source.git().run_capture(["remote", "get-url", "origin"])?;
150 outpost.git().run_check([
151 "fetch",
152 "--no-write-fetch-head",
153 "--",
154 &origin_url,
155 &source_branch_ref(branch.as_str()),
156 ])?;
157
158 if outpost
159 .git()
160 .run_status(["merge-base", "--is-ancestor", origin_oid, "HEAD"])?
161 {
162 Ok(())
163 } else {
164 Err(OutpostError::Divergence {
165 branch: branch.as_str().to_owned(),
166 })
167 }
168}
169
170fn pushed_commit_count(outpost: &Outpost, before: &str, after: &str) -> OutpostResult<u32> {
171 if before == after {
172 return Ok(0);
173 }
174
175 let range = format!("{before}..{after}");
176 let output = outpost.git().run_capture(["rev-list", "--count", &range])?;
177 let count = output
178 .split_whitespace()
179 .next()
180 .and_then(|value| value.parse::<u32>().ok())
181 .ok_or_else(|| invalid_rev_list_output(outpost, &output))?;
182 if output.split_whitespace().nth(1).is_some() {
183 return Err(invalid_rev_list_output(outpost, &output));
184 }
185 Ok(count)
186}
187
188fn source_to_origin_commit_count(
189 git: &GitInvoker,
190 before: Option<&str>,
191 after: &str,
192) -> OutpostResult<u32> {
193 let Some(before) = before else {
194 return parse_count(
195 git,
196 &git.run_capture(["rev-list", "--count", after, "--not", "--remotes=origin"])?,
197 );
198 };
199 if before == after {
200 return Ok(0);
201 }
202
203 let range = format!("{before}..{after}");
204 parse_count(git, &git.run_capture(["rev-list", "--count", &range])?)
205}
206
207fn parse_count(git: &GitInvoker, output: &str) -> OutpostResult<u32> {
208 let count = output
209 .split_whitespace()
210 .next()
211 .and_then(|value| value.parse::<u32>().ok())
212 .ok_or_else(|| invalid_count_output(git, output))?;
213 if output.split_whitespace().nth(1).is_some() {
214 return Err(invalid_count_output(git, output));
215 }
216 Ok(count)
217}
218
219fn ahead_behind(git: &GitInvoker, range: &str) -> OutpostResult<(u32, u32)> {
220 let output = git.run_capture(["rev-list", "--left-right", "--count", range])?;
221 let mut parts = output.split_whitespace();
222 let ahead = parts
223 .next()
224 .and_then(|value| value.parse::<u32>().ok())
225 .ok_or_else(|| invalid_count_output(git, &output))?;
226 let behind = parts
227 .next()
228 .and_then(|value| value.parse::<u32>().ok())
229 .ok_or_else(|| invalid_count_output(git, &output))?;
230 if parts.next().is_some() {
231 return Err(invalid_count_output(git, &output));
232 }
233 Ok((ahead, behind))
234}
235
236fn invalid_rev_list_output(outpost: &Outpost, output: &str) -> OutpostError {
237 OutpostError::IoAt {
238 path: outpost.work_tree().to_path_buf(),
239 source: std::io::Error::new(
240 std::io::ErrorKind::InvalidData,
241 format!("unexpected rev-list output: {output}"),
242 ),
243 }
244}
245
246fn invalid_count_output(git: &GitInvoker, output: &str) -> OutpostError {
247 OutpostError::IoAt {
248 path: git.cwd().to_path_buf(),
249 source: std::io::Error::new(
250 std::io::ErrorKind::InvalidData,
251 format!("unexpected rev-list output: {output}"),
252 ),
253 }
254}
255
256fn remote_origin_oid(source: &SourceRepo, branch: &BranchName) -> OutpostResult<Option<String>> {
257 let remote_ref = source_branch_ref(branch.as_str());
258 let output = source
259 .git()
260 .run_capture(["ls-remote", "origin", &remote_ref])?;
261 if output.is_empty() {
262 return Ok(None);
263 }
264
265 let mut fields = output.split_whitespace();
266 let oid = fields
267 .next()
268 .ok_or_else(|| invalid_count_output(source.git(), &output))?;
269 Ok(Some(oid.to_owned()))
270}
271
272fn branch_refspec(branch: &str) -> String {
273 format!("{branch}:{branch}")
274}
275
276fn source_branch_ref(branch: &str) -> String {
277 format!("refs/heads/{branch}")
278}