kodegen_tools_git/operations/
tag.rs

1//! Git tag operations
2//!
3//! Provides functionality for creating, deleting, and listing Git tags.
4
5use crate::{GitError, GitResult, RepoHandle};
6use chrono::{DateTime, Utc};
7use gix::bstr::ByteSlice;
8
9/// Options for creating a tag
10#[derive(Debug, Clone)]
11pub struct TagOpts {
12    /// Tag name
13    pub name: String,
14    /// Optional message for annotated tags
15    pub message: Option<String>,
16    /// Target commit (defaults to HEAD if None)
17    pub target: Option<String>,
18    /// Force creation (overwrite if exists)
19    pub force: bool,
20}
21
22/// Information about a Git tag
23#[derive(Debug, Clone)]
24pub struct TagInfo {
25    /// Tag name
26    pub name: String,
27    /// Tag message (if annotated)
28    pub message: Option<String>,
29    /// Target commit hash
30    pub target_commit: String,
31    /// Tag timestamp
32    pub timestamp: DateTime<Utc>,
33    /// Whether this is an annotated tag
34    pub is_annotated: bool,
35}
36
37/// Create a Git tag
38///
39/// Creates either a lightweight or annotated tag depending on whether a message is provided.
40///
41/// # Arguments
42///
43/// * `repo` - Repository handle
44/// * `opts` - Tag creation options
45///
46/// # Returns
47///
48/// Returns `TagInfo` on success containing information about the created tag.
49///
50/// # Example
51///
52/// ```rust,no_run
53/// use kodegen_git::{open_repo, create_tag, TagOpts};
54///
55/// # async fn example() -> kodegen_git::GitResult<()> {
56/// let repo = open_repo("/path/to/repo")?;
57/// let tag_info = create_tag(&repo, TagOpts {
58///     name: "v1.0.0".to_string(),
59///     message: Some("Release v1.0.0".to_string()),
60///     target: None,
61///     force: false,
62/// }).await?;
63/// # Ok(())
64/// # }
65/// ```
66pub async fn create_tag(repo: &RepoHandle, opts: TagOpts) -> GitResult<TagInfo> {
67    let repo_clone = repo.clone_inner();
68
69    tokio::task::spawn_blocking(move || {
70        let tag_ref_name = format!("refs/tags/{}", opts.name);
71
72        // Resolve target commit
73        let target = if let Some(ref target_str) = opts.target {
74            repo_clone
75                .rev_parse_single(target_str.as_bytes().as_bstr())
76                .map_err(|e| GitError::Parse(format!("Invalid target '{target_str}': {e}")))?
77                .into()
78        } else {
79            // Default to HEAD
80            let mut head = repo_clone.head().map_err(|e| GitError::Gix(Box::new(e)))?;
81            head.try_peel_to_id()
82                .map_err(|e| GitError::Gix(Box::new(e)))?
83                .ok_or_else(|| {
84                    GitError::InvalidInput("HEAD does not point to a commit".to_string())
85                })?
86                .detach()
87        };
88
89        // Check if tag exists
90        if !opts.force
91            && repo_clone
92                .refs
93                .find(tag_ref_name.as_bytes().as_bstr())
94                .is_ok()
95        {
96            return Err(GitError::InvalidInput(format!(
97                "Tag '{}' already exists",
98                opts.name
99            )));
100        }
101
102        // Create tag reference
103        let is_annotated = opts.message.is_some();
104
105        // For lightweight tags, use transaction
106        if is_annotated {
107            // For annotated tags, create tag object
108            let message = opts.message.as_deref().unwrap_or("");
109            let signature = get_signature(&repo_clone)?;
110
111            use gix::bstr::ByteSlice;
112            let time_str = signature.time.to_string();
113            let sig_ref = gix::actor::SignatureRef {
114                name: signature.name.as_bstr(),
115                email: signature.email.as_bstr(),
116                time: &time_str,
117            };
118
119            repo_clone
120                .tag(
121                    &opts.name,
122                    target,
123                    gix::objs::Kind::Commit,
124                    Some(sig_ref),
125                    message,
126                    if opts.force {
127                        gix::refs::transaction::PreviousValue::Any
128                    } else {
129                        gix::refs::transaction::PreviousValue::MustNotExist
130                    },
131                )
132                .map_err(|e| GitError::Gix(Box::new(e)))?;
133        } else {
134            let ref_name = gix::refs::FullName::try_from(tag_ref_name.as_bytes().as_bstr())
135                .map_err(|e| GitError::Gix(Box::new(e)))?;
136
137            let edit = gix::refs::transaction::RefEdit {
138                change: gix::refs::transaction::Change::Update {
139                    log: gix::refs::transaction::LogChange::default(),
140                    expected: if opts.force {
141                        gix::refs::transaction::PreviousValue::Any
142                    } else {
143                        gix::refs::transaction::PreviousValue::MustNotExist
144                    },
145                    new: gix::refs::Target::Object(target),
146                },
147                name: ref_name,
148                deref: false,
149            };
150
151            repo_clone
152                .refs
153                .transaction()
154                .prepare(
155                    vec![edit],
156                    gix::lock::acquire::Fail::Immediately,
157                    gix::lock::acquire::Fail::Immediately,
158                )
159                .map_err(|e| GitError::Gix(Box::new(e)))?
160                .commit(None)
161                .map_err(|e| GitError::Gix(Box::new(e)))?;
162        }
163
164        // Get tag info
165        let commit = repo_clone
166            .find_object(target)
167            .map_err(|e| GitError::Gix(Box::new(e)))?
168            .try_into_commit()
169            .map_err(|_| GitError::Parse("Target is not a commit".to_string()))?;
170
171        let commit_time = commit.time().map_err(|e| GitError::Gix(Box::new(e)))?;
172        let timestamp = DateTime::from_timestamp(commit_time.seconds, 0).unwrap_or_else(Utc::now);
173
174        Ok(TagInfo {
175            name: opts.name,
176            message: opts.message,
177            target_commit: target.to_string(),
178            timestamp,
179            is_annotated,
180        })
181    })
182    .await
183    .map_err(|e| GitError::Gix(Box::new(e)))?
184}
185
186/// Delete a Git tag
187///
188/// Deletes a local tag. Remote tag deletion is not supported by this function.
189///
190/// # Arguments
191///
192/// * `repo` - Repository handle
193/// * `tag_name` - Name of the tag to delete
194///
195/// # Example
196///
197/// ```rust,no_run
198/// use kodegen_git::{open_repo, delete_tag};
199///
200/// # async fn example() -> kodegen_git::GitResult<()> {
201/// let repo = open_repo("/path/to/repo")?;
202/// delete_tag(&repo, "v1.0.0").await?;
203/// # Ok(())
204/// # }
205/// ```
206pub async fn delete_tag(repo: &RepoHandle, tag_name: &str) -> GitResult<()> {
207    let repo_clone = repo.clone_inner();
208    let tag_name = tag_name.to_string();
209
210    tokio::task::spawn_blocking(move || {
211        let tag_ref_name = format!("refs/tags/{tag_name}");
212
213        // Check if tag exists
214        repo_clone
215            .refs
216            .find(tag_ref_name.as_bytes().as_bstr())
217            .map_err(|_| GitError::ReferenceNotFound(tag_name.clone()))?;
218
219        // Delete the tag using transaction
220        let ref_name = gix::refs::FullName::try_from(tag_ref_name.as_bytes().as_bstr())
221            .map_err(|e| GitError::Gix(Box::new(e)))?;
222
223        let edit = gix::refs::transaction::RefEdit {
224            change: gix::refs::transaction::Change::Delete {
225                expected: gix::refs::transaction::PreviousValue::Any,
226                log: gix::refs::transaction::RefLog::AndReference,
227            },
228            name: ref_name,
229            deref: false,
230        };
231
232        repo_clone
233            .refs
234            .transaction()
235            .prepare(
236                vec![edit],
237                gix::lock::acquire::Fail::Immediately,
238                gix::lock::acquire::Fail::Immediately,
239            )
240            .map_err(|e| GitError::Gix(Box::new(e)))?
241            .commit(None)
242            .map_err(|e| GitError::Gix(Box::new(e)))?;
243
244        Ok(())
245    })
246    .await
247    .map_err(|e| GitError::Gix(Box::new(e)))?
248}
249
250/// Check if a tag exists
251///
252/// # Arguments
253///
254/// * `repo` - Repository handle
255/// * `tag_name` - Name of the tag to check
256///
257/// # Returns
258///
259/// Returns `true` if the tag exists, `false` otherwise.
260///
261/// # Example
262///
263/// ```rust,no_run
264/// use kodegen_git::{open_repo, tag_exists};
265///
266/// # async fn example() -> kodegen_git::GitResult<()> {
267/// let repo = open_repo("/path/to/repo")?;
268/// if tag_exists(&repo, "v1.0.0").await? {
269///     println!("Tag exists!");
270/// }
271/// # Ok(())
272/// # }
273/// ```
274pub async fn tag_exists(repo: &RepoHandle, tag_name: &str) -> GitResult<bool> {
275    let repo_clone = repo.clone_inner();
276    let tag_name = tag_name.to_string();
277
278    tokio::task::spawn_blocking(move || {
279        let tag_ref_name = format!("refs/tags/{tag_name}");
280        Ok(repo_clone
281            .refs
282            .find(tag_ref_name.as_bytes().as_bstr())
283            .is_ok())
284    })
285    .await
286    .map_err(|e| GitError::Gix(Box::new(e)))?
287}
288
289/// List all tags in the repository
290///
291/// # Arguments
292///
293/// * `repo` - Repository handle
294///
295/// # Returns
296///
297/// Returns a vector of `TagInfo` for all tags in the repository.
298///
299/// # Example
300///
301/// ```rust,no_run
302/// use kodegen_git::{open_repo, list_tags};
303///
304/// # async fn example() -> kodegen_git::GitResult<()> {
305/// let repo = open_repo("/path/to/repo")?;
306/// let tags = list_tags(&repo).await?;
307/// for tag in tags {
308///     println!("Tag: {} -> {}", tag.name, tag.target_commit);
309/// }
310/// # Ok(())
311/// # }
312/// ```
313pub async fn list_tags(repo: &RepoHandle) -> GitResult<Vec<TagInfo>> {
314    let repo_clone = repo.clone_inner();
315
316    tokio::task::spawn_blocking(move || {
317        let mut tags = Vec::new();
318
319        // Iterate over all tag references
320        let refs_platform = repo_clone
321            .references()
322            .map_err(|e| GitError::Gix(Box::new(e)))?;
323        let tag_refs = refs_platform
324            .prefixed("refs/tags/")
325            .map_err(|e| GitError::Gix(Box::new(e)))?;
326
327        for reference in tag_refs {
328            let mut reference = reference.map_err(GitError::Gix)?;
329
330            let name = reference.name().as_bstr();
331            if !name.starts_with(b"refs/tags/") {
332                continue;
333            }
334
335            let tag_name = name
336                .strip_prefix(b"refs/tags/")
337                .and_then(|n| std::str::from_utf8(n).ok())
338                .ok_or_else(|| GitError::Parse("Invalid tag name".to_string()))?
339                .to_string();
340
341            // Get target
342            let target_id = reference
343                .peel_to_id()
344                .map_err(|e| GitError::Gix(Box::new(e)))?;
345
346            // Try to get tag object for annotated tags
347            let (message, is_annotated, timestamp) = if let Ok(obj) =
348                repo_clone.find_object(target_id)
349            {
350                if let Ok(tag_obj) = obj.try_into_tag() {
351                    let tag_ref = tag_obj.decode().ok();
352                    let msg = tag_ref.as_ref().map(|t| t.message.to_string());
353                    let ts = if let Some(ref tag) = tag_ref {
354                        if let Some(tagger) = &tag.tagger {
355                            if let Ok(time) = tagger.time() {
356                                DateTime::from_timestamp(time.seconds, 0).unwrap_or_else(Utc::now)
357                            } else {
358                                Utc::now()
359                            }
360                        } else {
361                            Utc::now()
362                        }
363                    } else {
364                        Utc::now()
365                    };
366                    (msg, true, ts)
367                } else if let Ok(obj2) = repo_clone.find_object(target_id) {
368                    if let Ok(commit) = obj2.try_into_commit() {
369                        let ts = commit
370                            .time()
371                            .ok()
372                            .unwrap_or_else(gix::date::Time::now_local_or_utc);
373                        let ts = DateTime::from_timestamp(ts.seconds, 0).unwrap_or_else(Utc::now);
374                        (None, false, ts)
375                    } else {
376                        (None, false, Utc::now())
377                    }
378                } else {
379                    (None, false, Utc::now())
380                }
381            } else {
382                (None, false, Utc::now())
383            };
384
385            tags.push(TagInfo {
386                name: tag_name,
387                message,
388                target_commit: target_id.to_string(),
389                timestamp,
390                is_annotated,
391            });
392        }
393
394        Ok(tags)
395    })
396    .await
397    .map_err(|e| GitError::Gix(Box::new(e)))?
398}
399
400/// Helper function to get signature from repository config
401fn get_signature(repo: &gix::Repository) -> GitResult<gix::actor::Signature> {
402    let config = repo.config_snapshot();
403
404    let name = config
405        .string("user.name")
406        .ok_or_else(|| GitError::InvalidInput("Git user.name not configured".to_string()))?;
407
408    let email = config
409        .string("user.email")
410        .ok_or_else(|| GitError::InvalidInput("Git user.email not configured".to_string()))?;
411
412    Ok(gix::actor::Signature {
413        name: name.into_owned(),
414        email: email.into_owned(),
415        time: gix::date::Time::now_local_or_utc(),
416    })
417}