From 6ed6a649879eab1aa1223478af974b35894624f0 Mon Sep 17 00:00:00 2001 From: Scott Linder Date: Fri, 7 Aug 2026 16:04:28 -0400 Subject: [PATCH] Add --tag-remote Add a workaround for GitHub losing old commits to garbage-collection after force-push This uses a separate remote (although the user can specify the same remote if they are OK polluting it with these tags) that must be set explicitly, as it is a leaky implementation detail, and they will need to separately manage these tags. The tool will likely grow a way to automate "pruning" tags from closed stacks, but even then it will likely have to be run explicitly. Future work could also consider replacing the interdiff generation with creating synthetic base-branches for each change at each revision of the stack, a la gherrit, which would allow the matrix of comparisons (any version with any other). Assisted by an LLM Change-Id: I2b54b1663d2767084f0eed7b941a8b642c036b6d --- src/cgh.rs | 6 ++++++ src/change.rs | 17 +++++++++++++++-- src/cli.rs | 10 ++++++++-- src/env.rs | 11 +++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/cgh.rs b/src/cgh.rs index 1a51123..5b0534b 100644 --- a/src/cgh.rs +++ b/src/cgh.rs @@ -119,6 +119,12 @@ fn push(cfg: &cli::Push) -> Result<()> { .context("could not build diffs")?; LocalChange::push_all(any_changes.iter().map(|ac| ac.local_change())) .context("could not push all local changes")?; + any_changes + .first() + .context("no local changes to tag")? + .local_change() + .push_tag() + .context("could not push tag for final local change")?; // FIXME: Should try to restore the original branch contents if we fail from this point on. It // would be at least an attempt at being "atomic" about the push, and it would mean we don't // lose the interdiff in a future re-run. diff --git a/src/change.rs b/src/change.rs index f470021..6815e42 100644 --- a/src/change.rs +++ b/src/change.rs @@ -81,11 +81,17 @@ impl LocalChange { let remote_branch_ref = self.remote_branch_ref(); format!("{oid}:{remote_branch_ref}") } + pub fn tag_refspec(&self) -> String { + let oid = self.oid; + let tag_name = format!("{}{oid}", env::user_branch_prefix()); + format!("{oid}:refs/tags/{tag_name}") + } pub fn push_all<'a, I: Iterator>(iterator: I) -> Result<()> { - let refspecs: Vec = iterator.map(|lc| lc.push_refspec()).collect(); - if refspecs.is_empty() { + let local_changes: Vec<&Self> = iterator.collect(); + if local_changes.is_empty() { bail!("no refs to push"); } + let refspecs: Vec = local_changes.iter().map(|lc| lc.push_refspec()).collect(); let mut cmd = Command::new("git"); let mut args = vec![ "push".to_string(), @@ -98,6 +104,13 @@ impl LocalChange { exec!(dry_return = (), cmd); Ok(()) } + pub fn push_tag(&self) -> Result<()> { + let tag_refspec = self.tag_refspec(); + let mut cmd = Command::new("git"); + cmd.args(["push", env::tag_remote(), "--atomic", tag_refspec.as_str()]); + exec!(dry_return = (), cmd); + Ok(()) + } pub fn fetch_all<'a, I: Iterator>(iterator: I) -> Result<()> { let refspecs: Vec = iterator .map(|lc| format!("{}:", lc.remote_branch_ref())) diff --git a/src/cli.rs b/src/cli.rs index 19d2a87..da4b18c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -74,6 +74,10 @@ pub struct Globals { /// The name of the git remote corresponding to the GitHub repo to operate on. #[arg(long, global = true)] pub remote: Option, + /// The name of the git remote corresponding to the GitHub repo to push one tag per stack + /// revision, to retain history after force-pushes. + #[arg(long, global = true)] + pub tag_remote: Option, /// The branch on `remote` which acts as the "base" branch, which all PRs are ultimately /// relative to. #[arg(long, global = true)] @@ -101,13 +105,15 @@ pub enum Command { /// /// The commits `${base}..HEAD` must each have a `Change-Id:` trailer. Each commit will be /// force-pushed to a corresponding branch named `${user_branch_prefix}${change_id}` on - /// `${remote}`. Each commit will be matched to its existing PR or else a new PR will be + /// `${remote}`. A tag named `${user_branch_prefix}${final_commit_oid}` will also be pushed to + /// `${tag_remote}`. Each commit will be matched to its existing PR or else a new PR will be /// created for it. The PRs will be "stacked" such that they reproduce the local branch /// sequence, with additional trailers in the PR message body to help reviewers navigate the /// stack. /// /// Note: This command will never modify your commits or refs, even their messages. No local - /// branches are created or destroyed. All mutation occurs exclusively on the `$remote`. + /// branches are created or destroyed. All mutation occurs exclusively on `$remote` and + /// `$tag_remote`. #[command(visible_alias = "p")] Push(Push), /// With no arguments, print the current stack's short-name. With an argument, set it. diff --git a/src/env.rs b/src/env.rs index da43115..e26d273 100644 --- a/src/env.rs +++ b/src/env.rs @@ -34,6 +34,7 @@ impl ThreadLocalRepo { #[derive(Default, Serialize, Deserialize)] pub struct Config { remote: String, + tag_remote: String, base_branch: String, user_branch_prefix: String, reviewer_groups: Option>>, @@ -86,6 +87,9 @@ pub fn validate() -> Result<()> { if remote().is_empty() { bail!("field `remote` cannot be empty"); } + if tag_remote().is_empty() { + bail!("field `tag_remote` cannot be empty"); + } if base_branch().is_empty() { bail!("field `base_branch` cannot be empty"); } @@ -106,6 +110,13 @@ pub fn remote() -> &'static str { .unwrap_or(CONFIG.remote.as_str()) } +pub fn tag_remote() -> &'static str { + CLI.globals + .tag_remote + .as_deref() + .unwrap_or(CONFIG.tag_remote.as_str()) +} + pub fn base_branch() -> &'static str { CLI.globals .base_branch