--- Begin Message ---
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
OpenPGP_signature.asc
Description: OpenPGP digital signature
--- End Message ---