Skip to content

[vibecoded] switch to file-based git remote - #112

Merged
slinder1 merged 5 commits into
users/slinder1/I2283b2343300cd3b3352cf6b06719f18323e0c16from
users/slinder1/Iab65979d26cbf8da739fcad1faa7b454f7ba2440
Sep 2, 2026
Merged

slinder1 merged 5 commits into
users/slinder1/I2283b2343300cd3b3352cf6b06719f18323e0c16from
users/slinder1/Iab65979d26cbf8da739fcad1faa7b454f7ba2440

Conversation

@slinder1

@slinder1 slinder1 commented Sep 2, 2026

Copy link
Copy Markdown
Owner

tests no longer need to allocate TCP sockets, everything is
self-contained in the test's tmp-dir, and all the network traffic
required is proxied through a unix socket in that dir

Change-Id: Iab65979d26cbf8da739fcad1faa7b454f7ba2440

@slinder1
slinder1 changed the base branch from main to users/slinder1/If7509da2b42d17d22cfe28d501e1983ce7889c4f September 2, 2026 17:10
@slinder1

slinder1 commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author
🛠️ Initial changes (click to expand):
diff --git b/README.md a/README.md
@@ -24,9 +24,10 @@ deal with what I perceive as design defects in their model.
 
 The `praddle-test-server` workspace crate provides a minimal, stateful GitHub
 implementation for integration tests. It serves the GraphQL and REST operations
-used by Praddle over a Unix socket and a single bare repository using Git smart
-HTTP. This lets tests invoke the real `gh`, `git`, and `praddle` executables
-without accessing GitHub or changing the user's configuration.
+used by Praddle over a Unix socket and redirects Git operations directly to a
+local bare repository. This lets tests invoke the real `gh`, `git`, and
+`praddle` executables without accessing GitHub or changing the user's
+configuration.
 
 Run the suite with `cargo test --workspace`. See `test-server/README.md` for
 standalone server usage.
diff --git b/test-server/README.md a/test-server/README.md
@@ -3,12 +3,12 @@
 This crate implements the GitHub behavior used by Praddle. It serves:
 
 * GitHub GraphQL and REST requests over a Unix socket for the real `gh` CLI.
-* Smart Git HTTP fetches and atomic pushes over a loopback TCP socket for the real `git` CLI.
+* A bare repository for direct file-based fetches and atomic pushes by the real `git` CLI.
+* A `post-receive` hook that reports pushes over the Unix socket.
 * `GET /_test/state` for test assertions.
 
-After each successful receive-pack operation, the server marks every open pull
-request as merged when its head ref is reachable from its base ref, matching
-GitHub's behavior.
+After each push, the server marks every open pull request as merged when its
+head ref is reachable from its base ref, matching GitHub's behavior.
 
 Run it with a single repository identity and an existing bare Git repository:
 
@@ -16,10 +16,12 @@ Run it with a single repository identity and an existing bare Git repository:
 cargo run -p praddle-test-server -- OWNER REPO /path/to/repo.git --socket /tmp/praddle-github.sock
 ```
 
-The first stdout line contains the selected TCP address and Unix socket as JSON. Configure an isolated `gh` config with `http_unix_socket` set to the socket. Redirect Git without changing the repository URL with:
+The first stdout line contains the Unix socket as JSON. Configure an isolated
+`gh` config with `http_unix_socket` set to the socket. Redirect Git to the bare
+repository without changing the GitHub repository URL with:
 
 ```console
-git config --global url.http://ADDRESS/OWNER/REPO.insteadOf https://github.com/OWNER/REPO
+git config --global url.file:///path/to/repo.git.insteadOf https://github.com/OWNER/REPO
 ```
 
 Tests should prefer `TestHarness::start`. It creates a temporary bare remote and
diff --git b/test-server/src/lib.rs a/test-server/src/lib.rs
@@ -3,7 +3,7 @@ use axum::{
     Router,
     body::Bytes,
     extract::{OriginalUri, State},
-    http::{HeaderMap, HeaderValue, Method, StatusCode},
+    http::{HeaderValue, Method, StatusCode},
     response::{IntoResponse, Response},
     routing::any,
 };
@@ -13,17 +13,13 @@ use std::{
     collections::BTreeMap,
     ffi::OsStr,
     fs,
-    io::Write,
+    os::unix::fs::PermissionsExt,
     path::{Path, PathBuf},
-    process::{Command, Output, Stdio},
+    process::{Command, Output},
     sync::{Arc, Mutex},
 };
 use tempfile::TempDir;
-use tokio::{
-    net::{TcpListener, UnixListener},
-    sync::oneshot,
-    task::JoinHandle,
-};
+use tokio::{net::UnixListener, sync::oneshot, task::JoinHandle};
 
 #[derive(Clone, Debug, Deserialize, Serialize)]
 #[serde(rename_all = "camelCase")]
@@ -238,7 +234,6 @@ impl AppState {
 
 pub struct TestServer {
     state: AppState,
-    address: std::net::SocketAddr,
     socket: PathBuf,
     config_dir: TempDir,
     shutdown: Option<oneshot::Sender<()>>,
@@ -271,21 +266,16 @@ impl TestServer {
         let app = Router::new()
             .fallback(any(dispatch))
             .with_state(state.clone());
-        let tcp = TcpListener::bind("127.0.0.1:0").await?;
-        let address = tcp.local_addr()?;
         let unix = UnixListener::bind(&socket)?;
+        install_push_hook(&state.git_dir, &socket)?;
         let (shutdown, shutdown_rx) = oneshot::channel();
-        let tcp_app = app.clone();
-        let tcp_task = tokio::spawn(async move {
-            let _ = axum::serve(tcp, tcp_app)
+        let unix_task = tokio::spawn(async move {
+            let _ = axum::serve(unix, app)
                 .with_graceful_shutdown(async move {
                     let _ = shutdown_rx.await;
                 })
                 .await;
         });
-        let unix_task = tokio::spawn(async move {
-            let _ = axum::serve(unix, app).await;
-        });
 
         fs::write(
             config_dir.path().join("config.yml"),
@@ -298,11 +288,10 @@ impl TestServer {
 
         Ok(Self {
             state,
-            address,
             socket,
             config_dir,
             shutdown: Some(shutdown),
-            tasks: vec![tcp_task, unix_task],
+            tasks: vec![unix_task],
         })
     }
 
@@ -311,10 +300,7 @@ impl TestServer {
             "https://github.com/{}/{}",
             self.state.owner, self.state.repository
         );
-        let target = format!(
-            "http://{}/{}/{}",
-            self.address, self.state.owner, self.state.repository
-        );
+        let target = format!("file://{}", self.state.git_dir.display());
         command
             .env("GH_CONFIG_DIR", self.config_dir.path())
             .env("GH_TOKEN", "test-token")
@@ -329,10 +315,6 @@ impl TestServer {
         self.state.snapshot()
     }
 
-    pub fn address(&self) -> std::net::SocketAddr {
-        self.address
-    }
-
     pub fn socket(&self) -> &Path {
         &self.socket
     }
@@ -356,14 +338,31 @@ fn validate_name(kind: &str, value: &str) -> Result<()> {
     Ok(())
 }
 
+fn install_push_hook(git_dir: &Path, socket: &Path) -> Result<()> {
+    let path = git_dir.join("hooks/post-receive");
+    let socket = path_arg(socket)?;
+    let contents = format!(
+        "#!/bin/sh\ncurl --silent --show-error --fail --unix-socket {socket} --request POST http://localhost/_test/push >/dev/null\n"
+    );
+    fs::write(&path, contents)?;
+    let mut permissions = fs::metadata(&path)?.permissions();
+    permissions.set_mode(0o755);
+    fs::set_permissions(path, permissions)?;
+    Ok(())
+}
+
+fn path_arg(path: &Path) -> Result<String> {
+    let path = path.to_str().context("hook path is not UTF-8")?;
+    Ok(format!("'{}'", path.replace('\'', "'\"'\"'")))
+}
+
 async fn dispatch(
     State(state): State<AppState>,
     method: Method,
     OriginalUri(uri): OriginalUri,
-    headers: HeaderMap,
     body: Bytes,
 ) -> Response {
-    match dispatch_inner(&state, method, uri.path(), uri.query(), &headers, &body).await {
+    match dispatch_inner(&state, method, uri.path(), &body) {
         Ok(response) => response,
         Err((status, message)) => {
             (status, json!({ "message": message }).to_string()).into_response()
@@ -373,14 +372,11 @@ async fn dispatch(
 
 type HttpResult = Result<Response, (StatusCode, String)>;
 
-async fn dispatch_inner(
-    state: &AppState,
-    method: Method,
-    path: &str,
-    query: Option<&str>,
-    headers: &HeaderMap,
-    body: &[u8],
-) -> HttpResult {
+fn dispatch_inner(state: &AppState, method: Method, path: &str, body: &[u8]) -> HttpResult {
+    if path == "/_test/push" && method == Method::POST {
+        detect_merged(state).map_err(internal)?;
+        return Ok(StatusCode::NO_CONTENT.into_response());
+    }
     if path == "/_test/state" && method == Method::GET {
         return json_response(
             StatusCode::OK,
@@ -394,10 +390,6 @@ async fn dispatch_inner(
     if let Some(rest) = path.strip_prefix(&api_prefix) {
         return rest_api(state, method, rest, body);
     }
-    let git_prefix = format!("/{}/{}", state.owner, state.repository);
-    if let Some(git_path) = path.strip_prefix(&git_prefix) {
-        return git_http(state, method, git_path, query, headers, body).await;
-    }
     Err((
         StatusCode::NOT_FOUND,
         format!("unimplemented {method} {path}"),
@@ -730,84 +722,7 @@ fn assign_stack(model: &mut Model, stack: u64, numbers: &[u64]) {
     }
 }
 
-async fn git_http(
-    state: &AppState,
-    method: Method,
-    path: &str,
-    query: Option<&str>,
-    _headers: &HeaderMap,
-    body: &[u8],
-) -> HttpResult {
-    let path = path.strip_prefix(".git").unwrap_or(path);
-    let (service, advertise) = match (method, path, query) {
-        (Method::GET, "/info/refs", Some("service=git-upload-pack")) => ("git-upload-pack", true),
-        (Method::GET, "/info/refs", Some("service=git-receive-pack")) => ("git-receive-pack", true),
-        (Method::POST, "/git-upload-pack", _) => ("git-upload-pack", false),
-        (Method::POST, "/git-receive-pack", _) => ("git-receive-pack", false),
-        _ => {
-            return Err((
-                StatusCode::NOT_FOUND,
-                format!("unimplemented git request {path}"),
-            ));
-        }
-    };
-    let git_dir = state.git_dir.clone();
-    let input = body.to_vec();
-    let service_owned = service.to_owned();
-    let output = tokio::task::spawn_blocking(move || -> Result<std::process::Output> {
-        let mut command = Command::new("git");
-        command
-            .arg(service_owned.strip_prefix("git-").unwrap())
-            .arg("--stateless-rpc");
-        if advertise {
-            command.arg("--advertise-refs");
-        }
-        command
-            .arg(git_dir)
-            .stdin(Stdio::piped())
-            .stdout(Stdio::piped())
-            .stderr(Stdio::piped());
-        let mut child = command.spawn()?;
-        if !input.is_empty() {
-            child.stdin.take().unwrap().write_all(&input)?;
-        }
-        Ok(child.wait_with_output()?)
-    })
-    .await
-    .map_err(internal)?
-    .map_err(internal)?;
-    if !output.status.success() {
-        return Err((
-            StatusCode::INTERNAL_SERVER_ERROR,
-            String::from_utf8_lossy(&output.stderr).into_owned(),
-        ));
-    }
-    let mut bytes = Vec::new();
-    if advertise {
-        let announcement = format!("# service={service}\n");
-        bytes.extend(format!("{:04x}{announcement}", announcement.len() + 4).as_bytes());
-        bytes.extend(b"0000");
-    }
-    bytes.extend(output.stdout);
-    if service == "git-receive-pack" && !advertise {
-        detect_merged(state).await?;
-    }
-    let mut response = (StatusCode::OK, bytes).into_response();
-    response.headers_mut().insert(
-        "content-type",
-        HeaderValue::from_str(&format!(
-            "application/x-{service}-{}",
-            if advertise { "advertisement" } else { "result" }
-        ))
-        .unwrap(),
-    );
-    response
-        .headers_mut()
-        .insert("cache-control", HeaderValue::from_static("no-cache"));
-    Ok(response)
-}
-
-async fn detect_merged(state: &AppState) -> Result<(), (StatusCode, String)> {
+fn detect_merged(state: &AppState) -> Result<()> {
     let open_pull_requests: Vec<(u64, String, String)> = {
         let model = state.model.lock().unwrap();
         model
@@ -827,29 +742,22 @@ async fn detect_merged(state: &AppState) -> Result<(), (StatusCode, String)> {
         return Ok(());
     }
 
-    let git_dir = state.git_dir.clone();
-    let merged = tokio::task::spawn_blocking(move || -> Result<Vec<(u64, String, String)>> {
-        let mut merged = Vec::new();
-        for (number, head, base) in open_pull_requests {
-            let output = Command::new("git")
-                .args([
-                    "--git-dir",
-                    path(&git_dir)?,
-                    "merge-base",
-                    "--is-ancestor",
-                    &head,
-                    &base,
-                ])
-                .output()?;
-            if output.status.success() {
-                merged.push((number, head, base));
-            }
+    let mut merged = Vec::new();
+    for (number, head, base) in open_pull_requests {
+        let output = Command::new("git")
+            .args([
+                "--git-dir",
+                path(&state.git_dir)?,
+                "merge-base",
+                "--is-ancestor",
+                &head,
+                &base,
+            ])
+            .output()?;
+        if output.status.success() {
+            merged.push((number, head, base));
         }
-        Ok(merged)
-    })
-    .await
-    .map_err(internal)?
-    .map_err(internal)?;
+    }
 
     let mut model = state.model.lock().unwrap();
     for (number, head, base) in merged {
@@ -943,7 +851,6 @@ pub async fn serve(
     owner: String,
     repository: String,
     git_dir: PathBuf,
-    listen: &str,
     socket: &Path,
 ) -> Result<()> {
     validate_name("owner", &owner)?;
@@ -968,19 +875,12 @@ pub async fn serve(
     if socket.exists() {
         fs::remove_file(socket)?;
     }
-    let tcp = TcpListener::bind(listen)
-        .await
-        .with_context(|| format!("could not listen on {listen}"))?;
     let unix = UnixListener::bind(socket)
         .with_context(|| format!("could not listen on {}", socket.display()))?;
-    println!(
-        "{{\"address\":\"{}\",\"socket\":\"{}\"}}",
-        tcp.local_addr()?,
-        socket.display()
-    );
+    install_push_hook(&state.git_dir, socket)?;
+    println!("{{\"socket\":\"{}\"}}", socket.display());
     let app = Router::new().fallback(any(dispatch)).with_state(state);
     tokio::select! {
-        result = axum::serve(tcp, app.clone()) => result?,
         result = axum::serve(unix, app) => result?,
         _ = tokio::signal::ctrl_c() => {},
     }
diff --git b/test-server/src/main.rs a/test-server/src/main.rs
@@ -7,8 +7,6 @@ struct Args {
     owner: String,
     repository: String,
     git_dir: PathBuf,
-    #[arg(long, default_value = "127.0.0.1:0")]
-    listen: String,
     #[arg(long)]
     socket: PathBuf,
 }
@@ -16,12 +14,5 @@ struct Args {
 #[tokio::main]
 async fn main() -> Result<()> {
     let args = Args::parse();
-    praddle_test_server::serve(
-        args.owner,
-        args.repository,
-        args.git_dir,
-        &args.listen,
-        &args.socket,
-    )
-    .await
+    praddle_test_server::serve(args.owner, args.repository, args.git_dir, &args.socket).await
 }

@slinder1
slinder1 marked this pull request as ready for review September 2, 2026 17:10
@slinder1
slinder1 changed the base branch from users/slinder1/If7509da2b42d17d22cfe28d501e1983ce7889c4f to users/slinder1/I2283b2343300cd3b3352cf6b06719f18323e0c16 September 2, 2026 17:12
@slinder1
slinder1 merged commit 136b6be into main Sep 2, 2026
1 check passed
@slinder1
slinder1 deleted the users/slinder1/Iab65979d26cbf8da739fcad1faa7b454f7ba2440 branch September 2, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant