Skip to content

Commit 54c7107

Browse files
notheotherbenclaude
andcommitted
refactor: relaunch updates via the update --state sub-command
Adopt update-rs 0.4's customizable launcher to restore Git-Tool's original relaunch convention: a `GitToolLauncher` (overriding `resume_args`) relaunches the binary as `gt update --state <json>` rather than with the library's default `--update-resume-internal` flag. This keeps resume handling in the `update` command (parsed by clap, and unit-testable) instead of an argv pre-scan in `main`. - Cargo.toml: update-rs 0.3 -> 0.4. - src/update.rs: install `GitToolLauncher` on the manager. - commands/update.rs: re-add the hidden `--state` arg; `run` resumes from it via `resume_from_arg`. Add a (network-free) test that resume failures bubble up without being reported to Sentry by the command. - main.rs: drop the argv pre-scan; keep `--update-resume-internal` as a tolerated hidden global so clap still accepts the relaunch emitted by currently-installed releases (which pass `--update-resume-internal <s> --trace-context <c> update --state <s>`) — the `update --state` sub-command drives the resume. The trace context still rides inside the serialized state (update-rs's `opentelemetry` feature), so the relaunch needs no `--trace-context` argument. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 462840b commit 54c7107

5 files changed

Lines changed: 88 additions & 24 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ tracing-batteries = { git = "https://github.com/sierrasoftworks/tracing-batterie
5050
# Self-update machinery (extracted from this project). The `opentelemetry`
5151
# feature emits tracing spans for the update and propagates the trace context
5252
# across the three relaunch phases via the update state.
53-
update-rs = { version = "0.3", features = ["opentelemetry"] }
53+
update-rs = { version = "0.4", features = ["opentelemetry"] }
5454
dirs = "6.0.0"
5555
shellexpand = "3"
5656

src/commands/update.rs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ impl CommandRunnable for UpdateCommand {
1616
.version("1.0")
1717
.about("updates Git-Tool automatically by fetching the latest release from GitHub")
1818
.long_about("Allows you to update Git-Tool to the latest version, or a specific version, automatically.")
19+
.arg(Arg::new("state")
20+
.long("state")
21+
.help("Serialized state used to resume an in-progress update. Set automatically when the updater relaunches Git-Tool between phases.")
22+
.hide(true)
23+
.action(clap::ArgAction::Set))
1924
.arg(Arg::new("list")
2025
.long("list")
2126
.help("Prints the list of available releases.")
@@ -31,13 +36,24 @@ impl CommandRunnable for UpdateCommand {
3136

3237
#[tracing::instrument(name = "gt update", err, skip(self, core, matches))]
3338
async fn run(&self, core: &Core, matches: &ArgMatches) -> Result<i32, engine::Error> {
39+
let manager = crate::update::manager();
40+
41+
// When the updater relaunches us between phases it invokes
42+
// `gt update --state <json>`; hand that straight back to the updater to
43+
// continue the in-progress update (the state also carries the trace
44+
// context, so the phases stay on one distributed trace).
45+
if let Some(state) = matches.get_one::<String>("state") {
46+
info!("Resuming an in-progress update.");
47+
manager.resume_from_arg(state).await?;
48+
return Ok(0);
49+
}
50+
3451
let current_version: semver::Version = version!().parse().map_err(|err| human_errors::wrap_system(
3552
err,
3653
"Could not parse the current application version into a SemVer version number.",
3754
&["Please report this issue to us on GitHub and try updating manually by downloading the latest release from GitHub once the problem is resolved."],
3855
))?;
3956

40-
let manager = crate::update::manager();
4157
let releases = manager.get_releases().await?;
4258

4359
if matches.get_flag("list") {
@@ -155,6 +171,8 @@ fn format_release_list(releases: &[Release], current_version: &semver::Version)
155171
#[cfg(test)]
156172
mod tests {
157173
use super::*;
174+
use crate::console::MockConsoleProvider;
175+
use std::sync::Arc;
158176

159177
fn release(id: &str, version: &str, prerelease: bool, supported: bool) -> Release {
160178
Release {
@@ -194,4 +212,40 @@ mod tests {
194212
"an available pre-release is left unmarked and labelled"
195213
);
196214
}
215+
216+
#[test]
217+
fn run_resume_bubbles_up_failures_without_reporting() {
218+
// `gt update --state <json>` resumes an in-progress update. The command
219+
// shouldn't report resume failures to Sentry itself; it bubbles them up so
220+
// `main` decides what to record (only system-caused errors are). Resuming
221+
// a cleanup phase with no temporary application path is a system error we
222+
// can trigger without any network access.
223+
let events = sentry::test::with_captured_events(|| {
224+
let rt = tokio::runtime::Builder::new_current_thread()
225+
.build()
226+
.expect("we should be able to build a Tokio runtime");
227+
228+
rt.block_on(async {
229+
let console = Arc::new(MockConsoleProvider::new());
230+
let core = Core::builder()
231+
.with_default_config()
232+
.with_console(console.clone())
233+
.build();
234+
235+
let cmd = UpdateCommand {};
236+
let args =
237+
cmd.app()
238+
.get_matches_from(vec!["update", "--state", r#"{"phase":"cleanup"}"#]);
239+
240+
cmd.run(&core, &args)
241+
.await
242+
.expect_err("resuming a cleanup phase without a temporary path should fail");
243+
});
244+
});
245+
246+
assert!(
247+
events.is_empty(),
248+
"the update command should not report resume failures to Sentry directly"
249+
);
250+
}
197251
}

src/main.rs

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ fn build_app() -> clap::Command {
8282
.value_name("FILE")
8383
.help("The path to your git-tool configuration file.")
8484
.action(clap::ArgAction::Set))
85+
.arg(Arg::new("update-resume-internal")
86+
.long("update-resume-internal")
87+
.help("A legacy flag emitted by older Git-Tool releases when coordinating an update. Tolerated (and ignored) so an update started by an older release can hand off to this one via the `update --state` sub-command it also passes.")
88+
.action(clap::ArgAction::Set)
89+
.hide(true))
8590
.arg(Arg::new("trace")
8691
.long("trace")
8792
.global(true)
@@ -95,20 +100,6 @@ fn build_app() -> clap::Command {
95100

96101
#[tracing::instrument(err, skip(app, session), fields(otel.name=EmptyField, command=EmptyField, exit_code=EmptyField, otel.status_code=0, exception=EmptyField))]
97102
async fn host(app: clap::Command, session: &TelemetrySession) -> Result<i32, human_errors::Error> {
98-
// If a previous update phase relaunched us with the resume flag, hand control
99-
// straight to the updater before any argument parsing. The relaunch may carry
100-
// trailing arguments (from older Git-Tool versions, which also appended an
101-
// `update --state` sub-command) that clap would otherwise reject, and the
102-
// serialized state continues the update's distributed trace.
103-
let raw_args: Vec<String> = std::env::args().collect();
104-
if let Some(idx) = raw_args.iter().position(|a| a == update::RESUME_FLAG)
105-
&& let Some(state) = raw_args.get(idx + 1)
106-
{
107-
info!("Detected an in-progress update relaunch; resuming the update.");
108-
update::manager().resume_from_arg(state).await?;
109-
return Ok(0);
110-
}
111-
112103
let matches = match app.clone().try_get_matches() {
113104
Ok(matches) => {
114105
if let Some(context) = matches.get_one::<String>("trace-context") {

src/update.rs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,41 @@
11
//! Self-update support, built on the [`update-rs`](https://docs.rs/update-rs)
22
//! crate (which was extracted from this project).
33
//!
4-
//! This module just configures the updater for Git-Tool's GitHub releases and
5-
//! re-exports the handful of types the rest of the application needs; all of the
6-
//! three-phase download/replace/relaunch machinery lives in the crate.
4+
//! This module configures the updater for Git-Tool's GitHub releases and its
5+
//! relaunch convention, then re-exports the handful of types the rest of the
6+
//! application needs; all of the three-phase download/replace/relaunch machinery
7+
//! lives in the crate.
78
8-
pub use update_rs::{RESUME_FLAG, Release};
9+
pub use update_rs::Release;
910

10-
use update_rs::{GitHubSource, UpdateManager, naming};
11+
use std::ffi::OsString;
12+
use update_rs::{GitHubSource, Launcher, UpdateManager, naming};
1113

1214
/// The GitHub repository Git-Tool's releases are published to.
1315
const REPO: &str = "SierraSoftworks/git-tool";
1416

17+
/// Relaunches Git-Tool between update phases via its `update --state <json>`
18+
/// sub-command — the convention Git-Tool has used since its Go implementation.
19+
///
20+
/// Keeping it means an update started by an older installed release (which
21+
/// relaunches the new binary with `update --state <json>`) hands off cleanly to
22+
/// this one. The active trace context is carried inside the state itself (via
23+
/// update-rs's `opentelemetry` feature), so no extra arguments are needed.
24+
struct GitToolLauncher;
25+
26+
impl Launcher for GitToolLauncher {
27+
fn resume_args(&self, state_json: &str) -> Vec<OsString> {
28+
vec!["update".into(), "--state".into(), state_json.into()]
29+
}
30+
}
31+
1532
/// Build an [`UpdateManager`] configured for Git-Tool's releases.
1633
///
1734
/// It downloads the Go-style `git-tool-<os>-<arch>[.exe]` asset for the current
1835
/// platform (matching the names produced by `.github/workflows/release.yml`)
19-
/// from the project's GitHub releases, whose tags are `vX.Y.Z`.
36+
/// from the project's GitHub releases, whose tags are `vX.Y.Z`, and relaunches
37+
/// through the `update --state` sub-command.
2038
pub fn manager() -> UpdateManager<GitHubSource> {
2139
UpdateManager::new(GitHubSource::new(REPO, naming::go("git-tool")).with_release_tag_prefix("v"))
40+
.with_launcher(Box::new(GitToolLauncher))
2241
}

0 commit comments

Comments
 (0)