| @@ -203,6 +203,13 @@ pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfi |
| let exclude = build_globset(&git_cfg.exclude)?; |
| let repo_path_clone = repo_path.clone(); |
| |
| + // `include`/`exclude`/`default_branch` are moved into the `spawn_blocking` |
| + // closure below, so keep clones around for `export_bare_clone`, which |
| + // needs the same filters to decide what to publish in `repository.git`. |
| + let bare_default_branch = default_branch.clone(); |
| + let bare_include = include.clone(); |
| + let bare_exclude = exclude.clone(); |
| + |
| // Compute clone URL before the blocking task so we can pass it into the |
| // browse page generator without re-deriving it. |
| let clone_url = generate_clone_command(config, git_cfg); |
| @@ -290,7 +297,15 @@ pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfi |
| |
| // ββ Bare clone + dumb HTTP setup ββββββββββββββββββββββββββββββββββββββββββ |
| pb.set_message("cloning bare repositoryβ¦"); |
| - if let Err(e) = export_bare_clone(&repo_path, &bare_dir).await { |
| + if let Err(e) = export_bare_clone( |
| + &repo_path, |
| + &bare_dir, |
| + &bare_default_branch, |
| + &bare_include, |
| + &bare_exclude, |
| + ) |
| + .await |
| + { |
| pb.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}")); |
| return Err(e); |
| } |
| @@ -829,7 +844,19 @@ fn parse_diff_output(text: &str) -> Vec<ChangedFile> { |
| |
| // ββ Bare clone export βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| |
| -async fn export_bare_clone(source: &Path, dest: &Path) -> Result<()> { |
| +/// Clone `source` as a bare repository at `dest`, then prune it down to the |
| +/// branches/tags allowed by `git_ui.include`/`git_ui.exclude` before enabling |
| +/// the dumb HTTP transport. |
| +/// |
| +/// `default_branch` is used to pick a sane `HEAD` for the bare clone if the |
| +/// branch it previously pointed to got filtered out. |
| +async fn export_bare_clone( |
| + source: &Path, |
| + dest: &Path, |
| + default_branch: &str, |
| + include: &GlobSet, |
| + exclude: &GlobSet, |
| +) -> Result<()> { |
| use tokio::process::Command; |
| |
| if dest.exists() { |
| @@ -853,6 +880,8 @@ async fn export_bare_clone(source: &Path, dest: &Path) -> Result<()> { |
| )); |
| } |
| |
| + prune_excluded_refs(dest, default_branch, include, exclude).await?; |
| + |
| // Enable dumb HTTP transport so the bare repo is clonable over plain HTTPS. |
| let status = Command::new("git") |
| .arg("-C") |
| @@ -871,6 +900,142 @@ async fn export_bare_clone(source: &Path, dest: &Path) -> Result<()> { |
| Ok(()) |
| } |
| |
| +/// Delete every branch/tag in the bare clone at `dest` that doesn't pass |
| +/// `git_ui.include`/`git_ui.exclude` (see [`ref_is_included`]), then run |
| +/// `git gc --prune=now` so the excluded history isn't merely unlisted but |
| +/// actually removed from what dumb HTTP ends up serving from disk. |
| +/// |
| +/// A no-op (skips even the ref scan) when both `include` and `exclude` are |
| +/// empty, which is the common case. |
| +async fn prune_excluded_refs( |
| + dest: &Path, |
| + default_branch: &str, |
| + include: &GlobSet, |
| + exclude: &GlobSet, |
| +) -> Result<()> { |
| + use tokio::process::Command; |
| + |
| + if include.is_empty() && exclude.is_empty() { |
| + return Ok(()); |
| + } |
| + |
| + let output = Command::new("git") |
| + .arg("-C") |
| + .arg(dest) |
| + .arg("for-each-ref") |
| + .arg("--format=%(refname)") |
| + .arg("refs/heads") |
| + .arg("refs/tags") |
| + .output() |
| + .await |
| + .into_diagnostic()?; |
| + |
| + if !output.status.success() { |
| + warn!("git for-each-ref failed; skipping include/exclude pruning of repository.git"); |
| + return Ok(()); |
| + } |
| + |
| + let refnames = String::from_utf8_lossy(&output.stdout); |
| + let mut removed_any = false; |
| + let mut kept_branches: Vec<String> = Vec::new(); |
| + |
| + for refname in refnames.lines() { |
| + let short_name = match refname |
| + .strip_prefix("refs/heads/") |
| + .or_else(|| refname.strip_prefix("refs/tags/")) |
| + { |
| + Some(s) => s, |
| + None => continue, |
| + }; |
| + |
| + if ref_is_included(short_name, include, exclude) { |
| + if refname.starts_with("refs/heads/") { |
| + kept_branches.push(short_name.to_string()); |
| + } |
| + continue; |
| + } |
| + |
| + let status = Command::new("git") |
| + .arg("-C") |
| + .arg(dest) |
| + .arg("update-ref") |
| + .arg("-d") |
| + .arg(refname) |
| + .stdout(std::process::Stdio::null()) |
| + .stderr(std::process::Stdio::null()) |
| + .status() |
| + .await |
| + .into_diagnostic()?; |
| + |
| + if status.success() { |
| + removed_any = true; |
| + } else { |
| + warn!("failed to delete excluded ref '{refname}' from repository.git"); |
| + } |
| + } |
| + |
| + if !removed_any { |
| + return Ok(()); |
| + } |
| + |
| + // `HEAD` may have pointed at a branch we just deleted; repoint it at the |
| + // configured default branch if it survived the filter, or otherwise at |
| + // whatever branch is left, so `git clone` of `repository.git` still |
| + // checks out something sensible. |
| + let new_head = if kept_branches.iter().any(|b| b == default_branch) { |
| + Some(default_branch.to_string()) |
| + } else if let Some(first) = kept_branches.first() { |
| + warn!( |
| + "git_ui: default branch '{}' excluded from repository.git; using '{}' for HEAD instead", |
| + default_branch, first |
| + ); |
| + Some(first.clone()) |
| + } else { |
| + warn!( |
| + "git_ui: include/exclude filtered out every branch; repository.git will have no usable HEAD" |
| + ); |
| + None |
| + }; |
| + |
| + if let Some(branch) = new_head { |
| + let status = Command::new("git") |
| + .arg("-C") |
| + .arg(dest) |
| + .arg("symbolic-ref") |
| + .arg("HEAD") |
| + .arg(format!("refs/heads/{branch}")) |
| + .stdout(std::process::Stdio::null()) |
| + .stderr(std::process::Stdio::null()) |
| + .status() |
| + .await |
| + .into_diagnostic()?; |
| + |
| + if !status.success() { |
| + warn!("failed to repoint HEAD in repository.git after pruning excluded refs"); |
| + } |
| + } |
| + |
| + // Physically remove the now-unreachable objects so excluded branches/tags |
| + // aren't just unlisted but actually absent from the published repo. |
| + let status = Command::new("git") |
| + .arg("-C") |
| + .arg(dest) |
| + .arg("gc") |
| + .arg("--prune=now") |
| + .arg("--quiet") |
| + .status() |
| + .await |
| + .into_diagnostic()?; |
| + |
| + if !status.success() { |
| + warn!( |
| + "git gc --prune=now failed on repository.git; excluded objects may still be present on disk" |
| + ); |
| + } |
| + |
| + Ok(()) |
| +} |
| + |
| // ββ Tree browser ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| |
| /// Build the full static tree browser for every revision in `revisions`. |