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
b5b4f0b
docs(forge): F2b plan (smart-HTTP git transport, hooks, ref policy, a…
tonibergholm-codento Oct 23, 2026
7c74a70
docs(forge): revise F2b plan after Opus and Sol review
tonibergholm-codento Oct 23, 2026
70cb322
feat(forge): F2b ref policy, pre-receive hook, read-only database ope…
tonibergholm-codento Oct 23, 2026
9fcf0a0
feat(forge): F2b transport whitelist, answers, CGI head parser, reser…
tonibergholm-codento Oct 23, 2026
4579b32
fix(forge): result_unit_err lint, lazy reservation guard
tonibergholm-codento Oct 23, 2026
da8dae4
feat(forge): F2b CGI transport (streamed, supervised), registry, admi…
tonibergholm-codento Oct 23, 2026
7bf54c5
test(forge): F2b real-git e2e suite (credentials, whitelist, policy, …
tonibergholm-codento Oct 23, 2026
94290a3
feat(forge): F2b gate (J K M V join), departures, defer entries, flag…
tonibergholm-codento Oct 23, 2026
0455935
docs(forge): F2b threat model, spec as built, roadmap; more transport…
tonibergholm-codento Oct 23, 2026
e08b4f7
fix(forge): F2b review fixes (private cache policy, gzip inflated bef…
tonibergholm-codento Oct 23, 2026
e019b08
fix(forge): overall deadline on the gzip pre-read; no allocation betw…
tonibergholm-codento Oct 23, 2026
eaa3cdf
fix(forge): shutdown refusals are 503, SIGTERM grace waits for stderr…
tonibergholm-codento Oct 23, 2026
2452bf3
fix(forge): stamp only on the push RPC, Unicode email match, propagat…
tonibergholm-codento Oct 23, 2026
c2387bf
fix(forge): credit a push's own bytes in reservation growth; credenti…
tonibergholm-codento Oct 23, 2026
cdf0143
fix(forge): stamp only a push the hook accepted; credentials and meth…
tonibergholm-codento Oct 23, 2026
adbb20b
fix(forge): clear stale accept markers before spawn, retry EINTR on s…
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
20 changes: 16 additions & 4 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 @@ -702,8 +702,9 @@ a Gitea-compatible Git forge (bare repos over smart HTTP, a recorded subset of
config. Design:
`docs/superpowers/specs/2026-10-22-p38-forge-design.md`. Slices F0 (contract
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
skeleton, users, organizations, tokens, `admin` CLI), F2a (repositories API, lifecycle
and reconciler, collaborators, branches) and F2b (smart-HTTP git transport via `git http-backend`,
`pre-receive` ref policy, admission control, threat-model pass #1) 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
1 change: 1 addition & 0 deletions crates/forge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ scl-crypto = { path = "../crypto" }
# these out of other targets stops the Windows workspace check from compiling
# bundled SQLite for an empty crate.
[target.'cfg(unix)'.dependencies]
flate2 = "1.1.10"
http-body-util = "0.1.5"
hyper = { version = "1", features = ["http1", "server"] }
hyper-util = { version = "0.1.21", features = ["tokio", "server"] }
Expand Down
115 changes: 110 additions & 5 deletions crates/forge/conformance/departures.json
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@
"expect": {
"status": 404,
"message_contains": [
"not found"
"couldn't be found"
],
"ignore_body": true
},
Expand All @@ -904,7 +904,7 @@
"expect": {
"status": 404,
"message_contains": [
"not found"
"couldn't be found"
],
"ignore_body": true,
"ignore_headers": [
Expand Down Expand Up @@ -950,7 +950,7 @@
"expect": {
"status": 404,
"message_contains": [
"not found"
"couldn't be found"
]
},
"reason": "the new name does not exist"
Expand All @@ -963,7 +963,7 @@
"expect": {
"status": 404,
"message_contains": [
"not found"
"couldn't be found"
],
"ignore_body": true
},
Expand All @@ -977,7 +977,7 @@
"expect": {
"status": 404,
"message_contains": [
"not found"
"couldn't be found"
],
"ignore_body": true
},
Expand Down Expand Up @@ -1336,5 +1336,110 @@
]
},
"reason": "the health body names this server and reports only the checks it has (database), not Gitea's description or cache check; status and content type stay compared"
},
{
"scenario": "C",
"exchange": 30,
"kind": "git",
"match": "push-read-collab",
"expect": {
"ok": false,
"message_contains": [
"forbidden"
]
},
"reason": "sc-forge refuses a read-only collaborator's push at the advertisement with 403 Forbidden; Gitea advertises and then declines in pre-receive"
},
{
"scenario": "J",
"exchange": 62,
"kind": "git",
"match": "delete-default-branch-by-push",
"expect": {
"ok": false,
"message_contains": [
"default branch",
"pre-receive hook declined"
]
},
"reason": "sc-forge's hook words the refusal 'refusing to delete the default branch'; Gitea says 'gitea: branch main is the default branch and cannot be deleted'. Both end in 'pre-receive hook declined'"
},
{
"scenario": "K",
"exchange": 34,
"kind": "http",
"match": "GET /acme/vis.git/info/refs?service=git-receive-pack",
"expect": {
"status": 403,
"message_contains": [
"forbidden"
],
"ignore_body": true,
"ignore_headers": [
"content-type"
]
},
"reason": "sc-forge refuses a read-only collaborator's receive-pack advertisement with 403; Gitea advertises (200) and declines the push in pre-receive [ignore_body: the recorded 200 body is the advertisement]"
},
{
"scenario": "V",
"exchange": 5,
"kind": "note",
"match": "v2_advert_first_bytes",
"expect": {
"value_prefix": "000eversion 2\\n"
},
"reason": "stock git's protocol v2 advertisement has no '# service=' preamble; Gitea prepends one. Git clients accept both (the rest of the note carries the git version and is not pinned)"
},
{
"scenario": "N",
"exchange": 13,
"kind": "http",
"expect": {
"status": 200,
"fields": {
"empty": true,
"default_branch": "main"
}
},
"reason": "the row is the authority for the default branch: sc-forge never moves HEAD or the default on a push, so a default that was unborn stays unborn (Gitea's first-push heuristic is not copied)",
"match": "GET /api/v1/repos/acme/fresh1"
},
{
"scenario": "N",
"exchange": 22,
"kind": "http",
"expect": {
"status": 200,
"fields": {
"empty": true,
"default_branch": "develop"
}
},
"reason": "the row is the authority for the default branch: sc-forge never moves HEAD or the default on a push, so a default that was unborn stays unborn (Gitea's first-push heuristic is not copied)",
"match": "GET /api/v1/repos/acme/fresh3"
},
{
"scenario": "N",
"exchange": 26,
"kind": "http",
"expect": {
"status": 200,
"fields": {
"default_branch": "develop"
}
},
"reason": "the row is the authority for the default branch: sc-forge never moves HEAD or the default on a push, so a default that was unborn stays unborn (Gitea's first-push heuristic is not copied)",
"match": "GET /api/v1/repos/acme/fresh3"
},
{
"scenario": "N",
"exchange": 16,
"kind": "note",
"match": "ls-remote-symref-trunk-first_stdout",
"expect": {
"value": ""
},
"reason": "HEAD stays on the unborn default branch, so ls-remote --symref HEAD prints nothing (Gitea moved HEAD to the first pushed branch)"
}
]
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,C,L,N,R "$@" || { echo "--- server log"; cat "$data/serve.log"; exit 1; }
--only A,B,C,J,K,L,M,N,R,V "$@" || { echo "--- server log"; cat "$data/serve.log"; exit 1; }
34 changes: 29 additions & 5 deletions crates/forge/conformance/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ def dep_indexes(spec, expected):
EXPECT_KEYS = {
"http": {"status", "message_contains", "fields", "ignore_fields", "ignore_headers", "ignore_body", "skip"},
"git": {"ok", "message_contains", "skip"},
"note": {"value", "skip"},
"note": {"value", "value_prefix", "skip"},
}


Expand Down Expand Up @@ -461,7 +461,13 @@ def apply_override(exp, act, ov, bij, match=None):
if act["kind"] == "note":
if "value" in e and not eq_val(e["value"], act["value"], bij):
d.append(("fail", f"unexpected: note {act['key']} {e['value']!r} expected, got {act['value']!r}"))
if "value_prefix" in e and not (isinstance(act["value"], str) and act["value"].startswith(e["value_prefix"])):
d.append(("fail", f"unexpected: note {act['key']} should start with {e['value_prefix']!r}, got {act['value']!r}"))
elif act["kind"] == "git":
# a departure replaces the outcome, never the command: the step must still run the recorded line
bij.new_exchange()
if exp.get("cmd") is not None and not eq_str(exp["cmd"], act["cmd"], bij, [], ""):
return [("fail", f"git command {exp['cmd']!r} expected, got {act['cmd']!r}")]
if "ok" in e and act["ok"] != e["ok"]:
d.append(("fail", f"unexpected: git ok={e['ok']} expected, got {act['ok']}: {act['output'][:160]!r}"))
txt = act["output"].lower()
Expand Down Expand Up @@ -525,6 +531,7 @@ def __init__(self, base, admin_token, mode):
self.cursor = 0
self.diffs = [] # (scenario, index, label, [(level, text)])
self.overrides = {} # fixture index -> departure entry, check mode only
self.defers = {} # fixture index -> exchange identity: runs, but a mismatch waits for a later slice
self.verbose = False
self.base_ready = False
self.first_read = None
Expand Down Expand Up @@ -560,7 +567,7 @@ def scrub(self, s):
return re.sub(r"\b%s\b" % re.escape(self.admin_user), "<admin>", s)

def ns(self, s):
s = re.sub(r"(://)[^/@\s\"':]*:[^/@\s\"']*@", r"\1<creds>@", self.scrub(s)) # user:password@ only (ssh://git@ stays)
s = re.sub(r"https?(://)[^/@\s\"':?#]*:[^/@\s\"'?#]*@", r"http\1<creds>@", self.scrub(s)) # user:password@ only (ssh://git@ stays); https checks like http
# pkt-line adverts glue a 4-hex length in front of the id ("0146<sha> HEAD"), so \b misses it
s = re.sub(r"[0-9a-f]{40}(?= (?:HEAD|refs/|capabilities))", lambda m: self.sha_ph(m.group(0)), s)
s = SHA_RE.sub(lambda m: self.sha_ph(m.group(0)), s)
Expand Down Expand Up @@ -620,6 +627,14 @@ def push_ex(self, ex):
ds = apply_override(exp, ex, self.overrides[i], self.bij)
else:
ds = cmp_exchange(exp, ex, self.bij)
if i in self.defers:
bad = [x for x in ds if x[0] == "fail"]
if ex_ident(exp) != ex_ident(ex) or exp["kind"] != ex["kind"]:
# a deferral waits for a later slice's *answer*; the call itself stays pinned
ds = [("fail", f"deferred exchange is {ex_ident(exp)!r} in the fixture but the scenario made {ex_ident(ex)!r}")]
else:
ds = ([("honoured", "deferred: this route belongs to a later slice")] if bad else
[("fail", "deferred exchange now passes: remove it from the pending file")])
self.diffs.append((i, ex_label(ex), ds))

def note(self, key, value, strict=True):
Expand Down Expand Up @@ -938,7 +953,7 @@ def run_all(c, only, out=None, fixtures=None, departures=(), pending=None):
c.ex, c.cursor, c.diffs = [], 0, []
c.reset_scenario()
c.expected, skip, stale, deferred, ndeferred, stop = [], None, [], None, 0, None
c.overrides, c.web_skipped = {}, 0
c.overrides, c.web_skipped, c.defers = {}, 0, {}
if c.mode == "check" and selected:
fp = os.path.join(fixtures, fname)
if not os.path.exists(fp):
Expand Down Expand Up @@ -973,8 +988,17 @@ def run_all(c, only, out=None, fixtures=None, departures=(), pending=None):
f"cannot match the recorded one, so it needs ignore_body with a reason")
continue
c.overrides[i] = dict(dep, match=matches[k] or got)
if sid in pending:
deferred, stop = pending[sid] if isinstance(pending[sid], list) else (pending[sid], None)
for i, ident in (pending.get(sid) or {}).get("defer", []) if isinstance(pending.get(sid), dict) else []:
if not 0 <= i < len(c.expected) or ex_ident(c.expected[i]) != ident:
stale.append(f"stale defer #{i}: {fname} has {ex_ident(c.expected[i])!r} there, the pending file says {ident!r}"
if 0 <= i < len(c.expected) else f"stale defer #{i}: {fname} has no such exchange")
else:
c.defers[i] = ident
spec = pending.get(sid)
if isinstance(spec, dict): # {"stop": [N, text], "defer": [[index, "METHOD /path"], ...]}
spec = spec.get("stop")
if spec is not None:
deferred, stop = spec if isinstance(spec, list) else (spec, None)
ndeferred = max(0, len(c.expected) - deferred)
if not 0 <= deferred < len(c.expected):
# nothing is deferred: the entry is stale (the scenario is whole) and must not hide an error
Expand Down
5 changes: 3 additions & 2 deletions crates/forge/conformance/slice-pending.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"B": [25, "GET /api/v1/repos/alice/scoped/contents/README.md"],
"C": [27, "git clone"],
"N": [0, "git clone"]
"J": {"defer": [[56, "GET /api/v1/repos/acme/smart/tags"], [57, "GET /api/v1/repos/acme/smart/git/refs/tags"], [59, "GET /api/v1/repos/acme/smart/tags"]]},
"K": {"defer": [[21, "GET /api/v1/repos/acme/vis/contents"], [26, "GET /api/v1/repos/acme/ghost/contents"], [31, "GET /api/v1/repos/acme/vis/contents"], [36, "GET /api/v1/repos/acme/ghost/contents"], [55, "GET /api/v1/orgs/acme/repos"], [56, "GET /api/v1/orgs/acme/repos?limit=50"]]},
"N": {"defer": [[6, "GET /api/v1/repos/acme/dflt/contents"]]}
}
Loading
Loading