fabrica

hanna/fabrica

feat(git): SHA-1 / SHA-256 object-format selection for new repos

5e112e6 · hanna committed on 2026-07-25

Add an ObjectFormat type and create_bare_with_format: SHA-1 keeps the in-process
git2 path; SHA-256 is created via the git binary (git init --object-format=
sha256), since libgit2's SHA-256 support is experimental — the repo is still a
valid git repo over the pack transport. Store the format per repo (migration
0007), expose a selector on the new-repo form (SHA-1 default, SHA-256 labelled
experimental), and show it read-only in repo settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
9 files changed · +276 −3UnifiedSplit
crates/auth/src/access.rs +1 −0
@@ -147,6 +147,7 @@ mod tests {
147147 description: None,
148148 visibility,
149149 default_branch: "main".to_string(),
150+ object_format: "sha1".to_string(),
150151 issues_enabled: true,
151152 pulls_enabled: true,
152153 next_iid: 1,
crates/git/src/lib.rs +164 −0
@@ -39,6 +39,7 @@ use std::fs;
3939 use std::io;
4040 use std::os::unix::fs::PermissionsExt;
4141 use std::path::{Path, PathBuf};
42+use std::process::Command;
4243
4344 use git2::{Repository, RepositoryInitOptions};
4445
@@ -102,6 +103,10 @@ pub enum GitError {
102103 /// rebase replay hit conflicts.
103104 #[error("merge has conflicts")]
104105 MergeConflict,
106+
107+ /// `git init` (used for SHA-256 repositories) failed.
108+ #[error("git init failed: {0}")]
109+ Init(String),
105110 }
106111
107112 /// Compute the on-disk path of a repository from its id: `{root}/{id[0..2]}/{id}.git`.
@@ -162,6 +167,135 @@ pub fn create_bare(
162167 }
163168 }
164169
170+/// The hash algorithm a repository's objects are named with.
171+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172+pub enum ObjectFormat {
173+ /// SHA-1 (the historical default).
174+ #[default]
175+ Sha1,
176+ /// SHA-256 (git's newer, collision-resistant format).
177+ Sha256,
178+}
179+
180+impl ObjectFormat {
181+ /// The stable token stored on the repo row and passed to `git init`.
182+ #[must_use]
183+ pub fn as_str(self) -> &'static str {
184+ match self {
185+ Self::Sha1 => "sha1",
186+ Self::Sha256 => "sha256",
187+ }
188+ }
189+
190+ /// Parse a stored/submitted token, defaulting to SHA-1.
191+ #[must_use]
192+ pub fn from_token(token: &str) -> Self {
193+ if token.eq_ignore_ascii_case("sha256") {
194+ Self::Sha256
195+ } else {
196+ Self::Sha1
197+ }
198+ }
199+}
200+
201+/// Create a bare repository with a chosen object format.
202+///
203+/// SHA-1 uses the in-process [`create_bare`]. SHA-256 is created by the `git`
204+/// binary (`git init --object-format=sha256`), because libgit2's SHA-256 support
205+/// is experimental and often unavailable — the resulting repository is still a
206+/// fully valid git repo served over the pack transport, though in-process
207+/// browsing depends on the local libgit2 build.
208+///
209+/// # Errors
210+///
211+/// Returns [`GitError`] if the id is invalid, the directory already exists, or a
212+/// git/filesystem step fails.
213+pub fn create_bare_with_format(
214+ root: &Path,
215+ id: &str,
216+ default_branch: &str,
217+ hook_binary: &Path,
218+ format: ObjectFormat,
219+) -> Result<(), GitError> {
220+ match format {
221+ ObjectFormat::Sha1 => create_bare(root, id, default_branch, hook_binary).map(|_| ()),
222+ ObjectFormat::Sha256 => create_bare_sha256(root, id, default_branch, hook_binary),
223+ }
224+}
225+
226+/// Create a SHA-256 bare repository via the `git` binary, then apply the same
227+/// server config and hooks [`create_bare`] installs.
228+fn create_bare_sha256(
229+ root: &Path,
230+ id: &str,
231+ default_branch: &str,
232+ hook_binary: &Path,
233+) -> Result<(), GitError> {
234+ let path = repo_path(root, id)?;
235+ if path.exists() {
236+ return Err(GitError::Io {
237+ path: path.clone(),
238+ source: io::Error::new(io::ErrorKind::AlreadyExists, "repository directory exists"),
239+ });
240+ }
241+ if let Some(parent) = path.parent() {
242+ fs::create_dir_all(parent).map_err(|source| GitError::Io {
243+ path: parent.to_path_buf(),
244+ source,
245+ })?;
246+ }
247+ match init_sha256_inner(&path, default_branch, hook_binary) {
248+ Ok(()) => Ok(()),
249+ Err(err) => {
250+ let _ = fs::remove_dir_all(&path);
251+ Err(err)
252+ }
253+ }
254+}
255+
256+/// The fallible body of [`create_bare_sha256`].
257+fn init_sha256_inner(
258+ path: &Path,
259+ default_branch: &str,
260+ hook_binary: &Path,
261+) -> Result<(), GitError> {
262+ let run = |args: &[&str]| -> Result<(), GitError> {
263+ let out = Command::new("git")
264+ .args(args)
265+ .output()
266+ .map_err(|source| GitError::Io {
267+ path: path.to_path_buf(),
268+ source,
269+ })?;
270+ if out.status.success() {
271+ Ok(())
272+ } else {
273+ Err(GitError::Init(
274+ String::from_utf8_lossy(&out.stderr).trim().to_string(),
275+ ))
276+ }
277+ };
278+ let path_str = path.to_string_lossy();
279+ run(&[
280+ "init",
281+ "--bare",
282+ "--object-format=sha256",
283+ "--initial-branch",
284+ default_branch,
285+ &path_str,
286+ ])?;
287+ // Mirror the server config `create_bare` sets (§3.2).
288+ for (key, value) in [
289+ ("core.logallrefupdates", "true"),
290+ ("receive.denyNonFastForwards", "false"),
291+ ("gc.auto", "0"),
292+ ] {
293+ run(&["--git-dir", &path_str, "config", key, value])?;
294+ }
295+ install_hooks(path, hook_binary)?;
296+ Ok(())
297+}
298+
165299 /// The fallible body of [`create_bare`], separated so the caller can clean up on
166300 /// any error.
167301 fn init_inner(path: &Path, default_branch: &str, hook_binary: &Path) -> Result<(), GitError> {
@@ -268,4 +402,34 @@ mod tests {
268402 // A second create at the same id fails (the directory already exists).
269403 assert!(create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).is_err());
270404 }
405+
406+ #[test]
407+ fn create_bare_sha256_uses_the_requested_format() {
408+ // SHA-256 creation shells out to `git`; skip cleanly where it is absent.
409+ if Command::new("git").arg("--version").output().is_err() {
410+ return;
411+ }
412+ let dir = tempfile::tempdir().unwrap();
413+ let id = "01hzxk9m2n8p7q6r5s4t3v2w1y";
414+ create_bare_with_format(
415+ dir.path(),
416+ id,
417+ "main",
418+ Path::new("/usr/bin/fabrica"),
419+ ObjectFormat::Sha256,
420+ )
421+ .unwrap();
422+
423+ let path = repo_path(dir.path(), id).unwrap();
424+ // git records the object format under extensions.objectformat.
425+ let out = Command::new("git")
426+ .args(["--git-dir"])
427+ .arg(&path)
428+ .args(["rev-parse", "--show-object-format"])
429+ .output()
430+ .unwrap();
431+ assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "sha256");
432+ // The server hooks are installed as for a SHA-1 repo.
433+ assert!(path.join("hooks").join("post-receive").exists());
434+ }
271435 }
crates/model/src/entity.rs +2 −0
@@ -388,6 +388,8 @@ pub struct Repo {
388388 pub visibility: Visibility,
389389 /// The branch shown by default and used for HEAD.
390390 pub default_branch: String,
391+ /// The object hash algorithm (`sha1` or `sha256`), fixed at creation.
392+ pub object_format: String,
391393 /// Whether issues are enabled for this repository.
392394 pub issues_enabled: bool,
393395 /// Whether pull requests are enabled for this repository.
crates/store/src/repos.rs +24 −2
@@ -12,8 +12,8 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
1313 /// The columns of `repos` in a fixed order, shared by every `SELECT`.
1414 const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \
15- default_branch, issues_enabled, pulls_enabled, next_iid, archived_at, size_bytes, \
16- pushed_at, created_at, updated_at";
15+ default_branch, object_format, issues_enabled, pulls_enabled, next_iid, archived_at, \
16+ size_bytes, pushed_at, created_at, updated_at";
1717
1818 /// Fields needed to create a repository. The server fills in the id, timestamps,
1919 /// and the derived size/push bookkeeping.
@@ -66,6 +66,7 @@ impl Store {
6666 description: new.description,
6767 visibility: new.visibility,
6868 default_branch: new.default_branch,
69+ object_format: "sha1".to_string(),
6970 issues_enabled: true,
7071 pulls_enabled: true,
7172 next_iid: 1,
@@ -101,6 +102,26 @@ impl Store {
101102 Ok(repo)
102103 }
103104
105+ /// Set a repository's object format (`sha1` | `sha256`). Called right after
106+ /// [`Store::create_repo`] when a non-default format was chosen, before the
107+ /// on-disk repository is created, so the row matches the on-disk repo.
108+ ///
109+ /// # Errors
110+ ///
111+ /// Returns [`StoreError::Query`] on a database failure.
112+ pub async fn set_object_format(
113+ &self,
114+ repo_id: &str,
115+ object_format: &str,
116+ ) -> Result<(), StoreError> {
117+ sqlx::query("UPDATE repos SET object_format = $1 WHERE id = $2")
118+ .bind(object_format)
119+ .bind(repo_id)
120+ .execute(&self.pool)
121+ .await?;
122+ Ok(())
123+ }
124+
104125 /// Resolve a repository by `(owner_id, path)`, or `None` if absent. This is
105126 /// the authoritative name→repo lookup; the on-disk tree is keyed by id.
106127 ///
@@ -404,6 +425,7 @@ impl Store {
404425 visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?)
405426 .unwrap_or(Visibility::Private),
406427 default_branch: row.try_get("default_branch")?,
428+ object_format: row.try_get("object_format")?,
407429 issues_enabled: self.get_bool(row, "issues_enabled")?,
408430 pulls_enabled: self.get_bool(row, "pulls_enabled")?,
409431 next_iid: row.try_get("next_iid")?,
crates/web/src/pages.rs +26 −1
@@ -274,6 +274,9 @@ pub struct NewRepoForm {
274274 /// Optional default branch override.
275275 #[serde(default)]
276276 default_branch: String,
277+ /// Object format (`sha1` | `sha256`).
278+ #[serde(default)]
279+ object_format: String,
277280 /// CSRF token.
278281 #[serde(rename = "_csrf")]
279282 csrf: String,
@@ -368,8 +371,22 @@ async fn create_repo_from_form(
368371 other => other.to_string(),
369372 })?;
370373
374+ let format = git::ObjectFormat::from_token(&form.object_format);
375+ if format != git::ObjectFormat::Sha1 {
376+ // Record the non-default format on the row before creating the on-disk repo.
377+ let _ = state
378+ .store
379+ .set_object_format(&repo.id, format.as_str())
380+ .await;
381+ }
371382 let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica"));
372- if let Err(e) = git::create_bare(&state.config.storage.repo_dir, &repo.id, &branch, &hook) {
383+ if let Err(e) = git::create_bare_with_format(
384+ &state.config.storage.repo_dir,
385+ &repo.id,
386+ &branch,
387+ &hook,
388+ format,
389+ ) {
373390 let _ = state.store.delete_repo(&repo.id).await;
374391 return Err(format!("Could not create the repository on disk: {e}"));
375392 }
@@ -406,6 +423,14 @@ fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str)
406423 label for="default_branch" { "Default branch" }
407424 input type="text" id="default_branch" name="default_branch"
408425 placeholder=(state.config.instance.default_branch);
426+ label for="object_format" { "Object format" }
427+ select id="object_format" name="object_format" {
428+ option value="sha1" selected { "SHA-1 (legacy, widely compatible)" }
429+ option value="sha256" { "SHA-256 (modern, experimental tooling support)" }
430+ }
431+ p class="muted field-hint" {
432+ "SHA-256 repositories are collision-resistant but not yet supported by all git clients and hosts. This cannot be changed later."
433+ }
409434 button class="btn btn-primary" type="submit" { "Create repository" }
410435 }
411436 }
crates/web/src/repo.rs +4 −0
@@ -791,6 +791,10 @@ async fn repo_settings_view(
791791 input type="checkbox" name="pulls_enabled" value="1"
792792 checked[ctx.repo.pulls_enabled]; " Pull requests"
793793 }
794+ p class="muted field-hint" {
795+ "Object format: " (ctx.repo.object_format.to_uppercase())
796+ " · fixed at creation."
797+ }
794798 }
795799 div class="settings-actions" {
796800 button class="btn btn-primary" type="submit" form="repo-general" { "Save changes" }
crates/web/src/tests.rs +49 −0
@@ -885,6 +885,55 @@ async fn pull_request_create_view_and_merge() {
885885 }
886886
887887 #[tokio::test]
888+async fn new_repo_can_use_sha256_object_format() {
889+ if std::process::Command::new("git")
890+ .arg("--version")
891+ .output()
892+ .is_err()
893+ {
894+ return;
895+ }
896+ let (state, app, _dir) = app_with_git().await;
897+ let cookie = login(&app).await;
898+ let token = cookie
899+ .split("fabrica_csrf=")
900+ .nth(1)
901+ .and_then(|s| s.split(';').next())
902+ .unwrap()
903+ .to_string();
904+
905+ let req = Request::builder()
906+ .method("POST")
907+ .uri("/new")
908+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
909+ .header(header::COOKIE, cookie)
910+ .body(Body::from(format!(
911+ "name=modern&visibility=private&default_branch=main&object_format=sha256&_csrf={token}"
912+ )))
913+ .unwrap();
914+ let res = app.oneshot(req).await.unwrap();
915+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
916+
917+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
918+ let repo = state
919+ .store
920+ .repo_by_owner_path(&ada.id, "modern")
921+ .await
922+ .unwrap()
923+ .unwrap();
924+ assert_eq!(repo.object_format, "sha256", "row records the format");
925+ // The on-disk repo is really SHA-256.
926+ let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap();
927+ let out = std::process::Command::new("git")
928+ .arg("--git-dir")
929+ .arg(&path)
930+ .args(["rev-parse", "--show-object-format"])
931+ .output()
932+ .unwrap();
933+ assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "sha256");
934+}
935+
936+#[tokio::test]
888937 async fn pulls_404_when_disabled() {
889938 let (state, app) = app().await;
890939 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
migrations/postgres/0007_repo_object_format.sql +3 −0
@@ -0,0 +1,3 @@
1+-- The object hash algorithm each repository was created with. Existing repos are
2+-- SHA-1; new repos may opt into SHA-256.
3+ALTER TABLE repos ADD COLUMN object_format TEXT NOT NULL DEFAULT 'sha1';
migrations/sqlite/0007_repo_object_format.sql +3 −0
@@ -0,0 +1,3 @@
1+-- The object hash algorithm each repository was created with. Existing repos are
2+-- SHA-1; new repos may opt into SHA-256.
3+ALTER TABLE repos ADD COLUMN object_format TEXT NOT NULL DEFAULT 'sha1';