Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5a6817d
docs(forge): F2 spec decisions (default-branch authority, capacity re…
tonibergholm-codento Oct 23, 2026
8017727
docs(forge): F2a plan (repos API, lifecycle, collaborators, branches)
tonibergholm-codento Oct 23, 2026
5dee242
docs(forge): revise F2a plan after Opus and Sol review
tonibergholm-codento Oct 23, 2026
66059d3
docs(forge): tidy collaborator rules in the F2a plan
tonibergholm-codento Oct 23, 2026
62f36d4
feat(forge): F2a repo store, access resolution, name and branch-name …
tonibergholm-codento Oct 23, 2026
dc24db7
feat(forge): F2a git runner (wip)
tonibergholm-codento Oct 23, 2026
c330f73
feat(forge): F2a lifecycle, reconciler, process-group-safe runner (wip)
tonibergholm-codento Oct 23, 2026
e80dff8
feat(forge): F2a repository API, collaborators, branches, router acto…
tonibergholm-codento Oct 23, 2026
0fd155b
feat(forge): F2a e2e tests, conformance gate (A B C L N R), owner and…
tonibergholm-codento Oct 23, 2026
782fc6b
docs(forge): F2a decisions as built, roadmap, conformance gate
tonibergholm-codento Oct 23, 2026
d247ed2
docs(forge): tidy README sentence
tonibergholm-codento Oct 23, 2026
c3c3961
fix(forge): F2a review fixes (Opus, Sol)
tonibergholm-codento Oct 23, 2026
4420286
fix(forge): re-encode q strictly in search Link URLs
tonibergholm-codento Oct 23, 2026
b2d6c9f
chore(deps): bump ctutils 0.4.2 -> 0.4.5 (RUSTSEC-2026-0385)
tonibergholm-codento Oct 23, 2026
b433e5a
fix(forge): bot review fixes on #301
tonibergholm-codento Oct 23, 2026
862408b
fix(forge): Codex review fixes on #301
tonibergholm-codento Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -701,8 +701,9 @@ a Gitea-compatible Git forge (bare repos over smart HTTP, a recorded subset of
`/api/v1`, SQLite metadata) so a platform can swap its project Git host by
config. Design:
`docs/superpowers/specs/2026-10-22-p38-forge-design.md`. Slices F0 (contract
capture) through F6 (acceptance on the first deployment); F0 and F1 (server
skeleton, users, organizations, tokens, `admin` CLI) are built. Hosted repos are
capture) through F6 (acceptance on the first deployment); F0, F1 (server
skeleton, users, organizations, tokens, `admin` CLI) and F2a (repositories API, lifecycle
and reconciler, collaborators, branches) are built. Hosted repos are
bare Git and get none of the `.sc/` features.

Other follow-up work is tracked as GitHub issues and listed under Deferred
Expand Down
3 changes: 2 additions & 1 deletion crates/forge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ scl-crypto = { path = "../crypto" }
http-body-util = "0.1.5"
hyper = { version = "1", features = ["http1", "server"] }
hyper-util = { version = "0.1.21", features = ["tokio", "server"] }
rusqlite = { version = "0.40.2", features = ["bundled"] }
libc = "0.2.191"
rusqlite = { version = "0.40.2", features = ["bundled", "functions"] }
serde_json = "1.0.154"
thiserror = "2.0.18"
tokio = { version = "1.54.1", features = ["rt-multi-thread", "net", "io-util", "signal", "time", "macros", "sync"] }
Expand Down
4 changes: 2 additions & 2 deletions crates/forge/conformance/FINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ Recorded 2026-10-22 against `gitea/gitea:1.22.6-rootless` and `1.26.2-rootless`
letters match `fixtures/<version>/<letter>-*.json`. Every statement below was observed, not read
from source. "Both" means both versions answered the same.

Each version passes its own fixtures (22/22). Checking the 1.22.6 fixtures against 1.26.2 passes 3/22
(I, Q, V); every failure is a difference listed in the last table.
Each version passes its own fixtures (24/24). Checking the 1.22.6 fixtures against 1.26.2 passes only 2/24;
every failure is a difference listed in the last table.

## Headline answers

Expand Down
2 changes: 1 addition & 1 deletion crates/forge/conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ python3 -I run.py diff fixtures/1.22.6 fixtures/1.26.2 # offline version diff
python3 -I run.py show fixtures/1.26.2 --only G --body # what was recorded
```

Against the in-tree server use `./forge-check.sh` (it bootstraps an empty data directory with the `admin` CLI,
Against the in-tree server use `./forge-check.sh` (it replays scenarios `A,B,C,L,N,R`; the others wait for their routes, see `slice-pending.json` and the spec's "F2a decisions"; it bootstraps an empty data directory with the `admin` CLI,
starts `sc-forge serve` on a free loopback port, runs `check` with the departures and `slice-pending.json`, and
stops the server; CI runs it). `check --pending FILE` takes a JSON map `{scenario: [N, "METHOD /path"]}` (a bare `N` is also accepted): the
server is expected to answer the first N exchanges of that scenario, the scenario must then stop at the named setup
Expand Down
24 changes: 15 additions & 9 deletions crates/forge/conformance/departures.json
Original file line number Diff line number Diff line change
Expand Up @@ -690,13 +690,15 @@
"match": "PATCH /api/v1/repos/acme/pub1",
"expect": {
"ignore_fields": [
"private"
"private",
"internal"
],
"fields": {
"private": true
"private": true,
"internal": false
}
},
"reason": "sc-forge keeps private:true; only `private` may differ"
"reason": "sc-forge keeps private:true and internal:false (every repository is private, none is `internal`); Gitea flips both when private:false is PATCHed. Only `private` and `internal` may differ"
},
{
"scenario": "L",
Expand All @@ -705,13 +707,15 @@
"match": "PATCH /api/v1/repos/acme/pub1",
"expect": {
"ignore_fields": [
"private"
"private",
"internal"
],
"fields": {
"private": true
"private": true,
"internal": false
}
},
"reason": "sc-forge keeps private:true; only `private` may differ"
"reason": "sc-forge keeps private:true and internal:false (every repository is private, none is `internal`); Gitea flips both when private:false is PATCHed. Only `private` and `internal` may differ"
},
{
"scenario": "L",
Expand All @@ -720,13 +724,15 @@
"match": "GET /api/v1/repos/acme/pub1",
"expect": {
"ignore_fields": [
"private"
"private",
"internal"
],
"fields": {
"private": true
"private": true,
"internal": false
}
},
"reason": "GET after PATCH private:false still reports private:true"
"reason": "sc-forge keeps private:true and internal:false (every repository is private, none is `internal`); Gitea flips both when private:false is PATCHed. Only `private` and `internal` may differ"
},
{
"scenario": "H",
Expand Down
2 changes: 1 addition & 1 deletion crates/forge/conformance/forge-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ for _ in $(seq 50); do
done
python3 -I "$here/run.py" check --base-url "http://127.0.0.1:$port" --admin-token "$token" \
--fixtures "$here/fixtures/1.22.6" --departures "$here/departures.json" --pending "$here/slice-pending.json" \
--only A,B "$@" || { echo "--- server log"; cat "$data/serve.log"; exit 1; }
--only A,B,C,L,N,R "$@" || { echo "--- server log"; cat "$data/serve.log"; exit 1; }
5 changes: 3 additions & 2 deletions crates/forge/conformance/slice-pending.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"A": [6, "POST /api/v1/user/repos"],
"B": [25, "POST /api/v1/user/repos"]
"B": [25, "GET /api/v1/repos/alice/scoped/contents/README.md"],
"C": [27, "git clone"],
"N": [0, "git clone"]
}
69 changes: 56 additions & 13 deletions crates/forge/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pub const GITEA_VERSION: &str = "1.22.6";
const DEFAULT_PAGE: i64 = 30;
const MAX_PAGE: i64 = 50;

fn user_json(base: &str, u: &User) -> Value {
pub(crate) fn user_json(base: &str, u: &User) -> Value {
json!({
"id": u.id,
"login": u.login,
Expand Down Expand Up @@ -44,7 +44,7 @@ fn user_json(base: &str, u: &User) -> Value {
})
}

fn org_json(base: &str, o: &User) -> Value {
pub(crate) fn org_json(base: &str, o: &User) -> Value {
json!({
"id": o.id,
"name": o.login,
Expand All @@ -60,7 +60,7 @@ fn org_json(base: &str, o: &User) -> Value {
})
}

fn no_such_user(ctx: &Ctx, name: &str) -> Resp {
pub(crate) fn no_such_user(ctx: &Ctx, name: &str) -> Resp {
error(
&ctx.app.base,
404,
Expand All @@ -77,12 +77,12 @@ pub fn org_missing(app: &crate::routes::App, name: &str) -> Resp {
)
}

fn no_such_org(ctx: &Ctx, name: &str) -> Resp {
pub(crate) fn no_such_org(ctx: &Ctx, name: &str) -> Resp {
org_missing(ctx.app, name)
}

/// A JSON object body; an empty body is `{}`.
fn body(ctx: &Ctx) -> Result<Value, Resp> {
pub(crate) fn body(ctx: &Ctx) -> Result<Value, Resp> {
if ctx.req.body.is_empty() {
return Ok(json!({}));
}
Expand All @@ -92,11 +92,11 @@ fn body(ctx: &Ctx) -> Result<Value, Resp> {
}
}

fn str_field(v: &Value, key: &str) -> Option<String> {
pub(crate) fn str_field(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(String::from)
}

fn page_params(ctx: &Ctx) -> (i64, i64) {
pub(crate) fn page_params(ctx: &Ctx) -> (i64, i64) {
let num = |k: &str| ctx.req.param(k).and_then(|v| v.parse::<i64>().ok());
// Page is clamped so offset arithmetic cannot overflow.
let page = num("page")
Expand All @@ -111,11 +111,48 @@ fn page_params(ctx: &Ctx) -> (i64, i64) {
}

/// 200 with `X-Total-Count` and Gitea's `Link` header.
fn paged(ctx: &Ctx, path: &str, total: i64, page: i64, limit: i64, items: Vec<Value>) -> Resp {
pub(crate) fn paged(
ctx: &Ctx,
path: &str,
total: i64,
page: i64,
limit: i64,
items: Vec<Value>,
) -> Resp {
paged_with(ctx, path, total, page, limit, &[], items)
}

/// Percent-encode everything except the RFC 3986 unreserved characters, so a value can sit inside
/// a `<...>` link target without being able to close it or start another entry.
pub(crate) fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}

/// Like [`paged`], carrying `extra` query parameters (already strictly percent-encoded with [`pct_encode`], in the order
/// Gitea emits them: alphabetical after `limit` and `page`) into every `Link` URL, so following
/// a page link repeats the same query.
pub(crate) fn paged_with(
ctx: &Ctx,
path: &str,
total: i64,
page: i64,
limit: i64,
extra: &[(&str, String)],
items: Vec<Value>,
) -> Resp {
let tail: String = extra.iter().map(|(k, v)| format!("&{k}={v}")).collect();
let last = ((total + limit - 1) / limit).max(1);
let url = |p: i64, rel: &str| {
format!(
"<{}/api/v1{path}?limit={limit}&page={p}>; rel=\"{rel}\"",
"<{}/api/v1{path}?limit={limit}&page={p}{tail}>; rel=\"{rel}\"",
ctx.app.base
)
};
Expand Down Expand Up @@ -168,7 +205,7 @@ pub fn get_user(ctx: &mut Ctx) -> Resp {

/// Visible to the caller: public and limited organizations to any signed-in
/// caller, private ones to owners and site admins.
fn org_visible(ctx: &Ctx, org: &User) -> rusqlite::Result<bool> {
pub(crate) fn org_visible(ctx: &Ctx, org: &User) -> rusqlite::Result<bool> {
let me = &ctx.who.user;
Ok(org.visibility != "private" || me.is_admin || store::is_org_owner(ctx.conn, org.id, me.id)?)
}
Expand Down Expand Up @@ -236,7 +273,13 @@ fn name_error(label: &str, name: &str, e: NameError) -> String {
}
}

fn valid_visibility(v: &str) -> bool {
/// An email must be usable as a commit identity too (it reaches git through the environment):
/// no control characters and no angle brackets.
fn email_ok(e: &str) -> bool {
e.contains('@') && !e.chars().any(|c| c.is_control() || matches!(c, '<' | '>'))
}

pub(crate) fn valid_visibility(v: &str) -> bool {
matches!(v, "public" | "limited" | "private")
}

Expand Down Expand Up @@ -308,7 +351,7 @@ pub fn admin_create_user(ctx: &mut Ctx) -> Resp {
if email.is_empty() {
return error(&base, 422, "[Email]: Required");
}
if !email.contains('@') {
if !email_ok(&email) {
return error(&base, 422, "[Email]: Email");
}
if password.is_empty() {
Expand Down Expand Up @@ -365,7 +408,7 @@ pub fn admin_patch_user(ctx: &mut Ctx) -> Resp {
return error(&base, 422, "[Visibility]: invalid visibility");
}
let email = str_field(&b, "email");
if email.as_deref().is_some_and(|e| !e.contains('@')) {
if email.as_deref().is_some_and(|e| !email_ok(e)) {
return error(&base, 422, "[Email]: Email");
}
let password_hash = match str_field(&b, "password") {
Expand Down
24 changes: 23 additions & 1 deletion crates/forge/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ struct ServeArgs {
/// Largest JSON request body, in bytes.
#[arg(long, default_value_t = 1 << 20)]
max_body_bytes: usize,
/// Concurrent `git` child processes (a busy server answers 503 rather than queueing).
#[arg(long, default_value_t = 8)]
git_concurrency: usize,
/// Concurrent password verifications (Argon2 is memory-hard).
#[arg(long, default_value_t = 4)]
argon2_concurrency: usize,
Expand Down Expand Up @@ -225,8 +228,27 @@ fn run_serve(a: ServeArgs) -> Result<()> {
header: a.trusted_proxy_header.map(|h| h.to_ascii_lowercase()),
cidrs,
};
// One server per data directory, taken before anything touches it (migrations included): the
// lock is held until this function returns.
let _data_lock =
scl_forge::lifecycle::DirLock::acquire(&a.data_dir).map_err(anyhow::Error::msg)?;
db::open_for_serve(&a.data_dir)?;
let app = make_app(a.data_dir, &public_url, proxies, a.argon2_concurrency);
let git_bin =
scl_forge::gitproc::find_git().context("git not found: install git or set SC_FORGE_GIT")?;
let git = std::sync::Arc::new(scl_forge::gitproc::Git::new(
&a.data_dir,
git_bin,
a.git_concurrency,
)?);
let git_version = scl_forge::gitproc::check_version(&git).map_err(anyhow::Error::msg)?;
eprintln!("sc-forge: {git_version}");
let life = scl_forge::lifecycle::Lifecycle::new(&a.data_dir, git)?;
// Resolve whatever a crash left, before the listener is bound.
let report = life.reconcile(&db::connect(&a.data_dir)?, scl_forge::timefmt::now())?;
if report != scl_forge::lifecycle::Report::default() {
eprintln!("sc-forge: reconciled {report:?}");
}
let app = make_app(a.data_dir, &public_url, proxies, a.argon2_concurrency, life);
let limits = Limits {
max_body: a.max_body_bytes,
max_connections: a.max_connections,
Expand Down
33 changes: 33 additions & 0 deletions crates/forge/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,33 @@ const MIGRATIONS: &[&str] = &[
expires_unix INTEGER,
UNIQUE (user_id, name)
);",
// 2: repositories (named by immutable id on disk) and collaborators. `private` is not
// stored: every repository is private.
"CREATE TABLE repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
lower_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
website TEXT NOT NULL DEFAULT '',
default_branch TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('creating', 'live', 'deleting')),
created_unix INTEGER NOT NULL,
updated_unix INTEGER NOT NULL,
UNIQUE (owner_id, lower_name)
);
CREATE TABLE collaborators (
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mode TEXT NOT NULL CHECK (mode IN ('read', 'write', 'admin')),
PRIMARY KEY (repo_id, user_id)
Comment on lines +67 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include migration-2 tables in readiness checks

This migration adds the repos and collaborators tables, but database_ready still probes only users, org_members, and tokens. If either new table is missing or unreadable while user_version remains 2, repository routes fail with 500 while both health endpoints continue returning 200, so deployment monitoring cannot detect that the newly added feature is unusable; extend the readiness table probe to cover the complete current schema.

Useful? React with 👍 / 👎.

);",
];

/// Every table the current schema creates; `/healthz` probes each, so a missing or unreadable one
/// (whatever the recorded version says) reads as not ready.
pub const TABLES: &[&str] = &["users", "org_members", "tokens", "repos", "collaborators"];

/// Database failures a caller reports to the operator.
#[derive(Debug, thiserror::Error)]
pub enum DbError {
Expand Down Expand Up @@ -81,6 +106,14 @@ pub fn connect(data_dir: &Path) -> Result<Connection, DbError> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "FULL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
// SQLite's built-in lower() folds ASCII only; searches need Unicode-aware case folding.
conn.create_scalar_function(
"ulower",
1,
rusqlite::functions::FunctionFlags::SQLITE_UTF8
| rusqlite::functions::FunctionFlags::SQLITE_DETERMINISTIC,
|ctx| Ok(ctx.get::<String>(0)?.to_lowercase()),
)?;
Ok(conn)
}

Expand Down
Loading
Loading