Skip to content

Commit 044f9d0

Browse files
committed
feat(git): delete worktree and directory upon termination
1 parent 7cb7576 commit 044f9d0

8 files changed

Lines changed: 457 additions & 33 deletions

File tree

apps/desktop/src-tauri/gen/schemas/acl-manifests.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/desktop/src-tauri/permissions/app.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ commands.allow = [
1313
"stage_all_changes",
1414
"unstage_all_changes",
1515
"discard_all_changes",
16+
"remove_session_worktree",
1617
"commit_git_changes",
1718
"get_last_commit_message",
1819
"load_config",
@@ -42,6 +43,7 @@ commands.allow = [
4243
"stage_all_changes",
4344
"unstage_all_changes",
4445
"discard_all_changes",
46+
"remove_session_worktree",
4547
"commit_git_changes",
4648
"get_last_commit_message",
4749
"load_config",

apps/desktop/src-tauri/src/lib.rs

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use base64::{engine::general_purpose, Engine as _};
33
use serde::{Deserialize, Serialize};
44
use std::collections::HashMap;
55
use std::io::{Read, Write};
6-
use std::path::{Path, PathBuf};
6+
use std::path::{Component, Path, PathBuf};
77
use std::sync::{atomic::{AtomicU32, Ordering}, Arc, Mutex};
88
use tauri::{AppHandle, Emitter, RunEvent, State, WindowEvent};
99
#[cfg(target_os = "macos")]
@@ -460,6 +460,117 @@ fn discard_all_changes(path: String) -> Result<(), String> {
460460
)
461461
}
462462

463+
fn remove_session_worktree_blocking(
464+
repo_path: String,
465+
worktree_path: String,
466+
branch: Option<String>,
467+
) -> Result<(), String> {
468+
let root = resolve_repo_root(repo_path)?;
469+
if !Path::new(&root).exists() {
470+
return Err(format!("Path '{}' does not exist", root));
471+
}
472+
473+
let trimmed_worktree = worktree_path.trim();
474+
if trimmed_worktree.is_empty() {
475+
return Err("Worktree path cannot be empty".to_string());
476+
}
477+
478+
let worktree_root = worktrees_root_dir()?;
479+
let worktree = PathBuf::from(trimmed_worktree);
480+
if !worktree.is_absolute() {
481+
return Err("Worktree path must be absolute".to_string());
482+
}
483+
let allowed = if worktree.exists() {
484+
let canonical_worktree = worktree
485+
.canonicalize()
486+
.map_err(|error| format!("Failed to resolve worktree path: {error}"))?;
487+
let canonical_root = worktree_root
488+
.canonicalize()
489+
.unwrap_or(worktree_root.clone());
490+
canonical_worktree.starts_with(&canonical_root)
491+
} else {
492+
let canonical_root = worktree_root
493+
.canonicalize()
494+
.unwrap_or(worktree_root.clone());
495+
let has_parent_dir = worktree
496+
.components()
497+
.any(|component| matches!(component, Component::ParentDir));
498+
!has_parent_dir && worktree.starts_with(&canonical_root)
499+
};
500+
if !allowed {
501+
return Err("Worktree path is outside managed Codelegate worktrees".to_string());
502+
}
503+
504+
let worktree_path = Path::new(trimmed_worktree);
505+
let worktree_exists_before = worktree_path.exists();
506+
507+
let remove_output = std::process::Command::new("git")
508+
.arg("-C")
509+
.arg(&root)
510+
.arg("worktree")
511+
.arg("remove")
512+
.arg("--force")
513+
.arg(trimmed_worktree)
514+
.output()
515+
.map_err(|error| format!("Failed to run git: {error}"))?;
516+
if !remove_output.status.success() {
517+
let stderr = String::from_utf8_lossy(&remove_output.stderr).trim().to_string();
518+
let is_not_worktree = stderr.contains("is not a working tree");
519+
let is_missing = stderr.contains("No such file or directory")
520+
|| stderr.contains("does not exist");
521+
if is_not_worktree && worktree_exists_before {
522+
return Err("Refusing to delete directory because target is not a registered git worktree".to_string());
523+
}
524+
let ignorable = is_not_worktree || is_missing;
525+
if !ignorable {
526+
return Err(if stderr.is_empty() {
527+
"Failed to remove worktree".to_string()
528+
} else {
529+
stderr
530+
});
531+
}
532+
}
533+
534+
if worktree_path.exists() {
535+
std::fs::remove_dir_all(worktree_path)
536+
.map_err(|error| format!("Failed to remove worktree directory: {error}"))?;
537+
}
538+
539+
let branch_name = branch.unwrap_or_default().trim().to_string();
540+
if !branch_name.is_empty() {
541+
let branch_output = std::process::Command::new("git")
542+
.arg("-C")
543+
.arg(&root)
544+
.arg("branch")
545+
.arg("-D")
546+
.arg(&branch_name)
547+
.output()
548+
.map_err(|error| format!("Failed to run git: {error}"))?;
549+
if !branch_output.status.success() {
550+
let stderr = String::from_utf8_lossy(&branch_output.stderr).trim().to_string();
551+
let ignorable = stderr.contains("not found");
552+
if !ignorable {
553+
return Err(if stderr.is_empty() {
554+
format!("Failed to delete branch '{}'", branch_name)
555+
} else {
556+
stderr
557+
});
558+
}
559+
}
560+
}
561+
562+
Ok(())
563+
}
564+
565+
#[tauri::command]
566+
async fn remove_session_worktree(repo_path: String, worktree_path: String, branch: Option<String>) -> Result<(), String> {
567+
tauri::async_runtime::spawn_blocking(move || {
568+
remove_session_worktree_blocking(repo_path, worktree_path, branch)
569+
})
570+
.await
571+
.map_err(|error| format!("Failed to join worktree removal task: {error}"))?
572+
}
573+
463574
#[tauri::command]
464575
fn commit_git_changes(path: String, message: String, amend: bool) -> Result<(), String> {
465576
let root = resolve_repo_root(path)?;
@@ -770,6 +881,13 @@ fn previous_sessions_file() -> Result<PathBuf, String> {
770881
Ok(home.join(".codelegate").join("previous_sessions.json"))
771882
}
772883

884+
fn worktrees_root_dir() -> Result<PathBuf, String> {
885+
let home = std::env::var_os("HOME")
886+
.map(PathBuf::from)
887+
.ok_or_else(|| "Unable to locate home directory".to_string())?;
888+
Ok(home.join(".codelegate").join("worktrees"))
889+
}
890+
773891
fn should_override_close_flow() -> bool {
774892
has_saved_config().unwrap_or(false) && load_config().is_ok()
775893
}
@@ -861,6 +979,7 @@ pub fn run() {
861979
stage_all_changes,
862980
unstage_all_changes,
863981
discard_all_changes,
982+
remove_session_worktree,
864983
commit_git_changes,
865984
get_last_commit_message,
866985
load_config,

apps/desktop/src/App.tsx

Lines changed: 98 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,21 @@ import {
66
useState,
77
type PointerEvent as ReactPointerEvent,
88
} from "react";
9-
import { confirm, open } from "@tauri-apps/plugin-dialog";
9+
import { invoke } from "@tauri-apps/api/core";
10+
import { open } from "@tauri-apps/plugin-dialog";
1011
import styles from "./App.module.css";
1112
import Sidebar from "./components/Sidebar/Sidebar";
1213
import MainPane from "./components/MainPane/MainPane";
1314
import NewSessionDialog from "./components/NewSessionDialog/NewSessionDialog";
1415
import SettingsDialog from "./components/SettingsDialog/SettingsDialog";
1516
import RenameDialog from "./components/RenameDialog/RenameDialog";
1617
import CloseDialog from "./components/CloseDialog/CloseDialog";
18+
import TerminateSessionDialog from "./components/TerminateSessionDialog/TerminateSessionDialog";
1719
import OnboardingDialog from "./components/OnboardingDialog/OnboardingDialog";
1820
import { useAppState } from "./hooks/useAppState";
1921
import { useToasts } from "./hooks/useToasts";
2022
import Toasts from "./components/Toasts/Toasts";
21-
import type { AgentId, CloseConfirmPayload, CloseConfirmResult, EnvVar, RepoConfig, PaneKind } from "./types";
23+
import type { AgentId, CloseConfirmPayload, CloseConfirmResult, EnvVar, RepoConfig, PaneKind, Session } from "./types";
2224
import { getRepoName, groupSessionsByRepo, validateEnvVars } from "./utils/session";
2325
import { defineHotkey, runHotkeys, type HotkeyBinding } from "./utils/hotkeys";
2426
import {
@@ -30,6 +32,15 @@ import { agentCatalog } from "./constants";
3032

3133
const emptyEnv: EnvVar[] = [{ key: "", value: "" }];
3234

35+
function canDeleteWorktreeForSession(session: Session | null | undefined) {
36+
if (!session?.repo.worktree?.enabled) {
37+
return false;
38+
}
39+
const cwd = session.cwd?.trim();
40+
const repoRoot = session.repo.repoPath.trim();
41+
return Boolean(cwd && repoRoot && cwd !== repoRoot);
42+
}
43+
3344
function buildModifierSessionSelectHotkeys(
3445
shortcutModifier: string,
3546
selectSessionByHotkeyIndex: (index: number) => void
@@ -72,6 +83,8 @@ export default function App() {
7283
const closeDialogResolveRef = useRef<((result: CloseConfirmResult) => void) | null>(null);
7384
const closeDialogPromiseRef = useRef<Promise<CloseConfirmResult> | null>(null);
7485
const previousActiveSessionIdRef = useRef<string | null>(null);
86+
const [terminateDialogSessionId, setTerminateDialogSessionId] = useState<string | null>(null);
87+
const [terminateDialogDeleteWorktree, setTerminateDialogDeleteWorktree] = useState(false);
7588

7689
function focusSearch() {
7790
if (searchInputRef.current) {
@@ -334,6 +347,14 @@ export default function App() {
334347
() => visibleSessions.find((session) => session.id === activeSessionId) ?? null,
335348
[activeSessionId, visibleSessions]
336349
);
350+
const terminateDialogSession = useMemo(
351+
() => sessions.find((session) => session.id === terminateDialogSessionId) ?? null,
352+
[sessions, terminateDialogSessionId]
353+
);
354+
const canDeleteDialogWorktree = useMemo(
355+
() => canDeleteWorktreeForSession(terminateDialogSession),
356+
[terminateDialogSession]
357+
);
337358

338359
const canRestartActiveAgent = useMemo(() => {
339360
if (activePaneKind !== "agent" || !activeSession) {
@@ -361,19 +382,73 @@ export default function App() {
361382
openRename(activeSessionId);
362383
}, [activeSessionId, openRename]);
363384

364-
const terminateActiveSession = useCallback(async () => {
365-
if (!activeSessionId) {
385+
const openTerminateDialog = useCallback((sessionId: string) => {
386+
setTerminateDialogSessionId(sessionId);
387+
setTerminateDialogDeleteWorktree(false);
388+
}, []);
389+
390+
const closeTerminateDialog = useCallback(() => {
391+
setTerminateDialogSessionId(null);
392+
setTerminateDialogDeleteWorktree(false);
393+
}, []);
394+
395+
const confirmTerminateDialog = useCallback(async () => {
396+
const session = sessions.find((item) => item.id === terminateDialogSessionId);
397+
const targetSessionId = terminateDialogSessionId;
398+
if (!session || !targetSessionId) {
399+
closeTerminateDialog();
366400
return;
367401
}
368-
const confirmed = await confirm(
369-
"Terminate this session? This will close the tab and stop ongoing shell sessions.",
370-
{ title: "Codelegate", kind: "warning" }
371-
);
372-
if (!confirmed) {
402+
const shouldDeleteWorktree = terminateDialogDeleteWorktree && canDeleteWorktreeForSession(session);
403+
closeTerminateDialog();
404+
try {
405+
let cleanupWorktree:
406+
| {
407+
repoPath: string;
408+
worktreePath: string;
409+
branch?: string;
410+
}
411+
| undefined;
412+
if (shouldDeleteWorktree) {
413+
let branchName: string | undefined;
414+
const branchPath = session.cwd?.trim();
415+
if (branchPath) {
416+
try {
417+
const resolvedBranch = await invoke<string>("get_git_branch", { path: branchPath });
418+
const trimmedBranch = resolvedBranch.trim();
419+
if (trimmedBranch) {
420+
branchName = trimmedBranch;
421+
}
422+
} catch (error) {
423+
// Keep branch optional; backend cleanup will still remove worktree directory.
424+
pushToast({ message: `Failed to resolve branch from git: ${String(error)}`, tone: "error" });
425+
}
426+
}
427+
cleanupWorktree = {
428+
repoPath: session.repo.repoPath,
429+
worktreePath: session.cwd ?? "",
430+
branch: branchName || undefined,
431+
};
432+
}
433+
await terminateSession(targetSessionId, cleanupWorktree ? { cleanupWorktree } : undefined);
434+
} catch (error) {
435+
pushToast({ message: `Failed to terminate session: ${String(error)}`, tone: "error" });
436+
}
437+
}, [
438+
closeTerminateDialog,
439+
pushToast,
440+
sessions,
441+
terminateDialogDeleteWorktree,
442+
terminateDialogSessionId,
443+
terminateSession,
444+
]);
445+
446+
const terminateActiveSession = useCallback(() => {
447+
if (!activeSessionId) {
373448
return;
374449
}
375-
terminateSession(activeSessionId);
376-
}, [activeSessionId, terminateSession]);
450+
openTerminateDialog(activeSessionId);
451+
}, [activeSessionId, openTerminateDialog]);
377452

378453
const openSettings = useCallback(() => {
379454
setFontFamily(config.settings.terminalFontFamily);
@@ -749,7 +824,7 @@ export default function App() {
749824
onNewSession={handleOpenDialog}
750825
onOpenSettings={openSettings}
751826
onRenameSession={openRename}
752-
onTerminateSession={terminateSession}
827+
onTerminateSession={openTerminateDialog}
753828
agentOutputting={agentOutputting}
754829
searchRef={searchInputRef}
755830
showShortcutHints={showShortcutHints}
@@ -835,6 +910,17 @@ export default function App() {
835910
onClose={handleCloseConfirmCancel}
836911
onConfirm={handleCloseConfirmSubmit}
837912
/>
913+
<TerminateSessionDialog
914+
open={Boolean(terminateDialogSession)}
915+
sessionLabel={terminateDialogSession?.branch?.trim() || getRepoName(terminateDialogSession?.repo.repoPath ?? "")}
916+
canDeleteWorktree={canDeleteDialogWorktree}
917+
deleteWorktree={terminateDialogDeleteWorktree}
918+
onDeleteWorktreeChange={setTerminateDialogDeleteWorktree}
919+
onClose={closeTerminateDialog}
920+
onConfirm={() => {
921+
void confirmTerminateDialog();
922+
}}
923+
/>
838924
<Toasts toasts={toasts} onDismiss={removeToast} />
839925
</div>
840926
);

apps/desktop/src/components/Sidebar/Sidebar.tsx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Bot, MoreHorizontal, Plus, Settings } from "lucide-react";
2-
import { confirm } from "@tauri-apps/plugin-dialog";
32
import { Fragment, useEffect, useState } from "react";
43
import type { SessionGroup } from "../../utils/session";
54
import { ClaudeIconIcon, OpenaiIconIcon } from "@codelegate/shared/icons";
@@ -195,14 +194,7 @@ export default function Sidebar({
195194
<button
196195
type="button"
197196
className={`${styles.menuItem} ${styles.menuItemWithShortcut} ${styles.menuItemDanger}`}
198-
onClick={async () => {
199-
const confirmed = await confirm(
200-
"Terminate this session? This will close the tab and stop ongoing shell sessions.",
201-
{ title: "Codelegate", kind: "warning" }
202-
);
203-
if (!confirmed) {
204-
return;
205-
}
197+
onClick={() => {
206198
setOpenMenuId(null);
207199
onTerminateSession(session.id);
208200
}}

0 commit comments

Comments
 (0)