Skip to main content

outpost_core/ops/
push.rs

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}