Skip to content

[metadata: 3/3]: Use git-config branch keys to track stack metadata#56

Merged
slinder1 merged 1 commit into
mainfrom
users/slinder1/Ib6b188c23fc84460fad74460e05fd6ba7a02e8ca
Jun 25, 2026
Merged

[metadata: 3/3]: Use git-config branch keys to track stack metadata#56
slinder1 merged 1 commit into
mainfrom
users/slinder1/Ib6b188c23fc84460fad74460e05fd6ba7a02e8ca

Conversation

@slinder1

@slinder1 slinder1 commented Jun 25, 2026

Copy link
Copy Markdown
Owner

We were using the branch.<name>.description entry already for the
short-name of the stack, but really it is meant for longer form content
that other tools already digest.

Add a name subcommand to edit and print the stack short-name, saved
to branch.<name>.cgh-shortName.

Add a merge subcommand to automatically maintain the
branch.<name>.cgh-mergedChangeIds metadata. If a user doesn't care
about the apparent size of the stack shrinking as it "forgets" about
merged changes, then they can ignore this and use the web-UI or gh pr merge or git push themselves.

Note that git-config doesn't seem to be able to nest keys any deeper
than branch.<name>.<key>, hence we namespace our sub-keys with the
cgh- prefix.

Also note that both entries default to the empty string, and are assumed
to be the default if not present in the config. This means a user
without interest in the features that depend on the keys won't see them
in their config.

Change-Id: Ib6b188c23fc84460fad74460e05fd6ba7a02e8ca


Stack:

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

@slinder1 slinder1 changed the base branch from main to users/slinder1/I3212a00b2ed3a52952eb3a9ccf9d30e40a51c3f4 June 25, 2026 15:34
@slinder1 slinder1 changed the title Abuse branch description for metadata [3/3]: Abuse branch description for metadata Jun 25, 2026
@slinder1

Copy link
Copy Markdown
Owner Author
🛠️ Initial changes (click to expand):
diff --git b/src/cgh.rs a/src/cgh.rs
@@ -5,7 +5,8 @@ use crate::change::{self, AnyChange, Change, LocalChange};
 use crate::cli;
 use crate::env;
 use crate::gh::{self, Pr, PrState};
-use crate::util::{Extract, RepoExt};
+use crate::metadata::StackMetadata;
+use crate::util::Extract;
 use anyhow::{Context, Result, bail};
 use rayon::prelude::*;
 use std::collections::HashSet;
@@ -26,15 +27,16 @@ pub fn cgh() -> Result<()> {
     env::validate().context("invalid configuration")?;
     match cli.command {
         cli::Command::Push(ref cfg) => push(cfg),
+        cli::Command::Name(ref cfg) => name(cfg),
+        cli::Command::Merge(ref cfg) => merge(cfg),
         cli::Command::Url(ref cfg) => url(cfg),
         cli::Command::InstallHook(ref cfg) => install_hook(cfg),
     }
 }
 
 fn push(cfg: &cli::Push) -> Result<()> {
-    let repo = env::repo();
-    let branch = repo.head_branch().context("HEAD must be a branch")?;
-    let branch_desc = repo.branch_desc(branch).ok();
+    let stack_meta =
+        StackMetadata::from_repo().context("could not parse branch description metadata")?;
     let mut reviewers = vec![];
     for group_key in cfg.reviewer_groups.iter() {
         let group = env::reviewer_groups()
@@ -47,6 +49,20 @@ fn push(cfg: &cli::Push) -> Result<()> {
         change::get_local_changes().context("could not enumerate current local branch")?;
     let mut prs_by_change_id = gh::prs_by_change_id(|pr| !pr.in_state(PrState::Closed))
         .context("could not enumerate remote prs")?;
+    let mut merged_prs = vec![];
+    for merged_change_id in stack_meta.merged_change_ids {
+        let pr = prs_by_change_id
+            .remove(&merged_change_id)
+            .with_context(|| format!("merged change {} has no pr", merged_change_id))?;
+        if pr.in_state(PrState::Open) {
+            bail!(
+                "pr {} for merged change {} is still open",
+                pr.number,
+                merged_change_id,
+            );
+        }
+        merged_prs.push(pr);
+    }
     let mut any_changes = vec![];
     for local_change in local_changes {
         any_changes.push(match prs_by_change_id.remove(&local_change.id) {
@@ -54,7 +70,7 @@ fn push(cfg: &cli::Push) -> Result<()> {
             Some(pr) => {
                 if pr.in_state(PrState::Merged) {
                     bail!(
-                        "pr {} with Change-Id {} already merged",
+                        "pr {} for unmerged change {} is already merged",
                         pr.number,
                         local_change.id
                     );
@@ -131,15 +147,13 @@ fn push(cfg: &cli::Push) -> Result<()> {
                 .next()
                 .map(|p| p.local_change.remote_branch())
                 .unwrap_or_else(|| env::base_branch().to_owned());
-            if c.is_nonempty() {
-                c.pr.set_base(base.as_ref()).with_context(|| {
-                    format!(
-                        "could not retarget pr {} to branch: {:?}",
-                        c.pr.number, base,
-                    )
-                })?;
-            }
-            c.render_pr_ui(&changes, branch_desc.as_deref())
+            c.pr.set_base(base.as_ref()).with_context(|| {
+                format!(
+                    "could not retarget pr {} to branch: {:?}",
+                    c.pr.number, base,
+                )
+            })?;
+            c.render_pr_ui(&changes, &stack_meta.stack_name, &merged_prs)
                 .context("could not render pseudo-ui in pr title/body")
         })
         .collect::<Result<Vec<_>>>()
@@ -181,6 +195,42 @@ fn detect_cycles(any_changes: &[AnyChange]) -> bool {
     false
 }
 
+fn name(cfg: &cli::Name) -> Result<()> {
+    let mut stack_meta = StackMetadata::from_repo()?;
+    match cfg.new_name {
+        None => println!("{}", stack_meta.stack_name),
+        Some(ref new_name) => {
+            stack_meta.stack_name = new_name.to_string();
+            stack_meta.to_repo()?;
+        },
+    }
+    Ok(())
+}
+
+fn merge(_cfg: &cli::Merge) -> Result<()> {
+    let mut stack_meta =
+        StackMetadata::from_repo().context("could not parse branch description metadata")?;
+    let local_change = change::get_local_changes()
+        .context("could not enumerate current local branch")?
+        .pop()
+        .context("no local changes")?;
+    if stack_meta.merged_change_ids.contains(&local_change.id) {
+        bail!("change {} was already merged", local_change.id);
+    }
+    let mut prs_by_change_id = gh::prs_by_change_id(|pr| !pr.in_state(PrState::Closed))
+        .context("could not enumerate remote prs")?;
+    let pr = prs_by_change_id
+        .remove(&local_change.id)
+        .with_context(|| format!("change {} has no pr", local_change.id))?;
+    let change = Change { local_change, pr };
+    change.merge()?;
+    stack_meta
+        .merged_change_ids
+        .push(change.local_change.id.clone());
+    stack_meta.to_repo()?;
+    Ok(())
+}
+
 fn url(_cfg: &cli::Url) -> Result<()> {
     let local_changes =
         change::get_local_changes().context("could not enumerate current local branch")?;
diff --git b/src/change.rs a/src/change.rs
@@ -143,7 +143,12 @@ pub struct Change {
 }
 
 impl Change {
-    pub fn render_pr_ui(&self, changes: &[Self], branch_desc: Option<&str>) -> Result<()> {
+    pub fn render_pr_ui(
+        &self,
+        changes: &[Self],
+        stack_name: &str,
+        merged_prs: &[Pr],
+    ) -> Result<()> {
         let commit = self.local_change.commit()?;
         let mut index = None;
         let title = String::from(
@@ -168,17 +173,20 @@ impl Change {
             }
             body.push('\n');
         }
+        let index = index.expect(
+            "render_pr_ui asked to render into a stack of changes which does not contain self?",
+        );
+        for pr in merged_prs.iter().rev() {
+            body.push_str(&format!("- #{}\n", pr.number));
+        }
         body.push_str(&format!("- `{}`\n\n<sub>(Note: Closed and merged PRs may not be reflected here and PR numbering is not stable.)</sub>\n", env::base_branch()));
-        let count = changes.len();
-        let position = count
-            - index.expect(
-                "render_pr_ui asked to render into a stack of changes which does not contain self?",
-            );
-        let prefix = branch_desc
-            .map(|d| d.trim())
-            .filter(|d| !d.is_empty())
-            .map(|d| format!("{d}: "))
-            .unwrap_or_else(|| "".into());
+        let count = changes.len() + merged_prs.len();
+        let position = count - index;
+        let prefix = if stack_name.is_empty() {
+            "".into()
+        } else {
+            format!("{}: ", stack_name)
+        };
         self.pr
             .set_title_and_body(&format!("[{prefix}{position}/{count}]: {title}"), &body)
     }
@@ -224,6 +232,15 @@ impl Change {
             .with_context(|| format!("failed to generate interdiff for change {change}"))?;
         Ok(out)
     }
+    pub fn merge(&self) -> Result<()> {
+        let commit = self.local_change.commit()?;
+        let subject_raw = commit.summary()?.context("couldn't get summary")?;
+        let subject = format!("{} (#{})", subject_raw, self.pr.number);
+        let body = commit.body()?.context("couldn't get summary")?;
+        let sha = format!("{}", self.local_change.oid);
+        self.pr.merge(&subject, body, &sha)?;
+        Ok(())
+    }
 }
 
 fn tree<'repo>(commit: &Commit<'repo>) -> Result<Tree<'repo>> {
diff --git b/src/cli.rs a/src/cli.rs
@@ -99,11 +99,24 @@ pub enum Command {
     /// sequence, with additional trailers in the PR message body to help reviewers navigate the
     /// stack.
     ///
-    /// Note: This command will never modify the local repo. No local branches are created or
-    /// destroyed, and no commits are touched. All mutation occurs exclusively on the `$remote`.
+    /// 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`.
     #[command(visible_alias = "p")]
     Push(Push),
-    /// Print the PR URL of the top-most change which already has one.
+    /// Merge the next change.
+    ///
+    /// If successful, this will modify the local `branch.<name>.description` git config entry to
+    /// record the merged change. This allows future `push`es to include merged changes in the
+    /// reviewer stack "UI", and to keep the relative numbering of changes stable.
+    ///
+    /// If a change's PR is merged in any other way, it will "disappear" from the stack, affecting
+    /// all downstream numbering and the total number of changes in the stack. If you want to
+    /// manually correct this, edit `branch.<name>.description` using e.g. `git branch
+    /// --edit-description` and append the merged change's ID to the `merged_change_ids` list.
+    Merge(Merge),
+    /// With no arguments, print the current stack name. With an argument, set it.
+    Name(Name),
+    /// Print the PR URL of the top-most (i.e. last) change which already has one.
     Url(Url),
     /// Install a commit-msg hook in the current git repo to create `Change-Id:` trailers.
     InstallHook(InstallHook),
@@ -126,6 +139,14 @@ pub struct Push {
 }
 
 #[derive(Args)]
+pub struct Name {
+    pub new_name: Option<String>,
+}
+
+#[derive(Args)]
+pub struct Merge;
+
+#[derive(Args)]
 pub struct Url;
 
 #[derive(Args)]
diff --git b/src/gh.rs a/src/gh.rs
@@ -223,6 +223,32 @@ impl Pr {
         bail!("gh pr create did not produce a URL")
     }
 
+    pub fn merge(&self, subject: &str, body: &str, sha: &str) -> Result<()> {
+        let mut cmd = gh();
+        let mut body_arg = ArgInlineOrFile::new("body");
+        let body_arg_string = body_arg.arg(body)?;
+        let args = self.args_for(
+            "merge",
+            [
+                "--squash",
+                "--match-head-commit",
+                sha,
+                "--subject",
+                subject,
+                &body_arg_string,
+            ],
+        );
+        cmd.args(args);
+        let output = exec!(dry_return = (), cmd);
+        // gh cli doesn't consider this a failure, but we want to so we don't mistakenly add an
+        // already-merged change to the metadata. We could instead infer that the change should be
+        // added to the metadata, but we can't necessarily assume it is the *next* merged change(?)
+        if String::from_utf8_lossy(output.stderr.as_ref()).contains("was already merged") {
+            bail!("pr {} was already merged", self.number);
+        }
+        Ok(())
+    }
+
     pub fn get_url(&self) -> String {
         format!("{}/pull/{}", REPO_URL.as_str(), self.number)
     }
diff --git b/src/main.rs a/src/main.rs
@@ -6,6 +6,7 @@ mod change;
 mod cli;
 mod env;
 mod gh;
+mod metadata;
 mod util;
 
 fn main() -> anyhow::Result<()> {
diff --git b/src/metadata.rs a/src/metadata.rs
@@ -0,0 +1,46 @@
+use crate::env;
+use crate::util::{RepoExt};
+use serde::{Deserialize, Serialize};
+use anyhow::{Result, Context, bail};
+use std::collections::HashSet;
+
+#[derive(Default, Serialize, Deserialize)]
+#[serde(default)]
+pub struct StackMetadata {
+    pub stack_name: String,
+    pub merged_change_ids: Vec<String>,
+}
+
+impl StackMetadata {
+    pub fn from_repo() -> Result<Self> {
+        let repo = env::repo();
+        let branch = repo.head_branch().context("HEAD must be a branch")?;
+        let branch_desc = repo.branch_desc(&branch);
+        let branch_desc = branch_desc.as_deref().unwrap_or("");
+        let res = Self::from_str(branch_desc)?;
+        let mut changed_set = HashSet::new();
+        for merged_change_id in res.merged_change_ids.iter() {
+            if !changed_set.insert(merged_change_id.as_str()) {
+                bail!(
+                    "duplicate in merged_change_ids: {merged_change_id} (correct with `git branch --edit-description`)"
+                );
+            }
+        }
+        Ok(res)
+    }
+    pub fn to_repo(&self) -> Result<()> {
+        if env::dry_run() {
+            eprintln!("would-set-description: {:?}", self.to_string()?);
+            return Ok(());
+        }
+        let repo = env::repo();
+        let branch = repo.head_branch().context("HEAD must be a branch")?;
+        repo.set_branch_desc(&branch, &self.to_string()?)
+    }
+    pub fn from_str(s: &str) -> Result<Self> {
+        Ok(toml::from_str(s).context("branch description is not valid metadata TOML")?)
+    }
+    pub fn to_string(&self) -> Result<String> {
+        Ok(toml::to_string_pretty(self).context("could not serialize metadata TOML")?)
+    }
+}
diff --git b/src/util.rs a/src/util.rs
@@ -64,7 +64,8 @@ impl<T, E: Debug> Extract for std::result::Result<T, E> {
 
 pub trait RepoExt {
     fn head_branch<'repo>(&'repo self) -> Result<Branch<'repo>>;
-    fn branch_desc(&self, str: Branch) -> Result<String>;
+    fn branch_desc(&self, branch: &Branch) -> Result<String>;
+    fn set_branch_desc(&self, branch: &Branch, desc: &str) -> Result<()>;
 }
 
 impl RepoExt for Repository {
@@ -75,7 +76,7 @@ impl RepoExt for Repository {
         }
         Ok(Branch::wrap(branch))
     }
-    fn branch_desc(&self, branch: Branch) -> Result<String> {
+    fn branch_desc(&self, branch: &Branch) -> Result<String> {
         let branch_name = branch
             .name()
             .context("HEAD branch has no name")?
@@ -88,10 +89,18 @@ impl RepoExt for Repository {
         let full_desc = config_entry
             .value()
             .context("branch description is not valid utf8")?;
-        Ok(full_desc
-            .split('\n')
-            .next()
-            .unwrap_or(full_desc)
-            .to_string())
+        Ok(full_desc.to_string())
+    }
+    fn set_branch_desc(&self, branch: &Branch, desc: &str) -> Result<()> {
+        let branch_name = branch
+            .name()
+            .context("HEAD branch has no name")?
+            .context("HEAD branch name is not valid utf-8")?;
+        let mut repo_config = self.config().context("repo has no config")?;
+        let config_key = format!("branch.{branch_name}.description");
+        repo_config
+            .set_str(config_key.as_str(), desc)
+            .context("no branch description")?;
+        Ok(())
     }
 }

@slinder1 slinder1 marked this pull request as ready for review June 25, 2026 15:34
@slinder1 slinder1 force-pushed the users/slinder1/Ib6b188c23fc84460fad74460e05fd6ba7a02e8ca branch 2 times, most recently from 4f133e4 to 56b6b61 Compare June 25, 2026 15:37
Comment thread src/cgh.rs Outdated
c.pr.number, base,
)
})?;
c.render_pr_ui(&changes, &stack_meta.stack_name, &merged_prs)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am undecided on whether merged_prs should also have their UI updated. I think starting with "the tool doesn't modify merged PRs whatsoever" is best, but it might be OK to clean up their UI so it doesn't fall out of sync as the rest of the stack changes.

@slinder1 slinder1 changed the title [3/3]: Abuse branch description for metadata [metadata: 3/3]: Use git-config branch keys to track stack metadata Jun 25, 2026
@slinder1

Copy link
Copy Markdown
Owner Author
🛠️ Changes since last version (click to expand):
diff --git b/src/cgh.rs a/src/cgh.rs
@@ -153,7 +153,7 @@ fn push(cfg: &cli::Push) -> Result<()> {
                     c.pr.number, base,
                 )
             })?;
-            c.render_pr_ui(&changes, &stack_meta.stack_name, &merged_prs)
+            c.render_pr_ui(&changes, &stack_meta.short_name, &merged_prs)
                 .context("could not render pseudo-ui in pr title/body")
         })
         .collect::<Result<Vec<_>>>()
@@ -198,9 +198,9 @@ fn detect_cycles(any_changes: &[AnyChange]) -> bool {
 fn name(cfg: &cli::Name) -> Result<()> {
     let mut stack_meta = StackMetadata::from_repo()?;
     match cfg.new_name {
-        None => println!("{}", stack_meta.stack_name),
+        None => println!("{}", stack_meta.short_name),
         Some(ref new_name) => {
-            stack_meta.stack_name = new_name.to_string();
+            stack_meta.short_name = new_name.to_string();
             stack_meta.to_repo()?;
         },
     }
diff --git b/src/change.rs a/src/change.rs
@@ -146,7 +146,7 @@ impl Change {
     pub fn render_pr_ui(
         &self,
         changes: &[Self],
-        stack_name: &str,
+        short_name: &str,
         merged_prs: &[Pr],
     ) -> Result<()> {
         let commit = self.local_change.commit()?;
@@ -182,10 +182,10 @@ impl Change {
         body.push_str(&format!("- `{}`\n\n<sub>(Note: Closed and merged PRs may not be reflected here and PR numbering is not stable.)</sub>\n", env::base_branch()));
         let count = changes.len() + merged_prs.len();
         let position = count - index;
-        let prefix = if stack_name.is_empty() {
+        let prefix = if short_name.is_empty() {
             "".into()
         } else {
-            format!("{}: ", stack_name)
+            format!("{}: ", short_name)
         };
         self.pr
             .set_title_and_body(&format!("[{prefix}{position}/{count}]: {title}"), &body)
diff --git b/src/cli.rs a/src/cli.rs
@@ -103,19 +103,26 @@ pub enum Command {
     /// branches are created or destroyed. All mutation occurs exclusively on the `$remote`.
     #[command(visible_alias = "p")]
     Push(Push),
+    /// With no arguments, print the current stack's short-name. With an argument, set it.
+    ///
+    /// The short-name is tracked in the `branch.<name>.cgh-shortName` git config entry,
+    /// and is used to prefix the PR title, e.g. `[${short-name} 3/5] ...`
+    Name(Name),
     /// Merge the next change.
     ///
-    /// If successful, this will modify the local `branch.<name>.description` git config entry to
-    /// record the merged change. This allows future `push`es to include merged changes in the
-    /// reviewer stack "UI", and to keep the relative numbering of changes stable.
+    /// If successful, this will modify the local `branch.<name>.cgh-mergedChangeIds` git config
+    /// entry to record that the change was merged. This allows future `cgh push`es to include
+    /// merged changes in the reviewer stack "UI", keeping the relative numbering of changes stable.
     ///
     /// If a change's PR is merged in any other way, it will "disappear" from the stack, affecting
     /// all downstream numbering and the total number of changes in the stack. If you want to
-    /// manually correct this, edit `branch.<name>.description` using e.g. `git branch
-    /// --edit-description` and append the merged change's ID to the `merged_change_ids` list.
+    /// manually correct this, edit `branch.<name>.cgh-mergedChangeIds` (a ':' separated list) using
+    /// e.g. `git config --edit` and append the merged change's ID to the list (or create it, if it
+    /// does not already exist).
+    ///
+    /// If you don't care about the renumbering behavior, you can safely ignore this subcommand (it
+    /// is purely aesthetic).
     Merge(Merge),
-    /// With no arguments, print the current stack name. With an argument, set it.
-    Name(Name),
     /// Print the PR URL of the top-most (i.e. last) change which already has one.
     Url(Url),
     /// Install a commit-msg hook in the current git repo to create `Change-Id:` trailers.
diff --git b/src/metadata.rs a/src/metadata.rs
@@ -1,23 +1,33 @@
 use crate::env;
 use crate::util::{RepoExt};
-use serde::{Deserialize, Serialize};
 use anyhow::{Result, Context, bail};
 use std::collections::HashSet;
 
-#[derive(Default, Serialize, Deserialize)]
-#[serde(default)]
+#[derive(Default, Debug)]
 pub struct StackMetadata {
-    pub stack_name: String,
+    pub short_name: String,
     pub merged_change_ids: Vec<String>,
 }
 
+const SHORT_NAME_KEY: &'static str = "shortName";
+const MERGED_CHANGE_IDS_KEY: &'static str = "mergedChangeIds";
+
 impl StackMetadata {
     pub fn from_repo() -> Result<Self> {
         let repo = env::repo();
         let branch = repo.head_branch().context("HEAD must be a branch")?;
-        let branch_desc = repo.branch_desc(&branch);
-        let branch_desc = branch_desc.as_deref().unwrap_or("");
-        let res = Self::from_str(branch_desc)?;
+        let branch_config = repo.branch_config(&branch)?;
+        let short_name = branch_config.get(SHORT_NAME_KEY)?;
+        let merged_change_ids = branch_config
+            .get(MERGED_CHANGE_IDS_KEY)?
+            .split(':')
+            .filter(|s| !s.is_empty())
+            .map(String::from)
+            .collect();
+        let res = StackMetadata {
+            short_name,
+            merged_change_ids,
+        };
         let mut changed_set = HashSet::new();
         for merged_change_id in res.merged_change_ids.iter() {
             if !changed_set.insert(merged_change_id.as_str()) {
@@ -30,17 +40,14 @@ impl StackMetadata {
     }
     pub fn to_repo(&self) -> Result<()> {
         if env::dry_run() {
-            eprintln!("would-set-description: {:?}", self.to_string()?);
+            eprintln!("would-set: {:?}", self);
             return Ok(());
         }
         let repo = env::repo();
         let branch = repo.head_branch().context("HEAD must be a branch")?;
-        repo.set_branch_desc(&branch, &self.to_string()?)
-    }
-    pub fn from_str(s: &str) -> Result<Self> {
-        Ok(toml::from_str(s).context("branch description is not valid metadata TOML")?)
-    }
-    pub fn to_string(&self) -> Result<String> {
-        Ok(toml::to_string_pretty(self).context("could not serialize metadata TOML")?)
+        let mut branch_config = repo.branch_config(&branch)?;
+        branch_config.set(SHORT_NAME_KEY, &self.short_name)?;
+        branch_config.set(MERGED_CHANGE_IDS_KEY, &self.merged_change_ids.join(":"))?;
+        Ok(())
     }
 }
diff --git b/src/util.rs a/src/util.rs
@@ -2,7 +2,7 @@
 // SPDX-License-Identifier: MIT
 
 use anyhow::{Context, Result, bail};
-use git2::{Branch, Repository};
+use git2::{Branch, Repository, Config};
 use std::fmt::Debug;
 use std::process::{Command, Output};
 
@@ -63,9 +63,8 @@ impl<T, E: Debug> Extract for std::result::Result<T, E> {
 }
 
 pub trait RepoExt {
-    fn head_branch<'repo>(&'repo self) -> Result<Branch<'repo>>;
-    fn branch_desc(&self, branch: &Branch) -> Result<String>;
-    fn set_branch_desc(&self, branch: &Branch, desc: &str) -> Result<()>;
+    fn head_branch(&self) -> Result<Branch<'_>>;
+    fn branch_config<'repo>(&self, branch: &'repo Branch) -> Result<BranchConfig<'repo>>;
 }
 
 impl RepoExt for Repository {
@@ -76,31 +75,58 @@ impl RepoExt for Repository {
         }
         Ok(Branch::wrap(branch))
     }
-    fn branch_desc(&self, branch: &Branch) -> Result<String> {
-        let branch_name = branch
-            .name()
-            .context("HEAD branch has no name")?
-            .context("HEAD branch name is not valid utf-8")?;
-        let repo_config = self.config().context("repo has no config")?;
-        let config_key = format!("branch.{branch_name}.description");
-        let config_entry = repo_config
-            .get_entry(config_key.as_str())
-            .context("no branch description")?;
-        let full_desc = config_entry
-            .value()
-            .context("branch description is not valid utf8")?;
-        Ok(full_desc.to_string())
+    fn branch_config<'repo>(&self, branch: &'repo Branch) -> Result<BranchConfig<'repo>> {
+        BranchConfig::new(self, branch)
     }
-    fn set_branch_desc(&self, branch: &Branch, desc: &str) -> Result<()> {
-        let branch_name = branch
-            .name()
-            .context("HEAD branch has no name")?
-            .context("HEAD branch name is not valid utf-8")?;
-        let mut repo_config = self.config().context("repo has no config")?;
-        let config_key = format!("branch.{branch_name}.description");
-        repo_config
-            .set_str(config_key.as_str(), desc)
-            .context("no branch description")?;
+}
+
+pub struct BranchConfig<'repo> {
+    branch_name: &'repo str,
+    config: Config,
+}
+
+impl<'repo> BranchConfig<'repo> {
+    pub fn new(repo: &Repository, branch: &'repo Branch<'_>) -> Result<Self> {
+        let branch_name = branch_name(branch)?;
+        let config = config(repo)?;
+        Ok(Self {
+            branch_name,
+            config,
+        })
+    }
+    fn format_key(&self, key: &str) -> String {
+        format!("branch.{}.cgh-{key}", self.branch_name)
+    }
+    pub fn get(&self, key: &str) -> Result<String> {
+        let config_key = self.format_key(key);
+        let value_result = self.config.get_entry(config_key.as_str());
+        let value = match value_result {
+            Ok(ce) => ce
+                .value()
+                .context("error getting config entry value")?
+                .to_string(),
+            // all the config values can safely default to the empty string
+            Err(e) if e.code() == git2::ErrorCode::NotFound => "".to_string(),
+            Err(e) => return Err(anyhow::Error::new(e).context("error getting config entry")),
+        };
+        Ok(value)
+    }
+    pub fn set(&mut self, key: &str, val: &str) -> Result<()> {
+        let config_key = self.format_key(key);
+        self.config
+            .set_str(config_key.as_str(), val)
+            .with_context(|| format!("could not update config key {config_key}"))?;
         Ok(())
     }
 }
+
+fn branch_name<'repo>(branch: &'repo Branch) -> Result<&'repo str> {
+    Ok(branch
+        .name()
+        .context("HEAD branch has no name")?
+        .context("HEAD branch name is not valid utf-8")?)
+}
+
+fn config(repo: &Repository) -> Result<Config> {
+    Ok(repo.config().context("repo has no config")?)
+}

@slinder1 slinder1 requested a review from palves June 25, 2026 17:43
@slinder1

Copy link
Copy Markdown
Owner Author
🛠️ Changes since last version (click to expand):
diff --git b/src/cgh.rs a/src/cgh.rs
@@ -201,7 +201,7 @@ fn name(cfg: &cli::Name) -> Result<()> {
         Some(ref new_name) => {
             stack_meta.short_name = new_name.to_string();
             stack_meta.to_repo()?;
-        },
+        }
     }
     Ok(())
 }
diff --git b/src/metadata.rs a/src/metadata.rs
@@ -1,6 +1,6 @@
 use crate::env;
-use crate::util::{RepoExt};
-use anyhow::{Result, Context, bail};
+use crate::util::RepoExt;
+use anyhow::{Context, Result, bail};
 use std::collections::HashSet;
 
 #[derive(Default, Debug)]
@@ -9,8 +9,8 @@ pub struct StackMetadata {
     pub merged_change_ids: Vec<String>,
 }
 
-const SHORT_NAME_KEY: &'static str = "shortName";
-const MERGED_CHANGE_IDS_KEY: &'static str = "mergedChangeIds";
+const SHORT_NAME_KEY: &str = "shortName";
+const MERGED_CHANGE_IDS_KEY: &str = "mergedChangeIds";
 
 impl StackMetadata {
     pub fn from_repo() -> Result<Self> {
diff --git b/src/util.rs a/src/util.rs
@@ -2,7 +2,7 @@
 // SPDX-License-Identifier: MIT
 
 use anyhow::{Context, Result, bail};
-use git2::{Branch, Repository, Config};
+use git2::{Branch, Config, Repository};
 use std::fmt::Debug;
 use std::process::{Command, Output};
 
@@ -121,12 +121,12 @@ impl<'repo> BranchConfig<'repo> {
 }
 
 fn branch_name<'repo>(branch: &'repo Branch) -> Result<&'repo str> {
-    Ok(branch
+    branch
         .name()
         .context("HEAD branch has no name")?
-        .context("HEAD branch name is not valid utf-8")?)
+        .context("HEAD branch name is not valid utf-8")
 }
 
 fn config(repo: &Repository) -> Result<Config> {
-    Ok(repo.config().context("repo has no config")?)
+    repo.config().context("repo has no config")
 }

@slinder1 slinder1 force-pushed the users/slinder1/Ib6b188c23fc84460fad74460e05fd6ba7a02e8ca branch 2 times, most recently from 56b6b61 to 2723877 Compare June 25, 2026 17:51
@slinder1 slinder1 changed the title [metadata: 3/3]: Use git-config branch keys to track stack metadata [3/3]: Use git-config branch keys to track stack metadata Jun 25, 2026
@slinder1 slinder1 force-pushed the users/slinder1/Ib6b188c23fc84460fad74460e05fd6ba7a02e8ca branch from 265efd1 to 24ec532 Compare June 25, 2026 19:38
@slinder1 slinder1 force-pushed the users/slinder1/I3212a00b2ed3a52952eb3a9ccf9d30e40a51c3f4 branch from 0e87fa6 to 1c0aade Compare June 25, 2026 19:38
@slinder1 slinder1 changed the title [3/3]: Use git-config branch keys to track stack metadata [2/2]: Use git-config branch keys to track stack metadata Jun 25, 2026
@slinder1 slinder1 changed the title [2/2]: Use git-config branch keys to track stack metadata [metadata: 3/3]: Use git-config branch keys to track stack metadata Jun 25, 2026
Base automatically changed from users/slinder1/I3212a00b2ed3a52952eb3a9ccf9d30e40a51c3f4 to main June 25, 2026 19:39
We were using the `branch.<name>.description` entry already for the
short-name of the stack, but really it is meant for longer form content
that other tools already digest.

Add a `name` subcommand to edit and print the stack short-name, saved
to `branch.<name>.cgh-shortName`.

Add a `merge` subcommand to automatically maintain the
`branch.<name>.cgh-mergedChangeIds` metadata. If a user doesn't care
about the apparent size of the stack shrinking as it "forgets" about
merged changes, then they can ignore this and use the web-UI or `gh pr
merge` or `git push` themselves.

Note that git-config doesn't seem to be able to nest keys any deeper
than `branch.<name>.<key>`, hence we namespace our sub-keys with the
`cgh-` prefix.

Also note that both entries default to the empty string, and are assumed
to be the default if not present in the config. This means a user
without interest in the features that depend on the keys won't see them
in their config.

Change-Id: Ib6b188c23fc84460fad74460e05fd6ba7a02e8ca
@slinder1 slinder1 force-pushed the users/slinder1/Ib6b188c23fc84460fad74460e05fd6ba7a02e8ca branch from 24ec532 to d4d4455 Compare June 25, 2026 19:40
@slinder1 slinder1 merged commit 0e4f91d into main Jun 25, 2026
2 checks passed
@slinder1 slinder1 deleted the users/slinder1/Ib6b188c23fc84460fad74460e05fd6ba7a02e8ca branch June 25, 2026 19:40
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