Repository navigation
[vibecoded] switch to file-based git remote - #112
Merged
slinder1 merged 5 commits intoSep 2, 2026
Conversation
slinder1
changed the base branch from
main
to
users/slinder1/If7509da2b42d17d22cfe28d501e1983ce7889c4f
September 2, 2026 17:10
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
marked this pull request as ready for review
September 2, 2026 17:10
slinder1
changed the base branch from
users/slinder1/If7509da2b42d17d22cfe28d501e1983ce7889c4f
to
users/slinder1/I2283b2343300cd3b3352cf6b06719f18323e0c16
September 2, 2026 17:12
slinder1
deleted the
users/slinder1/Iab65979d26cbf8da739fcad1faa7b454f7ba2440
branch
September 2, 2026 18:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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