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}