diff --git a/README.md b/README.md
index b17c4c8..c3cd33e 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,9 @@ Created becasue github is so unusable and [unreliable](https://red-squares.cian.
Supported platforms: GitHub, GitLab, Gitea, Forgejo
+> [!NOTE]
+> Meow
+
## Install
### Option 1. Install from source
@@ -36,7 +39,9 @@ docker compose up -d --build
docker compose run --rm --entrypoint nano refray /data/config/refray/config.toml
```
-## Configure
+## Usage
+
+### 1. Configure
Run the interactive configuration wizard:
@@ -44,7 +49,45 @@ Run the interactive configuration wizard:
refray config
```
-## One-time Sync
+Example Config
+
+```toml
+[[sites]]
+name = "github"
+provider = "github"
+base_url = "https://github.com"
+token = { env = "GITHUB_TOKEN" }
+
+[[sites]]
+name = "gitea"
+provider = "gitea"
+base_url = "https://gitea.example.com"
+token = { env = "GITEA_TOKEN" }
+
+[[mirrors]]
+name = "personal"
+sync_visibility = "all"
+repo_whitelist = ["^important-"]
+repo_blacklist = ["-archive$"]
+create_missing = true
+visibility = "private"
+allow_force = false
+conflict_resolution = "auto_rebase_pull_request"
+
+[[mirrors.endpoints]]
+site = "github"
+kind = "user"
+namespace = "hykilpikonna"
+
+[[mirrors.endpoints]]
+site = "gitea"
+kind = "user"
+namespace = "azalea"
+```
+
+
+
+### 2. One-time Sync
Run all configured mirror groups:
@@ -88,54 +131,40 @@ refray sync --jobs 8
-## Service & Webhooks
+### 3. Service & Webhooks
You can run `refray` as a service that listens for webhook events and runs full sync periodically. This is the recommended way to run `refray`.
-If you want to use webhooks, you need to expose port 8787 to a public URL that can be accessed by the git provider (e.g. using port forwarding, reverse proxy, or cloudflare tunnel).
+> [!NOTE]
+> If you want to use webhooks, you need to expose port 8787 to a public URL that can be accessed by the git provider.
+> This can be done using e.g. port forwarding, reverse proxy, cloudflare tunnel, or tailscale funnel.
-Start the receiver:
+Start the service:
```sh
refray serve
```
-Expose that listener with your reverse proxy or tunnel, then install repository webhooks. If `[webhook]` is configured, the URL and secret can come from config:
+Install webhooks on all repos:
```sh
refray webhook install
```
-
-Useful install filters:
-
-```sh
-refray webhook install \
- --url https://mirror.example.com/webhook \
- --secret-env REFRAY_WEBHOOK_SECRET \
- --group personal \
- --repo-pattern '^important-'
-```
-
-The receiver accepts `POST /` and `POST /webhook`. It verifies GitHub/Gitea HMAC SHA-256 signatures and GitLab webhook tokens, then queues `refray sync --group --repo-pattern '^$'` internally. Duplicate events for the same group/repo are coalesced while a job is queued or running. Sync jobs are serialized inside the receiver so the local ref and failure caches stay consistent.
-
-When `[webhook].install = true`, normal `refray sync` also checks webhook installation status and installs missing webhooks for repositories that have not been recorded yet. Installation status is stored in `webhook-state.toml` under the work directory.
+Webhook install uses `[webhook].url` and `[webhook].secret` from your config.
To uninstall webhooks previously installed by `refray`:
+> [!WARNING]
+> If you want to stop using `refray`, make sure you run this! Otherwise, all of your repos will keep trying to send webhooks to the URL.
+
```sh
refray webhook uninstall
```
-Manual `webhook uninstall` checks repositories on the provider instead of trusting only local state. To uninstall one repository exactly:
-
-```sh
-refray webhook uninstall important-repo
-```
-
To move installed hooks to a new public URL, use `webhook update`. It removes hooks matching the current configured `[webhook].url`, installs the new URL, updates `[webhook].url` in the config, and refreshes local webhook state:
```sh
-refray webhook update --url https://new.example.com/webhook
+refray webhook update https://new.example.com/webhook
```
Serve can also run periodic full syncs. The interval can be configured in `[webhook].full_sync_interval_minutes` or overridden at startup:
@@ -183,42 +212,6 @@ Branch deletion follows the same rule at branch scope: if a branch existed on ev
Tags are fetched into provider-specific cache refs and pushed only when the tag object agrees across providers or exists on one side. Divergent tags are skipped and reported. Tag deletion is not propagated.
-## Example Config
-
-```toml
-[[sites]]
-name = "github"
-provider = "github"
-base_url = "https://github.com"
-token = { env = "GITHUB_TOKEN" }
-
-[[sites]]
-name = "gitea"
-provider = "gitea"
-base_url = "https://gitea.example.com"
-token = { env = "GITEA_TOKEN" }
-
-[[mirrors]]
-name = "personal"
-sync_visibility = "all"
-repo_whitelist = ["^important-"]
-repo_blacklist = ["-archive$"]
-create_missing = true
-visibility = "private"
-allow_force = false
-conflict_resolution = "auto_rebase_pull_request"
-
-[[mirrors.endpoints]]
-site = "github"
-kind = "user"
-namespace = "hykilpikonna"
-
-[[mirrors.endpoints]]
-site = "gitea"
-kind = "user"
-namespace = "azalea"
-```
-
## Testing
Run the normal, non-destructive test suite:
diff --git a/src/main.rs b/src/main.rs
index 90f219f..06c3ca3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -89,50 +89,24 @@ enum WebhookCommand {
#[derive(Args, Debug)]
struct WebhookInstallCommand {
- #[arg(value_name = "REPO", conflicts_with = "repo_pattern")]
- repo: Option,
- #[arg(long, value_name = "URL")]
- url: Option,
- #[arg(long, conflicts_with = "secret_env")]
- secret: Option,
- #[arg(long, value_name = "ENV", conflicts_with = "secret")]
- secret_env: Option,
- #[arg(long, value_name = "NAME")]
- group: Option,
- #[arg(long, value_name = "REGEX")]
- repo_pattern: Option,
#[arg(long)]
dry_run: bool,
- #[arg(long, value_name = "PATH")]
- work_dir: Option,
#[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")]
jobs: usize,
}
#[derive(Args, Debug)]
struct WebhookUninstallCommand {
- #[arg(value_name = "REPO")]
- repo: Option,
- #[arg(long, value_name = "URL")]
- url: Option,
- #[arg(long, value_name = "NAME")]
- group: Option,
#[arg(long)]
dry_run: bool,
- #[arg(long, value_name = "PATH")]
- work_dir: Option,
#[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")]
jobs: usize,
}
#[derive(Args, Debug)]
struct WebhookUpdateCommand {
- #[arg(long, value_name = "URL")]
+ #[arg(value_name = "URL")]
url: String,
- #[arg(long, conflicts_with = "secret_env")]
- secret: Option,
- #[arg(long, value_name = "ENV", conflicts_with = "secret")]
- secret_env: Option,
#[arg(long)]
dry_run: bool,
#[arg(long, value_name = "PATH")]
@@ -199,33 +173,28 @@ fn main() -> Result<()> {
}
Command::Webhook(WebhookCommand::Install(command)) => {
let config = load_config(&config_path)?;
- let secret = resolve_webhook_secret(&config, command.secret, command.secret_env)?;
- let url = resolve_webhook_url(&config, command.url)?;
+ let secret = resolve_config_webhook_secret(&config)?;
+ let url = resolve_config_webhook_url(&config)?;
install_webhooks(
&config,
WebhookInstallOptions {
url,
secret,
- group: command.group,
- repo: command.repo,
- repo_pattern: command.repo_pattern,
dry_run: command.dry_run,
- work_dir: command.work_dir,
+ work_dir: None,
jobs: command.jobs,
},
)
}
Command::Webhook(WebhookCommand::Uninstall(command)) => {
let config = load_config(&config_path)?;
- let url = resolve_webhook_url(&config, command.url)?;
+ let url = resolve_config_webhook_url(&config)?;
uninstall_webhooks(
&config,
WebhookUninstallOptions {
url,
- group: command.group,
- repo: command.repo,
dry_run: command.dry_run,
- work_dir: command.work_dir,
+ work_dir: None,
jobs: command.jobs,
},
)
@@ -239,7 +208,7 @@ fn main() -> Result<()> {
.ok_or_else(|| {
anyhow::anyhow!("configure [webhook] before running webhook update")
})?;
- let secret = resolve_webhook_secret(&config, command.secret, command.secret_env)?;
+ let secret = resolve_config_webhook_secret(&config)?;
update_webhooks(
&config,
WebhookUpdateOptions {
@@ -261,9 +230,26 @@ fn main() -> Result<()> {
}
fn load_config(path: &Path) -> Result {
+ if !path.exists() {
+ anyhow::bail!(
+ "config not found at {}. Run `refray config` first to create one.",
+ path.display()
+ );
+ }
Config::load(path).with_context(|| format!("failed to load config at {}", path.display()))
}
+fn resolve_config_webhook_secret(config: &Config) -> Result {
+ config
+ .webhook
+ .as_ref()
+ .map(|webhook| webhook.secret())
+ .transpose()?
+ .ok_or_else(|| {
+ anyhow::anyhow!("configure [webhook].secret before running webhook commands")
+ })
+}
+
fn resolve_webhook_secret(
config: &Config,
value: Option,
@@ -283,10 +269,12 @@ fn resolve_webhook_secret(
}
}
-fn resolve_webhook_url(config: &Config, value: Option) -> Result {
- value
- .or_else(|| config.webhook.as_ref().map(|webhook| webhook.url.clone()))
- .ok_or_else(|| anyhow::anyhow!("pass --url or configure [webhook].url"))
+fn resolve_config_webhook_url(config: &Config) -> Result {
+ config
+ .webhook
+ .as_ref()
+ .map(|webhook| webhook.url.clone())
+ .ok_or_else(|| anyhow::anyhow!("configure [webhook].url before running webhook commands"))
}
fn set_config_webhook_url(config: &mut Config, url: String) {
diff --git a/src/webhook.rs b/src/webhook.rs
index e1793ad..1034687 100644
--- a/src/webhook.rs
+++ b/src/webhook.rs
@@ -39,9 +39,6 @@ pub struct ServeOptions {
pub struct WebhookInstallOptions {
pub url: String,
pub secret: String,
- pub group: Option,
- pub repo: Option,
- pub repo_pattern: Option,
pub dry_run: bool,
pub work_dir: Option,
pub jobs: usize,
@@ -50,8 +47,6 @@ pub struct WebhookInstallOptions {
#[derive(Clone, Debug)]
pub struct WebhookUninstallOptions {
pub url: String,
- pub group: Option,
- pub repo: Option,
pub dry_run: bool,
pub work_dir: Option,
pub jobs: usize,
@@ -183,21 +178,8 @@ pub fn install_webhooks(config: &Config, options: WebhookInstallOptions) -> Resu
}
let work_dir = options.work_dir.clone().unwrap_or_else(default_work_dir);
let state = Arc::new(Mutex::new(load_webhook_state(&work_dir)?));
- let repo_pattern = options
- .repo_pattern
- .as_deref()
- .map(regex::Regex::new)
- .transpose()
- .with_context(|| "invalid --repo-pattern regex")?;
for mirror in &config.mirrors {
- if options
- .group
- .as_ref()
- .is_some_and(|group| group != &mirror.name)
- {
- continue;
- }
crate::logln!();
crate::logln!(
"{} {}",
@@ -222,15 +204,6 @@ pub fn install_webhooks(config: &Config, options: WebhookInstallOptions) -> Resu
.filter(|repo| mirror.sync_visibility.matches_private(repo.private))
.filter(|repo| repo_filter.matches(&repo.name))
{
- if options.repo.as_ref().is_some_and(|name| name != &repo.name) {
- continue;
- }
- if repo_pattern
- .as_ref()
- .is_some_and(|pattern| !pattern.is_match(&repo.name))
- {
- continue;
- }
tasks.push(WebhookInstallTask {
site: site.clone(),
group: mirror.name.clone(),
@@ -262,13 +235,6 @@ pub fn uninstall_webhooks(config: &Config, options: WebhookUninstallOptions) ->
let mut state = load_webhook_state(&work_dir)?;
let mut tasks = Vec::new();
for mirror in &config.mirrors {
- if options
- .group
- .as_ref()
- .is_some_and(|group| group != &mirror.name)
- {
- continue;
- }
crate::logln!();
crate::logln!(
"{} {}",
@@ -287,9 +253,6 @@ pub fn uninstall_webhooks(config: &Config, options: WebhookUninstallOptions) ->
.list_repos(endpoint)
.with_context(|| format!("failed to list repos for {}", endpoint.label()))?;
for repo in repos {
- if options.repo.as_ref().is_some_and(|name| name != &repo.name) {
- continue;
- }
tasks.push(WebhookUninstallTask {
group: mirror.name.clone(),
site: site.clone(),
@@ -329,8 +292,6 @@ pub fn update_webhooks(config: &Config, options: WebhookUpdateOptions) -> Result
config,
WebhookUninstallOptions {
url: options.old_url.clone(),
- group: None,
- repo: None,
dry_run: options.dry_run,
work_dir: options.work_dir.clone(),
jobs: options.jobs,
@@ -343,9 +304,6 @@ pub fn update_webhooks(config: &Config, options: WebhookUpdateOptions) -> Result
WebhookInstallOptions {
url: options.new_url,
secret: options.secret,
- group: None,
- repo: None,
- repo_pattern: None,
dry_run: options.dry_run,
work_dir: options.work_dir,
jobs: options.jobs,
diff --git a/tests/e2e/sequential.rs b/tests/e2e/sequential.rs
index 5e1c42b..7a0d31a 100644
--- a/tests/e2e/sequential.rs
+++ b/tests/e2e/sequential.rs
@@ -646,33 +646,13 @@ namespace = "{}"
self.seed_all_main(&repo, "webhook base", 1_700_001_601)?;
self.sync(["--repo-pattern", &exact_pattern(&repo)])?;
- self.refray([
- "webhook",
- "install",
- "--dry-run",
- "--repo-pattern",
- &exact_pattern(&repo),
- "--url",
- "https://example.invalid/webhook",
- "--secret",
- WEBHOOK_SECRET,
- ])?;
- self.refray([
- "webhook",
- "uninstall",
- &repo,
- "--dry-run",
- "--url",
- "https://example.invalid/webhook",
- ])?;
+ self.refray(["webhook", "install", "--dry-run"])?;
+ self.refray(["webhook", "uninstall", "--dry-run"])?;
self.refray([
"webhook",
"update",
"--dry-run",
- "--url",
"https://example.invalid/new-webhook",
- "--secret",
- WEBHOOK_SECRET,
])?;
let listener = TcpListener::bind("127.0.0.1:0")?;
diff --git a/tests/unit/cli.rs b/tests/unit/cli.rs
index 34fa3a1..02b8cd2 100644
--- a/tests/unit/cli.rs
+++ b/tests/unit/cli.rs
@@ -85,98 +85,88 @@ fn cli_accepts_webhook_serve() {
#[test]
fn cli_accepts_webhook_install() {
- let cli = Cli::try_parse_from([
- "refray",
- "webhook",
- "install",
- "repo-one",
- "--url",
- "https://mirror.example.test/webhook",
- "--secret",
- "secret",
- "--group",
- "sync-1",
- "--jobs",
- "6",
- ])
- .unwrap();
+ let cli = Cli::try_parse_from(["refray", "webhook", "install", "--jobs", "6"]).unwrap();
let Command::Webhook(WebhookCommand::Install(args)) = cli.command else {
panic!("parsed unexpected command");
};
- assert_eq!(
- args.url,
- Some("https://mirror.example.test/webhook".to_string())
- );
- assert_eq!(args.secret, Some("secret".to_string()));
- assert_eq!(args.group, Some("sync-1".to_string()));
- assert_eq!(args.repo, Some("repo-one".to_string()));
- assert_eq!(args.repo_pattern, None);
assert_eq!(args.jobs, 6);
}
#[test]
-fn cli_accepts_webhook_install_repo_pattern() {
- let cli = Cli::try_parse_from([
- "refray",
- "webhook",
- "install",
- "--url",
- "https://mirror.example.test/webhook",
- "--secret",
- "secret",
- "--repo-pattern",
- "^repo$",
- ])
- .unwrap();
-
- let Command::Webhook(WebhookCommand::Install(args)) = cli.command else {
- panic!("parsed unexpected command");
- };
- assert_eq!(args.repo, None);
- assert_eq!(args.repo_pattern, Some("^repo$".to_string()));
+fn cli_rejects_removed_webhook_install_args() {
+ for args in [
+ ["refray", "webhook", "install", "repo-one"].as_slice(),
+ [
+ "refray",
+ "webhook",
+ "install",
+ "--url",
+ "https://mirror.example.test/webhook",
+ ]
+ .as_slice(),
+ ["refray", "webhook", "install", "--group", "sync-1"].as_slice(),
+ ["refray", "webhook", "install", "--work-dir", "/tmp/refray"].as_slice(),
+ ["refray", "webhook", "install", "--secret", "secret"].as_slice(),
+ [
+ "refray",
+ "webhook",
+ "install",
+ "--secret-env",
+ "WEBHOOK_SECRET",
+ ]
+ .as_slice(),
+ ["refray", "webhook", "install", "--repo-pattern", "^repo$"].as_slice(),
+ ] {
+ assert!(Cli::try_parse_from(args).is_err());
+ }
}
#[test]
fn cli_accepts_webhook_uninstall() {
- let cli = Cli::try_parse_from([
- "refray",
- "webhook",
- "uninstall",
- "repo-one",
- "--url",
- "https://mirror.example.test/webhook",
- "--group",
- "sync-1",
- "--dry-run",
- "--jobs",
- "3",
- ])
- .unwrap();
+ let cli = Cli::try_parse_from(["refray", "webhook", "uninstall", "--dry-run", "--jobs", "3"])
+ .unwrap();
let Command::Webhook(WebhookCommand::Uninstall(args)) = cli.command else {
panic!("parsed unexpected command");
};
- assert_eq!(
- args.url,
- Some("https://mirror.example.test/webhook".to_string())
- );
- assert_eq!(args.group, Some("sync-1".to_string()));
- assert_eq!(args.repo, Some("repo-one".to_string()));
assert!(args.dry_run);
assert_eq!(args.jobs, 3);
}
+#[test]
+fn cli_rejects_removed_webhook_uninstall_args() {
+ for args in [
+ ["refray", "webhook", "uninstall", "repo-one"].as_slice(),
+ [
+ "refray",
+ "webhook",
+ "uninstall",
+ "--url",
+ "https://mirror.example.test/webhook",
+ ]
+ .as_slice(),
+ ["refray", "webhook", "uninstall", "--group", "sync-1"].as_slice(),
+ [
+ "refray",
+ "webhook",
+ "uninstall",
+ "--work-dir",
+ "/tmp/refray",
+ ]
+ .as_slice(),
+ ] {
+ assert!(Cli::try_parse_from(args).is_err());
+ }
+}
+
#[test]
fn cli_accepts_webhook_update() {
let cli = Cli::try_parse_from([
"refray",
"webhook",
"update",
- "--url",
"https://new.example.test/webhook",
- "--secret-env",
- "WEBHOOK_SECRET",
"--jobs",
"5",
])
@@ -186,10 +176,43 @@ fn cli_accepts_webhook_update() {
panic!("parsed unexpected command");
};
assert_eq!(args.url, "https://new.example.test/webhook");
- assert_eq!(args.secret_env, Some("WEBHOOK_SECRET".to_string()));
assert_eq!(args.jobs, 5);
}
+#[test]
+fn cli_rejects_removed_webhook_update_secret_args() {
+ for args in [
+ [
+ "refray",
+ "webhook",
+ "update",
+ "--url",
+ "https://new.example.test/webhook",
+ ]
+ .as_slice(),
+ [
+ "refray",
+ "webhook",
+ "update",
+ "https://new.example.test/webhook",
+ "--secret",
+ "secret",
+ ]
+ .as_slice(),
+ [
+ "refray",
+ "webhook",
+ "update",
+ "https://new.example.test/webhook",
+ "--secret-env",
+ "WEBHOOK_SECRET",
+ ]
+ .as_slice(),
+ ] {
+ assert!(Cli::try_parse_from(args).is_err());
+ }
+}
+
#[test]
fn cli_rejects_scoped_webhook_update() {
let result = Cli::try_parse_from([
@@ -197,9 +220,19 @@ fn cli_rejects_scoped_webhook_update() {
"webhook",
"update",
"repo-one",
- "--url",
"https://new.example.test/webhook",
]);
assert!(result.is_err());
}
+
+#[test]
+fn missing_config_error_asks_user_to_run_config() {
+ let temp = tempfile::tempdir().unwrap();
+ let path = temp.path().join("config.toml");
+
+ let error = load_config(&path).unwrap_err().to_string();
+
+ assert!(error.contains("config not found at"));
+ assert!(error.contains("Run `refray config` first"));
+}