Skip to content

Commit 920ea2b

Browse files
authored
Merge pull request #23 from InterestingSoftware/large-repo-updates
Switch-Hook Lifecycle, Persistent Terminal Sessions and Worktree Creation UX
2 parents 99219e6 + b43d988 commit 920ea2b

20 files changed

Lines changed: 1718 additions & 303 deletions

e2e/specs/daily-workflow.spec.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -564,15 +564,11 @@ test.describe('Daily developer workflow', () => {
564564
// emitted — the shell script runs asynchronously afterward. Waiting for a
565565
// filesystem artifact (the output file) is therefore inherently racy.
566566
// Instead, assert on the UI state that appears synchronously:
567-
// 1. The hook-operation-header confirms hooks were triggered.
568-
// 2. The terminal session tab confirms the terminal_tab launch request
569-
// was handled by the frontend.
570-
const hookHeader = tauriPage.getByTestId('hook-operation-header');
571-
await hookHeader.waitFor(DEFAULT_UI_TIMEOUT);
572-
await expect(hookHeader).toBeVisible();
567+
// the terminal session tab confirms the terminal_tab launch request
568+
// was handled by the frontend.
573569

574570
const sessionTab = tauriPage.locator(
575-
`[data-testid="terminal-session-tab"][data-session-label^="${hookName} ("]`
571+
`[data-testid="terminal-session-tab"][data-session-label="${hookName}"]`
576572
);
577573
await sessionTab.waitFor(DEFAULT_UI_TIMEOUT);
578574
await expect(sessionTab).toBeVisible();
@@ -677,15 +673,11 @@ test.describe('Daily developer workflow', () => {
677673
const targetBranch = 'feature/multi-hooks';
678674
await createWorktreeViaUi(tauriPage, targetBranch);
679675

680-
const hookHeader = tauriPage.getByTestId('hook-operation-header');
681-
await hookHeader.waitFor(DEFAULT_UI_TIMEOUT);
682-
await expect(hookHeader).toBeVisible();
683-
684676
const firstSessionTab = tauriPage.locator(
685-
`[data-testid="terminal-session-tab"][data-session-label^="${firstHookName} ("]`
677+
`[data-testid="terminal-session-tab"][data-session-label="${firstHookName}"]`
686678
);
687679
const secondSessionTab = tauriPage.locator(
688-
`[data-testid="terminal-session-tab"][data-session-label^="${secondHookName} ("]`
680+
`[data-testid="terminal-session-tab"][data-session-label="${secondHookName}"]`
689681
);
690682

691683
await firstSessionTab.waitFor(DEFAULT_UI_TIMEOUT);
@@ -764,10 +756,10 @@ test.describe('Daily developer workflow', () => {
764756
await createWorktreeViaUi(tauriPage, targetBranch);
765757

766758
const autoCloseSessionTab = tauriPage.locator(
767-
`[data-testid="terminal-session-tab"][data-session-label^="${autoCloseHookName} ("]`
759+
`[data-testid="terminal-session-tab"][data-session-label="${autoCloseHookName}"]`
768760
);
769761
const keepOpenSessionTab = tauriPage.locator(
770-
`[data-testid="terminal-session-tab"][data-session-label^="${keepOpenHookName} ("]`
762+
`[data-testid="terminal-session-tab"][data-session-label="${keepOpenHookName}"]`
771763
);
772764

773765
// ── Why this test races and how we synchronise on it ──────────────────────
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE hook_definitions
2+
ADD COLUMN switch_once_per_session INTEGER NOT NULL DEFAULT 0;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ALTER TABLE hook_definitions
2+
ADD COLUMN switch_run_on_create INTEGER NOT NULL DEFAULT 1;
3+
4+
ALTER TABLE hook_definitions
5+
ADD COLUMN switch_run_on_delete INTEGER NOT NULL DEFAULT 0;

src-tauri/src/db.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ static WORKSPACE_MIGRATIONS: LazyLock<Migrations<'static>> = LazyLock::new(|| {
4848
M::up(include_str!(
4949
"../migrations/workspace/005_hooks_terminal_tab_only.sql"
5050
)),
51+
M::up(include_str!(
52+
"../migrations/workspace/006_hook_switch_once_per_session.sql"
53+
)),
54+
M::up(include_str!(
55+
"../migrations/workspace/007_hook_switch_auto_run_contexts.sql"
56+
)),
5157
])
5258
});
5359

src-tauri/src/editor.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,16 @@ pub async fn open_in_editor(worktree_path: String) -> Result<String, String> {
349349
cmd.spawn()
350350
.map_err(|e| format!("Failed to open editor '{}': {e}", parts[0]))?;
351351

352-
Ok(editor)
352+
// Return a friendly display name: check known editors first, then fall back
353+
// to just the bare command name (strips flags like --wait from the label).
354+
let cmd_name = parts[0].as_str();
355+
let display = known_editors()
356+
.into_iter()
357+
.find(|e| e.command == cmd_name || e.mac_bundle_bin == Some(cmd_name))
358+
.map(|e| e.name.to_string())
359+
.unwrap_or_else(|| cmd_name.to_string());
360+
361+
Ok(display)
353362
}
354363

355364
#[tauri::command]

src-tauri/src/git/operations.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,31 @@ pub async fn delete_managed_worktree(
738738
"--force",
739739
],
740740
)?;
741+
742+
// On Windows, `git worktree remove` can fail with "Permission denied" even
743+
// with --force when a file watcher or shell process holds an open handle to
744+
// the worktree directory. If that happens, fall back to Rust's own
745+
// directory removal (which uses the same Win32 APIs but retries on transient
746+
// sharing violations), then let `git worktree prune` clean up the metadata.
747+
#[cfg(target_os = "windows")]
748+
if !output.status.success() {
749+
let stderr = String::from_utf8_lossy(&output.stderr);
750+
if stderr.contains("Permission denied") || stderr.contains("failed to delete") {
751+
if let Err(rm_err) = std::fs::remove_dir_all(&wt_path) {
752+
// Both git and Rust failed — surface the original git error.
753+
return Err(format!(
754+
"Failed to remove worktree (git: {}; fs: {})",
755+
stderr.trim(),
756+
rm_err
757+
));
758+
}
759+
// Directory removed; git metadata will be pruned below.
760+
} else {
761+
ensure_git_success(output, "Failed to remove worktree")?;
762+
}
763+
}
764+
765+
#[cfg(not(target_os = "windows"))]
741766
ensure_git_success(output, "Failed to remove worktree")?;
742767

743768
// Prune stale worktree metadata regardless of branch deletion.
@@ -758,6 +783,8 @@ pub async fn delete_managed_worktree(
758783
}
759784

760785
if let Some(workspace_path) = workspace_from_root_repo(&root_repo) {
786+
crate::hooks::clear_switch_hook_session_runs_for_worktree(&workspace_path, &wt_path);
787+
761788
if let Err(err) = delete_worktree_provenance(&workspace_path, &wt_path).await {
762789
eprintln!("Failed to remove worktree provenance: {err}");
763790
}

0 commit comments

Comments
 (0)