wok_dev/cmd/
tag.rs

1use anyhow::*;
2use std::collections::HashMap;
3use std::io::Write;
4use std::panic::{self, AssertUnwindSafe};
5use std::result::Result::Ok;
6
7use crate::{config, repo};
8
9#[allow(clippy::too_many_arguments)]
10pub fn tag<W: Write>(
11    wok_config: &mut config::Config,
12    umbrella: &repo::Repo,
13    stdout: &mut W,
14    tag_name: Option<&str>,
15    sign: bool,
16    push: bool,
17    all: bool,
18    target_repos: &[std::path::PathBuf],
19) -> Result<()> {
20    // Determine which repos to tag
21    let repos_to_tag: Vec<config::Repo> = if all {
22        // Tag all configured repos, skipping those opted out unless explicitly targeted
23        wok_config
24            .repos
25            .iter()
26            .filter(|config_repo| {
27                !config_repo.is_skipped_for("tag")
28                    || target_repos.contains(&config_repo.path)
29            })
30            .cloned()
31            .collect()
32    } else if !target_repos.is_empty() {
33        // Tag only specified repos
34        wok_config
35            .repos
36            .iter()
37            .filter(|config_repo| target_repos.contains(&config_repo.path))
38            .cloned()
39            .collect()
40    } else {
41        // Tag repos that match the current main repo branch
42        wok_config
43            .repos
44            .iter()
45            .filter(|config_repo| {
46                config_repo.head == umbrella.head && !config_repo.is_skipped_for("tag")
47            })
48            .cloned()
49            .collect()
50    };
51
52    if repos_to_tag.is_empty() {
53        writeln!(stdout, "No repositories to tag")?;
54        return Ok(());
55    }
56
57    match tag_name {
58        Some(name) => {
59            // Create new tag
60            writeln!(
61                stdout,
62                "Creating tag '{}' in {} repositories...",
63                name,
64                repos_to_tag.len()
65            )?;
66
67            for config_repo in &repos_to_tag {
68                if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
69                    match create_tag(subrepo, name, sign) {
70                        Ok(result) => match result {
71                            TagResult::Created => {
72                                writeln!(
73                                    stdout,
74                                    "- '{}': created tag '{}'",
75                                    config_repo.path.display(),
76                                    name
77                                )?;
78                            },
79                            TagResult::AlreadyExists => {
80                                writeln!(
81                                    stdout,
82                                    "- '{}': tag '{}' already exists",
83                                    config_repo.path.display(),
84                                    name
85                                )?;
86                            },
87                        },
88                        Err(e) => {
89                            writeln!(
90                                stdout,
91                                "- '{}': failed to create tag '{}' - {}",
92                                config_repo.path.display(),
93                                name,
94                                e
95                            )?;
96                        },
97                    }
98                }
99            }
100        },
101        None => {
102            // List existing tags
103            writeln!(
104                stdout,
105                "Listing tags in {} repositories...",
106                repos_to_tag.len()
107            )?;
108
109            for config_repo in &repos_to_tag {
110                if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
111                    match list_tags(subrepo) {
112                        Ok(tags) => {
113                            if tags.is_empty() {
114                                writeln!(
115                                    stdout,
116                                    "- '{}': no tags found",
117                                    config_repo.path.display()
118                                )?;
119                            } else {
120                                writeln!(
121                                    stdout,
122                                    "- '{}': {}",
123                                    config_repo.path.display(),
124                                    tags.join(", ")
125                                )?;
126                            }
127                        },
128                        Err(e) => {
129                            writeln!(
130                                stdout,
131                                "- '{}': failed to list tags - {}",
132                                config_repo.path.display(),
133                                e
134                            )?;
135                        },
136                    }
137                }
138            }
139        },
140    }
141
142    // Push tags if requested
143    if push {
144        writeln!(stdout, "Pushing tags to remotes...")?;
145        for config_repo in &repos_to_tag {
146            if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
147                match push_tags(subrepo) {
148                    Ok(PushResult::Pushed) => {
149                        writeln!(
150                            stdout,
151                            "- '{}': pushed tags",
152                            config_repo.path.display()
153                        )?;
154                    },
155                    Ok(PushResult::Skipped) => {
156                        writeln!(
157                            stdout,
158                            "- '{}': no tags to push",
159                            config_repo.path.display()
160                        )?;
161                    },
162                    Err(e) => {
163                        writeln!(
164                            stdout,
165                            "- '{}': failed to push tags - {}",
166                            config_repo.path.display(),
167                            e
168                        )?;
169                    },
170                }
171            }
172        }
173    }
174
175    writeln!(
176        stdout,
177        "Successfully processed {} repositories",
178        repos_to_tag.len()
179    )?;
180    Ok(())
181}
182
183#[derive(Debug, Clone, PartialEq)]
184enum TagResult {
185    Created,
186    AlreadyExists,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190enum PushResult {
191    Pushed,
192    Skipped,
193}
194
195fn create_tag(repo: &repo::Repo, tag_name: &str, sign: bool) -> Result<TagResult> {
196    // Check if tag already exists by trying to find it
197    if repo
198        .git_repo
199        .revparse_single(&format!("refs/tags/{}", tag_name))
200        .is_ok()
201    {
202        return Ok(TagResult::AlreadyExists);
203    }
204
205    // Get the current HEAD commit
206    let head = repo.git_repo.head()?;
207    let commit = head.peel_to_commit()?;
208    let commit_obj = commit.as_object();
209
210    // Create the tag
211    if sign {
212        // Create signed tag
213        let signature = repo.git_repo.signature()?;
214        let _tag_ref = repo.git_repo.tag(
215            tag_name,
216            commit_obj,
217            &signature,
218            &format!("Tag {}", tag_name),
219            false,
220        )?;
221    } else {
222        // Create lightweight tag
223        let _tag_ref = repo.git_repo.tag_lightweight(tag_name, commit_obj, false)?;
224    }
225
226    Ok(TagResult::Created)
227}
228
229fn list_tags(repo: &repo::Repo) -> Result<Vec<String>> {
230    let mut tags = Vec::new();
231
232    // Get all tag references
233    let tag_names = repo.git_repo.tag_names(None)?;
234
235    for tag_name in tag_names.iter().flatten() {
236        tags.push(tag_name.to_string());
237    }
238
239    // Sort tags for consistent output
240    tags.sort();
241
242    Ok(tags)
243}
244
245fn push_tags(repo: &repo::Repo) -> Result<PushResult> {
246    // Get the remote name for the current branch
247    let head_ref = repo.git_repo.head()?;
248    let branch_name = head_ref.shorthand().with_context(|| {
249        format!(
250            "Cannot get branch name for repo at `{}`",
251            repo.work_dir.display()
252        )
253    })?;
254
255    let remote_name = repo.get_remote_name_for_branch(branch_name)?;
256
257    // Check if remote exists
258    let mut remote = match repo.git_repo.find_remote(&remote_name) {
259        Ok(remote) => remote,
260        Err(_) => {
261            return Err(anyhow!("No remote '{}' configured", remote_name));
262        },
263    };
264
265    // Collect explicit tag refspecs; libgit2 does not expand wildcards automatically.
266    let tag_names = repo.git_repo.tag_names(None)?;
267    if tag_names.is_empty() {
268        return Ok(PushResult::Skipped);
269    }
270
271    // Discover which tags already exist on the remote so we avoid redundant pushes.
272    let connection = remote.connect_auth(
273        git2::Direction::Push,
274        Some(repo.remote_callbacks()?),
275        None,
276    )?;
277
278    let remote_tags =
279        match panic::catch_unwind(AssertUnwindSafe(|| -> Result<_, git2::Error> {
280            let mut tags = HashMap::new();
281            for head in connection.list()?.iter() {
282                let name = head.name();
283                if name.starts_with("refs/tags/") {
284                    tags.insert(name.to_string(), head.oid());
285                }
286            }
287            Ok(tags)
288        })) {
289            Ok(Ok(tags)) => tags,
290            Ok(Err(err)) => return Err(err.into()),
291            Err(_) => HashMap::new(),
292        };
293    drop(connection);
294
295    let mut refspecs: Vec<String> = Vec::new();
296    for tag_name in tag_names.iter().flatten() {
297        let refname = format!("refs/tags/{tag_name}");
298        let reference = repo.git_repo.find_reference(&refname)?;
299        let target_oid = reference.target().with_context(|| {
300            format!("Tag '{}' does not point to an object", tag_name)
301        })?;
302
303        match remote_tags.get(&refname) {
304            Some(remote_oid) if *remote_oid == target_oid => {
305                // Remote already has this tag pointing at the same object.
306            },
307            _ => refspecs.push(format!("{refname}:{refname}")),
308        }
309    }
310
311    if refspecs.is_empty() {
312        return Ok(PushResult::Skipped);
313    }
314
315    let refspec_refs: Vec<&str> =
316        refspecs.iter().map(|refspec| refspec.as_str()).collect();
317    let mut push_options = git2::PushOptions::new();
318    push_options.remote_callbacks(repo.remote_callbacks()?);
319
320    let push_result = remote.push(&refspec_refs, Some(&mut push_options));
321    let disconnect_result = remote.disconnect();
322    push_result?;
323    disconnect_result?;
324
325    Ok(PushResult::Pushed)
326}