feat: Initial commit

This commit is contained in:
Ashhhleyyy 2022-05-26 12:32:43 +01:00
commit f14f270822
Signed by: ash
GPG Key ID: 83B789081A0878FB
18 changed files with 2835 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
repos/
gitit.toml

1874
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "gitit"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.5", features = ["headers", "multipart"] }
git2 = "0.14"
liquid = "0.26"
thiserror = "1.0.31"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
html-escape = "0.2.11"
tower-http = { version = "0.3.3", features = ["trace"] }
axum-macros = "0.2.2"
mime_guess = "2.0.4"
mime = "0.3.16"
rust-embed = "6.4.0"
hex = "0.4.3"
toml = "0.5.9"
clap = { version = "3.1.18", features = ["derive"] }
syntect = "5.0.0"

24
README.md Normal file
View File

@ -0,0 +1,24 @@
# Gitit
A simple repository browser and mirrorer for Git.
## Configuration
Gitit uses a single `toml` file named `gitit.toml` for its configuration, and the contents look something like this:
```toml
[server]
address = "0.0.0.0:3000"
[repos.gitit]
url = "https://github.com/ashhhleyyy/gitit.git"
title = "Gitit"
[repos.website]
url = "https://github.com/ashhhleyyy/website.git"
title = "Website"
```
In order to keep the repositories in sync with their upstream, you should call `gitit update-repos` on a regular schedule, or even automate it with webhooks (instructions not included).
To run the web server, run `gitit web`.

24
assets/css/style.css Normal file
View File

@ -0,0 +1,24 @@
body {
font-family: Poppins, sans-serif;
background-color: #181118;
color: white;
margin-left: 32px;
}
a {
color: inherit;
}
.link-quiet {
color: #aaf;
text-decoration: none;
}
.file-content > pre {
background: none !important;
}
.icon {
height: 1em;
fill: #aaf;
}

32
src/config.rs Normal file
View File

@ -0,0 +1,32 @@
use std::{path::Path, fs, collections::HashMap};
use serde::Deserialize;
use crate::errors::{Result, GititError};
#[derive(Clone, Debug, Deserialize)]
pub struct Config {
pub server: ListenConfig,
pub repos: HashMap<String, RepoConfig>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ListenConfig {
pub address: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct RepoConfig {
pub url: String,
pub title: String,
}
pub(super) fn load() -> Result<Config> {
let path = Path::new("gitit.toml");
if path.exists() {
let content = fs::read_to_string(path)?;
Ok(toml::from_str(&content)?)
} else {
Err(GititError::MissingConfig)
}
}

40
src/errors.rs Normal file
View File

@ -0,0 +1,40 @@
use axum::{response::IntoResponse, http::{StatusCode, header}};
#[derive(Debug, thiserror::Error)]
pub enum GititError {
#[error("template error: {0}")]
LiquidError(#[from] liquid::Error),
#[error("git error: {0}")]
GitError(#[from] git2::Error),
#[error("not found")]
NotFound,
#[error("redirect to: {0}")]
Redirect(String),
#[error("highlighting error: {0}")]
HighlightingError(#[from] syntect::Error),
#[error("missing config")]
MissingConfig,
#[error("io error: {0}")]
IOError(#[from] std::io::Error),
#[error("toml parser error: {0}")]
TomlError(#[from] toml::de::Error),
}
impl IntoResponse for GititError {
fn into_response(self) -> axum::response::Response {
tracing::error!("{}", self);
let (status, body) = match self {
GititError::LiquidError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "templating error"),
GititError::GitError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "git error"),
GititError::NotFound => (StatusCode::NOT_FOUND, "not found"),
GititError::HighlightingError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "highlighting error"),
GititError::Redirect(target) => {
return (StatusCode::TEMPORARY_REDIRECT, [(header::LOCATION, target)]).into_response();
}
_ => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error")
};
(status, body).into_response()
}
}
pub type Result<T> = std::result::Result<T, GititError>;

46
src/main.rs Normal file
View File

@ -0,0 +1,46 @@
use axum::Extension;
use clap::Parser;
use config::Config;
use tower_http::trace::TraceLayer;
use tracing_subscriber::prelude::*;
mod config;
mod errors;
mod routes;
mod update;
mod utils;
#[derive(clap::Parser)]
enum Cli {
Web,
UpdateRepos,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::registry()
.with(tracing_subscriber::filter::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "debug,hyper=info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
let cli = Cli::parse();
let config = config::load()?;
match cli {
Cli::Web => run_server(config).await,
Cli::UpdateRepos => update::update_repos(config).map_err(|e| e.into()),
}
}
async fn run_server(config: Config) -> Result<(), Box<dyn std::error::Error>> {
let app = routes::build_router()
.layer(TraceLayer::new_for_http())
.layer(Extension(config.clone()));
axum::Server::bind(&config.server.address.parse().unwrap())
.serve(app.into_make_service())
.await?;
Ok(())
}

44
src/routes/assets.rs Normal file
View File

@ -0,0 +1,44 @@
use axum::{headers::{ETag, IfNoneMatch, HeaderMapExt}, http::{HeaderValue, StatusCode, header::CONTENT_TYPE}, TypedHeader, extract::Path, response::IntoResponse};
use hex::ToHex;
use rust_embed::{EmbeddedFile, RustEmbed};
// Yes I've shamelessly stolen this from the code from my own website.
#[derive(RustEmbed)]
#[folder = "assets/"]
struct Asset;
pub struct AutoContentType(String, ETag, EmbeddedFile);
impl IntoResponse for AutoContentType {
fn into_response(self) -> axum::response::Response {
let mut res = self.2.data.into_response();
res.headers_mut().remove(CONTENT_TYPE);
res.headers_mut().typed_insert(self.1);
if let Some(mime) = mime_guess::from_path(&self.0).first_raw() {
res.headers_mut()
.append(CONTENT_TYPE, HeaderValue::from_static(mime));
}
res
}
}
#[tracing::instrument]
pub async fn get(
Path(path): Path<String>,
if_none_match: Option<TypedHeader<IfNoneMatch>>,
) -> Result<AutoContentType, StatusCode> {
match Asset::get(&path[1..]) {
Some(asset) => {
let hash = asset.metadata.sha256_hash().encode_hex::<String>();
let etag = format!(r#"{:?}"#, hash).parse::<ETag>().unwrap();
if let Some(if_none_match) = if_none_match {
if !if_none_match.precondition_passes(&etag) {
return Err(StatusCode::NOT_MODIFIED);
}
}
Ok(AutoContentType(path[1..].to_string(), etag, asset))
}
None => Err(StatusCode::NOT_FOUND),
}
}

14
src/routes/mod.rs Normal file
View File

@ -0,0 +1,14 @@
use axum::{Router, routing::get};
mod assets;
mod repo;
pub fn build_router() -> Router {
Router::new()
.route("/", get(repo::list))
.route("/:repo/", get(repo::index))
.route("/:repo/commit/:commit_id/", get(repo::commit))
.route("/:repo/commit/:commit_id/contents/*tree_path", get(repo::commit_tree))
.route("/:repo/commit/:commit_id/diff", get(repo::commit_raw))
.route("/assets/*path", get(assets::get))
}

189
src/routes/repo.rs Normal file
View File

@ -0,0 +1,189 @@
use std::path::PathBuf;
use axum::{extract::{Path, OriginalUri}, response::{Html, IntoResponse}, http::header, Extension};
use git2::{Repository, Sort, Tree, Blob};
use crate::{errors::{Result, GititError}, utils::{templates, ObjectId, HtmlOrRaw, safe_mime}, config::{Config, RepoConfig}};
fn repo_from_name<'config>(repo_name: &str, config: &'config Config) -> Result<(&'config RepoConfig, Repository)> {
let repo_config = config.repos.get(repo_name).ok_or(GititError::NotFound)?;
let mut path = PathBuf::new();
path.push("repos");
path.push(format!("{}.git", repo_name));
let repo = Repository::open_bare(path)
.map_err(|e| {
match e.code() {
git2::ErrorCode::NotFound => GititError::NotFound,
_ => e.into(),
}
})?;
Ok((repo_config, repo))
}
#[tracing::instrument]
pub(crate) async fn list(Extension(config): Extension<Config>) -> Result<Html<String>> {
let template = liquid::ParserBuilder::with_stdlib()
.build()?
.parse(include_str!("templates/repo/list.html.liquid"))?;
let mut repos = Vec::with_capacity(config.repos.len());
for (slug, repo) in config.repos {
repos.push(liquid::object!({
"slug": slug,
"title": repo.title,
"upstream_url": repo.url,
}));
}
Ok(Html(template.render(&liquid::object!({
"repos": repos,
}))?))
}
#[tracing::instrument]
pub(crate) async fn index(Path(repo_name): Path<String>, Extension(config): Extension<Config>) -> Result<Html<String>> {
let template = liquid::ParserBuilder::with_stdlib()
.build()?
.parse(include_str!("templates/repo/index.html.liquid"))?;
let (repo_config, repo) = repo_from_name(&repo_name, &config)?;
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(Sort::TIME)?;
let mut commits = Vec::with_capacity(100);
for commit in revwalk.take(500) {
let commit_id = commit?;
let commit = repo.find_commit(commit_id)?;
commits.push(templates::commit_to_object(&repo, &commit)?);
}
let head = repo.head()?.target().unwrap().to_string();
let repo = liquid::object!({
"name": repo_config.title,
"recent_commits": commits,
"head": head,
});
Ok(Html(template.render(&liquid::object!({
"repo": repo,
}))?))
}
#[tracing::instrument]
pub(crate) async fn commit(Path((repo_name, ObjectId(commit))): Path<(String, ObjectId)>, Extension(config): Extension<Config>) -> Result<impl IntoResponse> {
let template = liquid::ParserBuilder::with_stdlib()
.build()?
.parse(include_str!("templates/repo/commit.html.liquid"))?;
let (repo_config, repo) = repo_from_name(&repo_name, &config)?;
let commit = repo.find_commit(commit)?;
let repo_data = liquid::object!({
"name": repo_config.title,
});
Ok(Html(template.render(&liquid::object!({
"repo": repo_data,
"commit": templates::commit_to_object(&repo, &commit)?,
"diff": templates::full_diff(&repo, &commit, false)?,
}))?))
}
#[tracing::instrument]
pub(crate) async fn commit_raw(Path((repo_name, ObjectId(commit))): Path<(String, ObjectId)>, Extension(config): Extension<Config>) -> Result<impl IntoResponse> {
let (_, repo) = repo_from_name(&repo_name, &config)?;
let commit = repo.find_commit(commit)?;
Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], templates::full_diff(&repo, &commit, true)?))
}
#[tracing::instrument]
pub(crate) async fn commit_tree(Path((repo_name, ObjectId(commit), path)): Path<(String, ObjectId, String)>, OriginalUri(full_uri): OriginalUri, Extension(config): Extension<Config>) -> Result<HtmlOrRaw> {
let (_, repo) = repo_from_name(&repo_name, &config)?;
let commit = repo.find_commit(commit)?;
let tree = commit.tree()?;
if path.len() <= 1 {
return render_tree(&commit.id().to_string(), path, &tree);
};
let subtree = tree.get_path(&std::path::Path::new(&path[1..]))?;
match subtree.kind().unwrap() {
git2::ObjectType::Tree => {
if !path.ends_with("/") {
let path_and_query = full_uri.path_and_query().unwrap();
let target = if let Some(query) = path_and_query.query() {
format!("{}/?{}", path_and_query.path(), query)
} else {
format!("{}/", path_and_query.path())
};
return Err(GititError::Redirect(target));
}
if let Some(subtree) = subtree.to_object(&repo)?.as_tree() {
render_tree(&commit.id().to_string(), path, subtree)
} else {
Err(GititError::NotFound)
}
},
git2::ObjectType::Blob => {
if let Some(blob) = subtree.to_object(&repo)?.as_blob() {
render_file(&commit.id().to_string(), path, &blob)
} else {
Err(GititError::NotFound)
}
},
_ => Err(GititError::NotFound)
}
}
fn render_file(commit: &str, path: String, blob: &Blob) -> Result<HtmlOrRaw> {
let template = liquid::ParserBuilder::with_stdlib()
.build()?
.parse(include_str!("templates/repo/text_file.html.liquid"))?;
if blob.is_binary() {
Ok(HtmlOrRaw::Raw(safe_mime(mime_guess::from_path(path).first_or_octet_stream()).to_string(), blob.content().to_owned()))
} else {
let string_content = std::str::from_utf8(blob.content()).unwrap().to_owned();
let extension = std::path::Path::new(&path).extension().map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "txt".to_owned());
Ok(HtmlOrRaw::Html(template.render(&liquid::object!({
"commit": {
"hash": commit,
},
"file": {
"path": path,
"content": templates::syntax_highlight(&extension, &string_content)?,
},
}))?))
}
}
#[tracing::instrument]
fn render_tree(commit: &str, path: String, subtree: &Tree<'_>) -> Result<HtmlOrRaw> {
let template = liquid::ParserBuilder::with_stdlib()
.build()?
.parse(include_str!("templates/repo/commit_tree.html.liquid"))?;
let mut files = vec![];
for file in subtree.iter() {
files.push(liquid::object!({
"filename": file.name(),
"kind": match file.kind().unwrap() {
git2::ObjectType::Tree => "tree",
git2::ObjectType::Blob => "blob",
_ => {
tracing::warn!("strange kind in tree");
continue
}
},
}));
}
Ok(HtmlOrRaw::Html(template.render(&liquid::object!({
"commit": {
"hash": commit,
},
"path": path,
"files": files,
}))?))
}

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ repo.name }}</title>
<link rel="stylesheet" href="https://unpkg.com/@fontsource/poppins@4.5.8/index.css">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<h1>
<a href="../..">
{{ repo.name }}
</a>
</h1>
<main>
<section>
<h2>{{ commit.summary | escape }}</h2>
<p>
{{ commit.message | escape | replace: "
", "<br>" }}
</p>
</section>
<nav>
<a href="contents/">View files</a>
</nav>
<section>
<h2>Diff</h2>
<div class="file-content">
{{ diff }}
</div>
</section>
</main>
</body>
</html>

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ path }}</title>
<script src="https://kit.fontawesome.com/fb3a746f63.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://unpkg.com/@fontsource/poppins@4.5.8/index.css">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<main>
<h1>{{ commit.hash }}</h1>
<section>
<h2>Files in {{ path }}</h2>
<ul>
<li>
<a href="..">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M318 145.6c-3.812 8.75-12.45 14.41-22 14.41L224 160v272c0 44.13-35.89 80-80 80H32c-17.67 0-32-14.31-32-32s14.33-32 32-32h112C152.8 448 160 440.8 160 432V160L88 159.1c-9.547 0-18.19-5.656-22-14.41S63.92 126.7 70.41 119.7l104-112c9.498-10.23 25.69-10.23 35.19 0l104 112C320.1 126.7 321.8 136.8 318 145.6z"/></svg>
..
</a>
</li>
{% for file in files %}
<li>
{% if file.kind == "blob" %}
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M0 64C0 28.65 28.65 0 64 0H224V128C224 145.7 238.3 160 256 160H384V448C384 483.3 355.3 512 320 512H64C28.65 512 0 483.3 0 448V64zM256 128V0L384 128H256z"/></svg>
<a href="{{ file.filename }}">
{{ file.filename }}
</a>
{% else %}
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M512 144v288c0 26.5-21.5 48-48 48h-416C21.5 480 0 458.5 0 432v-352C0 53.5 21.5 32 48 32h160l64 64h192C490.5 96 512 117.5 512 144z"/></svg>
<a href="{{ file.filename }}/">
{{ file.filename }}/
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</section>
</main>
</body>
</html>

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ repo.name }}</title>
<link rel="stylesheet" href="https://unpkg.com/@fontsource/poppins@4.5.8/index.css">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<nav>
<a href="/">&larr; Back to projects</a>
</nav>
<h1>{{ repo.name }}</h1>
<nav>
<ul>
<li>
<a href="commit/{{ repo.head }}">HEAD</a>
<a class="link-quiet" href="commit/{{ repo.head }}/contents/">[files]</a>
</li>
</ul>
</nav>
<section>
<h2>Recent commits</h2>
<ul>
{% for commit in repo.recent_commits %}
<li>
<a href="commit/{{ commit.hash }}">
<b><code>{{ commit.short_hash }}</code></b>: {{ commit.summary }} (+{{ commit.diff.added }}, -{{ commit.diff.removed }})</a>
~ {{ commit.author.name }}
<a class="link-quiet" href="commit/{{ commit.hash }}/contents/">[files]</a>
</li>
{% endfor %}
</ul>
</section>
</body>
</html>

View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Repositories</title>
<link rel="stylesheet" href="https://unpkg.com/@fontsource/poppins@4.5.8/index.css">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<h1>Repositories</h1>
<section>
<ul>
{% for repo in repos %}
<li>
<a href="{{ repo.slug }}">{{ repo.title }}</a>
<a class="link-quiet" href="{{ repo.upstream_url }}" target="_blank">[upstream]</a>
</li>
{% endfor %}
</ul>
</section>
</body>
</html>

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ file.path }}</title>
<script src="https://kit.fontawesome.com/fb3a746f63.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://unpkg.com/@fontsource/poppins@4.5.8/index.css">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<main>
<h1>{{ file.path }}</h1>
<section class="file-content">
{{ file.content }}
</section>
</main>
</body>
</html>

195
src/update.rs Normal file
View File

@ -0,0 +1,195 @@
use std::{path::{PathBuf, Path}, io::{self, Write}, cell::RefCell};
use git2::{Progress, RemoteCallbacks, FetchOptions, build::RepoBuilder, AutotagOption, Repository};
use crate::{config::{RepoConfig, Config}, errors::Result};
// Most of this clone/fetch code is copied from the git2-rs examples
struct State {
progress: Option<Progress<'static>>,
total: usize,
current: usize,
path: Option<PathBuf>,
newline: bool,
}
fn print(state: &mut State) {
let stats = state.progress.as_ref().unwrap();
let network_pct = (100 * stats.received_objects()) / stats.total_objects();
let index_pct = (100 * stats.indexed_objects()) / stats.total_objects();
let co_pct = if state.total > 0 {
(100 * state.current) / state.total
} else {
0
};
let kbytes = stats.received_bytes() / 1024;
if stats.received_objects() == stats.total_objects() {
if !state.newline {
println!();
state.newline = true;
}
print!(
"Resolving deltas {}/{}\r",
stats.indexed_deltas(),
stats.total_deltas()
);
} else {
print!(
"net {:3}% ({:4} kb, {:5}/{:5}) / idx {:3}% ({:5}/{:5}) \
/ chk {:3}% ({:4}/{:4}) {}\r",
network_pct,
kbytes,
stats.received_objects(),
stats.total_objects(),
index_pct,
stats.indexed_objects(),
stats.total_objects(),
co_pct,
state.current,
state.total,
state
.path
.as_ref()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
)
}
io::stdout().flush().unwrap();
}
#[tracing::instrument]
fn clone_repository(repo_config: RepoConfig, path: &Path) -> Result<()> {
let state = RefCell::new(State {
progress: None,
total: 0,
current: 0,
path: None,
newline: false,
});
let mut cb = RemoteCallbacks::new();
cb.transfer_progress(|stats| {
let mut state = state.borrow_mut();
state.progress = Some(stats.to_owned());
print(&mut *state);
true
});
let mut fo = FetchOptions::new();
fo.remote_callbacks(cb);
RepoBuilder::new()
.fetch_options(fo)
.bare(true)
.clone(&repo_config.url, path)?;
println!();
Ok(())
}
#[tracing::instrument]
fn fetch_repo(repo_config: RepoConfig, path: &Path) -> Result<()> {
let repo = Repository::open(path)?;
let mut cb = RemoteCallbacks::new();
let mut remote = repo
.find_remote("origin")
.or_else(|_| repo.remote_anonymous(&repo_config.url))?;
cb.sideband_progress(|data| {
print!("remote: {}", std::str::from_utf8(data).unwrap());
io::stdout().flush().unwrap();
true
});
// This callback gets called for each remote-tracking branch that gets
// updated. The message we output depends on whether it's a new one or an
// update.
cb.update_tips(|refname, a, b| {
if a.is_zero() {
tracing::info!("[new] {:20} {}", b, refname);
} else {
tracing::info!("[updated] {:10}..{:10} {}", a, b, refname);
}
true
});
// Here we show processed and total objects in the pack and the amount of
// received data. Most frontends will probably want to show a percentage and
// the download rate.
cb.transfer_progress(|stats| {
if stats.received_objects() == stats.total_objects() {
print!(
"Resolving deltas {}/{}\r",
stats.indexed_deltas(),
stats.total_deltas()
);
} else if stats.total_objects() > 0 {
print!(
"Received {}/{} objects ({}) in {} bytes\r",
stats.received_objects(),
stats.total_objects(),
stats.indexed_objects(),
stats.received_bytes()
);
}
io::stdout().flush().unwrap();
true
});
// Download the packfile and index it. This function updates the amount of
// received data and the indexer stats which lets you inform the user about
// progress.
let mut fo = FetchOptions::new();
fo.remote_callbacks(cb);
remote.download(&[] as &[&str], Some(&mut fo))?;
{
// If there are local objects (we got a thin pack), then tell the user
// how many objects we saved from having to cross the network.
let stats = remote.stats();
if stats.local_objects() > 0 {
tracing::info!(
"Received {}/{} objects in {} bytes (used {} local \
objects)",
stats.indexed_objects(),
stats.total_objects(),
stats.received_bytes(),
stats.local_objects()
);
} else {
tracing::info!(
"Received {}/{} objects in {} bytes",
stats.indexed_objects(),
stats.total_objects(),
stats.received_bytes()
);
}
}
// Disconnect the underlying connection to prevent from idling.
remote.disconnect()?;
// Update the references in the remote's namespace to point to the right
// commits. This may be needed even if there was no packfile to download,
// which can happen e.g. when the branches have been changed but all the
// needed objects are available locally.
remote.update_tips(None, true, AutotagOption::Unspecified, None)?;
Ok(())
}
pub(crate) fn update_repos(config: Config) -> Result<()> {
for (slug, repo_config) in config.repos {
let mut path = PathBuf::new();
path.push("repos");
path.push(format!("{}.git", slug));
if !path.exists() {
tracing::info!("==> Cloning {} into {:?}...", repo_config.url, &path);
clone_repository(repo_config, &path)?;
} else {
tracing::info!("==> Fetching {} in {:?}...", repo_config.url, &path);
fetch_repo(repo_config, &path)?;
}
}
Ok(())
}

159
src/utils.rs Normal file
View File

@ -0,0 +1,159 @@
use axum::{response::{IntoResponse, Html}, http::header};
use git2::Oid;
use serde::{Deserializer, de::Visitor};
pub enum HtmlOrRaw {
String(String),
Html(String),
Raw(String, Vec<u8>),
}
impl IntoResponse for HtmlOrRaw {
fn into_response(self) -> axum::response::Response {
match self {
HtmlOrRaw::String(s) => s.into_response(),
HtmlOrRaw::Html(s) => Html(s).into_response(),
HtmlOrRaw::Raw(content_type, data) => ([(header::CONTENT_TYPE, content_type)], data).into_response(),
}
}
}
pub fn safe_mime(mime: mime_guess::Mime) -> mime_guess::Mime {
if mime.essence_str().starts_with("application/") {
return mime::APPLICATION_OCTET_STREAM;
} else {
return mime;
}
}
#[derive(serde::Deserialize)]
pub struct ObjectId(#[serde(deserialize_with = "deserialize_oid")] pub Oid);
// deserialize_with
fn deserialize_oid<'de, D>(deserializer: D) -> Result<Oid, D::Error> where D: Deserializer<'de> {
deserializer.deserialize_str(OidVisitor)
}
struct OidVisitor;
impl<'de> Visitor<'de> for OidVisitor {
type Value = Oid;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a Git OID")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> where E: serde::de::Error {
Oid::from_str(v).map_err(|_| serde::de::Error::custom(format!("invalid OID: {}", v)))
}
}
pub mod templates {
use git2::{Commit, Repository, DiffFormat, DiffOptions, Diff};
use syntect::{parsing::SyntaxSet, highlighting::ThemeSet};
use std::fmt::Write;
use crate::errors::Result;
#[tracing::instrument]
pub fn syntax_highlight(extension: &str, code: &str) -> Result<String> {
let ss = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();
let theme = &ts.themes["base16-ocean.dark"];
let syntax = ss.find_syntax_by_extension(extension).unwrap_or(ss.find_syntax_plain_text());
let html = syntect::html::highlighted_html_for_string(code, &ss, syntax, theme)?;
Ok(html)
}
pub fn commit_to_object(repo: &Repository, commit: &Commit) -> Result<liquid::Object> {
let hash = commit.id().to_string();
let short_hash = hash[..7].to_owned();
let author_name = commit.author().name().map(|s| s.to_owned());
let author_email = commit.author().email().map(|s| s.to_owned());
let (diff_line, (added, removed)) = diff_info(repo, commit)?;
let (summary, description) = commit.message().unwrap().split_once('\n')
.unwrap_or_else(|| (commit.message().unwrap(), ""));
Ok(liquid::object!({
"hash": hash,
"short_hash": short_hash,
"summary": summary,
"message": description,
"author": {
"name": author_name,
"email": author_email,
},
"diff": {
"added": added,
"removed": removed,
"summary": diff_line,
},
}))
}
pub fn full_diff(repo: &Repository, commit: &Commit, raw: bool) -> Result<String> {
let diff = makediff(repo, commit)?;
let mut output = String::new();
diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
let c = match line.origin() {
'+' | '-' => {
line.origin()
},
_ => ' ',
};
let line_str = std::str::from_utf8(line.content()).unwrap();
output.push_str(&format_line(line_str, c, raw));
true
})?;
if raw {
Ok(output)
} else {
syntax_highlight("patch", &output)
}
}
fn format_line(line: &str, c: char, raw: bool) -> String {
if raw {
line.to_owned()
} else {
format!("{} {}", c, line)
}
}
fn diff_info(repo: &Repository, commit: &Commit) -> Result<(String, (i32, i32))> {
let diff = makediff(repo, commit)?;
let mut output = String::new();
let mut added = 0;
let mut removed = 0;
diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
match line.origin() {
' ' | '+' | '-' => {
write!(&mut output, "{}", line.origin()).unwrap()
},
_ => {}
}
match line.origin() {
'-' => { removed += 1; }
'+' => { added += 1; }
_ => { }
}
true
})?;
Ok((output, (added, removed)))
}
fn makediff<'repo>(repo: &'repo Repository, commit: &Commit) -> Result<Diff<'repo>> {
let mut diffopts = DiffOptions::new();
let a = if commit.parents().len() == 1 {
let parent = commit.parent(0)?;
Some(parent.tree()?)
} else {
None
};
let b = commit.tree()?;
Ok(repo.diff_tree_to_tree(a.as_ref(), Some(&b), Some(&mut diffopts))?)
}
}