Skip to content

Commit 7836c30

Browse files
committed
feat: add detection for stale remote tracking branches
1 parent 86b47c7 commit 7836c30

4 files changed

Lines changed: 105 additions & 39 deletions

File tree

wimygit-tauri/src-tauri/src/git/parsers/branch.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,43 @@ pub async fn get_branches(cwd: String) -> Result<Vec<BranchInfo>, String> {
139139
Ok(branches)
140140
}
141141

142+
/// Get stale remote tracking branches (deleted on remote but still tracked locally)
143+
#[tauri::command]
144+
pub async fn get_stale_remote_branches(cwd: String) -> Result<Vec<String>, String> {
145+
// Get list of remotes
146+
let remotes_result = crate::git::run_git(
147+
vec!["remote".to_string()],
148+
cwd.clone(),
149+
)
150+
.await?;
151+
152+
let mut stale_branches = Vec::new();
153+
154+
for remote in remotes_result.stdout.lines() {
155+
let remote = remote.trim();
156+
if remote.is_empty() {
157+
continue;
158+
}
159+
160+
let prune_result = crate::git::run_git(
161+
vec!["remote".to_string(), "prune".to_string(), "--dry-run".to_string(), remote.to_string()],
162+
cwd.clone(),
163+
)
164+
.await?;
165+
166+
// Parse output lines like: " * [would prune] origin/branch-name"
167+
for line in prune_result.stdout.lines() {
168+
if line.contains("[would prune]") {
169+
if let Some(branch) = line.split("[would prune]").nth(1) {
170+
stale_branches.push(branch.trim().to_string());
171+
}
172+
}
173+
}
174+
}
175+
176+
Ok(stale_branches)
177+
}
178+
142179
/// Get current branch name
143180
#[tauri::command]
144181
pub async fn get_current_branch(cwd: String) -> Result<String, String> {

wimygit-tauri/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ pub fn run() {
151151
// Git branch commands
152152
git::get_branches,
153153
git::get_current_branch,
154+
git::get_stale_remote_branches,
154155
// Git remote commands
155156
git::get_remotes,
156157
git::add_remote,

wimygit-tauri/src/components/tabs/HistoryTab.tsx

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
getHistory,
44
getCommitFiles,
55
getCommitParents,
6+
getStaleBranches,
67
type CommitInfo,
78
type CommitFile,
89
type SelectedDiffInfo,
@@ -40,10 +41,10 @@ function parseRefNames(refNames: string) {
4041
}
4142

4243
const REF_BADGE: Record<string, string> = {
43-
head: "bg-green-600 text-white",
44+
head: "bg-green-600 text-white",
4445
branch: "bg-blue-500 text-white",
4546
remote: "bg-gray-500 text-white",
46-
tag: "bg-yellow-500 text-white",
47+
tag: "bg-yellow-500 text-white",
4748
};
4849

4950
function formatDate(ts: number) {
@@ -97,32 +98,38 @@ function ContextMenu({ x, y, commit, repoPath, onClose, onRefresh }: ContextMenu
9798
};
9899

99100
const items: { label: string; action: () => void; danger?: boolean }[] = [
100-
{ label: "Checkout", action: () => run(["checkout", commit.hash]) },
101-
{ label: "Create Branch here…", action: async () => {
101+
{ label: "Checkout", action: () => run(["checkout", commit.hash]) },
102+
{
103+
label: "Create Branch here…", action: async () => {
102104
onClose();
103105
const name = prompt("New branch name:"); if (!name?.trim()) return;
104106
try { await invoke("run_git_simple", { args: ["checkout", "-b", name.trim(), commit.hash], cwd: repoPath }); onRefresh(); }
105107
catch (e) { alert(String(e)); }
106-
}},
107-
{ label: "Create Tag here…", action: async () => {
108+
}
109+
},
110+
{
111+
label: "Create Tag here…", action: async () => {
108112
onClose();
109113
const name = prompt("Tag name:"); if (!name?.trim()) return;
110114
try { await invoke("run_git_simple", { args: ["tag", name.trim(), commit.hash], cwd: repoPath }); onRefresh(); }
111115
catch (e) { alert(String(e)); }
112-
}},
113-
{ label: "Cherry-pick", action: async () => {
116+
}
117+
},
118+
{
119+
label: "Cherry-pick", action: async () => {
114120
onClose();
115121
try { await invoke("run_git", { args: ["cherry-pick", commit.hash], cwd: repoPath }); }
116122
catch { /* run_git only fails if git is not found; conflicts are non-throwing */ }
117123
onRefresh();
118-
}},
119-
{ label: "──", action: () => {} },
120-
{ label: "Reset Soft", action: () => { if (confirm(`Reset soft to ${commit.short_hash}?`)) run(["reset", "--soft", commit.hash]); } },
121-
{ label: "Reset Mixed", action: () => { if (confirm(`Reset mixed to ${commit.short_hash}?`)) run(["reset", "--mixed", commit.hash]); } },
124+
}
125+
},
126+
{ label: "──", action: () => { } },
127+
{ label: "Reset Soft", action: () => { if (confirm(`Reset soft to ${commit.short_hash}?`)) run(["reset", "--soft", commit.hash]); } },
128+
{ label: "Reset Mixed", action: () => { if (confirm(`Reset mixed to ${commit.short_hash}?`)) run(["reset", "--mixed", commit.hash]); } },
122129
{ label: "Reset Hard", danger: true, action: () => { if (confirm(`Reset HARD to ${commit.short_hash}?`)) run(["reset", "--hard", commit.hash]); } },
123-
{ label: "──", action: () => {} },
124-
{ label: "Copy Commit ID", action: () => { onClose(); navigator.clipboard.writeText(commit.hash).catch(() => {}); } },
125-
{ label: "Copy Short ID", action: () => { onClose(); navigator.clipboard.writeText(commit.short_hash).catch(() => {}); } },
130+
{ label: "──", action: () => { } },
131+
{ label: "Copy Commit ID", action: () => { onClose(); navigator.clipboard.writeText(commit.hash).catch(() => { }); } },
132+
{ label: "Copy Short ID", action: () => { onClose(); navigator.clipboard.writeText(commit.short_hash).catch(() => { }); } },
126133
];
127134

128135
return (
@@ -132,9 +139,9 @@ function ContextMenu({ x, y, commit, repoPath, onClose, onRefresh }: ContextMenu
132139
item.label === "──"
133140
? <div key={i} className="border-t border-gray-200 dark:border-gray-700 my-1" />
134141
: <button key={i} onClick={item.action}
135-
className={`w-full text-left px-4 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 ${item.danger ? "text-red-600 dark:text-red-400" : ""}`}>
136-
{item.label}
137-
</button>
142+
className={`w-full text-left px-4 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 ${item.danger ? "text-red-600 dark:text-red-400" : ""}`}>
143+
{item.label}
144+
</button>
138145
)}
139146
</div>
140147
);
@@ -195,6 +202,7 @@ export function HistoryTab({ repoPath, filePath, refreshKey, onRefresh, onFileSe
195202
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; commit: CommitInfo } | null>(null);
196203
const [fileCtxMenu, setFileCtxMenu] = useState<{ x: number; y: number; absolutePath: string } | null>(null);
197204
const [allBranches, setAllBranches] = useState(true);
205+
const [staleBranches, setStaleBranches] = useState<Set<string>>(new Set());
198206

199207
// ── load history ──────────────────────────────────────────────────────────
200208

@@ -222,6 +230,13 @@ export function HistoryTab({ repoPath, filePath, refreshKey, onRefresh, onFileSe
222230
loadHistory(0);
223231
}, [repoPath, refreshKey, loadHistory]);
224232

233+
useEffect(() => {
234+
if (!repoPath) return;
235+
getStaleBranches(repoPath)
236+
.then((branches) => setStaleBranches(new Set(branches)))
237+
.catch(() => setStaleBranches(new Set()));
238+
}, [repoPath, refreshKey]);
239+
225240
// ── select commit → load files + parents ─────────────────────────────────
226241

227242
const handleSelectCommit = useCallback(async (commit: CommitInfo) => {
@@ -321,30 +336,40 @@ export function HistoryTab({ repoPath, filePath, refreshKey, onRefresh, onFileSe
321336
key={commit.hash}
322337
onClick={() => handleSelectCommit(commit)}
323338
onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, commit }); }}
324-
className={`flex items-center gap-2 px-2 cursor-pointer border-b border-gray-100 dark:border-gray-800 ${
325-
isSelected ? "bg-blue-50 dark:bg-blue-900/30" : "hover:bg-gray-50 dark:hover:bg-gray-800"
326-
}`}
339+
className={`flex items-center gap-2 px-2 cursor-pointer border-b border-gray-100 dark:border-gray-800 ${isSelected ? "bg-blue-50 dark:bg-blue-900/30" : "hover:bg-gray-50 dark:hover:bg-gray-800"
340+
}`}
327341
style={{ height: ROW_H }}
328342
>
329343
{/* Graph (SVG) */}
330344
{graphRow && <GraphSvg row={graphRow} />}
331345
{/* Message (with inline refs) */}
332346
<div className="flex-1 min-w-0 flex items-center gap-1">
333-
{refs.map((r, i) => (
334-
<span key={i} className={`text-[10px] px-1 rounded leading-4 shrink-0 inline-flex items-center gap-0.5 ${REF_BADGE[r.kind]}`}>
335-
{(r.kind === "head" || r.kind === "branch" || r.kind === "remote") && (
336-
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" className="shrink-0">
337-
<path d="M11.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zm-2.25.75a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25zM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0z" />
338-
</svg>
339-
)}
340-
{r.kind === "tag" && (
341-
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" className="shrink-0">
342-
<path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775zM6 5a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" />
343-
</svg>
344-
)}
345-
{r.label}
346-
</span>
347-
))}
347+
{refs.map((r, i) => {
348+
const isStale = r.kind === "remote" && staleBranches.has(r.label);
349+
return (
350+
<span key={i} className={`inline-flex items-center shrink-0 leading-4 overflow-hidden ${isStale ? "rounded border border-red-950 bg-red-600" : "rounded"}`}>
351+
<span className={`text-[10px] px-1 leading-4 inline-flex items-center gap-0.5 ${isStale ? "bg-red-800 text-white" : REF_BADGE[r.kind]}`}>
352+
{(r.kind === "head" || r.kind === "branch" || r.kind === "remote") && (
353+
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" className="shrink-0">
354+
<path d="M11.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zm-2.25.75a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25zM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0z" />
355+
</svg>
356+
)}
357+
{r.kind === "tag" && (
358+
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" className="shrink-0">
359+
<path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775zM6 5a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" />
360+
</svg>
361+
)}
362+
{r.label}
363+
</span>
364+
{isStale && (
365+
<>
366+
<span className="self-stretch w-px bg-red-950" />
367+
<span className="text-[10px] px-1 leading-4 text-white">deleted on remote</span>
368+
</>
369+
)}
370+
</span>
371+
);
372+
})}
348373
<span className="text-xs text-gray-800 dark:text-gray-200 truncate">{commit.message}</span>
349374
</div>
350375
{/* Author */}
@@ -421,9 +446,8 @@ export function HistoryTab({ repoPath, filePath, refreshKey, onRefresh, onFileSe
421446
<div key={i}
422447
onClick={() => handleSelectFile(file)}
423448
onContextMenu={(e) => { e.preventDefault(); setFileCtxMenu({ x: e.clientX, y: e.clientY, absolutePath: absPath }); }}
424-
className={`flex items-center gap-2 px-3 py-1 text-xs cursor-pointer border-b border-gray-50 dark:border-gray-800 ${
425-
isSelected ? "bg-blue-50 dark:bg-blue-900/30" : "hover:bg-gray-50 dark:hover:bg-gray-800"
426-
}`}
449+
className={`flex items-center gap-2 px-3 py-1 text-xs cursor-pointer border-b border-gray-50 dark:border-gray-800 ${isSelected ? "bg-blue-50 dark:bg-blue-900/30" : "hover:bg-gray-50 dark:hover:bg-gray-800"
450+
}`}
427451
>
428452
<span className={`font-mono font-bold w-4 shrink-0 ${si.cls}`}>{si.icon}</span>
429453
<span className="truncate" title={file.display}>{file.display}</span>

wimygit-tauri/src/lib/git-api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ export async function getCurrentBranch(cwd: string): Promise<string> {
9494
return invoke<string>("get_current_branch", { cwd });
9595
}
9696

97+
export async function getStaleBranches(cwd: string): Promise<string[]> {
98+
return invoke<string[]>("get_stale_remote_branches", { cwd });
99+
}
100+
97101
// ============= Git Remotes =============
98102

99103
export async function getRemotes(cwd: string): Promise<RemoteInfo[]> {

0 commit comments

Comments
 (0)