Skip to content

[1/1]: Add --tag-remote - #72

Open
slinder1 wants to merge 1 commit into
mainfrom
users/slinder1/I2b54b1663d2767084f0eed7b941a8b642c036b6d
Open

[1/1]: Add --tag-remote#72
slinder1 wants to merge 1 commit into
mainfrom
users/slinder1/I2b54b1663d2767084f0eed7b941a8b642c036b6d

Conversation

@slinder1

@slinder1 slinder1 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

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


Stack:

(Note: Closed and merged PRs may not be reflected here and PR numbering is not stable.)

@slinder1 slinder1 changed the title Add --tag-remote [1/1]: Add --tag-remote Aug 7, 2026
@slinder1

slinder1 commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author
🛠️ Initial changes (click to expand):
diff --git b/src/cgh.rs a/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 b/src/change.rs a/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<Item = &'a Self>>(iterator: I) -> Result<()> {
-        let refspecs: Vec<String> = 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<String> = 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,18 @@ 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<Item = &'a Self>>(iterator: I) -> Result<()> {
         let refspecs: Vec<String> = iterator
             .map(|lc| format!("{}:", lc.remote_branch_ref()))
diff --git b/src/cli.rs a/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<String>,
+    /// 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<String>,
     /// 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 b/src/env.rs a/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<HashMap<String, Vec<String>>>,
@@ -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

@slinder1
slinder1 marked this pull request as ready for review August 7, 2026 20:14
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
@slinder1
slinder1 force-pushed the users/slinder1/I2b54b1663d2767084f0eed7b941a8b642c036b6d branch from 014caa7 to 6ed6a64 Compare August 7, 2026 20:16
@slinder1

slinder1 commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author
🛠️ Changes since last version (click to expand):
diff --git b/src/change.rs a/src/change.rs
@@ -107,12 +107,7 @@ impl LocalChange {
     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(),
-        ]);
+        cmd.args(["push", env::tag_remote(), "--atomic", tag_refspec.as_str()]);
         exec!(dry_return = (), cmd);
         Ok(())
     }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant