1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
//! Background-thread runner for `git push` / `pull` / `fetch` /
//! `cherry-pick`. Same pattern as the integration crates' loader-thread
//! refactor (slack/teams/gmail/mandrill/buttondown/docker async
//! migrations earlier today): the App sends a job + drains results
//! in `tick()`, so a slow remote / credential-prompt / SSH key
//! prompt no longer freezes the UI thread.
//!
//! Surface visible to the rest of `App`:
//! * [`GitJob`] / [`GitResult`] enums + `spawn_git_loader` builder.
//! * App stores the `Sender<GitJob>` + `Receiver<GitResult>` returned
//! here as `git_loader_tx` / `git_loader_rx`.
//! * `App::run_git_{fetch,pull,push,cherry_pick}` now SEND the job
//! (instead of calling the sync helper directly) and toast a
//! pending status. `App::drain_git_results` (called from `tick`)
//! applies the result.
//!
//! Push has a fallback: if the first `git push` fails with "no
//! upstream branch", retry with `--set-upstream origin <current>`.
//! That fallback lives ON the loader thread so a slow network only
//! pays the cost ONCE per user action; the alternative (toast then
//! re-send) would cost two round-trips through the channel and
//! show a confusing intermediate "no upstream" error before the
//! retry recovered.
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;
#[derive(Debug, Clone)]
pub enum GitJob {
Fetch {
repo: PathBuf,
},
Pull {
repo: PathBuf,
},
Push {
repo: PathBuf,
current_branch: Option<String>,
},
CherryPick {
repo: PathBuf,
hash: String,
},
/// `git blame` for `rel` (workspace-relative) under `repo`.
/// Carries `path` (absolute) so the result can re-find the open
/// editor pane after the shell-out returns. Used by
/// `toggle_blame` and `refresh_blame_for`.
/// untouched-surfaces-hunt-2026-06-08 SEV-2 #9.
Blame {
repo: PathBuf,
rel: String,
path: PathBuf,
},
/// `git checkout` (local), `git checkout --track` (remote-track),
/// or `git checkout` (plain id). `kind` distinguishes which
/// branch helper to call. `from_branch` is carried through so
/// post-success undo-stack registration can fire on the main
/// thread without re-querying. untouched-surfaces SEV-2 #9.
Checkout {
repo: PathBuf,
kind: CheckoutKind,
target: String,
from_branch: Option<String>,
},
/// 2026-06-21 power-user-ws-git SEV-2 merge-rebase-block-ui:
/// merge / rebase / delete-branch / worktree-add /
/// worktree-remove all sync-shell-out'd on the main app
/// thread, freezing UI for the duration. Now via the loader.
Merge {
repo: PathBuf,
name: String,
},
Rebase {
repo: PathBuf,
name: String,
},
DeleteBranch {
repo: PathBuf,
name: String,
},
WorktreeAdd {
repo: PathBuf,
path: PathBuf,
branch: String,
},
WorktreeRemove {
repo: PathBuf,
path: PathBuf,
},
}
/// Which checkout helper to call. The string carried in the job's
/// `target` is the bare branch / remote ref (no `local:` / `remote:`
/// prefix — those are stripped on the caller side).
#[derive(Debug, Clone, Copy)]
pub enum CheckoutKind {
/// `git checkout -- <branch>`. Joins the undo stack.
Local,
/// `git checkout --track -- <remote>`. Creates a new local
/// tracking branch; does NOT join the undo stack (redo semantics
/// get fuzzy when an extra branch was created as a side effect).
RemoteTrack,
}
/// What the loader thread reports back.
#[derive(Debug)]
pub enum GitResult {
Fetched(Result<String, String>),
Pulled(Result<String, String>),
Pushed {
kind: PushKind,
result: Result<String, String>,
},
CherryPicked {
hash: String,
result: Result<String, String>,
},
/// Blame result. `lines` is empty when the file is untracked or
/// the path isn't in any commit yet. Caller matches by `path`
/// to find the editor pane to update.
Blamed {
path: PathBuf,
lines: Vec<crate::git::blame::BlameLine>,
},
/// Checkout result. `from_branch` is carried through unchanged
/// so the post-success handler can register the undo + run
/// after_checkout without re-querying.
CheckedOut {
kind: CheckoutKind,
from_branch: Option<String>,
result: Result<String, String>,
},
Merged {
name: String,
result: Result<(), String>,
},
Rebased {
name: String,
result: Result<(), String>,
},
BranchDeleted {
name: String,
result: Result<(), String>,
},
WorktreeAdded {
path: PathBuf,
branch: String,
result: Result<(), String>,
},
WorktreeRemoved {
path: PathBuf,
result: Result<(), String>,
},
}
/// Push outcome flavor — affects the toast prefix.
#[derive(Debug, Clone, Copy)]
pub enum PushKind {
/// Plain `git push` succeeded.
Normal,
/// `git push --set-upstream origin <branch>` succeeded after
/// the plain push reported "no upstream branch".
SetUpstream,
}
/// Spawn the git-loader thread. Returns `(job_tx, result_rx)` for
/// App to store. Dropping `job_tx` (App-drop) closes the channel
/// and the thread exits cleanly.
pub fn spawn_git_loader() -> (Sender<GitJob>, Receiver<GitResult>) {
let (job_tx, job_rx) = channel::<GitJob>();
let (res_tx, res_rx) = channel::<GitResult>();
thread::Builder::new()
.name("mnml-git-loader".into())
.spawn(move || {
while let Ok(job) = job_rx.recv() {
let result = match job {
GitJob::Fetch { repo } => {
GitResult::Fetched(crate::git::sync::fetch_all(&repo))
}
GitJob::Pull { repo } => {
GitResult::Pulled(crate::git::sync::pull_ff_only(&repo))
}
GitJob::Push {
repo,
current_branch,
} => match crate::git::sync::push(&repo) {
Ok(s) => GitResult::Pushed {
kind: PushKind::Normal,
result: Ok(s),
},
Err(e)
if (e.contains("has no upstream branch")
|| e.contains("--set-upstream"))
&& let Some(branch) = current_branch
&& !branch.is_empty() =>
{
GitResult::Pushed {
kind: PushKind::SetUpstream,
result: crate::git::sync::push_set_upstream(&repo, &branch),
}
}
Err(e) => GitResult::Pushed {
kind: PushKind::Normal,
result: Err(e),
},
},
GitJob::CherryPick { repo, hash } => GitResult::CherryPicked {
result: crate::git::commit::cherry_pick(&repo, &hash),
hash,
},
GitJob::Blame { repo, rel, path } => GitResult::Blamed {
lines: crate::git::blame::blame(&repo, &rel),
path,
},
GitJob::Checkout {
repo,
kind,
target,
from_branch,
} => {
let result = match kind {
CheckoutKind::Local => {
crate::git::branch::checkout(&repo, &target).map(|_| target.clone())
}
CheckoutKind::RemoteTrack => {
crate::git::branch::checkout_track(&repo, &target)
.map(|_| target.clone())
}
};
GitResult::CheckedOut {
kind,
from_branch,
result,
}
}
GitJob::Merge { repo, name } => GitResult::Merged {
result: crate::git::branch::merge(&repo, &name),
name,
},
GitJob::Rebase { repo, name } => GitResult::Rebased {
result: crate::git::branch::rebase(&repo, &name),
name,
},
GitJob::DeleteBranch { repo, name } => GitResult::BranchDeleted {
result: crate::git::branch::delete_branch(&repo, &name),
name,
},
GitJob::WorktreeAdd { repo, path, branch } => GitResult::WorktreeAdded {
result: crate::git::branch::worktree_add(&repo, &path, &branch),
path,
branch,
},
GitJob::WorktreeRemove { repo, path } => GitResult::WorktreeRemoved {
result: crate::git::branch::worktree_remove(&repo, &path),
path,
},
};
if res_tx.send(result).is_err() {
// App dropped the receiver — exit cleanly.
break;
}
}
})
.expect("spawn mnml-git-loader");
(job_tx, res_rx)
}