Skip to main content

rskit_git/embedded/
manage.rs

1//! Management trait implementations for the libgit2 repository.
2
3use std::cell::RefCell;
4use std::rc::Rc;
5
6use git2::{BranchType, FetchPrune};
7use rskit_errors::{AppError, AppResult};
8
9use crate::error::GitError;
10use crate::manage::{ConfigReader, RefManager, RemoteManager};
11use crate::options::{FetchOptions, PushOptions};
12use crate::types::{Branch, BranchFilter, Remote, Tag};
13
14use super::{
15    Git2Repository, map_push_error, map_remote_error, oid_from_git2, redact_url_credentials,
16    signature_from_git2,
17};
18
19impl RefManager for Git2Repository {
20    fn list_branches(&self, filter: BranchFilter) -> AppResult<Vec<Branch>> {
21        let branch_filter = match filter {
22            BranchFilter::Local => Some(BranchType::Local),
23            BranchFilter::Remote => Some(BranchType::Remote),
24            BranchFilter::All => None,
25        };
26
27        let mut branches = self
28            .repo
29            .branches(branch_filter)
30            .map_err(GitError::Internal)?
31            .map(|item| {
32                let (branch, kind) = item.map_err(GitError::Internal)?;
33                let name = branch
34                    .name()
35                    .map_err(GitError::Internal)?
36                    .unwrap_or_default()
37                    .to_string();
38                let target = branch
39                    .get()
40                    .target()
41                    .map(oid_from_git2)
42                    .unwrap_or_else(|| oid_from_git2(git2::Oid::ZERO_SHA1));
43                let upstream = if kind == BranchType::Local {
44                    branch
45                        .upstream()
46                        .ok()
47                        .and_then(|upstream| upstream.name().ok().flatten().map(str::to_string))
48                } else {
49                    None
50                };
51                Ok(Branch {
52                    name,
53                    target,
54                    upstream,
55                })
56            })
57            .collect::<Result<Vec<_>, GitError>>()
58            .map_err::<rskit_errors::AppError, _>(Into::into)?;
59        branches.sort_by(|a, b| a.name.cmp(&b.name));
60        Ok(branches)
61    }
62
63    fn list_tags(&self) -> AppResult<Vec<Tag>> {
64        let mut tags = Vec::new();
65        let names = self.repo.tag_names(None).map_err(GitError::Internal)?;
66        for name in collect_git_strings(names.iter())? {
67            let reference = self
68                .repo
69                .find_reference(&format!("refs/tags/{}", name))
70                .map_err(GitError::Internal)?;
71            let target = reference.target().ok_or_else(|| GitError::RefNotFound {
72                refname: name.clone(),
73            })?;
74
75            if let Ok(tag) = self.repo.find_tag(target) {
76                tags.push(Tag {
77                    name: name.clone(),
78                    target: oid_from_git2(tag.target_id()),
79                    tagger: tag.tagger().map(|sig| signature_from_git2(&sig)),
80                    message: tag
81                        .message()
82                        .ok()
83                        .flatten()
84                        .unwrap_or_default()
85                        .trim_end_matches('\n')
86                        .to_string(),
87                });
88            } else {
89                tags.push(Tag {
90                    name,
91                    target: oid_from_git2(target),
92                    tagger: None,
93                    message: String::new(),
94                });
95            }
96        }
97        tags.sort_by(|a, b| a.name.cmp(&b.name));
98        Ok(tags)
99    }
100
101    fn create_branch(&self, name: &str, target: &str) -> AppResult<()> {
102        let commit = self.resolve_commit(target)?;
103        self.repo
104            .branch(name, &commit, false)
105            .map_err(|err| map_exists_error("branch", name, err))?;
106        Ok(())
107    }
108
109    fn delete_branch(&self, name: &str) -> AppResult<()> {
110        let mut branch = self
111            .repo
112            .find_branch(name, BranchType::Local)
113            .map_err(|_| GitError::RefNotFound {
114                refname: name.to_string(),
115            })?;
116        branch
117            .delete()
118            .map_err(|err| map_branch_delete_error(name, err))?;
119        Ok(())
120    }
121
122    fn create_tag(&self, name: &str, target: &str, message: Option<&str>) -> AppResult<()> {
123        let obj = self
124            .repo
125            .revparse_single(target)
126            .map_err(|_| GitError::RefNotFound {
127                refname: target.to_string(),
128            })?;
129        if let Some(message) = message {
130            let signature = self.repo.signature().map_err(GitError::Internal)?;
131            self.repo
132                .tag(name, &obj, &signature, message, false)
133                .map_err(|err| map_exists_error("tag", name, err))?;
134        } else {
135            self.repo
136                .reference(
137                    &format!("refs/tags/{name}"),
138                    obj.id(),
139                    false,
140                    "create lightweight tag",
141                )
142                .map_err(|err| map_exists_error("tag", name, err))?;
143        }
144        Ok(())
145    }
146
147    fn delete_tag(&self, name: &str) -> AppResult<()> {
148        self.repo.tag_delete(name).map_err(|e| {
149            if e.code() == git2::ErrorCode::NotFound {
150                GitError::RefNotFound {
151                    refname: name.to_string(),
152                }
153            } else {
154                GitError::Internal(e)
155            }
156        })?;
157        Ok(())
158    }
159}
160
161impl RemoteManager for Git2Repository {
162    fn list_remotes(&self) -> AppResult<Vec<Remote>> {
163        let remotes = self.repo.remotes().map_err(GitError::Internal)?;
164        let mut items = Vec::new();
165
166        for name in collect_git_strings(remotes.iter())? {
167            let remote = self.repo.find_remote(&name).map_err(GitError::Internal)?;
168            items.push(Remote {
169                name,
170                url: remote.url().unwrap_or_default().to_string(),
171                fetch_specs: collect_git_strings(
172                    remote.fetch_refspecs().map_err(GitError::Internal)?.iter(),
173                )?,
174                push_specs: collect_git_strings(
175                    remote.push_refspecs().map_err(GitError::Internal)?.iter(),
176                )?,
177            });
178        }
179
180        items.sort_by(|a, b| a.name.cmp(&b.name));
181        Ok(items)
182    }
183
184    fn fetch(&self, remote: &str, opts: Option<&FetchOptions>) -> AppResult<()> {
185        let mut handle = self
186            .repo
187            .find_remote(remote)
188            .map_err(|_| GitError::RemoteNotFound {
189                name: remote.to_string(),
190            })?;
191        let refspecs = opts
192            .map(|opts| opts.refspecs.iter().map(String::as_str).collect::<Vec<_>>())
193            .unwrap_or_default();
194
195        let auth = self.auth.transport_auth(Some(remote))?;
196        let callbacks = crate::embedded::auth::remote_callbacks(auth.as_ref())?;
197        let mut fetch_opts = match opts {
198            Some(opts) => fetch_options_to_git2(opts)?,
199            None => git2::FetchOptions::new(),
200        };
201        fetch_opts.remote_callbacks(callbacks);
202        handle
203            .fetch(&refspecs, Some(&mut fetch_opts), None)
204            .map_err(map_remote_error)?;
205        Ok(())
206    }
207
208    fn push(&self, remote: &str, opts: Option<&PushOptions>) -> AppResult<()> {
209        let mut handle = self
210            .repo
211            .find_remote(remote)
212            .map_err(|_| GitError::RemoteNotFound {
213                name: remote.to_string(),
214            })?;
215        let refspecs = push_refspecs(&handle, opts)?;
216
217        // libgit2 reports a server-side per-ref rejection ("ng <ref> <reason>")
218        // through this callback while `remote.push` may still return `Ok`; a
219        // rejection recorded here must therefore surface as a typed error rather
220        // than a silent success.
221        let rejections: Rc<RefCell<Vec<(String, String)>>> = Rc::new(RefCell::new(Vec::new()));
222        // Start from the auth-bearing callbacks (credentials) and merge the
223        // rejection recorder onto the same object so token auth and the typed
224        // `PushRejected` path both apply.
225        let auth = self.auth.transport_auth(Some(remote))?;
226        let mut callbacks = crate::embedded::auth::remote_callbacks(auth.as_ref())?;
227        {
228            let rejections = Rc::clone(&rejections);
229            callbacks.push_update_reference(move |refname, status| {
230                if let Some(reason) = status {
231                    rejections
232                        .borrow_mut()
233                        .push((refname.to_string(), reason.to_string()));
234                }
235                Ok(())
236            });
237        }
238        let mut push_opts = git2::PushOptions::new();
239        push_opts.remote_callbacks(callbacks);
240
241        let result = handle.push(&refspecs, Some(&mut push_opts));
242
243        let rejections = rejections.borrow();
244        if !rejections.is_empty() {
245            // Name every rejected ref, and surface each distinct reason once —
246            // a protected branch typically rejects every ref with one reason.
247            // The seen-set keeps the first-seen order at O(n) rather than a
248            // `Vec::contains` scan per reason.
249            let mut seen = std::collections::HashSet::new();
250            let refname = rejections
251                .iter()
252                .map(|(name, _)| name.clone())
253                .collect::<Vec<_>>()
254                .join(", ");
255            let reason = rejections
256                .iter()
257                .map(|(_, reason)| redact_url_credentials(reason))
258                .filter(|reason| seen.insert(reason.clone()))
259                .collect::<Vec<_>>()
260                .join("; ");
261            return Err(GitError::PushRejected { refname, reason }.into());
262        }
263        drop(rejections);
264        result.map_err(|err| map_push_error(err, &refspecs))?;
265        Ok(())
266    }
267
268    fn tracking_branch(&self, branch: &str) -> AppResult<String> {
269        let branch = self
270            .repo
271            .find_branch(branch, BranchType::Local)
272            .map_err(|_| GitError::RefNotFound {
273                refname: branch.to_string(),
274            })?;
275        let upstream = branch.upstream().map_err(|_| GitError::RefNotFound {
276            refname: format!(
277                "{branch}@{{upstream}}",
278                branch = branch.name().ok().flatten().unwrap_or_default()
279            ),
280        })?;
281        upstream
282            .name()
283            .map_err(GitError::Internal)?
284            .map(str::to_string)
285            .ok_or_else(|| {
286                AppError::invalid_input("branch", "upstream branch name is not valid utf-8")
287            })
288    }
289}
290
291impl ConfigReader for Git2Repository {
292    fn config_get(&self, key: &str) -> AppResult<String> {
293        let config = self.repo.config().map_err(GitError::Internal)?;
294        config
295            .get_string(key)
296            .map_err(|err| map_config_error(key, err))
297    }
298
299    fn config_get_all(&self, key: &str) -> AppResult<Vec<String>> {
300        let config = self.repo.config().map_err(GitError::Internal)?;
301        let mut entries = config
302            .multivar(key, None)
303            .map_err(|err| map_config_error(key, err))?;
304        let mut values = Vec::new();
305
306        while let Some(entry) = entries.next() {
307            let entry = entry.map_err(GitError::Internal)?;
308            let value = entry.value().map_err(GitError::Internal)?;
309            values.push(value.to_string());
310        }
311
312        Ok(values)
313    }
314
315    fn config_set(&self, key: &str, value: &str) -> AppResult<()> {
316        let mut config = self.repo.config().map_err(GitError::Internal)?;
317        config
318            .set_str(key, value)
319            .map_err(|err| map_config_error(key, err))?;
320        Ok(())
321    }
322}
323
324fn fetch_options_to_git2(opts: &FetchOptions) -> AppResult<git2::FetchOptions<'static>> {
325    let mut fetch_opts = git2::FetchOptions::new();
326    fetch_opts.prune(if opts.prune {
327        FetchPrune::On
328    } else {
329        FetchPrune::Off
330    });
331
332    if let Some(depth) = opts.depth {
333        let depth = i32::try_from(depth)
334            .map_err(|_| AppError::invalid_input("depth", "fetch depth exceeds supported range"))?;
335        fetch_opts.depth(depth);
336    }
337
338    Ok(fetch_opts)
339}
340
341fn push_refspecs(remote: &git2::Remote<'_>, opts: Option<&PushOptions>) -> AppResult<Vec<String>> {
342    let mut refspecs = match opts {
343        Some(o) if !o.refspecs.is_empty() => o.refspecs.clone(),
344        _ => collect_git_strings(remote.push_refspecs().map_err(GitError::Internal)?.iter())?,
345    };
346
347    let force = opts.is_some_and(|o| o.force);
348    if force {
349        for refspec in &mut refspecs {
350            if !refspec.starts_with('+') {
351                refspec.insert(0, '+');
352            }
353        }
354    }
355
356    Ok(refspecs)
357}
358
359fn collect_git_strings<'a>(
360    iter: impl IntoIterator<Item = Result<Option<&'a str>, git2::Error>>,
361) -> AppResult<Vec<String>> {
362    let mut values = Vec::new();
363    for item in iter {
364        let Some(value) = item.map_err(GitError::Internal)? else {
365            return Err(AppError::invalid_format("git string array", "utf-8 string"));
366        };
367        values.push(value.to_string());
368    }
369    Ok(values)
370}
371
372fn map_config_error(key: &str, err: git2::Error) -> rskit_errors::AppError {
373    if err.code() == git2::ErrorCode::NotFound {
374        GitError::ConfigNotFound {
375            key: key.to_string(),
376        }
377        .into()
378    } else {
379        GitError::Internal(err).into()
380    }
381}
382
383fn map_exists_error(kind: &'static str, name: &str, err: git2::Error) -> GitError {
384    if err.code() == git2::ErrorCode::Exists {
385        GitError::AlreadyExists {
386            kind,
387            name: name.to_string(),
388        }
389    } else {
390        GitError::Internal(err)
391    }
392}
393
394fn map_branch_delete_error(name: &str, err: git2::Error) -> GitError {
395    if err.code() == git2::ErrorCode::NotFound {
396        GitError::RefNotFound {
397            refname: name.to_string(),
398        }
399    } else if err.message().contains("is checked out") {
400        GitError::CheckedOutBranch {
401            name: name.to_string(),
402        }
403    } else {
404        GitError::Internal(err)
405    }
406}