Skip to main content

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
123#[allow(clippy::too_many_arguments)]
124pub fn tag_create<W: Write>(
125    wok_config: &config::Config,
126    umbrella: &repo::Repo,
127    stdout: &mut W,
128    tag_name: &str,
129    sign: bool,
130    message: Option<&str>,
131    all: bool,
132    include_umbrella: bool,
133    updated: bool,
134    target_repos: &[std::path::PathBuf],
135) -> Result<()> {
136    let repos_to_tag =
137        determine_repos_to_operate_on(wok_config, umbrella, all, target_repos);
138
139    // Filter repos based on --updated flag: only include repos where HEAD has no tags
140    let repos_to_tag: Vec<config::Repo> = if updated {
141        repos_to_tag
142            .into_iter()
143            .filter(|config_repo| {
144                if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
145                    // Only include repos where current commit has no tags
146                    !commit_has_tags(subrepo).unwrap_or(false)
147                } else {
148                    // If we can't find the subrepo, exclude it
149                    false
150                }
151            })
152            .collect()
153    } else {
154        repos_to_tag
155    };
156
157    let total_targets = repos_to_tag.len() + usize::from(include_umbrella);
158
159    if total_targets == 0 {
160        writeln!(stdout, "No repositories to tag")?;
161        return Ok(());
162    }
163
164    writeln!(
165        stdout,
166        "Creating tag '{}' in {} repositories...",
167        tag_name, total_targets
168    )?;
169
170    if include_umbrella {
171        match create_tag(umbrella, tag_name, sign, message) {
172            Ok(result) => match result {
173                TagResult::Created => {
174                    writeln!(stdout, "- 'umbrella': created tag '{}'", tag_name)?;
175                },
176                TagResult::AlreadyExists => {
177                    writeln!(
178                        stdout,
179                        "- 'umbrella': tag '{}' already exists",
180                        tag_name
181                    )?;
182                },
183            },
184            Err(e) => {
185                writeln!(
186                    stdout,
187                    "- 'umbrella': failed to create tag '{}' - {}",
188                    tag_name, e
189                )?;
190            },
191        }
192    }
193
194    for config_repo in &repos_to_tag {
195        if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
196            match create_tag(subrepo, tag_name, sign, message) {
197                Ok(result) => match result {
198                    TagResult::Created => {
199                        writeln!(
200                            stdout,
201                            "- '{}': created tag '{}'",
202                            config_repo.path.display(),
203                            tag_name
204                        )?;
205                    },
206                    TagResult::AlreadyExists => {
207                        writeln!(
208                            stdout,
209                            "- '{}': tag '{}' already exists",
210                            config_repo.path.display(),
211                            tag_name
212                        )?;
213                    },
214                },
215                Err(e) => {
216                    writeln!(
217                        stdout,
218                        "- '{}': failed to create tag '{}' - {}",
219                        config_repo.path.display(),
220                        tag_name,
221                        e
222                    )?;
223                },
224            }
225        }
226    }
227
228    writeln!(
229        stdout,
230        "Successfully processed {} repositories",
231        total_targets
232    )?;
233    Ok(())
234}
235
236/// Push tags to remote repositories
237pub fn tag_push<W: Write>(
238    wok_config: &config::Config,
239    umbrella: &repo::Repo,
240    stdout: &mut W,
241    all: bool,
242    include_umbrella: bool,
243    target_repos: &[std::path::PathBuf],
244) -> Result<()> {
245    let repos_to_tag =
246        determine_repos_to_operate_on(wok_config, umbrella, all, target_repos);
247    let total_targets = repos_to_tag.len() + usize::from(include_umbrella);
248
249    if total_targets == 0 {
250        writeln!(stdout, "No repositories to tag")?;
251        return Ok(());
252    }
253
254    writeln!(stdout, "Pushing tags to remotes...")?;
255
256    if include_umbrella {
257        match push_tags(umbrella) {
258            Ok(PushResult::Pushed) => {
259                writeln!(stdout, "- 'umbrella': pushed tags")?;
260            },
261            Ok(PushResult::Skipped) => {
262                writeln!(stdout, "- 'umbrella': no tags to push")?;
263            },
264            Err(e) => {
265                writeln!(stdout, "- 'umbrella': failed to push tags - {}", e)?;
266            },
267        }
268    }
269
270    for config_repo in &repos_to_tag {
271        if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
272            match push_tags(subrepo) {
273                Ok(PushResult::Pushed) => {
274                    writeln!(
275                        stdout,
276                        "- '{}': pushed tags",
277                        config_repo.path.display()
278                    )?;
279                },
280                Ok(PushResult::Skipped) => {
281                    writeln!(
282                        stdout,
283                        "- '{}': no tags to push",
284                        config_repo.path.display()
285                    )?;
286                },
287                Err(e) => {
288                    writeln!(
289                        stdout,
290                        "- '{}': failed to push tags - {}",
291                        config_repo.path.display(),
292                        e
293                    )?;
294                },
295            }
296        }
297    }
298
299    writeln!(
300        stdout,
301        "Successfully processed {} repositories",
302        total_targets
303    )?;
304    Ok(())
305}
306
307/// Legacy function for backward compatibility with tests
308#[allow(clippy::too_many_arguments)]
309pub fn tag<W: Write>(
310    wok_config: &mut config::Config,
311    umbrella: &repo::Repo,
312    stdout: &mut W,
313    tag_name: Option<&str>,
314    sign: bool,
315    message: Option<&str>,
316    push: bool,
317    all: bool,
318    include_umbrella: bool,
319    target_repos: &[std::path::PathBuf],
320) -> Result<()> {
321    match tag_name {
322        Some(name) => {
323            tag_create(
324                wok_config,
325                umbrella,
326                stdout,
327                name,
328                sign,
329                message,
330                all,
331                include_umbrella,
332                false, // updated is false for legacy function
333                target_repos,
334            )?;
335            if push {
336                tag_push(
337                    wok_config,
338                    umbrella,
339                    stdout,
340                    all,
341                    include_umbrella,
342                    target_repos,
343                )?;
344            }
345        },
346        None => {
347            tag_list(
348                wok_config,
349                umbrella,
350                stdout,
351                all,
352                include_umbrella,
353                target_repos,
354            )?;
355            if push {
356                tag_push(
357                    wok_config,
358                    umbrella,
359                    stdout,
360                    all,
361                    include_umbrella,
362                    target_repos,
363                )?;
364            }
365        },
366    }
367    Ok(())
368}
369
370#[derive(Debug, Clone, PartialEq)]
371enum TagResult {
372    Created,
373    AlreadyExists,
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377enum PushResult {
378    Pushed,
379    Skipped,
380}
381
382fn create_tag(
383    repo: &repo::Repo,
384    tag_name: &str,
385    sign: bool,
386    message: Option<&str>,
387) -> Result<TagResult> {
388    // Check if tag already exists by trying to find it
389    if repo
390        .git_repo
391        .revparse_single(&format!("refs/tags/{}", tag_name))
392        .is_ok()
393    {
394        return Ok(TagResult::AlreadyExists);
395    }
396
397    // Get the current HEAD commit
398    let head = repo.git_repo.head()?;
399    let commit = head.peel_to_commit()?;
400    let commit_obj = commit.as_object();
401
402    // Create the tag
403    if sign || message.is_some() {
404        // Create annotated tag (signed or with message)
405        let signature = repo.git_repo.signature()?;
406        let default_message = format!("Tag {}", tag_name);
407        let tag_message = message.unwrap_or(&default_message);
408        let _tag_ref = repo.git_repo.tag(
409            tag_name,
410            commit_obj,
411            &signature,
412            tag_message,
413            sign, // Pass true for GPG signing, false otherwise
414        )?;
415    } else {
416        // Create lightweight tag (no message, no signature)
417        let _tag_ref = repo.git_repo.tag_lightweight(tag_name, commit_obj, false)?;
418    }
419
420    Ok(TagResult::Created)
421}
422
423fn list_tags(repo: &repo::Repo) -> Result<Vec<String>> {
424    let mut tags = Vec::new();
425
426    // Get all tag references
427    let tag_names = repo.git_repo.tag_names(None)?;
428
429    for tag_name in tag_names.iter().flatten() {
430        tags.push(tag_name.to_string());
431    }
432
433    // Sort tags for consistent output
434    tags.sort();
435
436    Ok(tags)
437}
438
439/// Check if the current HEAD commit has any tags pointing to it
440fn commit_has_tags(repo: &repo::Repo) -> Result<bool> {
441    // Get the current HEAD commit OID
442    let head = repo.git_repo.head()?;
443    let head_oid = head.peel_to_commit()?.id();
444
445    // Get all tag references
446    let tag_names = repo.git_repo.tag_names(None)?;
447
448    // Check each tag to see if it points to HEAD
449    for tag_name in tag_names.iter().flatten() {
450        let tag_ref = repo
451            .git_repo
452            .find_reference(&format!("refs/tags/{}", tag_name))?;
453
454        // Peel the tag to get the commit it points to
455        if let Ok(tag_commit) = tag_ref.peel_to_commit()
456            && tag_commit.id() == head_oid
457        {
458            return Ok(true);
459        }
460    }
461
462    Ok(false)
463}
464
465fn push_tags(repo: &repo::Repo) -> Result<PushResult> {
466    // Get the remote name for the current branch
467    let head_ref = repo.git_repo.head()?;
468    let branch_name = head_ref.shorthand().with_context(|| {
469        format!(
470            "Cannot get branch name for repo at `{}`",
471            repo.work_dir.display()
472        )
473    })?;
474
475    let remote_name = repo.get_remote_name_for_branch(branch_name)?;
476
477    // Check if remote exists
478    let mut remote = match repo.git_repo.find_remote(&remote_name) {
479        Ok(remote) => remote,
480        Err(_) => {
481            return Err(anyhow!("No remote '{}' configured", remote_name));
482        },
483    };
484
485    // Collect explicit tag refspecs; libgit2 does not expand wildcards automatically.
486    let tag_names = repo.git_repo.tag_names(None)?;
487    if tag_names.is_empty() {
488        return Ok(PushResult::Skipped);
489    }
490
491    // Discover which tags already exist on the remote so we avoid redundant pushes.
492    let connection = remote.connect_auth(
493        git2::Direction::Push,
494        Some(repo.remote_callbacks()?),
495        None,
496    )?;
497
498    let remote_tags =
499        match panic::catch_unwind(AssertUnwindSafe(|| -> Result<_, git2::Error> {
500            let mut tags = HashMap::new();
501            for head in connection.list()?.iter() {
502                let name = head.name();
503                if name.starts_with("refs/tags/") {
504                    tags.insert(name.to_string(), head.oid());
505                }
506            }
507            Ok(tags)
508        })) {
509            Ok(Ok(tags)) => tags,
510            Ok(Err(err)) => return Err(err.into()),
511            Err(_) => HashMap::new(),
512        };
513    drop(connection);
514
515    let mut refspecs: Vec<String> = Vec::new();
516    for tag_name in tag_names.iter().flatten() {
517        let refname = format!("refs/tags/{tag_name}");
518        let reference = repo.git_repo.find_reference(&refname)?;
519        let target_oid = reference.target().with_context(|| {
520            format!("Tag '{}' does not point to an object", tag_name)
521        })?;
522
523        match remote_tags.get(&refname) {
524            Some(remote_oid) if *remote_oid == target_oid => {
525                // Remote already has this tag pointing at the same object.
526            },
527            _ => refspecs.push(format!("{refname}:{refname}")),
528        }
529    }
530
531    if refspecs.is_empty() {
532        return Ok(PushResult::Skipped);
533    }
534
535    let refspec_refs: Vec<&str> =
536        refspecs.iter().map(|refspec| refspec.as_str()).collect();
537    let mut push_options = git2::PushOptions::new();
538    push_options.remote_callbacks(repo.remote_callbacks()?);
539
540    let push_result = remote.push(&refspec_refs, Some(&mut push_options));
541    let disconnect_result = remote.disconnect();
542    push_result?;
543    disconnect_result?;
544
545    Ok(PushResult::Pushed)
546}