irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

CLI: install.sh saves its server as the default host, built-in default host, automatic versions per CLI change, daily update notice; ig config default-host

huncholanehuncholaneauthored
parent 6c74cf6commit 1d300f5251ca11dcbcb0101c378956f275029731Browse files

8 files changed, +119 -16

+1-1Cargo.lock
@@ -1873,7 +1873,7 @@ dependencies = [
18731873
18741874 [[package]]
18751875 name = "ig"
1876-version = "0.1.0"
1876+version = "0.2.0"
18771877 dependencies = [
18781878 "anyhow",
18791879 "chrono",
+7-1Dockerfile
@@ -25,12 +25,18 @@ COPY backend/ backend/
2525 COPY cli/ cli/
2626 COPY shared/ shared/
2727 COPY --from=frontend /src/frontend/dist frontend/dist
28+# deploy.sh passes the CLI version (bumped automatically when cli/ or shared/
29+# change) and the server URL ig defaults to. Empty values fall back to the
30+# crate version and to no built-in server.
31+ARG IG_BUILD_VERSION=""
32+ARG IG_DEFAULT_HOST=""
2833 # Cache mounts keep the registry and build artifacts between image builds;
2934 # the binaries are copied out of the cache within the same step.
3035 RUN --mount=type=cache,target=/usr/local/cargo/registry \
3136 --mount=type=cache,target=/src/target \
3237 cargo build --release --locked -p irongit --no-default-features \
33- && cargo build --release --locked -p ig --target x86_64-unknown-linux-musl \
38+ && IG_BUILD_VERSION="$IG_BUILD_VERSION" IG_DEFAULT_HOST="$IG_DEFAULT_HOST" \
39+ cargo build --release --locked -p ig --target x86_64-unknown-linux-musl \
3440 && mkdir -p /out \
3541 && cp target/release/irongit /out/irongit \
3642 && cp target/x86_64-unknown-linux-musl/release/ig /out/ig
+3-1backend/src/api/release.rs
@@ -218,13 +218,15 @@ trap - EXIT INT TERM
218218 ln -sf ig "$DIR/docker-credential-ig"
219219
220220 say "Installed ig $VERSION to $DIR/ig"
221+# Remember where ig came from, so plain `ig login` talks to this server.
222+"$DIR/ig" config default-host "$HOST" --if-unset >/dev/null 2>&1 || true
221223 case ":$PATH:" in
222224 *":$DIR:"*) ;;
223225 *) say "Note: $DIR is not on your PATH. Add it, for example:"
224226 say " echo 'export PATH=\"$DIR:\$PATH\"' >> ~/.profile" ;;
225227 esac
226228 say ""
227-say "Next: ig login --host $HOST"
229+say "Next: ig login"
228230 "#;
229231
230232 #[cfg(test)]
+1-1cli/Cargo.toml
@@ -1,6 +1,6 @@
11 [package]
22 name = "ig"
3-version = "0.1.0"
3+version = "0.2.0"
44 edition = "2024"
55
66 [dependencies]
+8-3cli/src/config.rs
@@ -9,6 +9,10 @@
99
1010 use std::{collections::BTreeMap, path::PathBuf};
1111
12+/// The server a release build was made for (IG_DEFAULT_HOST at build time),
13+/// used when the config names none. install.sh also saves its server.
14+pub const BUILT_IN_HOST: Option<&str> = option_env!("IG_DEFAULT_HOST");
15+
1216 use anyhow::{Context, bail};
1317 use serde::{Deserialize, Serialize};
1418
@@ -74,9 +78,10 @@ impl Config {
7478 pub fn resolve_host(&self, flag: Option<&str>) -> anyhow::Result<String> {
7579 match flag.filter(|h| !h.trim().is_empty()) {
7680 Some(host) => normalize_host(host),
77- None => match &self.default_host {
78- Some(host) => Ok(host.clone()),
79- None => bail!("no server configured. Run: ig login --host https://your-irongit-server"),
81+ None => match (&self.default_host, BUILT_IN_HOST.filter(|h| !h.is_empty())) {
82+ (Some(host), _) => Ok(host.clone()),
83+ (None, Some(host)) => normalize_host(host),
84+ (None, None) => bail!("no server configured. Run: ig login --host https://your-irongit-server"),
8085 },
8186 }
8287 }
+49-3cli/src/main.rs
@@ -19,8 +19,8 @@ use crate::cmds::Session;
1919
2020 /// Release builds set IG_BUILD_VERSION; otherwise the crate version.
2121 pub const VERSION: &str = match option_env!("IG_BUILD_VERSION") {
22- Some(v) => v,
23- None => env!("CARGO_PKG_VERSION"),
22+ Some(v) if !v.is_empty() => v,
23+ _ => env!("CARGO_PKG_VERSION"),
2424 };
2525
2626 #[derive(Parser)]
@@ -78,6 +78,9 @@ enum Cmd {
7878 #[arg(short = 'd', long = "data")]
7979 data: Option<String>,
8080 },
81+ /// Show or change ig's settings.
82+ #[command(subcommand)]
83+ Config(ConfigCmd),
8184 /// Replace ig with the newest release from the server.
8285 Upgrade {
8386 /// Reinstall even if this version is current.
@@ -89,6 +92,18 @@ enum Cmd {
8992 },
9093 }
9194
95+#[derive(Subcommand)]
96+enum ConfigCmd {
97+ /// Print the default server, or set it.
98+ DefaultHost {
99+ /// Server URL, e.g. https://git.example.com
100+ url: Option<String>,
101+ /// Only set it when no default exists yet (what install.sh uses).
102+ #[arg(long)]
103+ if_unset: bool,
104+ },
105+}
106+
92107 #[derive(Subcommand)]
93108 enum ImportCmd {
94109 /// Import a GitHub user's or organization's repositories.
@@ -297,7 +312,15 @@ fn main() {
297312 let result = if invoked_as.starts_with("docker-credential-") {
298313 auth::docker_credential_helper(&args)
299314 } else {
300- run(Cli::parse())
315+ let cli = Cli::parse();
316+ // Not for upgrade itself, and not under git or docker (helpers) or scripts.
317+ let notify = !matches!(cli.command, Cmd::Upgrade { .. } | Cmd::Auth(AuthCmd::GitCredential { .. }) | Cmd::Config(_));
318+ let host = cli.host.clone();
319+ let result = run(cli);
320+ if notify && result.is_ok() {
321+ upgrade::notify_if_outdated(host.as_deref());
322+ }
323+ result
301324 };
302325 if let Err(error) = result {
303326 eprintln!("ig: {error:#}");
@@ -404,5 +427,28 @@ fn run(cli: Cli) -> anyhow::Result<()> {
404427 }
405428 Cmd::Api { method, path, data } => cmds::api(&session()?, &method, &path, data.as_deref()),
406429 Cmd::Upgrade { force, check } => upgrade::upgrade(host, force, check),
430+ Cmd::Config(ConfigCmd::DefaultHost { url, if_unset }) => {
431+ let mut config = config::Config::load()?;
432+ match url {
433+ None => match config.resolve_host(None) {
434+ Ok(host) => println!("{host}"),
435+ Err(_) => println!("(none)"),
436+ },
437+ Some(url) => {
438+ let url = config::normalize_host(&url)?;
439+ match &config.default_host {
440+ Some(current) if if_unset && *current != url => {
441+ println!("default server stays {current} (switch with: ig config default-host {url})");
442+ }
443+ _ => {
444+ config.default_host = Some(url.clone());
445+ config.save()?;
446+ println!("default server: {url}");
447+ }
448+ }
449+ }
450+ }
451+ Ok(())
452+ }
407453 }
408454 }
+41-2cli/src/upgrade.rs
@@ -1,6 +1,9 @@
11 //! `ig upgrade`: replace this executable with the server's newest release.
22
3-use std::io::Write;
3+use std::{
4+ io::{IsTerminal, Write},
5+ time::Duration,
6+};
47
58 use anyhow::{Context, bail};
69 use irongit_shared::{self as shared, version_newer};
@@ -10,7 +13,7 @@ use crate::{
1013 VERSION,
1114 client::{Client, http_client},
1215 config::Config,
13- output::{bold, green},
16+ output::{bold, green, yellow},
1417 };
1518
1619 pub fn upgrade(host_flag: Option<&str>, force: bool, check_only: bool) -> anyhow::Result<()> {
@@ -60,3 +63,39 @@ pub fn upgrade(host_flag: Option<&str>, force: bool, check_only: bool) -> anyhow
6063 println!("{} Upgraded ig {VERSION} -> {} ({})", green("✓"), bold(&latest.version), exe.display());
6164 Ok(())
6265 }
66+
67+#[derive(serde::Serialize, serde::Deserialize, Default)]
68+struct UpdateState {
69+ checked_at: i64,
70+ latest: Option<String>,
71+}
72+
73+fn state_path() -> Option<std::path::PathBuf> {
74+ crate::config::path().ok().map(|p| p.with_file_name("update-check.toml"))
75+}
76+
77+/// At most once a day, asks the server for the newest ig and, if it is newer,
78+/// prints one line to stderr. Never fails a command: any error is ignored.
79+/// Silent when stderr is not a terminal or IG_NO_UPDATE_NOTIFIER is set.
80+pub fn notify_if_outdated(host_flag: Option<&str>) {
81+ if std::env::var_os("IG_NO_UPDATE_NOTIFIER").is_some() || !std::io::stderr().is_terminal() {
82+ return;
83+ }
84+ let Some(path) = state_path() else { return };
85+ let mut state: UpdateState = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
86+ let now = chrono::Utc::now().timestamp();
87+ if now - state.checked_at > 24 * 3600 {
88+ let latest = Config::load().ok().and_then(|config| config.resolve_host(host_flag).ok()).and_then(|host| {
89+ let http = reqwest::blocking::Client::builder().timeout(Duration::from_millis(1500)).build().ok()?;
90+ let release: shared::CliRelease = http.get(format!("{host}/api/v1/cli/latest")).send().ok()?.error_for_status().ok()?.json().ok()?;
91+ Some(release.version)
92+ });
93+ state = UpdateState { checked_at: now, latest: latest.or(state.latest) };
94+ if let Ok(text) = toml::to_string(&state) {
95+ let _ = std::fs::write(&path, text);
96+ }
97+ }
98+ if let Some(latest) = state.latest.filter(|l| version_newer(l, VERSION)) {
99+ eprintln!("{}", yellow(&format!("A new ig is available: {latest} (you have {VERSION}). Run: ig upgrade")));
100+ }
101+}
+9-4deploy.sh
@@ -15,10 +15,15 @@ TAG=$(git rev-parse --short HEAD)
1515 if ! git diff --quiet HEAD; then
1616 TAG="$TAG-dirty-$(date +%Y%m%d%H%M%S)"
1717 fi
18-IG_VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' cli/Cargo.toml | head -1)
18+PUBLIC_URL=${DEPLOY_PUBLIC_URL:-https://git.hygo.ai}
19+# ig's version is major.minor from cli/Cargo.toml plus the number of commits
20+# that touched the CLI, so any CLI change publishes a newer release that
21+# `ig upgrade` (and its daily notice) picks up. No manual bumps.
22+IG_BASE=$(sed -n 's/^version = "\([0-9]*\.[0-9]*\)\..*"/\1/p' cli/Cargo.toml | head -1)
23+IG_VERSION="$IG_BASE.$(git rev-list --count HEAD -- cli shared)"
1924
20-echo "==> building irongit:$TAG (ig $IG_VERSION)"
21-docker build -t "irongit:$TAG" .
25+echo "==> building irongit:$TAG (ig $IG_VERSION, default host $PUBLIC_URL)"
26+docker build --build-arg "IG_BUILD_VERSION=$IG_VERSION" --build-arg "IG_DEFAULT_HOST=$PUBLIC_URL" -t "irongit:$TAG" .
2227
2328 echo "==> shipping to $REMOTE"
2429 docker save "irongit:$TAG" | gzip -1 | ssh "$REMOTE" 'gunzip | docker load'
@@ -59,4 +64,4 @@ docker images irongit --format '{{.Tag}} {{.CreatedAt}}' | grep -v '^latest ' |
5964 | while read -r old; do [ "$old" = "$tag" ] || docker rmi "irongit:$old" >/dev/null 2>&1 || true; done
6065 REMOTE_SCRIPT
6166
62-echo "==> done: https://git.hygo.ai"
67+echo "==> done: $PUBLIC_URL"