Contributing
Where to contribute
Issues and pull requests are handled on GitHub. Development happens upstream on the maintainer's Forgejo instance and is mirrored to GitHub, so an accepted pull request is landed upstream and appears on GitHub through the mirror.
Security issues are reported privately through a GitHub security advisory, never in a public issue.
Setup
You need a recent stable Rust toolchain (with rustfmt and clippy) and git 2.28 or newer. make is optional: see the table below for the direct command behind each target.
git clone https://github.com/Dxsk/repos-manager.git
cd repos-manager
make build Run the CLI without installing it:
cargo run -- github sync --dry-run --base-dir /tmp/rm Make targets
The root Makefile wraps the usual commands:
| Target | What it does |
|---|---|
make build | cargo build --release --locked, binary in target/release/ |
make test | Unit and integration tests |
make coverage | Tests plus a line coverage summary (needs cargo install cargo-llvm-cov) |
make lint | cargo fmt --check and cargo clippy --all-targets -- -D warnings |
make install / make uninstall | Install into $(PREFIX)/bin (PREFIX ?= ~/.local, DESTDIR supported) |
make completions | bash, zsh and fish completions in your user directories |
make release V=x.y.z | Cut a release, see below |
make is a convenience, not a requirement. Every target is one command, so a checkout with no make (every Windows one) runs them directly:
| Target | Direct command |
|---|---|
make build | cargo build --release --locked |
make test | cargo test --locked |
make lint | cargo fmt --check then cargo clippy --all-targets --locked -- -D warnings |
make metrics | python scripts/code-metrics.py |
make coverage | cargo llvm-cov --locked --summary-only |
make release V=x.y.z | python scripts/release.py x.y.z |
Running tests
make test # everything
cargo test sync:: # one module
cargo test -- --exact matcher::tests::exact_and_glob Unit tests live next to the code in each module (#[cfg(test)] mod tests), and tests/cli.rs runs the compiled binary end to end. Tests shell out to the real git and create bare remotes in temporary directories, which is why git init -b support (git 2.28+) is required.
make coverage prints per-file line coverage. The Forgejo CI runs it on every push, so you can read the figures in the job log.
When running the binary from scripts, set NO_COLOR=1 and REPOS_MANAGER_NO_UPDATE_CHECK=1 to get plain output and no background network call.
Formatting and linting
cargo fmt
make lint CI runs cargo fmt --check and clippy with warnings as errors, so run make lint before pushing.
CI pipeline
GitHub runs CI on every pull request and on main. The upstream forge runs its own checks before anything is mirrored:
| Where | Job | What it does |
|---|---|---|
| Forgejo Actions | CI | cargo fmt --check, cargo clippy -D warnings, ShellCheck on installers/install.sh, tests and coverage on Linux |
| GitHub Actions | CI | Format, lint and ShellCheck, then tests on Ubuntu, macOS and Windows |
| GitHub Actions | Links | Lychee validates URLs in the markdown files |
All checks must pass before merging.
Releases
Releases are cut by the maintainer, by hand, from main on the upstream forge. There is no automatic version bump.
- Describe your changes under
## UnreleasedinCHANGELOG.md, grouped as### Added,### Changed,### Removedand### Fixed. Contributors do this in their pull request. - The maintainer runs
python scripts/release.py x.y.zon a clean tree (make release V=x.y.zcalls the same script). It sets the version inCargo.toml,Cargo.lockandsite/src/_data/site.json, renames## Unreleasedto## x.y.z (YYYY-MM-DD), commitschore: release vx.y.zand creates the signedvx.y.ztag (git tag -s, so a GPG signing key is needed). Nothing is pushed yet. git push origin dev dev:main vx.y.zsends the commit,mainand the tag to the forge in one push. The mirror runs per push, so a tag pushed on its own leavesmainbehind until the next sync, and the site keeps serving the previous version while the GitHub release is already out.
The tag starts the Forgejo release (Linux x86_64 and aarch64 musl, x86_64-pc-windows-gnu zip and the x86_64 installer). The forge push mirror then forwards main and the tag to GitHub, which builds the full release: Linux, macOS, Windows x86_64 and ARM64 (msvc) and both installers. Both releases ship SHA256SUMS and SHA256SUMS-binaries. The install script and repos-manager update download from GitHub, so its release must carry every asset.
Once the GitHub release is complete, publish the packages:
cargo publish # crates.io (needs `cargo login`)
packaging/aur/render.sh x.y.z --publish # AUR repos-manager and repos-manager-bin (needs your AUR SSH key) render.sh without --publish only writes PKGBUILD and .SRCINFO to dist/aur/ for review.
Since the mirror overwrites GitHub main on every sync, pull requests are never merged with the GitHub button: the maintainer lands them upstream (keeping the author) and the mirror closes the loop.
Adding a provider
Providers live in src/providers/, one file each. A provider lists remote repos as a Vec<Repo> (full_name, ssh_url, https_url) and knows how to log in.
Step 1: Create the provider file
Create src/providers/yourprovider.rs:
use std::process::Command;
use anyhow::Result;
use serde_json::Value;
use super::{ListError, Repo, require_cli, run_capture, run_interactive, str_field};
pub fn login() -> Result<()> {
run_interactive("yourcli", &["auth", "login"])
}
/// Map the provider payload to `Repo`. Kept separate from I/O so it can be unit tested.
pub fn parse(items: &[Value]) -> Vec<Repo> {
items
.iter()
.map(|r| Repo {
full_name: str_field(r, "full_name"),
ssh_url: str_field(r, "ssh_url"),
https_url: str_field(r, "https_url"),
})
.filter(|r| !r.full_name.is_empty())
.collect()
}
pub fn list_repos(host: &str) -> Result<Vec<Repo>, ListError> {
require_cli("yourcli")?;
let raw = run_capture(Command::new("yourcli").args(["repo", "list", "--json", "--host", host]))?;
let items: Vec<Value> = serde_json::from_str(&raw).map_err(anyhow::Error::from)?;
Ok(parse(&items))
} Return ListError::Skip(reason) when the host is simply not configured (no login, no credentials): the sync warns and moves on. Any other error is a ListError::Fail. If the CLI paginates by printing several JSON arrays back to back, use parse_paginated from mod.rs.
Step 2: Register it
In src/providers/mod.rs:
- Declare the module:
mod yourprovider; - Add a
YourProvidervariant to theProviderenum and toProvider::ALL - Fill in its arms in
name(),from_name(),cli(),default_host(),login()andlist_repos()
default_host() returns an empty string for a provider that only exists self-hosted, like OneDev or any: it then has no default host, and nothing is synced until the user lists an instance in the config.
In src/cli.rs:
- Add a
YourProvider(ProviderCmd)variant toCommand(its doc comment becomes the help text) - Add a
YourProvidervariant toProviderArgand to theFrom<ProviderArg> for Providerimpl
In src/main.rs:
- Dispatch it in
run():Command::YourProvider(p) => provider_cmd(Provider::YourProvider, p.action, settings)
The compiler points at every match you missed.
Step 3: Add tests
At the bottom of the provider file, test the payload parser with a sample response:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_payload() {
let items: Vec<Value> = serde_json::from_str(
r#"[{"full_name":"user/repo","ssh_url":"git@host:user/repo.git","https_url":"https://host/user/repo.git"}]"#,
)
.unwrap();
let r = &parse(&items)[0];
assert_eq!(r.full_name, "user/repo");
assert_eq!(r.ssh_url, "git@host:user/repo.git");
}
} Step 4: Document
Update readme.md, site/src/docs/providers.md, the default hosts table in site/src/docs/configuration.md and the glossary.
Project structure
$ tree repos-manager/
repos-manager/
├── Cargo.toml # Crate manifest
├── Makefile # build, test, coverage, lint, install, release
├── src/
│ ├── main.rs # Entry point, command dispatch
│ ├── cli.rs # clap command tree and flags
│ ├── config.rs # Settings: flags, env vars, config.json
│ ├── matcher.rs # .repos.yml, --filter, --ignore
│ ├── sync/
│ │ ├── mod.rs # Per-host lock, listing, scope, recon
│ │ ├── paths.rs # Repo paths, filesystem name rules
│ │ ├── jobs.rs # Scheduler, clone and update
│ │ └── prune.rs # Prune and its guards
│ ├── status.rs # Status scan (network-mount aware)
│ ├── update.rs # Update banner and self-update
│ ├── git.rs # git command helpers
│ ├── http.rs # HTTP client
│ ├── output.rs # Colors, log levels
│ └── providers/
│ ├── mod.rs # Provider enum, shared helpers
│ ├── github.rs # GitHub (gh)
│ ├── gitlab.rs # GitLab (glab)
│ ├── forgejo.rs # Forgejo/Gitea/Gogs (token + REST API)
│ ├── bitbucket.rs # Bitbucket (CLI or REST API)
│ ├── radicle.rs # Radicle (rad)
│ ├── sourcehut.rs # Sourcehut (hut graphql)
│ ├── onedev.rs # OneDev (REST API, saved token)
│ └── any.rs # Plain git servers (config URLs, SSH discovery)
├── tests/
│ └── cli.rs # End-to-end CLI tests
├── installers/ # install.sh, install.ps1, NSIS installer
└── site/ # This documentation site (Eleventy) Pull requests
| Rule | Details |
|---|---|
| Where | Pull requests on GitHub, from a fork |
| Branch from | main |
| Scope | One feature per PR |
| Tests | Must pass (make test) |
| Lint | Must pass (make lint) |
| Changelog | Add your entry under ## Unreleased in CHANGELOG.md |
| Commits | Conventional style (feat:, fix:, docs:, test:) |
Do not bump the version in your pull request: it only changes through scripts/release.py.