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