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