Skip to content

Commit 6d1bab3

Browse files
committedDec 31, 2023
Add ability to download tar.gz of commit/branch/tag
1 parent a5ba9bb commit 6d1bab3

File tree

8 files changed

+337
-6
lines changed

8 files changed

+337
-6
lines changed
 

‎Cargo.lock

+121
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml

+3
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,13 @@ serde = { version = "1.0", features = ["derive", "rc"] }
3333
sha2 = "0.10"
3434
syntect = "5"
3535
sled = { version = "0.34", features = ["compression"] }
36+
tar = "0.4"
37+
flate2 = "1.0"
3638
time = { version = "0.3", features = ["serde"] }
3739
timeago = { version = "0.4.2", default-features = false }
3840
tokio = { version = "1.19", features = ["full"] }
3941
tokio-util = { version = "0.7.3", features = ["io"] }
42+
tokio-stream = "0.1"
4043
tower = "0.4"
4144
tower-service = "0.3"
4245
tower-layer = "0.3"

‎src/git.rs

+95-4
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ use std::{
77
time::Duration,
88
};
99

10-
use anyhow::{Context, Result};
11-
use bytes::{Bytes, BytesMut};
10+
use anyhow::{anyhow, Context, Result};
11+
use bytes::{BufMut, Bytes, BytesMut};
1212
use comrak::{ComrakOptions, ComrakPlugins};
1313
use git2::{
1414
DiffFormat, DiffLineType, DiffOptions, DiffStatsFormat, Email, EmailCreateOptions, ObjectType,
15-
Oid, Signature,
15+
Oid, Signature, TreeWalkResult,
1616
};
1717
use moka::future::Cache;
1818
use parking_lot::Mutex;
@@ -22,7 +22,7 @@ use syntect::{
2222
util::LinesWithEndings,
2323
};
2424
use time::OffsetDateTime;
25-
use tracing::instrument;
25+
use tracing::{error, instrument, warn};
2626

2727
use crate::syntax_highlight::ComrakSyntectAdapter;
2828

@@ -272,6 +272,16 @@ impl OpenRepository {
272272
.await
273273
}
274274

275+
pub async fn default_branch(self: Arc<Self>) -> Result<Option<String>> {
276+
tokio::task::spawn_blocking(move || {
277+
let repo = self.repo.lock();
278+
let head = repo.head().context("Couldn't find HEAD of repository")?;
279+
Ok(head.shorthand().map(ToString::to_string))
280+
})
281+
.await
282+
.context("Failed to join Tokio task")?
283+
}
284+
275285
#[instrument(skip(self))]
276286
pub async fn latest_commit(self: Arc<Self>) -> Result<Commit> {
277287
tokio::task::spawn_blocking(move || {
@@ -299,6 +309,87 @@ impl OpenRepository {
299309
.context("Failed to join Tokio task")?
300310
}
301311

312+
#[instrument(skip_all)]
313+
pub async fn archive(
314+
self: Arc<Self>,
315+
res: tokio::sync::mpsc::Sender<Result<Bytes, anyhow::Error>>,
316+
cont: tokio::sync::oneshot::Sender<()>,
317+
commit: Option<&str>,
318+
) -> Result<(), anyhow::Error> {
319+
const BUFFER_CAP: usize = 512 * 1024;
320+
321+
let commit = commit
322+
.map(Oid::from_str)
323+
.transpose()
324+
.context("failed to build oid")?;
325+
326+
tokio::task::spawn_blocking(move || {
327+
let buffer = BytesMut::with_capacity(BUFFER_CAP + 1024);
328+
329+
let flate = flate2::write::GzEncoder::new(buffer.writer(), flate2::Compression::fast());
330+
let mut archive = tar::Builder::new(flate);
331+
332+
let repo = self.repo.lock();
333+
334+
let tree = if let Some(commit) = commit {
335+
repo.find_commit(commit)?.tree()?
336+
} else if let Some(reference) = &self.branch {
337+
repo.resolve_reference_from_short_name(reference)?
338+
.peel_to_tree()?
339+
} else {
340+
repo.head()
341+
.context("Couldn't find HEAD of repository")?
342+
.peel_to_tree()?
343+
};
344+
345+
// tell the web server it can send response headers to the requester
346+
if cont.send(()).is_err() {
347+
return Err(anyhow!("requester gone"));
348+
}
349+
350+
let mut callback = |root: &str, entry: &git2::TreeEntry| -> TreeWalkResult {
351+
if let Ok(blob) = entry.to_object(&repo).unwrap().peel_to_blob() {
352+
let path =
353+
Path::new(root).join(String::from_utf8_lossy(entry.name_bytes()).as_ref());
354+
355+
let mut header = tar::Header::new_gnu();
356+
if let Err(error) = header.set_path(&path) {
357+
warn!(%error, "Attempted to write invalid path to archive");
358+
return TreeWalkResult::Skip;
359+
}
360+
header.set_size(blob.size() as u64);
361+
#[allow(clippy::cast_sign_loss)]
362+
header.set_mode(entry.filemode() as u32);
363+
header.set_cksum();
364+
365+
if let Err(error) = archive.append(&header, blob.content()) {
366+
error!(%error, "Failed to write blob to archive");
367+
return TreeWalkResult::Abort;
368+
}
369+
}
370+
371+
if archive.get_ref().get_ref().get_ref().len() >= BUFFER_CAP {
372+
let b = archive.get_mut().get_mut().get_mut().split().freeze();
373+
if let Err(error) = res.blocking_send(Ok(b)) {
374+
error!(%error, "Failed to send buffer to client");
375+
return TreeWalkResult::Abort;
376+
}
377+
}
378+
379+
TreeWalkResult::Ok
380+
};
381+
382+
tree.walk(git2::TreeWalkMode::PreOrder, &mut callback)?;
383+
384+
res.blocking_send(Ok(archive.into_inner()?.finish()?.into_inner().freeze()))?;
385+
386+
Ok::<_, anyhow::Error>(())
387+
})
388+
.await??;
389+
390+
Ok(())
391+
}
392+
302393
#[instrument(skip(self))]
303394
pub async fn commit(self: Arc<Self>, commit: &str) -> Result<Arc<Commit>, Arc<anyhow::Error>> {
304395
let commit = Oid::from_str(commit)

‎src/methods/repo/commit.rs

+21-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ pub struct View {
1717
pub repo: Repository,
1818
pub commit: Arc<Commit>,
1919
pub branch: Option<Arc<str>>,
20+
pub dl_branch: Arc<str>,
21+
pub id: Option<String>,
2022
}
2123

2224
#[derive(Deserialize)]
@@ -33,8 +35,23 @@ pub async fn handle(
3335
Query(query): Query<UriQuery>,
3436
) -> Result<Response> {
3537
let open_repo = git.repo(repository_path, query.branch.clone()).await?;
36-
let commit = if let Some(commit) = query.id {
37-
open_repo.commit(&commit).await?
38+
39+
let dl_branch = if let Some(branch) = query.branch.clone() {
40+
branch
41+
} else {
42+
Arc::from(
43+
open_repo
44+
.clone()
45+
.default_branch()
46+
.await
47+
.ok()
48+
.flatten()
49+
.unwrap_or_else(|| "master".to_string()),
50+
)
51+
};
52+
53+
let commit = if let Some(commit) = query.id.as_deref() {
54+
open_repo.commit(commit).await?
3855
} else {
3956
Arc::new(open_repo.latest_commit().await?)
4057
};
@@ -43,5 +60,7 @@ pub async fn handle(
4360
repo,
4461
commit,
4562
branch: query.branch,
63+
id: query.id,
64+
dl_branch,
4665
}))
4766
}

‎src/methods/repo/mod.rs

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod diff;
44
mod log;
55
mod refs;
66
mod smart_git;
7+
mod snapshot;
78
mod summary;
89
mod tag;
910
mod tree;
@@ -32,6 +33,7 @@ use self::{
3233
log::handle as handle_log,
3334
refs::handle as handle_refs,
3435
smart_git::handle as handle_smart_git,
36+
snapshot::handle as handle_snapshot,
3537
summary::handle as handle_summary,
3638
tag::handle as handle_tag,
3739
tree::handle as handle_tree,
@@ -89,6 +91,7 @@ where
8991
Some("diff") => h!(handle_diff),
9092
Some("patch") => h!(handle_patch),
9193
Some("tag") => h!(handle_tag),
94+
Some("snapshot") => h!(handle_snapshot),
9295
Some(v) => {
9396
uri_parts.push(v);
9497

‎src/methods/repo/snapshot.rs

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use std::sync::Arc;
2+
3+
use anyhow::{anyhow, Context};
4+
use axum::{
5+
body::{boxed, Body, BoxBody},
6+
extract::Query,
7+
http::Response,
8+
Extension,
9+
};
10+
use serde::Deserialize;
11+
use tokio_stream::wrappers::ReceiverStream;
12+
use tracing::{error, info_span, Instrument};
13+
14+
use super::{RepositoryPath, Result};
15+
use crate::git::Git;
16+
17+
#[derive(Deserialize)]
18+
pub struct UriQuery {
19+
#[serde(rename = "h")]
20+
branch: Option<Arc<str>>,
21+
id: Option<Arc<str>>,
22+
}
23+
24+
pub async fn handle(
25+
Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
26+
Extension(git): Extension<Arc<Git>>,
27+
Query(query): Query<UriQuery>,
28+
) -> Result<Response<BoxBody>> {
29+
let open_repo = git.repo(repository_path, query.branch.clone()).await?;
30+
31+
// byte stream back to the client
32+
let (send, recv) = tokio::sync::mpsc::channel(1);
33+
34+
// channel for `archive` to tell us we can send headers etc back to
35+
// the user so it has time to return an error
36+
let (send_cont, recv_cont) = tokio::sync::oneshot::channel();
37+
38+
let id = query.id.clone();
39+
40+
let res = tokio::spawn(
41+
async move {
42+
if let Err(error) = open_repo
43+
.archive(send.clone(), send_cont, id.as_deref())
44+
.await
45+
{
46+
error!(%error, "Failed to build archive for client");
47+
let _res = send.send(Err(anyhow!("archive builder failed"))).await;
48+
return Err(error);
49+
}
50+
51+
Ok(())
52+
}
53+
.instrument(info_span!("sender")),
54+
);
55+
56+
// don't send any headers until `archive` has told us we're good
57+
// to continue
58+
if recv_cont.await.is_err() {
59+
// sender disappearing means `archive` hit an issue during init, lets
60+
// wait for the error back from the spawned tokio task to return to
61+
// the client
62+
res.await
63+
.context("Tokio task failed")?
64+
.context("Failed to build archive")?;
65+
66+
// ok, well this isn't ideal. the sender disappeared but we never got
67+
// an error. this shouldn't be possible, i guess lets just return an
68+
// internal error
69+
return Err(anyhow!("Ran into inconsistent error state whilst building archive, please file an issue at https://github.com/w4/rgit/issues").into());
70+
}
71+
72+
let file_name = query
73+
.id
74+
.as_deref()
75+
.or(query.branch.as_deref())
76+
.unwrap_or("main");
77+
78+
Ok(Response::builder()
79+
.header("Content-Type", "application/gzip")
80+
.header(
81+
"Content-Disposition",
82+
format!("attachment; filename=\"{file_name}.tar.gz\""),
83+
)
84+
.body(boxed(Body::wrap_stream(ReceiverStream::new(recv))))
85+
.context("failed to build response")?)
86+
}

‎templates/repo/commit.html

+4
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
<td colspan="2"><pre><a href="/{{ repo.display() }}/commit?id={{ parent }}{% call link::maybe_branch_suffix(branch) %}" class="no-style">{{ parent }}</a></pre></td>
3636
</tr>
3737
{%- endfor %}
38+
<tr>
39+
<th>download (tar.gz)</th>
40+
<td colspan="2"><pre><a href="/{{ repo.display() }}/snapshot?{% if let Some(id) = id %}id={{ id }}{% else %}h={{ dl_branch }}{% endif %}">{{ id.as_deref().unwrap_or(dl_branch.as_ref()) }}</a></pre></td>
41+
</tr>
3842
</tbody>
3943
</table>
4044

‎templates/repo/tag.html

+4
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
</td>
3232
</tr>
3333
{% endif %}
34+
<tr>
35+
<th>download (tar.gz)</th>
36+
<td colspan="2"><pre><a href="/{{ repo.display() }}/snapshot?h={{ tag.name }}">{{ tag.name }}</a></pre></td>
37+
</tr>
3438
</tbody>
3539
</table>
3640

0 commit comments

Comments
 (0)
Please sign in to comment.