Skip to main content

release_tool/
git.rs

1use crate::command::{CommandRequest, CommandRunner};
2use crate::domain::{ReleaseCandidate, ReleaseIntent, SealedRelease};
3use crate::tag::TagCatalog;
4use anyhow::{Context, Result, bail};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct RepositorySnapshot {
10    pub root: PathBuf,
11    pub branch: String,
12    pub head: String,
13    pub remote_branch_head: String,
14    pub tags: TagCatalog,
15}
16
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct GitRefSnapshot {
19    local: Vec<String>,
20    remote: Vec<String>,
21}
22
23pub struct GitRepository {
24    root: PathBuf,
25    runner: Arc<dyn CommandRunner>,
26}
27
28impl GitRepository {
29    pub fn new(root: impl Into<PathBuf>, runner: Arc<dyn CommandRunner>) -> Self {
30        Self {
31            root: root.into(),
32            runner,
33        }
34    }
35
36    pub fn snapshot(&self, expected_branch: &str) -> Result<RepositorySnapshot> {
37        let status = self.git(&["status", "--porcelain", "--untracked-files=all"])?;
38        if !status.trim().is_empty() {
39            bail!("release requires a clean worktree:\n{}", status.trim_end());
40        }
41        let branch = self.git(&["branch", "--show-current"])?;
42        let branch = branch.trim().to_owned();
43        if branch.is_empty() {
44            bail!("release requires a branch checkout; detached HEAD is not supported");
45        }
46        if branch != expected_branch {
47            bail!("release requires branch `{expected_branch}`; current branch is `{branch}`");
48        }
49        let head = self.git(&["rev-parse", "HEAD"])?;
50        let head = head.trim().to_owned();
51        let branch_ref = format!("refs/heads/{expected_branch}");
52        let remote_branch = self.git(&["ls-remote", "origin", &branch_ref])?;
53        let remote_branch_head = parse_single_ref(&remote_branch, &branch_ref)?;
54        if head != remote_branch_head {
55            bail!(
56                "HEAD must equal origin/{expected_branch}: local {head}, remote {remote_branch_head}"
57            );
58        }
59        let remote_tags = self.git(&["ls-remote", "--tags", "origin"])?;
60        let tags = TagCatalog::parse_ls_remote(&remote_tags)?;
61        Ok(RepositorySnapshot {
62            root: self.root.clone(),
63            branch,
64            head,
65            remote_branch_head,
66            tags,
67        })
68    }
69
70    pub fn root(&self) -> &Path {
71        &self.root
72    }
73
74    pub fn origin_url(&self) -> Result<String> {
75        Ok(self
76            .git(&["remote", "get-url", "origin"])?
77            .trim()
78            .to_owned())
79    }
80
81    pub fn ref_snapshot(&self) -> Result<GitRefSnapshot> {
82        let local = self.git(&["for-each-ref", "--format=%(objectname)%09%(refname)"])?;
83        let remote = self.git(&["ls-remote", "--heads", "--tags", "origin"])?;
84        Ok(GitRefSnapshot {
85            local: normalized_refs(&local),
86            remote: normalized_refs(&remote),
87        })
88    }
89
90    pub fn ensure_refs_unchanged(&self, before: &GitRefSnapshot, phase: &str) -> Result<()> {
91        let after = self.ref_snapshot()?;
92        if after.local != before.local {
93            bail!("local Git refs changed during {phase}");
94        }
95        if after.remote != before.remote {
96            bail!("remote Git refs changed during {phase}");
97        }
98        Ok(())
99    }
100
101    pub fn seal(
102        &self,
103        candidate: &ReleaseCandidate,
104        expected_branch: &str,
105    ) -> Result<SealedRelease> {
106        let before = self.snapshot(expected_branch)?;
107        if before.head != candidate.commit {
108            bail!(
109                "release candidate commit changed: expected {}, found {}",
110                candidate.commit,
111                before.head
112            );
113        }
114        if let Some(existing) = before.tags.unique_release_for_commit(&candidate.commit)? {
115            if existing.name != candidate.tag {
116                bail!(
117                    "commit {} is already sealed by release tag {}, not {}",
118                    candidate.commit,
119                    existing.name,
120                    candidate.tag
121                );
122            }
123            return Ok(sealed(candidate, &existing.object));
124        }
125        if candidate.tag_already_sealed || candidate.intent == ReleaseIntent::Existing {
126            bail!(
127                "remote release tag {} disappeared; existing releases are never recreated automatically",
128                candidate.tag
129            );
130        }
131        if let Some(conflict) = before.tags.get(candidate.tag) {
132            bail!(
133                "remote release tag {} points to {}, expected {}",
134                candidate.tag,
135                conflict.commit,
136                candidate.commit
137            );
138        }
139        let expected = before.tags.next(candidate.tag.date())?;
140        if expected != candidate.tag {
141            bail!(
142                "release candidate is stale: expected next tag {expected}, planned {}",
143                candidate.tag
144            );
145        }
146
147        let reference = format!("refs/tags/{}", candidate.tag);
148        let local_type = self.git_result(&["cat-file", "-t", &reference])?;
149        if local_type.status == 0 {
150            if local_type.stdout.trim() != "tag" {
151                bail!("local release tag {} must be annotated", candidate.tag);
152            }
153            let commit_reference = format!("{reference}^{{commit}}");
154            let local_commit = self.git(&["rev-parse", &commit_reference])?;
155            if local_commit.trim() != candidate.commit {
156                bail!(
157                    "local release tag {} points to {}, expected {}",
158                    candidate.tag,
159                    local_commit.trim(),
160                    candidate.commit
161                );
162            }
163        } else {
164            let message = format!("Release {}", candidate.tag);
165            self.git(&["tag", "-a", &candidate.tag.to_string(), "-m", &message])?;
166        }
167
168        let push = self.git_result(&["push", "origin", &reference])?;
169        let after = self.snapshot(expected_branch)?;
170        if let Some(current_release) = after.tags.unique_release_for_commit(&candidate.commit)? {
171            if current_release.name != candidate.tag {
172                bail!(
173                    "release identity changed during tag push: expected {}, found {}",
174                    candidate.tag,
175                    current_release.name
176                );
177            }
178        }
179        match after.tags.get(candidate.tag) {
180            Some(remote) if remote.annotated && remote.commit == candidate.commit => {
181                Ok(sealed(candidate, &remote.object))
182            }
183            Some(remote) => bail!(
184                "remote release tag {} is inconsistent after push: annotated={}, commit={}",
185                candidate.tag,
186                remote.annotated,
187                remote.commit
188            ),
189            None => {
190                let expected_after_push = after.tags.next(candidate.tag.date())?;
191                if expected_after_push != candidate.tag {
192                    bail!(
193                        "remote tag catalog changed during push; planned {}, next is {expected_after_push}; stop automatic retry",
194                        candidate.tag
195                    );
196                }
197                if push.status != 0 {
198                    bail!(
199                        "release tag push did not modify remote; local annotated tag retained for retry: {}",
200                        candidate.tag
201                    );
202                }
203                bail!(
204                    "release tag push reported success but remote tag is absent: {}",
205                    candidate.tag
206                );
207            }
208        }
209    }
210
211    pub fn revalidate_prepared(
212        &self,
213        candidate: &ReleaseCandidate,
214        expected_branch: &str,
215    ) -> Result<()> {
216        let status = self.git(&["status", "--porcelain", "--untracked-files=all"])?;
217        if !status.trim().is_empty() {
218            if status.lines().all(|line| line.starts_with("?? ")) {
219                bail!(
220                    "Prepare created untracked repository files outside tool staging:\n{}",
221                    status.trim_end()
222                );
223            }
224            bail!("Prepare modified tracked source:\n{}", status.trim_end());
225        }
226        let branch = self.git(&["branch", "--show-current"])?;
227        if branch.trim() != expected_branch {
228            bail!("release branch changed during Prepare");
229        }
230        let head = self.git(&["rev-parse", "HEAD"])?;
231        if head.trim() != candidate.commit {
232            bail!("HEAD changed during Prepare");
233        }
234        let branch_ref = format!("refs/heads/{expected_branch}");
235        let remote = self.git(&["ls-remote", "origin", &branch_ref])?;
236        if parse_single_ref(&remote, &branch_ref)? != candidate.commit {
237            bail!("origin/{expected_branch} changed during Prepare");
238        }
239        let tags = TagCatalog::parse_ls_remote(&self.git(&["ls-remote", "--tags", "origin"])?)?;
240        if candidate.tag_already_sealed {
241            let release = tags
242                .unique_release_for_commit(&candidate.commit)?
243                .context("remote release tag disappeared during Prepare")?;
244            if release.name != candidate.tag {
245                bail!("remote release identity changed during Prepare");
246            }
247        } else {
248            if let Some(conflict) = tags.get(candidate.tag) {
249                bail!(
250                    "candidate tag {} appeared during Prepare at commit {}",
251                    candidate.tag,
252                    conflict.commit
253                );
254            }
255            let expected = tags.next(candidate.tag.date())?;
256            if expected != candidate.tag {
257                bail!(
258                    "next release tag changed during Prepare: {} -> {expected}",
259                    candidate.tag
260                );
261            }
262        }
263        Ok(())
264    }
265
266    fn git(&self, arguments: &[&str]) -> Result<String> {
267        Ok(self
268            .git_result(arguments)?
269            .require_success(&format!("git {}", arguments.join(" ")))?
270            .stdout)
271    }
272
273    fn git_result(&self, arguments: &[&str]) -> Result<crate::command::CommandResult> {
274        let request = CommandRequest::new("git", arguments.iter().copied(), &self.root);
275        self.runner.execute(&request)
276    }
277}
278
279fn sealed(candidate: &ReleaseCandidate, object: &str) -> SealedRelease {
280    let mut release = candidate.clone();
281    release.tag_already_sealed = true;
282    SealedRelease {
283        release,
284        tag_object: object.to_owned(),
285    }
286}
287
288fn parse_single_ref(output: &str, expected_ref: &str) -> Result<String> {
289    let mut matches = output.lines().filter_map(|line| {
290        let (oid, reference) = line.split_once(char::is_whitespace)?;
291        (reference.trim() == expected_ref).then(|| oid.to_owned())
292    });
293    let value = matches
294        .next()
295        .with_context(|| format!("remote ref `{expected_ref}` does not exist"))?;
296    if matches.next().is_some() {
297        bail!("remote ref `{expected_ref}` was returned more than once");
298    }
299    Ok(value)
300}
301
302fn normalized_refs(output: &str) -> Vec<String> {
303    let mut refs = output
304        .lines()
305        .map(str::trim)
306        .filter(|line| !line.is_empty())
307        .map(str::to_owned)
308        .collect::<Vec<_>>();
309    refs.sort();
310    refs
311}