From 60a2255455c79d896e75cef88c6344145ba39016 Mon Sep 17 00:00:00 2001 From: Lars Kellogg-Stedman Date: Wed, 10 Jun 2026 20:04:45 -0400 Subject: [PATCH] Add `push` command Add the missing `ti push` command documented in the README. Without this, ticgit is unable to perform the initial push of tickets to a remote. Closes: #48 --- crates/ticgit/src/cli.rs | 5 ++++ crates/ticgit/src/commands/mod.rs | 1 + crates/ticgit/src/commands/push.rs | 39 ++++++++++++++++++++++++++++++ crates/ticgit/src/commands/sync.rs | 8 +++--- crates/ticgit/tests/cli.rs | 26 ++++++++++++++++++-- 5 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 crates/ticgit/src/commands/push.rs diff --git a/crates/ticgit/src/cli.rs b/crates/ticgit/src/cli.rs index 96826636..e836f426 100644 --- a/crates/ticgit/src/cli.rs +++ b/crates/ticgit/src/cli.rs @@ -59,6 +59,7 @@ use crate::commands; \x1b[1;36mSync & Setup:\x1b[0m sync Sync ticket metadata with a Git remote pull Pull tickets from a fork or remote URL + push Push ticket metadata to a Git remote init Initialise ticgit on the current repo setup Configure git-meta remote from .git-meta update Update ti to the latest release @@ -215,6 +216,9 @@ pub enum Command { /// Pull tickets from a fork or remote URL. Pull(commands::pull::Args), + /// Push ticket metadata to a Git remote. + Push(commands::push::Args), + /// Initialise ticgit metadata on the current repo (idempotent). Init, @@ -277,6 +281,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { Some(Command::Users(args)) => commands::users::run(args), Some(Command::Sync(args)) => commands::sync::run_sync(args), Some(Command::Pull(args)) => commands::pull::run(args), + Some(Command::Push(args)) => commands::push::run(args), Some(Command::Update(args)) => commands::update::run(args), } } diff --git a/crates/ticgit/src/commands/mod.rs b/crates/ticgit/src/commands/mod.rs index 4f478100..23570406 100644 --- a/crates/ticgit/src/commands/mod.rs +++ b/crates/ticgit/src/commands/mod.rs @@ -21,6 +21,7 @@ pub mod next; pub mod points; pub mod priority; pub mod pull; +pub mod push; pub mod recent; pub mod review; pub mod setup; diff --git a/crates/ticgit/src/commands/push.rs b/crates/ticgit/src/commands/push.rs new file mode 100644 index 00000000..fb813827 --- /dev/null +++ b/crates/ticgit/src/commands/push.rs @@ -0,0 +1,39 @@ +use anyhow::Result; +use clap::Parser; + +use crate::commands::open_store; +use crate::commands::sync::{meta_namespace, remote_url, ssh_project_web_url, sync_remote}; + +#[derive(Debug, Parser)] +pub struct Args { + /// Remote to push to. Defaults to git-meta's first configured meta remote. + #[arg(short = 'r', long = "remote")] + pub remote: Option, +} + +pub fn run(args: Args) -> Result<()> { + let store = open_store()?; + let remote = sync_remote(args.remote.as_deref())?; + let namespace = meta_namespace()?; + let url = remote + .as_deref() + .map(remote_url) + .transpose()? + .unwrap_or_else(|| "(none)".to_string()); + + if let Some(remote) = &remote { + println!("Remote: {remote}"); + } + println!("Ref: refs/{namespace}/main"); + println!("URL: {url}"); + if let Some(web_url) = ssh_project_web_url(&url) { + println!("Web URL: {web_url}"); + } + + store.push(args.remote.as_deref())?; + + let total = store.list()?.len(); + println!("Push: {total} ticket(s) synced."); + println!("Done."); + Ok(()) +} diff --git a/crates/ticgit/src/commands/sync.rs b/crates/ticgit/src/commands/sync.rs index d593b22c..f0da834f 100644 --- a/crates/ticgit/src/commands/sync.rs +++ b/crates/ticgit/src/commands/sync.rs @@ -76,7 +76,7 @@ pub fn run_sync(args: Args) -> Result<()> { Ok(()) } -fn sync_remote(explicit: Option<&str>) -> Result> { +pub(crate) fn sync_remote(explicit: Option<&str>) -> Result> { if let Some(remote) = explicit { return Ok(Some(remote.to_string())); } @@ -94,15 +94,15 @@ fn sync_remote(explicit: Option<&str>) -> Result> { Ok(remotes.into_iter().next()) } -fn meta_namespace() -> Result { +pub(crate) fn meta_namespace() -> Result { Ok(git_config_get("meta.namespace")?.unwrap_or_else(|| "meta".to_string())) } -fn remote_url(remote: &str) -> Result { +pub(crate) fn remote_url(remote: &str) -> Result { git_output(&["remote", "get-url", remote]) } -fn ssh_project_web_url(url: &str) -> Option { +pub(crate) fn ssh_project_web_url(url: &str) -> Option { let (host, path) = if let Some(rest) = url.strip_prefix("git@") { rest.split_once(':')? } else if let Some(rest) = url.strip_prefix("ssh://git@") { diff --git a/crates/ticgit/tests/cli.rs b/crates/ticgit/tests/cli.rs index 86981bf6..686046a9 100644 --- a/crates/ticgit/tests/cli.rs +++ b/crates/ticgit/tests/cli.rs @@ -546,14 +546,14 @@ fn version_flags_print_cargo_version() { } #[test] -fn help_lists_sync_and_pull_but_not_push() { +fn help_lists_sync_pull_and_push() { let mut cmd = assert_cmd::Command::cargo_bin("ti").expect("ti binary"); cmd.arg("--help") .assert() .success() .stdout(predicate::str::contains("sync")) .stdout(predicate::str::contains("pull")) - .stdout(predicate::str::contains(" push ").not()); + .stdout(predicate::str::contains("push")); } #[test] @@ -657,6 +657,28 @@ fn sync_prints_remote_url_and_ref() { .stdout(predicate::str::contains(format!("URL: {remote_url}"))); } +#[test] +fn push_sends_tickets_to_bare_remote() { + let repo = TestRepo::new(); + let remote = tempfile::tempdir().expect("bare remote tempdir"); + git(remote.path(), &["init", "--bare", "--quiet"]); + let remote_url = remote.path().to_string_lossy().to_string(); + + git(repo.dir.path(), &["remote", "add", "origin", &remote_url]); + repo.ti().arg("init").assert().success(); + create_ticket(&repo, "pushed ticket"); + + repo.ti() + .arg("push") + .assert() + .success() + .stdout(predicate::str::contains("Remote: origin")) + .stdout(predicate::str::contains("Ref: refs/meta/main")) + .stdout(predicate::str::contains(format!("URL: {remote_url}"))) + .stdout(predicate::str::contains("1 ticket(s) synced")) + .stdout(predicate::str::contains("Done.")); +} + #[test] fn new_show_and_list_round_trip() { let repo = TestRepo::new();