Source: stgit
Version: 2.6.1-1.1
Severity: important
Tags: patch
User: [email protected]
Usertags: gix-0.87
X-Debbugs-Cc: [email protected]

Dear maintainer,

I am preparing a rust-gix transition to 0.87, which will likely be uploaded 
soon.

stgit currently depends on gix 0.86. My patch was rejected upstream in favor of 
a 0.87 port, that needs to be cherry-picked with a small test fix.

I have attached a debdiff fixing this.

Best regards,
Simon Quigley
[email protected]
diff -Nru stgit-2.6.1/debian/changelog stgit-2.6.1/debian/changelog
--- stgit-2.6.1/debian/changelog        2026-08-24 09:43:17.000000000 -0500
+++ stgit-2.6.1/debian/changelog        2026-09-18 10:19:50.000000000 -0500
@@ -1,3 +1,12 @@
+stgit (2.6.1-1.2) unstable; urgency=medium
+
+  * Non-maintainer upload.
+  * Drop gix-0.86.patch in favor of a direct upstream cherry-pick in
+    gix-0.87.patch.
+  * Cherry-pick upstream fix to reject a lone "@" as a partial ref name.
+
+ -- Simon Quigley <[email protected]>  Fri, 18 Sep 2026 10:19:50 -0500
+
 stgit (2.6.1-1.1) unstable; urgency=medium
 
   * Non-maintainer upload (Closes: #1144970).
diff -Nru stgit-2.6.1/debian/control stgit-2.6.1/debian/control
--- stgit-2.6.1/debian/control  2026-08-24 09:43:17.000000000 -0500
+++ stgit-2.6.1/debian/control  2026-09-18 10:01:34.000000000 -0500
@@ -24,7 +24,7 @@
  librust-curl-dev,
  librust-encoding-rs-dev,
  librust-flate2-dev,
- librust-gix-dev (>= 0.86),
+ librust-gix-dev (>= 0.87),
  librust-indexmap-dev,
  librust-is-terminal-dev,
  librust-jiff-dev (>= 0.2.1),
diff -Nru stgit-2.6.1/debian/patches/gix-0.86.patch 
stgit-2.6.1/debian/patches/gix-0.86.patch
--- stgit-2.6.1/debian/patches/gix-0.86.patch   2026-08-24 09:43:17.000000000 
-0500
+++ stgit-2.6.1/debian/patches/gix-0.86.patch   1969-12-31 18:00:00.000000000 
-0600
@@ -1,430 +0,0 @@
-Description: Port to gix 0.86
- Bump the gix crate from 0.84 to 0.86 and update call sites for the
- gix-config 0.59 API that gix 0.86 re-exports. A version-only bump does
- not compile.
- .
- gix::config::File no longer takes a lifetime, so File<'static> and
- CommitOptions<'_> lose that parameter. Snapshot::string() now returns
- Option<BString> rather than Option<Cow<BStr>>, so CommitOptions stores
- BString and for_label() takes s.as_ref().
- .
- Snapshot::string_by and boolean_by are gone. Look up dotted keys with
- string() / boolean() / try_boolean(), which stgit already uses for
- keys such as branch.{name}.remote. try_boolean() is Result<Option<bool>>
- rather than Option<Result<bool>>, so drop the extra transpose() on the
- autostash lookups.
- .
- SectionRef::num_values() moved to BodyRef (section.body().num_values()).
- value_names() yields String rather than BStr, so skip to_str() and pass
- the name through to value(). File::rename_section's new subsection is
- IntoBStringOpt (Option<BString>), not Option<Cow<BStr>>.
- .
- Snapshot::trusted_path returns Result<Option<PathBuf>, _> instead of an
- Option that needs transpose(). Hook path construction can use PathBuf
- directly; editor lookup maps PathBuf with into_os_string().
-Author: Simon Quigley <[email protected]>
-Origin: vendor
-Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1144970
-Forwarded: https://github.com/stacked-git/stgit/pull/648
-Last-Update: 2026-08-20
----
-This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
---- a/Cargo.toml
-+++ b/Cargo.toml
-@@ -43,7 +43,7 @@ clap = { version = "~4.6", default-featu
- ctrlc = "3.4"
- encoding_rs = "0.8"
- flate2 = "1"
--gix = { version = "0.84", default-features = false, features = [
-+gix = { version = "0.86", default-features = false, features = [
-   "command",
-   "revision",
-   "sha1",
---- a/src/alias.rs
-+++ b/src/alias.rs
-@@ -100,15 +100,9 @@ where
-                 .filter(|section| section.header().subsection_name() == 
Some("alias".into()))
-             {
-                 for value_name in section.value_names() {
--                    let name = value_name.to_str().map_err(|_| {
--                        anyhow!(
--                            "alias name `{}` in {} is not valid UTF-8",
--                            value_name.to_str_lossy(),
--                            config_source_str(section.meta().source),
--                        )
--                    })?;
-+                    let name = value_name.as_str();
-                     if let Some(value) = section
--                        .value(value_name)
-+                        .value(name)
-                         .and_then(|v| (!v.is_empty()).then_some(v))
-                     {
-                         if !exclude(name) {
---- a/src/cmd/branch/list.rs
-+++ b/src/cmd/branch/list.rs
-@@ -101,7 +101,7 @@ pub(super) fn dispatch(repo: &gix::Repos
-         stdout.set_color(&color_spec)?;
- 
-         let description = config
--            .string_by("branch", Some(branchname.into()), "description")
-+            .string(format!("branch.{branchname}.description").as_str())
-             .unwrap_or_default();
-         if description.is_empty() {
-             writeln!(stdout)?;
---- a/src/cmd/branch/mod.rs
-+++ b/src/cmd/branch/mod.rs
-@@ -151,7 +151,7 @@ fn set_description(
-             value.delete();
-         }
-         if let Ok(section) = local_config_file.section("branch", 
Some(branchname.into())) {
--            if section.num_values() == 0 {
-+            if section.body().num_values() == 0 {
-                 local_config_file.remove_section_by_id(section.id());
-             }
-         }
-@@ -171,11 +171,7 @@ fn set_description(
- 
- fn get_stgit_parent(config: &gix::config::Snapshot, branchname: 
&PartialRefName) -> Option<String> {
-     config
--        .string_by(
--            "branch",
--            Some(format!("{branchname}.stgit").as_str().into()),
--            "parentbranch",
--        )
-+        .string(format!("branch.{branchname}.stgit.parentbranch").as_str())
-         .and_then(|bs| bs.to_str().ok().map(str::to_string))
- }
- 
-@@ -202,7 +198,7 @@ fn set_stgit_parent(
-             value.delete();
-         }
-         if let Ok(section) = local_config_file.section("branch", 
Some(subsection.as_str().into())) {
--            if section.num_values() == 0 {
-+            if section.body().num_values() == 0 {
-                 local_config_file.remove_section_by_id(section.id());
-             }
-         }
---- a/src/cmd/branch/rename.rs
-+++ b/src/cmd/branch/rename.rs
-@@ -84,8 +84,7 @@ pub(super) fn dispatch(repo: &gix::Repos
-             .section("branch", Some(old_section_name))
-             .is_ok()
-         {
--            let new_section_name =
--                
std::borrow::Cow::Owned(BString::from(format!("{new_branchname}.stgit")));
-+            let new_section_name = 
BString::from(format!("{new_branchname}.stgit"));
-             local_config_file
-                 .rename_section(
-                     "branch",
---- a/src/cmd/pick.rs
-+++ b/src/cmd/pick.rs
-@@ -297,7 +297,7 @@ fn pick_picks(
-                  {body}"
-             )
-         } else if matches.get_flag("expose") {
--            let expose_format = config.string_by("stgit", 
Some("pick".into()), "expose-format");
-+            let expose_format = config.string("stgit.pick.expose-format");
-             let expose_format = expose_format
-                 .as_ref()
-                 .map(|bs| bs.to_str().ok())
---- a/src/cmd/pull.rs
-+++ b/src/cmd/pull.rs
-@@ -101,11 +101,7 @@ fn run(matches: &ArgMatches) -> Result<(
-     let config = repo.config_snapshot();
-     let policy = PullPolicy::from_str(
-         &config
--            .string_by(
--                "branch",
--                Some(format!("{branch_name}.stgit").as_str().into()),
--                "pull-policy",
--            )
-+            
.string(format!("branch.{branch_name}.stgit.pull-policy").as_str())
-             .or_else(|| config.string("stgit.pull-policy"))
-             .map(|bs| bs.to_str_lossy().to_string())
-             .unwrap_or_else(|| "pull".to_string()),
-@@ -125,7 +121,7 @@ fn run(matches: &ArgMatches) -> Result<(
-         }
-         PullPolicy::Pull | PullPolicy::FetchRebase => {
-             parent_remote = config
--                .string_by("branch", Some(branch_name.as_str().into()), 
"remote")
-+                .string(format!("branch.{branch_name}.remote").as_str())
-                 .and_then(|bs| bs.to_str().map(str::to_string).ok());
-             let remote_name = matches
-                 .get_one::<String>("repository")
-@@ -163,11 +159,7 @@ fn run(matches: &ArgMatches) -> Result<(
-     let rebase_target = match policy {
-         PullPolicy::Pull => {
-             let pull_cmd = config
--                .string_by(
--                    "branch",
--                    Some(format!("{branch_name}.stgit").as_str().into()),
--                    "pullcmd",
--                )
-+                
.string(format!("branch.{branch_name}.stgit.pullcmd").as_str())
-                 .or_else(|| config.string("stgit.pullcmd"))
-                 .and_then(|bs| bs.to_str().map(str::to_string).ok())
-                 .unwrap_or_else(|| "git pull".to_string());
-@@ -183,11 +175,7 @@ fn run(matches: &ArgMatches) -> Result<(
-         }
-         PullPolicy::FetchRebase => {
-             let fetch_cmd = config
--                .string_by(
--                    "branch",
--                    Some(format!("{branch_name}.stgit").as_str().into()),
--                    "fetchcmd",
--                )
-+                
.string(format!("branch.{branch_name}.stgit.fetchcmd").as_str())
-                 .or_else(|| config.string("stgit.fetchcmd"))
-                 .and_then(|bs| bs.to_str().map(str::to_string).ok())
-                 .unwrap_or_else(|| "git fetch".to_string());
-@@ -203,11 +191,8 @@ fn run(matches: &ArgMatches) -> Result<(
-             Some(target_id)
-         }
-         PullPolicy::Rebase => {
--            let parent_branch_name = config.string_by(
--                "branch",
--                Some(format!("{branch_name}.stgit").as_str().into()),
--                "parentbranch",
--            );
-+            let parent_branch_name =
-+                
config.string(format!("branch.{branch_name}.stgit.parentbranch").as_str());
-             let parent_branch_name = 
parent_branch_name.as_ref().and_then(|bs| bs.to_str().ok());
- 
-             let parent_object = if let Some(name) = parent_branch_name {
-@@ -227,11 +212,7 @@ fn run(matches: &ArgMatches) -> Result<(
- 
-     if let Some(rebase_target) = rebase_target {
-         let rebase_cmd = config
--            .string_by(
--                "branch",
--                Some(format!("{branch_name}.stgit").as_str().into()),
--                "rebasecmd",
--            )
-+            .string(format!("branch.{branch_name}.stgit.rebasecmd").as_str())
-             .or_else(|| config.string("stgit.rebasecmd"))
-             .and_then(|bs| bs.to_str().map(str::to_string).ok())
-             .unwrap_or_else(|| "git reset --hard".to_string());
---- a/src/cmd/rebase.rs
-+++ b/src/cmd/rebase.rs
-@@ -175,12 +175,7 @@ fn run(matches: &ArgMatches) -> Result<(
-         true
-     } else {
-         config
--            .boolean_by(
--                "branch",
--                Some(format!("{branch_name}.stgit").as_str().into()),
--                "autostash",
--            )
--            .transpose()
-+            
.try_boolean(format!("branch.{branch_name}.stgit.autostash").as_str())
-             .unwrap_or_else(|e| {
-                 crate::print_warning_message(
-                     matches,
-@@ -189,16 +184,13 @@ fn run(matches: &ArgMatches) -> Result<(
-                 Some(false)
-             })
-             .or_else(|| {
--                config
--                    .try_boolean("stgit.autostash")
--                    .transpose()
--                    .unwrap_or_else(|e| {
--                        crate::print_warning_message(
--                            matches,
--                            &format!("Invalid config value `stgit.autostash`: 
{e}"),
--                        );
--                        Some(false)
--                    })
-+                config.try_boolean("stgit.autostash").unwrap_or_else(|e| {
-+                    crate::print_warning_message(
-+                        matches,
-+                        &format!("Invalid config value `stgit.autostash`: 
{e}"),
-+                    );
-+                    Some(false)
-+                })
-             })
-             .unwrap_or(false)
-     };
-@@ -225,11 +217,7 @@ fn run(matches: &ArgMatches) -> Result<(
-         .execute("rebase (pop)")?;
- 
-     let rebase_cmd = config
--        .string_by(
--            "branch",
--            Some(format!("{branch_name}.stgit").as_str().into()),
--            "rebasecmd",
--        )
-+        .string(format!("branch.{branch_name}.stgit.rebasecmd").as_str())
-         .or_else(|| config.string("stgit.rebasecmd"))
-         .and_then(|bs| bs.to_str().map(str::to_string).ok())
-         .unwrap_or_else(|| "git reset --hard".to_string());
---- a/src/ext/repository.rs
-+++ b/src/ext/repository.rs
-@@ -1,9 +1,7 @@
- // SPDX-License-Identifier: GPL-2.0-only
- 
--use std::borrow::Cow;
--
- use anyhow::{anyhow, Result};
--use bstr::{BStr, ByteSlice};
-+use bstr::{BString, ByteSlice};
- 
- use crate::{
-     stupid::Stupid,
-@@ -42,7 +40,7 @@ pub(crate) trait RepositoryExtended {
- 
-     /// Get repository-local config file which can be used to change local
-     /// configuration.
--    fn local_config_file(&self) -> Result<gix::config::File<'static>>;
-+    fn local_config_file(&self) -> Result<gix::config::File>;
- 
-     /// Write repository-local config file.
-     fn write_local_config(&self, file: gix::config::File) -> Result<()>;
-@@ -73,7 +71,7 @@ pub(crate) trait RepositoryExtended {
-         message: &Message,
-         tree_id: gix::ObjectId,
-         parent_ids: impl IntoIterator<Item = gix::ObjectId>,
--        options: &CommitOptions<'_>,
-+        options: &CommitOptions,
-     ) -> Result<gix::ObjectId>;
- 
-     /// [`gix::Repository::rev_parse_single()`] with StGit-specific error 
mapping.
-@@ -88,9 +86,9 @@ pub(crate) trait RepositoryExtended {
- }
- 
- /// Options for creating a git commit object.
--pub(crate) struct CommitOptions<'a> {
-+pub(crate) struct CommitOptions {
-     /// The target encoding for the commit message.
--    pub(crate) commit_encoding: Option<Cow<'a, BStr>>,
-+    pub(crate) commit_encoding: Option<BString>,
- 
-     /// Determine whether the commit object should be signed with GPG.
-     pub(crate) gpgsign: bool,
-@@ -171,7 +169,7 @@ impl RepositoryExtended for gix::Reposit
-         }
-     }
- 
--    fn local_config_file(&self) -> Result<gix::config::File<'static>> {
-+    fn local_config_file(&self) -> Result<gix::config::File> {
-         let source = gix::config::Source::Local;
- 
-         let local_config_path = self.common_dir().join(
-@@ -233,11 +231,11 @@ impl RepositoryExtended for gix::Reposit
-         message: &Message,
-         tree_id: gix::ObjectId,
-         parent_ids: impl IntoIterator<Item = gix::ObjectId>,
--        options: &CommitOptions<'_>,
-+        options: &CommitOptions,
-     ) -> Result<gix::ObjectId> {
-         let commit_encoding = match &options.commit_encoding {
-             Some(s) => {
--                let encoding = encoding_rs::Encoding::for_label(s)
-+                let encoding = encoding_rs::Encoding::for_label(s.as_ref())
-                     .ok_or_else(|| anyhow!("unhandled i18n.commitEncoding 
`{s}`"))?;
-                 Some(encoding)
-             }
---- a/src/hook.rs
-+++ b/src/hook.rs
-@@ -3,7 +3,6 @@
- //! Support for using git repository hooks.
- 
- use std::{
--    borrow::Cow,
-     io::Write,
-     path::{Path, PathBuf},
- };
-@@ -18,22 +17,21 @@ use crate::wrap::Message;
- /// Returns None if the hook script is not found or is not executable.
- fn get_hook_path(repo: &gix::Repository, hook_name: &str) -> 
Result<Option<PathBuf>> {
-     let config = repo.config_snapshot();
--    let hooks_path =
--        if let Some(core_hooks_path) = 
config.trusted_path("core.hookspath").transpose()? {
--            if core_hooks_path.is_absolute() {
--                core_hooks_path
--            } else if repo.is_bare() {
--                // The hooks path is relative to GIT_DIR in the case of a 
bare repo
--                Cow::Owned(repo.common_dir().join(core_hooks_path))
--            } else {
--                // The hooks path is relative to the root of the working tree 
otherwise
--                let work_dir = repo.workdir().expect("non-bare repo must have 
work dir");
--                Cow::Owned(work_dir.join(core_hooks_path))
--            }
-+    let hooks_path = if let Some(core_hooks_path) = 
config.trusted_path("core.hookspath")? {
-+        if core_hooks_path.is_absolute() {
-+            core_hooks_path
-+        } else if repo.is_bare() {
-+            // The hooks path is relative to GIT_DIR in the case of a bare 
repo
-+            repo.common_dir().join(core_hooks_path)
-         } else {
--            // No core.hookspath, use default .git/hooks location
--            Cow::Owned(repo.common_dir().join("hooks"))
--        };
-+            // The hooks path is relative to the root of the working tree 
otherwise
-+            let work_dir = repo.workdir().expect("non-bare repo must have 
work dir");
-+            work_dir.join(core_hooks_path)
-+        }
-+    } else {
-+        // No core.hookspath, use default .git/hooks location
-+        repo.common_dir().join("hooks")
-+    };
-     let hook_path = hooks_path.join(hook_name);
- 
-     let hook_meta = match std::fs::metadata(&hook_path) {
---- a/src/patch/edit/interactive.rs
-+++ b/src/patch/edit/interactive.rs
-@@ -135,15 +135,13 @@ fn get_editor(config: &gix::config::Snap
-     let editor = if let Some(editor) = std::env::var_os("GIT_EDITOR") {
-         editor
-     } else if let Some(editor) = config
--        .trusted_path("stgit.editor")
--        .transpose()?
--        .map(|p| p.as_os_str().to_os_string())
-+        .trusted_path("stgit.editor")?
-+        .map(|p| p.into_os_string())
-     {
-         editor
-     } else if let Some(editor) = config
--        .trusted_path("core.editor")
--        .transpose()?
--        .map(|p| p.as_os_str().to_os_string())
-+        .trusted_path("core.editor")?
-+        .map(|p| p.into_os_string())
-     {
-         editor
-     } else if let Some(editor) = std::env::var_os("VISUAL") {
---- a/src/stack/stack.rs
-+++ b/src/stack/stack.rs
-@@ -238,12 +238,7 @@ impl<'repo> Stack<'repo> {
-     /// Check whether the stack is marked as protected in the config.
-     pub(crate) fn is_protected(&self, config: &gix::config::Snapshot) -> bool 
{
-         config
--            .boolean_by(
--                "branch",
--                Some(format!("{}.stgit", self.branch_name).as_str().into()),
--                "protect",
--            )
--            .unwrap_or(Ok(false))
-+            .boolean(format!("branch.{}.stgit.protect", 
self.branch_name).as_str())
-             .unwrap_or(false)
-     }
- 
-@@ -271,7 +266,7 @@ impl<'repo> Stack<'repo> {
-             if let Ok(section) = local_config_file
-                 
.section_by_key(format!("{section}.{subsection}").as_bytes().as_bstr())
-             {
--                if section.num_values() == 0 {
-+                if section.body().num_values() == 0 {
-                     local_config_file.remove_section_by_id(section.id());
-                 }
-             }
---- a/src/stack/upgrade.rs
-+++ b/src/stack/upgrade.rs
-@@ -325,7 +325,7 @@ fn rm_stackformatversion(repo: &gix::Rep
-     if let Ok(section) =
-         
local_config_file.section_by_key(format!("{section}.{subsection}").as_bytes().as_bstr())
-     {
--        if section.num_values() == 0 {
-+        if section.body().num_values() == 0 {
-             local_config_file.remove_section_by_id(section.id());
-         }
-     }
diff -Nru stgit-2.6.1/debian/patches/gix-0.87.patch 
stgit-2.6.1/debian/patches/gix-0.87.patch
--- stgit-2.6.1/debian/patches/gix-0.87.patch   1969-12-31 18:00:00.000000000 
-0600
+++ stgit-2.6.1/debian/patches/gix-0.87.patch   2026-09-18 10:01:21.000000000 
-0500
@@ -0,0 +1,474 @@
+Description: feat: update gix to 0.87.1 introducing git approxidates
+ Skip gix 0.86.0 and 0.87.0 because both use a version of gix-protocol
+ that depends on bisync, and every published bisync release has been
+ yanked. So neither gix 0.86.0 or 0.87.0 resolve at all. gix 0.87.1 drops
+ bisync.
+ . 
+ gix-date 0.16 reads relative dates the way git's approxidate does, so
+ StGit now accepts spellings it used to reject: "2 days 3 hours ago",
+ "one week ago", "last week", "3.days.ago", and a "@" before an epoch.
+ .
+ Relative dates are also anchored in the local zone rather than UTC. That
+ same leniency made the offset-appending retry in parse_time unsound: "a
+ long time ago" carries no offset, so the retry turned it into "a long
+ time ago-04:00", which parses as four seconds ago. Git accepts that
+ mangled string too, so the fault is appending an offset blind; only
+ inputs that look like a time of day are retried now.
+ .
+ The remaining changes are mechanical responses to gix interface changes.
+Author: Peter Grayson <[email protected]>
+Origin: upstream
+Bug: https://github.com/stacked-git/stgit/pull/648
+Applied-Upstream: 68ad69139ad69cb90bd1ea2f5c379dc342fa57a0
+Reviewed-by: Simon Quigley <[email protected]>
+Last-Update: 2026-09-18
+---
+This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
+--- a/Cargo.toml
++++ b/Cargo.toml
+@@ -43,7 +43,7 @@ clap = { version = "~4.6", default-featu
+ ctrlc = "3.4"
+ encoding_rs = "0.8"
+ flate2 = "1"
+-gix = { version = "0.84", default-features = false, features = [
++gix = { version = "0.87", default-features = false, features = [
+   "command",
+   "revision",
+   "sha1",
+--- a/src/alias.rs
++++ b/src/alias.rs
+@@ -100,15 +100,9 @@ where
+                 .filter(|section| section.header().subsection_name() == 
Some("alias".into()))
+             {
+                 for value_name in section.value_names() {
+-                    let name = value_name.to_str().map_err(|_| {
+-                        anyhow!(
+-                            "alias name `{}` in {} is not valid UTF-8",
+-                            value_name.to_str_lossy(),
+-                            config_source_str(section.meta().source),
+-                        )
+-                    })?;
++                    let name = value_name.as_str();
+                     if let Some(value) = section
+-                        .value(value_name)
++                        .value(name)
+                         .and_then(|v| (!v.is_empty()).then_some(v))
+                     {
+                         if !exclude(name) {
+--- a/src/cmd/branch/mod.rs
++++ b/src/cmd/branch/mod.rs
+@@ -151,7 +151,7 @@ fn set_description(
+             value.delete();
+         }
+         if let Ok(section) = local_config_file.section("branch", 
Some(branchname.into())) {
+-            if section.num_values() == 0 {
++            if section.body().num_values() == 0 {
+                 local_config_file.remove_section_by_id(section.id());
+             }
+         }
+@@ -202,7 +202,7 @@ fn set_stgit_parent(
+             value.delete();
+         }
+         if let Ok(section) = local_config_file.section("branch", 
Some(subsection.as_str().into())) {
+-            if section.num_values() == 0 {
++            if section.body().num_values() == 0 {
+                 local_config_file.remove_section_by_id(section.id());
+             }
+         }
+--- a/src/cmd/branch/rename.rs
++++ b/src/cmd/branch/rename.rs
+@@ -84,8 +84,7 @@ pub(super) fn dispatch(repo: &gix::Repos
+             .section("branch", Some(old_section_name))
+             .is_ok()
+         {
+-            let new_section_name =
+-                
std::borrow::Cow::Owned(BString::from(format!("{new_branchname}.stgit")));
++            let new_section_name = 
BString::from(format!("{new_branchname}.stgit"));
+             local_config_file
+                 .rename_section(
+                     "branch",
+--- a/src/cmd/rebase.rs
++++ b/src/cmd/rebase.rs
+@@ -180,7 +180,6 @@ fn run(matches: &ArgMatches) -> Result<(
+                 Some(format!("{branch_name}.stgit").as_str().into()),
+                 "autostash",
+             )
+-            .transpose()
+             .unwrap_or_else(|e| {
+                 crate::print_warning_message(
+                     matches,
+@@ -189,16 +188,13 @@ fn run(matches: &ArgMatches) -> Result<(
+                 Some(false)
+             })
+             .or_else(|| {
+-                config
+-                    .try_boolean("stgit.autostash")
+-                    .transpose()
+-                    .unwrap_or_else(|e| {
+-                        crate::print_warning_message(
+-                            matches,
+-                            &format!("Invalid config value `stgit.autostash`: 
{e}"),
+-                        );
+-                        Some(false)
+-                    })
++                config.try_boolean("stgit.autostash").unwrap_or_else(|e| {
++                    crate::print_warning_message(
++                        matches,
++                        &format!("Invalid config value `stgit.autostash`: 
{e}"),
++                    );
++                    Some(false)
++                })
+             })
+             .unwrap_or(false)
+     };
+--- a/src/ext/repository.rs
++++ b/src/ext/repository.rs
+@@ -1,9 +1,7 @@
+ // SPDX-License-Identifier: GPL-2.0-only
+ 
+-use std::borrow::Cow;
+-
+ use anyhow::{anyhow, Result};
+-use bstr::{BStr, ByteSlice};
++use bstr::{BString, ByteSlice};
+ 
+ use crate::{
+     stupid::Stupid,
+@@ -42,7 +40,7 @@ pub(crate) trait RepositoryExtended {
+ 
+     /// Get repository-local config file which can be used to change local
+     /// configuration.
+-    fn local_config_file(&self) -> Result<gix::config::File<'static>>;
++    fn local_config_file(&self) -> Result<gix::config::File>;
+ 
+     /// Write repository-local config file.
+     fn write_local_config(&self, file: gix::config::File) -> Result<()>;
+@@ -73,7 +71,7 @@ pub(crate) trait RepositoryExtended {
+         message: &Message,
+         tree_id: gix::ObjectId,
+         parent_ids: impl IntoIterator<Item = gix::ObjectId>,
+-        options: &CommitOptions<'_>,
++        options: &CommitOptions,
+     ) -> Result<gix::ObjectId>;
+ 
+     /// [`gix::Repository::rev_parse_single()`] with StGit-specific error 
mapping.
+@@ -88,9 +86,9 @@ pub(crate) trait RepositoryExtended {
+ }
+ 
+ /// Options for creating a git commit object.
+-pub(crate) struct CommitOptions<'a> {
++pub(crate) struct CommitOptions {
+     /// The target encoding for the commit message.
+-    pub(crate) commit_encoding: Option<Cow<'a, BStr>>,
++    pub(crate) commit_encoding: Option<BString>,
+ 
+     /// Determine whether the commit object should be signed with GPG.
+     pub(crate) gpgsign: bool,
+@@ -171,7 +169,7 @@ impl RepositoryExtended for gix::Reposit
+         }
+     }
+ 
+-    fn local_config_file(&self) -> Result<gix::config::File<'static>> {
++    fn local_config_file(&self) -> Result<gix::config::File> {
+         let source = gix::config::Source::Local;
+ 
+         let local_config_path = self.common_dir().join(
+@@ -233,11 +231,11 @@ impl RepositoryExtended for gix::Reposit
+         message: &Message,
+         tree_id: gix::ObjectId,
+         parent_ids: impl IntoIterator<Item = gix::ObjectId>,
+-        options: &CommitOptions<'_>,
++        options: &CommitOptions,
+     ) -> Result<gix::ObjectId> {
+         let commit_encoding = match &options.commit_encoding {
+             Some(s) => {
+-                let encoding = encoding_rs::Encoding::for_label(s)
++                let encoding = encoding_rs::Encoding::for_label(s.as_ref())
+                     .ok_or_else(|| anyhow!("unhandled i18n.commitEncoding 
`{s}`"))?;
+                 Some(encoding)
+             }
+@@ -296,7 +294,7 @@ impl RepositoryExtended for gix::Reposit
+                 // is malformed" by looking for a typed NotFound frame 
anywhere in the
+                 // error tree underlying SingleError::Parse.
+                 if let SingleError::Parse(ref gix_err) = single_err {
+-                    let not_found = gix_err.sources().any(|src| {
++                    let not_found = gix_err.iter_errors().any(|src| {
+                         matches!(
+                             src.downcast_ref::<RefFindError>(),
+                             Some(RefFindError::NotFound { .. })
+--- a/src/ext/time.rs
++++ b/src/ext/time.rs
+@@ -19,29 +19,37 @@ pub(crate) trait TimeExtended {
+     /// | `gitoxide default`| `Thu Jan 6 2022 09:32:07 -0500`  |
+     fn parse_time(time_str: &str) -> Result<gix::date::Time> {
+         let time_str = time_str.trim();
+-        let now = std::time::SystemTime::now();
+-        let zoned_now = jiff::Zoned::try_from(now).unwrap_or_else(|_| 
jiff::Zoned::now());
++        let zoned_now = jiff::Zoned::try_from(std::time::SystemTime::now())
++            .unwrap_or_else(|_| jiff::Zoned::now());
+ 
+         if time_str == "now" {
+-            Ok(gix::date::Time::new(
++            return Ok(gix::date::Time::new(
+                 zoned_now.timestamp().as_second(),
+                 zoned_now.offset().seconds(),
+-            ))
+-        } else if let Ok(time) = gix::date::parse(time_str, Some(now)) {
+-            Ok(time)
+-        } else if let Ok(time) = gix::date::parse(
+-            &format!("{time_str} {}", zoned_now.strftime("%z")),
+-            Some(now),
+-        ) {
+-            Ok(time)
+-        } else if let Ok(time) = gix::date::parse(
+-            &format!("{time_str}{}", zoned_now.strftime("%:z")),
+-            Some(now),
+-        ) {
+-            Ok(time)
+-        } else {
+-            Err(anyhow!("invalid date `{time_str}`"))
++            ));
+         }
++
++        if let Ok(time) = gix::date::parse(time_str, Some(zoned_now.clone())) 
{
++            return Ok(time);
++        }
++
++        // A date-time that is only missing its UTC offset is retried with 
the local
++        // offset appended. Only do that for something that looks like a time 
of day:
++        // git's approxidate reads the appended offset as part of a relative 
date, so
++        // "a long time ago" would otherwise become "a long time ago-04:00", 
which
++        // parses as four seconds ago.
++        if time_str.contains(':') && time_str.ends_with(|c: char| 
c.is_ascii_digit()) {
++            for with_offset in [
++                format!("{time_str} {}", zoned_now.strftime("%z")),
++                format!("{time_str}{}", zoned_now.strftime("%:z")),
++            ] {
++                if let Ok(time) = gix::date::parse(&with_offset, 
Some(zoned_now.clone())) {
++                    return Ok(time);
++                }
++            }
++        }
++
++        Err(anyhow!("invalid date `{time_str}`"))
+     }
+ }
+ 
+@@ -95,6 +103,69 @@ mod tests {
+         Time::parse_time("now").unwrap();
+     }
+ 
++    /// Formats that carry no UTC offset are retried with the local offset 
appended.
++    /// Comparing wall clock time keeps this independent of the test 
machine's zone.
++    #[test]
++    fn parse_time_without_offset() {
++        // 2022-01-06T09:32:07 read as if it were UTC.
++        let wall_clock = 1641461527;
++        for s in [
++            "Thu Jan 6 09:32:07 2022",
++            "Thu, 6 Jan 2022 09:32:07",
++            "2022-01-06 09:32:07",
++            "2022-01-06T09:32:07",
++        ] {
++            let time = Time::parse_time(s).unwrap();
++            assert_eq!(
++                time.seconds + i64::from(time.offset),
++                wall_clock,
++                "`{s}` did not parse to the expected wall clock time"
++            );
++        }
++    }
++
++    /// Git allows a `@` before a commit-header date, with or without an 
offset.
++    #[test]
++    fn parse_time_at_prefixed_epoch() {
++        let time = Time::parse_time("@1641479527").unwrap();
++        assert_eq!(time.seconds, 1641479527);
++
++        let time = Time::parse_time("@1641479527 -0500").unwrap();
++        assert_eq!(time.seconds, 1641479527);
++        assert_eq!(time.offset, -5 * 60 * 60);
++    }
++
++    /// Relative dates are resolved against the current time, so equivalent 
spellings
++    /// are compared against each other rather than against a fixed value.
++    #[test]
++    fn parse_time_relative_spellings() {
++        fn assert_same_moment(a: &str, b: &str) {
++            let (ta, tb) = (Time::parse_time(a).unwrap(), 
Time::parse_time(b).unwrap());
++            let delta = (ta.seconds - tb.seconds).abs();
++            assert!(delta <= 2, "`{a}` and `{b}` differ by {delta} seconds");
++        }
++
++        assert_same_moment("one week ago", "7 days ago");
++        assert_same_moment("last week", "7 days ago");
++        assert_same_moment("ten days ago", "10 days ago");
++        assert_same_moment("3.days.ago", "3 days ago");
++        assert_same_moment("2 days 3 hours ago", "51 hours ago");
++        assert_same_moment("last month", "1 month ago");
++    }
++
++    /// Relative dates are anchored in the local zone, not UTC.
++    #[test]
++    fn parse_time_relative_uses_local_offset() {
++        let now = Time::parse_time("now").unwrap();
++        for s in ["3 days ago", "yesterday", "1 month ago"] {
++            assert_eq!(
++                Time::parse_time(s).unwrap().offset,
++                now.offset,
++                "`{s}` did not use the same UTC offset as `now`"
++            );
++        }
++    }
++
+     #[test]
+     fn test_parse_time_negative_offset() {
+         let time = Time::parse_time("123456 -0230").unwrap();
+@@ -110,6 +181,8 @@ mod tests {
+             "123456 -060",
+             "123456 +06000",
+             "123456 06000",
++            "a long time ago",
++            "bogus nonsense",
+         ] {
+             assert!(Time::parse_time(bad_str).is_err());
+         }
+--- a/src/hook.rs
++++ b/src/hook.rs
+@@ -3,7 +3,6 @@
+ //! Support for using git repository hooks.
+ 
+ use std::{
+-    borrow::Cow,
+     io::Write,
+     path::{Path, PathBuf},
+ };
+@@ -18,22 +17,21 @@ use crate::wrap::Message;
+ /// Returns None if the hook script is not found or is not executable.
+ fn get_hook_path(repo: &gix::Repository, hook_name: &str) -> 
Result<Option<PathBuf>> {
+     let config = repo.config_snapshot();
+-    let hooks_path =
+-        if let Some(core_hooks_path) = 
config.trusted_path("core.hookspath").transpose()? {
+-            if core_hooks_path.is_absolute() {
+-                core_hooks_path
+-            } else if repo.is_bare() {
+-                // The hooks path is relative to GIT_DIR in the case of a 
bare repo
+-                Cow::Owned(repo.common_dir().join(core_hooks_path))
+-            } else {
+-                // The hooks path is relative to the root of the working tree 
otherwise
+-                let work_dir = repo.workdir().expect("non-bare repo must have 
work dir");
+-                Cow::Owned(work_dir.join(core_hooks_path))
+-            }
++    let hooks_path = if let Some(core_hooks_path) = 
config.trusted_path("core.hookspath")? {
++        if core_hooks_path.is_absolute() {
++            core_hooks_path
++        } else if repo.is_bare() {
++            // The hooks path is relative to GIT_DIR in the case of a bare 
repo
++            repo.common_dir().join(core_hooks_path)
+         } else {
+-            // No core.hookspath, use default .git/hooks location
+-            Cow::Owned(repo.common_dir().join("hooks"))
+-        };
++            // The hooks path is relative to the root of the working tree 
otherwise
++            let work_dir = repo.workdir().expect("non-bare repo must have 
work dir");
++            work_dir.join(core_hooks_path)
++        }
++    } else {
++        // No core.hookspath, use default .git/hooks location
++        repo.common_dir().join("hooks")
++    };
+     let hook_path = hooks_path.join(hook_name);
+ 
+     let hook_meta = match std::fs::metadata(&hook_path) {
+--- a/src/patch/edit/interactive.rs
++++ b/src/patch/edit/interactive.rs
+@@ -135,15 +135,13 @@ fn get_editor(config: &gix::config::Snap
+     let editor = if let Some(editor) = std::env::var_os("GIT_EDITOR") {
+         editor
+     } else if let Some(editor) = config
+-        .trusted_path("stgit.editor")
+-        .transpose()?
+-        .map(|p| p.as_os_str().to_os_string())
++        .trusted_path("stgit.editor")?
++        .map(|p| p.into_os_string())
+     {
+         editor
+     } else if let Some(editor) = config
+-        .trusted_path("core.editor")
+-        .transpose()?
+-        .map(|p| p.as_os_str().to_os_string())
++        .trusted_path("core.editor")?
++        .map(|p| p.into_os_string())
+     {
+         editor
+     } else if let Some(editor) = std::env::var_os("VISUAL") {
+--- a/src/stack/stack.rs
++++ b/src/stack/stack.rs
+@@ -243,7 +243,7 @@ impl<'repo> Stack<'repo> {
+                 Some(format!("{}.stgit", self.branch_name).as_str().into()),
+                 "protect",
+             )
+-            .unwrap_or(Ok(false))
++            .unwrap_or(Some(false))
+             .unwrap_or(false)
+     }
+ 
+@@ -271,7 +271,7 @@ impl<'repo> Stack<'repo> {
+             if let Ok(section) = local_config_file
+                 
.section_by_key(format!("{section}.{subsection}").as_bytes().as_bstr())
+             {
+-                if section.num_values() == 0 {
++                if section.body().num_values() == 0 {
+                     local_config_file.remove_section_by_id(section.id());
+                 }
+             }
+--- a/src/stack/upgrade.rs
++++ b/src/stack/upgrade.rs
+@@ -325,7 +325,7 @@ fn rm_stackformatversion(repo: &gix::Rep
+     if let Ok(section) =
+         
local_config_file.section_by_key(format!("{section}.{subsection}").as_bytes().as_bstr())
+     {
+-        if section.num_values() == 0 {
++        if section.body().num_values() == 0 {
+             local_config_file.remove_section_by_id(section.id());
+         }
+     }
+--- a/t/t3300-edit.sh
++++ b/t/t3300-edit.sh
+@@ -345,6 +345,36 @@ test_expect_success 'Set author date to
+     printf "$before\n$(adate HEAD)\n$after\n" | sort -c -
+ '
+ 
++test_expect_success 'Set author date to a relative date' '
++    stg edit p2 --authdate "one week ago" &&
++    week=$(adate HEAD) &&
++    stg edit p2 --authdate "3 days ago" &&
++    days=$(adate HEAD) &&
++    stg edit p2 --authdate "3.days.ago" &&
++    dotted=$(adate HEAD) &&
++    stg edit p2 --authdate "2 days 3 hours ago" &&
++    hours=$(adate HEAD) &&
++    test "$week" \< "$days" &&
++    test "$week" \< "$dotted" &&
++    test "$days" \< "$hours" &&
++    test "$dotted" \< "$hours"
++'
++
++test_expect_success 'Relative author date uses the local time zone' '
++    TZ=Asia/Kolkata stg edit p2 --authdate "1 day ago" &&
++    case "$(adate HEAD)" in
++    *" +0530") : ;;
++    *) echo "expected +0530, got $(adate HEAD)" && false ;;
++    esac
++'
++
++test_expect_success 'Set author date from epoch with @ prefix' '
++    stg edit p2 --authdate "@1641479527 -0500" &&
++    test "$(adate HEAD)" = "2022-01-06 09:32:07 -0500" &&
++    stg edit p2 --authdate "@1641479527" &&
++    test "$(adate HEAD)" = "2022-01-06 14:32:07 +0000"
++'
++
+ test_expect_success 'Set patch tree' '
+     p2tree=$(git log -1 --pretty=format:%T $(stg id p2)) &&
+     p4commit=$(stg id p4) &&
diff -Nru stgit-2.6.1/debian/patches/reject-lone-at-partial-ref.patch 
stgit-2.6.1/debian/patches/reject-lone-at-partial-ref.patch
--- stgit-2.6.1/debian/patches/reject-lone-at-partial-ref.patch 1969-12-31 
18:00:00.000000000 -0600
+++ stgit-2.6.1/debian/patches/reject-lone-at-partial-ref.patch 2026-09-18 
10:19:36.000000000 -0500
@@ -0,0 +1,53 @@
+Description: Reject a lone "@" as a partial ref name
+Author: Peter Grayson <[email protected]>
+Origin: upstream
+Applied-Upstream: 10d8dec999f783459ad45671539d37752fad1406
+Last-Update: 2026-09-18
+---
+--- a/src/wrap/partialrefname.rs
++++ b/src/wrap/partialrefname.rs
+@@ -101,7 +101,9 @@ pub(crate) fn partial_ref_name(input: &m
+ 
+     let name = input.next_slice(split_offset);
+ 
+-    if name.is_empty() || name == "-" {
++    if name.is_empty() || name == "-" || name == "@" {
++        // A lone "@" is shorthand for "HEAD" in revspecs, so git does not 
allow it as a
++        // reference name. It remains valid as a component, e.g. 
"refs/heads/@".
+         Err(ErrMode::Backtrack(ContextError::from_input(input)))
+     } else if name.ends_with(".lock") {
+         // Names ending with ".lock" are invalid and there is no recovery.
+@@ -141,5 +143,15 @@ mod tests {
+             partial_ref_name.parse_peek("abc.def/"),
+             Ok(("/", PartialRefName(String::from("abc.def"))))
+         );
++        assert!(partial_ref_name.parse_peek("@").is_err());
++        assert!(partial_ref_name.parse_peek("@~").is_err());
++        assert_eq!(
++            partial_ref_name.parse_peek("@abc"),
++            Ok(("", PartialRefName(String::from("@abc"))))
++        );
++        assert_eq!(
++            partial_ref_name.parse_peek("abc/@"),
++            Ok(("", PartialRefName(String::from("abc/@"))))
++        );
+     }
+ }
+--- a/t/t1000-branch-create.sh
++++ b/t/t1000-branch-create.sh
+@@ -168,4 +168,15 @@ test_expect_success 'Reuse name of parti
+     stg branch --create new
+ '
+ 
++test_expect_success 'Create branch with reserved name "@"' '
++    general_error stg branch --create @ &&
++    test_must_fail git rev-parse --verify refs/heads/@
++'
++
++test_expect_success 'Create branch with "@" as a name component' '
++    stg branch --create @-suffix &&
++    stg branch --create with/@ &&
++    stg branch master
++'
++
+ test_done
diff -Nru stgit-2.6.1/debian/patches/series stgit-2.6.1/debian/patches/series
--- stgit-2.6.1/debian/patches/series   2026-08-24 09:43:17.000000000 -0500
+++ stgit-2.6.1/debian/patches/series   2026-09-18 10:10:37.000000000 -0500
@@ -2,4 +2,5 @@
 disable_interactive_test
 use_bzip2_crate
 use_winnow_0_7
-gix-0.86.patch
+gix-0.87.patch
+reject-lone-at-partial-ref.patch

Attachment: OpenPGP_signature.asc
Description: OpenPGP digital signature

Reply via email to