initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ife1391ed23a1e7f388b1b5eca90b9ea76a6a6964
This commit is contained in:
commit
ef28bdaeb4
63 changed files with 17292 additions and 0 deletions
589
src/git/mod.rs
Normal file
589
src/git/mod.rs
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
use std::path::Path;
|
||||
|
||||
use git2::{
|
||||
Cred,
|
||||
FetchOptions,
|
||||
Oid,
|
||||
RemoteCallbacks,
|
||||
Repository,
|
||||
ResetType,
|
||||
build::RepoBuilder,
|
||||
};
|
||||
|
||||
use crate::error::{PakkerError, Result};
|
||||
|
||||
/// Check if a directory is a Git repository
|
||||
pub fn is_git_repository<P: AsRef<Path>>(path: P) -> bool {
|
||||
Repository::open(path).is_ok()
|
||||
}
|
||||
|
||||
/// Get the URL of a remote
|
||||
pub fn get_remote_url<P: AsRef<Path>>(
|
||||
path: P,
|
||||
remote_name: &str,
|
||||
) -> Result<String> {
|
||||
let repo = Repository::open(path)?;
|
||||
let remote = repo.find_remote(remote_name).map_err(|e| {
|
||||
PakkerError::GitError(format!("Remote '{remote_name}' not found: {e}"))
|
||||
})?;
|
||||
|
||||
remote
|
||||
.url()
|
||||
.ok_or_else(|| {
|
||||
PakkerError::GitError("Remote URL is not valid UTF-8".to_string())
|
||||
})
|
||||
.map(std::string::ToString::to_string)
|
||||
}
|
||||
|
||||
pub fn get_current_commit_sha<P: AsRef<Path>>(
|
||||
path: P,
|
||||
ref_name: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let repo = Repository::open(path)?;
|
||||
|
||||
let commit = if let Some(ref_name) = ref_name {
|
||||
let obj = repo.revparse_single(ref_name)?;
|
||||
obj.peel_to_commit()?
|
||||
} else {
|
||||
let head = repo.head()?;
|
||||
head.peel_to_commit()?
|
||||
};
|
||||
|
||||
Ok(commit.id().to_string())
|
||||
}
|
||||
|
||||
/// Get the commit SHA for a specific ref (alias for compatibility)
|
||||
pub fn get_commit_sha<P: AsRef<Path>>(
|
||||
path: P,
|
||||
ref_name: &str,
|
||||
) -> Result<String> {
|
||||
get_current_commit_sha(path, Some(ref_name))
|
||||
}
|
||||
|
||||
/// Clone a Git repository
|
||||
pub fn clone_repository<P: AsRef<Path>>(
|
||||
url: &str,
|
||||
target_path: P,
|
||||
ref_name: &str,
|
||||
progress_callback: Option<
|
||||
Box<dyn FnMut(usize, usize, Option<usize>) + 'static>,
|
||||
>,
|
||||
) -> Result<Repository> {
|
||||
let target_path = target_path.as_ref();
|
||||
|
||||
// Check if target directory exists and is not empty
|
||||
if target_path.exists() {
|
||||
let is_empty = target_path.read_dir()?.next().is_none();
|
||||
if !is_empty {
|
||||
return Err(PakkerError::GitError(format!(
|
||||
"Target directory is not empty: {}",
|
||||
target_path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut callbacks = RemoteCallbacks::new();
|
||||
|
||||
// Setup SSH key authentication
|
||||
callbacks.credentials(|_url, username_from_url, _allowed_types| {
|
||||
let username = username_from_url.unwrap_or("git");
|
||||
Cred::ssh_key_from_agent(username)
|
||||
});
|
||||
|
||||
// Setup progress callback if provided
|
||||
if let Some(mut progress_fn) = progress_callback {
|
||||
callbacks.transfer_progress(move |stats| {
|
||||
progress_fn(
|
||||
stats.received_objects(),
|
||||
stats.total_objects(),
|
||||
Some(stats.received_bytes()),
|
||||
);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
let mut fetch_options = FetchOptions::new();
|
||||
fetch_options.remote_callbacks(callbacks);
|
||||
|
||||
let mut builder = RepoBuilder::new();
|
||||
builder.fetch_options(fetch_options);
|
||||
|
||||
// Perform the clone. Avoid forcing a branch at clone time because some
|
||||
// local repositories (or bare repos) may not expose the exact remote
|
||||
// tracking refs that libgit2 expects. We'll attempt to set the desired
|
||||
// ref after cloning when possible.
|
||||
let repo = builder.clone(url, target_path).map_err(|e| {
|
||||
PakkerError::GitError(format!("Failed to clone repository '{url}': {e}"))
|
||||
})?;
|
||||
|
||||
// If a branch/ref name was requested, try to make HEAD point to it.
|
||||
// Prefer local branch refs (refs/heads/*), then tags, then raw rev-parse.
|
||||
let branch_ref = format!("refs/heads/{ref_name}");
|
||||
if repo.find_reference(&branch_ref).is_ok() {
|
||||
repo.set_head(&branch_ref).map_err(|e| {
|
||||
PakkerError::GitError(format!(
|
||||
"Cloned repository but failed to set HEAD to {branch_ref}: {e}"
|
||||
))
|
||||
})?;
|
||||
} else if let Ok(obj) = repo.revparse_single(ref_name) {
|
||||
// Create a detached HEAD pointing to the commit/tag
|
||||
let commit = obj.peel_to_commit().map_err(|e| {
|
||||
PakkerError::GitError(format!(
|
||||
"Resolved ref '{ref_name}' but could not peel to commit: {e}"
|
||||
))
|
||||
})?;
|
||||
repo.set_head_detached(commit.id()).map_err(|e| {
|
||||
PakkerError::GitError(format!(
|
||||
"Cloned repository but failed to set detached HEAD to {ref_name}: {e}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// Fetch updates from a remote
|
||||
pub fn fetch_updates<P: AsRef<Path>>(
|
||||
path: P,
|
||||
remote_name: &str,
|
||||
ref_name: &str,
|
||||
progress_callback: Option<
|
||||
Box<dyn FnMut(usize, usize, Option<usize>) + 'static>,
|
||||
>,
|
||||
) -> Result<()> {
|
||||
let repo = Repository::open(path)?;
|
||||
let mut remote = repo.find_remote(remote_name).map_err(|e| {
|
||||
PakkerError::GitError(format!("Remote '{remote_name}' not found: {e}"))
|
||||
})?;
|
||||
|
||||
let mut callbacks = RemoteCallbacks::new();
|
||||
|
||||
// Setup SSH key authentication
|
||||
callbacks.credentials(|_url, username_from_url, _allowed_types| {
|
||||
let username = username_from_url.unwrap_or("git");
|
||||
Cred::ssh_key_from_agent(username)
|
||||
});
|
||||
|
||||
// Setup progress callback if provided
|
||||
if let Some(mut progress_fn) = progress_callback {
|
||||
callbacks.transfer_progress(move |stats| {
|
||||
progress_fn(
|
||||
stats.received_objects(),
|
||||
stats.total_objects(),
|
||||
Some(stats.received_bytes()),
|
||||
);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
let mut fetch_options = FetchOptions::new();
|
||||
fetch_options.remote_callbacks(callbacks);
|
||||
|
||||
remote
|
||||
.fetch(&[ref_name], Some(&mut fetch_options), None)
|
||||
.map_err(|e| {
|
||||
PakkerError::GitError(format!("Failed to fetch updates: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hard reset to a specific ref (like git reset --hard)
|
||||
pub fn reset_to_ref<P: AsRef<Path>>(
|
||||
path: P,
|
||||
remote_name: &str,
|
||||
ref_name: &str,
|
||||
) -> Result<()> {
|
||||
let repo = Repository::open(path)?;
|
||||
|
||||
// Construct the full ref path (e.g., "origin/main")
|
||||
let full_ref = format!("{remote_name}/{ref_name}");
|
||||
|
||||
let obj = repo.revparse_single(&full_ref).map_err(|e| {
|
||||
PakkerError::GitError(format!("Failed to find ref '{full_ref}': {e}"))
|
||||
})?;
|
||||
|
||||
let commit = obj.peel_to_commit().map_err(|e| {
|
||||
PakkerError::GitError(format!("Failed to resolve ref to commit: {e}"))
|
||||
})?;
|
||||
|
||||
repo
|
||||
.reset(commit.as_object(), ResetType::Hard, None)
|
||||
.map_err(|e| {
|
||||
PakkerError::GitError(format!("Failed to reset to ref: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Determine the ref type (branch, tag, or commit)
|
||||
pub fn resolve_ref_type<P: AsRef<Path>>(
|
||||
path: P,
|
||||
ref_name: &str,
|
||||
) -> Result<crate::model::fork::RefType> {
|
||||
let repo = Repository::open(path)?;
|
||||
|
||||
// Check if it's a branch
|
||||
if repo.find_branch(ref_name, git2::BranchType::Local).is_ok()
|
||||
|| repo.find_branch(ref_name, git2::BranchType::Remote).is_ok()
|
||||
{
|
||||
return Ok(crate::model::fork::RefType::Branch);
|
||||
}
|
||||
|
||||
// Check if it's a tag
|
||||
let tag_ref = format!("refs/tags/{ref_name}");
|
||||
if repo.find_reference(&tag_ref).is_ok() {
|
||||
return Ok(crate::model::fork::RefType::Tag);
|
||||
}
|
||||
|
||||
// Try to resolve as commit SHA
|
||||
if repo.revparse_single(ref_name).is_ok() {
|
||||
return Ok(crate::model::fork::RefType::Commit);
|
||||
}
|
||||
|
||||
Err(PakkerError::GitError(format!(
|
||||
"Could not resolve ref '{ref_name}' as branch, tag, or commit"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Get the primary remote URL for a repository at path. Prefer 'origin',
|
||||
/// otherwise first remote with a URL.
|
||||
pub fn get_primary_remote_url<P: AsRef<Path>>(path: P) -> Result<String> {
|
||||
let repo = Repository::open(path)?;
|
||||
if let Ok(remote) = repo.find_remote("origin")
|
||||
&& let Some(url) = remote.url()
|
||||
{
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
|
||||
// Fallback: first remote with a URL
|
||||
if let Ok(remotes) = repo.remotes() {
|
||||
for name in remotes.iter().flatten() {
|
||||
if let Ok(remote) = repo.find_remote(name)
|
||||
&& let Some(url) = remote.url()
|
||||
{
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(PakkerError::GitError(
|
||||
"No remote with a valid URL found on repository".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VcsType {
|
||||
Git,
|
||||
Jujutsu,
|
||||
None,
|
||||
}
|
||||
|
||||
/// Detect the VCS type for a given path
|
||||
pub fn detect_vcs_type<P: AsRef<Path>>(path: P) -> VcsType {
|
||||
let path = path.as_ref();
|
||||
|
||||
// Check for jujutsu first (higher priority)
|
||||
if let Ok(output) = std::process::Command::new("jj")
|
||||
.args(["root"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
return VcsType::Jujutsu;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for git
|
||||
if let Ok(output) = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
return VcsType::Git;
|
||||
}
|
||||
}
|
||||
|
||||
VcsType::None
|
||||
}
|
||||
|
||||
/// Check whether the repository has uncommitted changes (working tree or index)
|
||||
pub fn repo_has_uncommitted_changes<P: AsRef<Path>>(path: P) -> Result<bool> {
|
||||
let vcs_type = detect_vcs_type(&path);
|
||||
|
||||
match vcs_type {
|
||||
VcsType::Git => {
|
||||
let repo = Repository::open(path)?;
|
||||
let statuses = repo.statuses(None)?;
|
||||
for entry in statuses.iter() {
|
||||
let s = entry.status();
|
||||
// Consider any change in index or working tree as uncommitted
|
||||
if !(s.is_empty()) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
},
|
||||
VcsType::Jujutsu => {
|
||||
// Use jj status to check for changes - look for "The working copy has no
|
||||
// changes"
|
||||
let output = std::process::Command::new("jj")
|
||||
.args(["status"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
PakkerError::GitError(format!("Failed to run jj status: {}", e))
|
||||
})?;
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
// Check if the output indicates no changes
|
||||
Ok(!output_str.contains("The working copy has no changes"))
|
||||
},
|
||||
VcsType::None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt a lightweight fetch of a single ref from the named remote into the
|
||||
/// repository at path
|
||||
pub fn fetch_remote_light<P: AsRef<Path>>(
|
||||
path: P,
|
||||
remote_name: &str,
|
||||
ref_name: &str,
|
||||
) -> Result<()> {
|
||||
let repo = Repository::open(path)?;
|
||||
let mut remote = repo.find_remote(remote_name).map_err(|e| {
|
||||
PakkerError::GitError(format!("Remote '{remote_name}' not found: {e}"))
|
||||
})?;
|
||||
|
||||
let mut callbacks = RemoteCallbacks::new();
|
||||
callbacks.credentials(|_url, username_from_url, _allowed_types| {
|
||||
let username = username_from_url.unwrap_or("git");
|
||||
Cred::ssh_key_from_agent(username)
|
||||
});
|
||||
|
||||
let mut fetch_options = FetchOptions::new();
|
||||
fetch_options.remote_callbacks(callbacks);
|
||||
|
||||
// Build a refspec that attempts to fetch the branch into the remote-tracking
|
||||
// namespace
|
||||
let fetch_refspec = if ref_name.starts_with("refs/") {
|
||||
ref_name.to_string()
|
||||
} else {
|
||||
format!("refs/heads/{ref_name}:refs/remotes/{remote_name}/{ref_name}")
|
||||
};
|
||||
|
||||
remote
|
||||
.fetch(&[&fetch_refspec], Some(&mut fetch_options), None)
|
||||
.map_err(|e| {
|
||||
PakkerError::GitError(format!("Failed lightweight fetch: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a ref name to an Oid (commit)
|
||||
pub fn get_ref_oid<P: AsRef<Path>>(path: P, ref_name: &str) -> Result<Oid> {
|
||||
let repo = Repository::open(path)?;
|
||||
let obj = repo.revparse_single(ref_name).map_err(|e| {
|
||||
PakkerError::GitError(format!("Failed to resolve ref '{ref_name}': {e}"))
|
||||
})?;
|
||||
let commit = obj.peel_to_commit().map_err(|e| {
|
||||
PakkerError::GitError(format!(
|
||||
"Failed to peel ref '{ref_name}' to commit: {e}"
|
||||
))
|
||||
})?;
|
||||
Ok(commit.id())
|
||||
}
|
||||
|
||||
/// Count commits reachable from `oid` in `repo`
|
||||
fn count_commits(repo: &Repository, oid: Oid) -> Result<usize> {
|
||||
let mut revwalk = repo.revwalk().map_err(|e| {
|
||||
PakkerError::GitError(format!(
|
||||
"Failed to create revwalk for counting commits: {e}"
|
||||
))
|
||||
})?;
|
||||
revwalk.push(oid).map_err(|e| {
|
||||
PakkerError::GitError(format!(
|
||||
"Failed to start revwalk from oid {oid}: {e}"
|
||||
))
|
||||
})?;
|
||||
let mut count = 0usize;
|
||||
for _ in revwalk {
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Compute how many commits `local_ref` is ahead/behind `remote_ref`
|
||||
pub fn ahead_behind<P: AsRef<Path>>(
|
||||
path: P,
|
||||
local_ref: &str,
|
||||
remote_ref: &str,
|
||||
) -> Result<(usize, usize)> {
|
||||
let repo = Repository::open(&path)?;
|
||||
|
||||
// Try to resolve local OID
|
||||
let local_oid = match get_ref_oid(&path, local_ref) {
|
||||
Ok(oid) => oid,
|
||||
Err(e) => {
|
||||
return Err(PakkerError::GitError(format!(
|
||||
"Local ref not found '{local_ref}': {e}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
|
||||
// Try to resolve remote OID. If remote ref is missing, consider remote empty
|
||||
// and count all commits in local as "ahead".
|
||||
if let Ok(remote_oid) = get_ref_oid(&path, remote_ref) {
|
||||
let (ahead, behind) = repo
|
||||
.graph_ahead_behind(local_oid, remote_oid)
|
||||
.map_err(|e| {
|
||||
PakkerError::GitError(format!("Failed to compute ahead/behind: {e}"))
|
||||
})?;
|
||||
Ok((ahead, behind))
|
||||
} else {
|
||||
// Remote ref missing — count commits reachable from local
|
||||
let ahead_count = count_commits(&repo, local_oid)?;
|
||||
Ok((ahead_count, 0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the URL for a remote in the repository
|
||||
pub fn set_remote_url<P: AsRef<Path>>(
|
||||
path: P,
|
||||
remote_name: &str,
|
||||
url: &str,
|
||||
) -> Result<()> {
|
||||
let repo = Repository::open(path)?;
|
||||
repo.remote_set_url(remote_name, url).map_err(|e| {
|
||||
PakkerError::GitError(format!("Failed to set remote URL: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::Write};
|
||||
|
||||
use git2::{Repository, Signature};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn init_bare_repo(path: &std::path::Path) -> Repository {
|
||||
Repository::init_bare(path).expect("init bare")
|
||||
}
|
||||
|
||||
fn init_repo_with_commit(
|
||||
path: &std::path::Path,
|
||||
file_name: &str,
|
||||
content: &str,
|
||||
branch: &str,
|
||||
) -> Repository {
|
||||
let repo = Repository::init(path).expect("init repo");
|
||||
let sig = Signature::now("Test", "test@example.com").unwrap();
|
||||
let mut index = repo.index().unwrap();
|
||||
let file_path = path.join(file_name);
|
||||
let mut f = File::create(&file_path).unwrap();
|
||||
writeln!(f, "{}", content).unwrap();
|
||||
drop(f);
|
||||
index.add_path(std::path::Path::new(file_name)).unwrap();
|
||||
let tree_id = index.write_tree().unwrap();
|
||||
// limit the scope of tree to avoid borrow while moving repo
|
||||
{
|
||||
let tree = repo.find_tree(tree_id).unwrap();
|
||||
let _commit_id = repo
|
||||
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
|
||||
.unwrap();
|
||||
}
|
||||
// Create branch pointing at HEAD and set HEAD
|
||||
let head_oid = repo.refname_to_id("HEAD").unwrap();
|
||||
repo
|
||||
.branch(branch, &repo.find_commit(head_oid).unwrap(), true)
|
||||
.unwrap();
|
||||
repo.set_head(&format!("refs/heads/{}", branch)).unwrap();
|
||||
repo
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_git_repository_and_remote_url() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_path = tmp.path().join("repo");
|
||||
let _repo = init_repo_with_commit(&repo_path, "a.txt", "hello", "master");
|
||||
assert!(is_git_repository(&repo_path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fetch_remote_light_and_ahead_behind() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let bare_path = tmp.path().join("bare.git");
|
||||
let _bare = init_bare_repo(&bare_path);
|
||||
|
||||
let work_path = tmp.path().join("work");
|
||||
let repo = init_repo_with_commit(&work_path, "a.txt", "hello", "master");
|
||||
|
||||
// Add bare remote and push
|
||||
repo.remote("origin", bare_path.to_str().unwrap()).unwrap();
|
||||
let mut remote = repo.find_remote("origin").unwrap();
|
||||
remote.connect(git2::Direction::Push).unwrap();
|
||||
remote
|
||||
.push(&["refs/heads/master:refs/heads/master"], None)
|
||||
.unwrap();
|
||||
|
||||
// Ensure bare HEAD points to master
|
||||
let bare_repo = Repository::open(&bare_path).unwrap();
|
||||
bare_repo.set_head("refs/heads/master").unwrap();
|
||||
|
||||
// Now test fetch_remote_light against the work repo (fetch from origin into
|
||||
// work should succeed)
|
||||
assert!(fetch_remote_light(&work_path, "origin", "master").is_ok());
|
||||
|
||||
// Test ahead_behind with remote tracking ref
|
||||
let (ahead, behind) = ahead_behind(
|
||||
&work_path,
|
||||
"refs/heads/master",
|
||||
"refs/remotes/origin/master",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ahead, 0);
|
||||
assert_eq!(behind, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repository_and_origin_rewrite_integration() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let bare_path = tmp.path().join("upstream.git");
|
||||
let _bare = init_bare_repo(&bare_path);
|
||||
|
||||
let work_path = tmp.path().join("workrepo");
|
||||
let repo = init_repo_with_commit(&work_path, "b.txt", "hello2", "master");
|
||||
|
||||
// Add remote upstream and push
|
||||
repo.remote("origin", bare_path.to_str().unwrap()).unwrap();
|
||||
let mut remote = repo.find_remote("origin").unwrap();
|
||||
remote.connect(git2::Direction::Push).unwrap();
|
||||
remote
|
||||
.push(&["refs/heads/master:refs/heads/master"], None)
|
||||
.unwrap();
|
||||
let bare_repo = Repository::open(&bare_path).unwrap();
|
||||
bare_repo.set_head("refs/heads/master").unwrap();
|
||||
|
||||
// Now clone from the local path into a new dir
|
||||
let clone_target = tmp.path().join("clone_target");
|
||||
let _cloned = clone_repository(
|
||||
bare_path.to_str().unwrap(),
|
||||
&clone_target,
|
||||
"master",
|
||||
None,
|
||||
)
|
||||
.expect("clone");
|
||||
|
||||
// After cloning from a local path, simulate rewriting origin to the
|
||||
// upstream network URL
|
||||
set_remote_url(&clone_target, "origin", "https://example.com/upstream.git")
|
||||
.unwrap();
|
||||
let url = get_remote_url(&clone_target, "origin").unwrap();
|
||||
assert_eq!(url, "https://example.com/upstream.git");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue