Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ and reconciler, collaborators, branches) and F2b (smart-HTTP git transport via `
`pre-receive` ref policy, admission control, threat-model pass #1) and F3a (content reads: refs, tags, commits,
trees, contents, archives) and F3b (contents writes: create, update, delete as server-made commits) and F4a (branch protection
enforced on push, contents API and branch delete; commit statuses) and F4b (pull requests, reviews, comments, retention
refs) are built. Hosted repos are
refs) and F4c (merge with a crash-safe journal, required-check gating, kill -9 and protection-matrix tests) 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
48 changes: 46 additions & 2 deletions crates/forge/conformance/departures.json
Original file line number Diff line number Diff line change
Expand Up @@ -1004,10 +1004,12 @@
"match": "GET /api/v1/repos/acme/life/pulls/<id:path:pulls#3>",
"expect": {
"ignore_fields": [
"head.sha"
"head.sha",
"additions",
"changed_files"
]
},
"reason": "head.sha is the frozen merge-time sha in sc-forge; the notes below pin that"
"reason": "head.sha is the frozen merge-time sha in sc-forge; the notes below pin that; additions and changed_files are those frozen at merge (Gitea follows the recreated branch)"
},
{
"scenario": "O",
Expand Down Expand Up @@ -1525,5 +1527,47 @@
]
},
"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": "Q",
"exchange": 11,
"kind": "git",
"match": "force-protected-push-enabled",
"expect": {
"ok": false,
"message_contains": [
"force-push",
"pre-receive hook declined"
]
},
"reason": "sc-forge's hook words the refusal 'refusing to force-push protected branch <b>'; Gitea says 'gitea: branch main is protected from force push'. Both end in 'pre-receive hook declined'"
},
{
"scenario": "Q",
"exchange": 12,
"kind": "git",
"match": "delete-protected-push-enabled",
"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": "Q",
"exchange": 14,
"kind": "git",
"match": "delete-protected-push-disabled",
"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'"
}
]
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,D,E,F,H,I,J,K,L,M,N,O,P,Q,R,V,W "$@" || { echo "--- server log"; cat "$data/serve.log"; exit 1; }
--only A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,T,U,V,W "$@" || { echo "--- server log"; cat "$data/serve.log"; exit 1; }
52 changes: 0 additions & 52 deletions crates/forge/conformance/slice-pending.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,57 +10,5 @@
"GET /api/v1/orgs/acme/repos?limit=50"
]
]
},
"Q": {
"stop": [
8,
"origin/main~1"
],
"defer": [
[
5,
"POST /api/v1/repos/acme/bypass/pulls/<id:path:pulls#1>/merge"
]
]
},
"O": {
"defer": [
[
9,
"POST /api/v1/repos/acme/life/pulls/<id:path:pulls#2>/merge"
],
[
19,
"POST /api/v1/repos/acme/life/pulls/<id:path:pulls#3>/merge"
],
[
20,
"GET /api/v1/repos/acme/life/pulls/<id:path:pulls#3>"
],
[
21,
"merged_pr_head_sha_frozen"
],
[
22,
"merged_pr_head_sha_follows_new_branch"
],
[
23,
"PATCH /api/v1/repos/acme/life/pulls/<id:path:pulls#4>"
],
[
25,
"GET /api/v1/repos/acme/life/pulls/<id:path:pulls#4>"
],
[
26,
"PATCH /api/v1/repos/acme/life/pulls/<id:path:pulls#4>"
],
[
27,
"GET /api/v1/repos/acme/life/pulls/<id:path:pulls#4>"
]
]
}
}
7 changes: 4 additions & 3 deletions crates/forge/src/cgi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -811,9 +811,6 @@ impl Supervise {
let status = child.wait();
let ok = reason == "exited" && status.as_ref().is_ok_and(|s| s.success());
let _ = verdict.send(ok);
// The child is gone: release the transport registration, so a repository delete waiting
// for it is not also waiting on the sync below (which takes the shared lifecycle lock).
drop(reg);
let err = tail.and_then(|t| t.join().ok()).unwrap_or_default();
if !ok {
eprintln!(
Expand All @@ -824,6 +821,10 @@ impl Supervise {
} else if receive && marker.exists() {
if let Ok(conn) = crate::db::connect(&app.data_dir) {
let _ = repos::touch(&conn, repo_id, crate::timefmt::now());
// The child is gone and the stamp is written: release the transport registration, so
// a repository delete waiting for it is not also waiting on the sync below (which
// takes the shared lifecycle lock).
drop(reg);
crate::pulls::sync(&app.life, &conn, repo_id, crate::timefmt::now());
}
}
Expand Down
3 changes: 3 additions & 0 deletions crates/forge/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,9 @@ fn run_serve(a: ServeArgs) -> Result<()> {
&std::env::current_exe().context("cannot find the sc-forge binary")?,
)?;
let life = scl_forge::lifecycle::Lifecycle::new(&a.data_dir, git)?;
// A killed predecessor may have left git children running and their ref locks behind; they
// go before anything below looks at a repository (we hold the data-directory lock).
scl_forge::orphans::sweep(&std::fs::canonicalize(&a.data_dir)?, Some(life.git.bin()));
// 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() {
Expand Down
2 changes: 1 addition & 1 deletion crates/forge/src/content_write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ pub fn write_contents(ctx: &mut Ctx) -> Resp {

/// The address git will record: surrounding blanks are stripped by git, so strip them here too and the
/// response matches the commit.
fn commit_email(user: &crate::store::User) -> String {
pub(crate) fn commit_email(user: &crate::store::User) -> String {
let e = user.email.trim();
if e.is_empty() {
format!("{}@noreply.localhost", user.login)
Expand Down
28 changes: 28 additions & 0 deletions crates/forge/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,33 @@ const MIGRATIONS: &[&str] = &[
updated_unix INTEGER NOT NULL
);
CREATE INDEX issue_comments_by_issue ON issue_comments (repo_id, number, id);",
// 5: the merge journal. A row is written before the base branch moves; `refs/forge/merge/<id>/landed`
// is written right after, and a crash is resolved from the marker or, failing that, from the merge
// commit being part of the base's history (see `merges.rs`). `recovered` marks a row finished by
// recovery rather than by the request that started it.
// The first statement drops cached PR statistics computed before unrelated histories were
// compared against the base tip (they are derived data and are recomputed on demand).
"DELETE FROM mergeability_cache;
CREATE TABLE merge_intents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
pull_number INTEGER NOT NULL,
base_ref TEXT NOT NULL,
head_ref TEXT NOT NULL,
base_oid TEXT NOT NULL,
head_oid TEXT NOT NULL,
merge_oid TEXT NOT NULL,
merge_base TEXT NOT NULL DEFAULT '',
changed_files INTEGER NOT NULL,
additions INTEGER NOT NULL,
deletions INTEGER NOT NULL,
merger_id INTEGER NOT NULL REFERENCES users(id),
merged_unix INTEGER NOT NULL,
recovered INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL CHECK (state IN ('pending', 'done', 'aborted')),
created_unix INTEGER NOT NULL
);
CREATE UNIQUE INDEX merge_intents_one_pending ON merge_intents (repo_id, pull_number) WHERE state = 'pending';",
];

/// Every table the current schema creates; `/healthz` probes each, so a missing or unreadable one
Expand All @@ -179,6 +206,7 @@ pub const TABLES: &[&str] = &[
"mergeability_cache",
"reviews",
"issue_comments",
"merge_intents",
];

/// Database failures a caller reports to the operator.
Expand Down
2 changes: 2 additions & 0 deletions crates/forge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ pub mod hook;
pub mod http;
pub mod lifecycle;
pub mod limits;
pub mod merges;
pub mod names;
pub mod orphans;
pub mod policy;
pub mod protection;
pub mod pulls;
Expand Down
Loading
Loading