mirror of https://github.com/ashhhleyyy/gitit.git
feat: Initial commit
This commit is contained in:
commit
f14f270822
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
repos/
|
||||
gitit.toml
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
|
@ -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`.
|
|
@ -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;
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>;
|
|
@ -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(())
|
||||
}
|
|
@ -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),
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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,
|
||||
}))?))
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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="/">← 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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(())
|
||||
}
|
|
@ -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))?)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue