1use std::path::{Path, PathBuf};
4
5use crate::config::SelfUpdateConfig;
6
7#[derive(Debug, Clone)]
8pub struct SelfUpdater {
9 workspace: PathBuf,
10 config: SelfUpdateConfig,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum UpdateOutcome {
15 NoRepo,
16 Disabled,
17 DirtyWorktree,
18 AlreadyCurrent,
19 Updated { restarted: bool },
20}
21
22impl SelfUpdater {
23 pub fn new(workspace: PathBuf, config: SelfUpdateConfig) -> Self {
24 Self { workspace, config }
25 }
26
27 pub fn start(self) -> tokio::task::JoinHandle<()> {
28 tokio::spawn(self.run_loop())
29 }
30
31 async fn run_loop(self) {
32 if !self.config.enabled {
33 tracing::info!("self-update disabled");
34 return;
35 }
36
37 let mut interval =
38 tokio::time::interval(tokio::time::Duration::from_secs(self.config.interval_secs));
39 interval.tick().await;
40
41 loop {
42 interval.tick().await;
43 match self.run_once().await {
44 Ok(UpdateOutcome::Updated { restarted }) => {
45 tracing::info!(restarted, "self-update applied");
46 }
47 Ok(UpdateOutcome::AlreadyCurrent) => {
48 tracing::debug!("self-update: already current");
49 }
50 Ok(UpdateOutcome::DirtyWorktree) => {
51 tracing::warn!("self-update: skipping dirty worktree");
52 }
53 Ok(UpdateOutcome::NoRepo) => {
54 tracing::debug!("self-update: workspace is not a git repo");
55 }
56 Ok(UpdateOutcome::Disabled) => break,
57 Err(error) => {
58 tracing::warn!(error = %error, "self-update failed");
59 }
60 }
61 }
62 }
63
64 pub async fn run_once(&self) -> anyhow::Result<UpdateOutcome> {
65 if !self.config.enabled {
66 return Ok(UpdateOutcome::Disabled);
67 }
68
69 if !is_git_repo(&self.workspace).await? {
70 return Ok(UpdateOutcome::NoRepo);
71 }
72
73 if !worktree_is_clean(&self.workspace).await? {
74 return Ok(UpdateOutcome::DirtyWorktree);
75 }
76
77 git_fetch(&self.workspace, &self.config.remote, &self.config.branch).await?;
78
79 if current_head(&self.workspace).await?
80 == upstream_head(&self.workspace, &self.config).await?
81 {
82 return Ok(UpdateOutcome::AlreadyCurrent);
83 }
84
85 git_fast_forward(&self.workspace, &self.config.remote, &self.config.branch).await?;
86 cargo_build_release(&self.workspace).await?;
87 let restarted = maybe_restart_service(&self.workspace, &self.config).await?;
88
89 Ok(UpdateOutcome::Updated { restarted })
90 }
91}
92
93async fn is_git_repo(workspace: &Path) -> anyhow::Result<bool> {
94 let output = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]).await?;
95 Ok(output.trim() == "true")
96}
97
98async fn worktree_is_clean(workspace: &Path) -> anyhow::Result<bool> {
99 let output = run_git(workspace, &["status", "--porcelain"]).await?;
100 Ok(output.trim().is_empty())
101}
102
103async fn git_fetch(workspace: &Path, remote: &str, branch: &str) -> anyhow::Result<()> {
104 let _ = run_git(workspace, &["fetch", remote, branch, "--quiet"]).await?;
105 Ok(())
106}
107
108async fn current_head(workspace: &Path) -> anyhow::Result<String> {
109 run_git(workspace, &["rev-parse", "HEAD"]).await
110}
111
112async fn upstream_head(workspace: &Path, config: &SelfUpdateConfig) -> anyhow::Result<String> {
113 run_git(
114 workspace,
115 &[
116 "rev-parse",
117 &format!("refs/remotes/{}/{}", config.remote, config.branch),
118 ],
119 )
120 .await
121}
122
123async fn git_fast_forward(workspace: &Path, remote: &str, branch: &str) -> anyhow::Result<()> {
124 let _ = run_git(
125 workspace,
126 &["merge", "--ff-only", &format!("{}/{}", remote, branch)],
127 )
128 .await?;
129 Ok(())
130}
131
132async fn cargo_build_release(workspace: &Path) -> anyhow::Result<()> {
133 let output = tokio::process::Command::new("cargo")
134 .arg("build")
135 .arg("--release")
136 .current_dir(workspace)
137 .output()
138 .await?;
139
140 if !output.status.success() {
141 anyhow::bail!(
142 "cargo build --release failed: {}",
143 String::from_utf8_lossy(&output.stderr)
144 );
145 }
146
147 Ok(())
148}
149
150async fn maybe_restart_service(
151 workspace: &Path,
152 config: &SelfUpdateConfig,
153) -> anyhow::Result<bool> {
154 let Some(service) = &config.restart_service else {
155 return Ok(false);
156 };
157
158 let output = tokio::process::Command::new("systemctl")
159 .arg("--user")
160 .arg("restart")
161 .arg(service)
162 .current_dir(workspace)
163 .output()
164 .await?;
165
166 if output.status.success() {
167 return Ok(true);
168 }
169
170 tracing::warn!(
171 service = %service,
172 stderr = %String::from_utf8_lossy(&output.stderr),
173 "self-update build succeeded but service restart failed"
174 );
175 Ok(false)
176}
177
178async fn run_git(workspace: &Path, args: &[&str]) -> anyhow::Result<String> {
179 let output = tokio::process::Command::new("git")
180 .args(args)
181 .current_dir(workspace)
182 .output()
183 .await?;
184
185 if !output.status.success() {
186 anyhow::bail!(
187 "git {} failed: {}",
188 args.join(" "),
189 String::from_utf8_lossy(&output.stderr)
190 );
191 }
192
193 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
194}