Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ alt="patch-hub-demo-v0.1.0">
- **Apply patch(set)** to your local kernel tree.
- **Bookmark** important patches
- **Reply with `Reviewed-by` tags** to the series.
- **KwOps** (`w`) — build and remotely deploy the applied tree with `kw`.

- **Bookmarking System** — Bookmark patchsets for easy reference
later.
Expand All @@ -98,12 +99,25 @@ alt="patch-hub-demo-v0.1.0">
visualization or use the built-in vanilla renderer (`default`) for a
dependency-free experience.

### More Features Coming!
### KwOps (build and deploy with kw)

Future updates will introduce deeper integration with kw, including:
From a patchset's Details screen, press `w` to open **Kw operations** and
build or remotely deploy the applied tree with `kw`.

- Seamlessly compile and deploy patchset versions of the kernel to target
machines.
- **Build** (`b`), **Deploy** (`d`), and **Build then deploy** (`D`).
- Live logs while a job runs — you can leave the screen without freezing the
TUI.
- **Cancel** (`c`) a running job, or **Restore previous branch** (`r`) after
a job switched `HEAD`. Press `?` for in-screen help.

KwOps uses your existing `kw` setup: [`kw`](https://github.com/kworkflow/kworkflow/)
0.10 or newer on `PATH`, a `.kw/` directory in the kernel tree (`kw init` /
`kw env`), and for deploy a remote plus `kw deploy --setup`. Local deploy is
not available from the UI. Extra `kw` arguments can be typed per run; they
are not saved.

Related settings in Edit Config / `config.json`: `stay_on_applied_branch`,
`kw_reboot_after_deploy`, and `kw_deploy_force`.

## :package: How To Install

Expand Down
65 changes: 61 additions & 4 deletions src/app/actions/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ pub(crate) fn apply_patchset(
}
Err(e) => {
switch_to_branch(shell, kernel_tree, &original_branch)?;
Err(format!(" `git am` failed\n{}{}", &original_branch, e))
let cleanup_note = match delete_branch(shell, kernel_tree, &target_branch) {
Ok(()) => String::new(),
Err(error) => format!("\n\n(could not delete '{target_branch}': {error})"),
};
Err(format!(
" `git am` failed (back on branch '{original_branch}')\n{e}{cleanup_note}"
))
}
}
}
Expand Down Expand Up @@ -218,6 +224,30 @@ fn create_target_branch(
Ok(target_branch_name)
}

/// Deletes a branch `create_target_branch` made for an apply that failed.
/// `-D` because `git am --abort` leaves it at the base commit, which is not
/// necessarily merged into the branch HEAD switched back to.
fn delete_branch(
shell: &dyn ShellTrait,
kernel_tree: &KernelTree,
branch: &str,
) -> Result<(), String> {
let out = shell
.execute(
&ShellCommand::new("git")
.arg("-C")
.arg(kernel_tree.path())
.args(["branch", "-D", branch]),
)
.map_err(|e| e.to_string())?;

if !out.success {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}

Ok(())
}

fn run_git_am(
request: &ApplyPatchsetRequest,
shell: &dyn ShellTrait,
Expand Down Expand Up @@ -515,14 +545,15 @@ mod tests {
output("", "apply failed", false),
output("", "", true),
output("", "", true),
output("", "", true),
]);

let result = apply_patchset(&request(), &fs, &shell, &config).unwrap_err();

assert!(result.contains("`git am` failed"));
assert!(result.contains("feature"));
assert!(result.contains("apply failed"));
assert!(result.starts_with(" `git am` failed (back on branch 'feature')\napply failed"));
assert!(!result.contains("could not delete"));
let calls = calls.lock().unwrap();
assert_eq!(9, calls.len());
assert_eq!(
&calls[6],
&command(&["git", "-C", KERNEL_TREE_PATH, "am", "--abort"])
Expand All @@ -531,6 +562,32 @@ mod tests {
&calls[7],
&command(&["git", "-C", KERNEL_TREE_PATH, "switch", "feature"])
);
assert_eq!(
&calls[8],
&command(&["git", "-C", KERNEL_TREE_PATH, "branch", "-D", &calls[4][5]])
);
}
}

#[test]
fn failed_branch_cleanup_is_noted_without_hiding_the_git_am_error() {
let fs = clean_fs();
let (shell, _calls) = shell_with_outputs(vec![
output("", "", true),
output("", "", true),
output("feature\n", "", true),
output("", "", true),
output("", "", true),
output("", "apply failed", false),
output("", "", true),
output("", "", true),
output("", "error: branch is locked\n", false),
]);

let result = apply_patchset(&request(), &fs, &shell, &config()).unwrap_err();

assert!(result.starts_with(" `git am` failed (back on branch 'feature')\napply failed"));
assert!(result.contains("could not delete 'patchset-"));
assert!(result.contains("error: branch is locked)"));
}
}
226 changes: 222 additions & 4 deletions src/app/flows/edit_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ pub async fn handle_edit_config(app: &mut App, input: InputEvent) -> Result<()>
edit_config_state.clear_edit();
edit_config_state.toggle_editing();
}
InputEvent::NavigateLeft => {
edit_config_state.cycle_edit(false);
}
InputEvent::NavigateRight => {
edit_config_state.cycle_edit(true);
}
_ => {}
}
}
Expand Down Expand Up @@ -75,12 +81,224 @@ pub async fn handle_edit_config(app: &mut App, input: InputEvent) -> Result<()>
pub fn generate_help_popup() -> AppPopup {
AppPopup::help()
.title("Edit Config")
.description("This screen allows you to edit the configuration options for patch-hub.\nMore configurations may be available in the configuration file.")
.keybind("ESC", "Exit")
.keybind("ENTER", "Save changes")
.description("This screen allows you to edit the configuration options for patch-hub.\nKernel trees are added by editing the config file; this screen selects among existing keys.")
.keybind("ESC / q", "Save and exit")
.keybind("ENTER", "Edit the highlighted option")
.keybind("?", "Show this help screen")
.keybind("j/🡇", "Down")
.keybind("k/🡅", "Up")
.keybind("e", "Toggle editing for a configuration option")
.keybind("←/→", "Cycle the target kernel tree while editing that row")
.build()
}

#[cfg(test)]
mod tests {
use std::{
collections::HashMap,
env::VarError,
fs,
path::PathBuf,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
};

use tokio::sync::mpsc;

use crate::{
app::{
screens::{
bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState,
CurrentScreen,
},
state::{AppState, ConfigUiState, LoreUiState, NavigationState, UserLoreState},
App, AppServices,
},
config::{bootstrap_parts, ConfigActor, ConfigHandle, DEFAULT_CONFIG_PATH_SUFFIX},
infrastructure::{
env::MockEnvTrait, file_system::MockFileSystemTrait, file_system::OsFileSystem,
shell::MockShellTrait,
},
kw::history::MockKwHistoryStore,
lore::{application::handle::LoreApiHandle, domain::mailing_list::MailingList},
render::handle::RenderHandle,
};

use super::*;

static TEST_SEQ: AtomicU64 = AtomicU64::new(0);

fn unique_test_dir(prefix: &str) -> PathBuf {
let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst);
let p = std::env::temp_dir().join(format!(
"patch-hub-edit-config-{prefix}-{}-{n}",
std::process::id()
));
fs::create_dir_all(&p).unwrap();
p
}

fn default_env() -> (MockEnvTrait, PathBuf) {
let home = unique_test_dir("home");
let home_s = home.to_string_lossy().into_owned();
let mut mock = MockEnvTrait::new();
mock.expect_var()
.withf(|key| key == "PATCH_HUB_CONFIG_PATH")
.returning(|_| Err(VarError::NotPresent.into()));
mock.expect_var()
.withf(move |key| key == "HOME")
.returning(move |_| Ok(home_s.clone()));
mock.expect_var()
.withf(|key| {
matches!(
key,
"PATCH_HUB_PAGE_SIZE"
| "PATCH_HUB_CACHE_DIR"
| "PATCH_HUB_DATA_DIR"
| "PATCH_HUB_GIT_SEND_EMAIL_OPTIONS"
| "PATCH_HUB_PATCH_RENDERER"
)
})
.returning(|_| Err(VarError::NotPresent.into()));
(mock, home)
}

fn app_with_kernel_trees(keys: &[&str], target: Option<&str>) -> (App, ConfigHandle, PathBuf) {
let (env, home) = default_env();
let (mut state, repo) = bootstrap_parts(&env, OsFileSystem).unwrap();
for key in keys {
state.kernel_trees.insert(
(*key).to_string(),
serde_json::from_value(serde_json::json!({
"path": format!("/{key}"),
"branch": "master"
}))
.unwrap(),
);
}
state.target_kernel_tree = target.map(str::to_string);
let snapshot = state.to_snapshot();
let config = ConfigActor::spawn(state, repo);

let dummy_list = MailingList::new("test-list", "Test list");
let (lore_tx, _lore_rx) = mpsc::channel(1);
let (render_tx, _render_rx) = mpsc::channel(1);
let app = App {
state: AppState {
navigation: NavigationState {
current_screen: CurrentScreen::EditConfig,
},
lore: LoreUiState {
mailing_list_selection: MailingListSelectionState {
mailing_lists: vec![dummy_list.clone()],
target_list: String::new(),
possible_mailing_lists: vec![dummy_list],
highlighted_list_index: 0,
},
latest_patchsets: None,
details: None,
},
user_state: UserLoreState {
bookmarked_patchsets: BookmarkedPatchsetsState {
bookmarked_patchsets: vec![],
patchset_index: 0,
},
reviewed_patchsets: HashMap::new(),
},
config_state: ConfigUiState { edit_config: None },
config: snapshot,
popup: None,
kw: Default::default(),
},
services: AppServices {
lore_api: LoreApiHandle::new(lore_tx),
render: RenderHandle::new(render_tx),
shell: Box::new(MockShellTrait::new()),
fs: Arc::new(MockFileSystemTrait::new()),
config: config.clone(),
kw_history: Arc::new(MockKwHistoryStore::new()),
kw: None,
},
};
(app, config, home)
}

#[test]
fn help_documents_tree_cycle_and_save_keys() {
let AppPopup::Help {
description,
formatted_keybinds,
..
} = generate_help_popup()
else {
panic!("expected help popup");
};
assert!(description
.as_deref()
.is_some_and(|text| text.contains("selects among existing keys")));
assert!(formatted_keybinds.contains("ESC / q: Save and exit"));
assert!(formatted_keybinds.contains("ENTER: Edit the highlighted option"));
assert!(formatted_keybinds.contains("←/→: Cycle the target kernel tree"));
assert!(!formatted_keybinds.contains("Save changes"));
}

#[tokio::test]
async fn cycling_the_tree_row_and_saving_updates_the_snapshot() {
let (mut app, config, home) = app_with_kernel_trees(&["linux", "zebra"], None);
app.init_edit_config();

for _ in 0..11 {
handle_edit_config(&mut app, InputEvent::NavigateDown)
.await
.unwrap();
}
handle_edit_config(&mut app, InputEvent::EditConfigField)
.await
.unwrap();
handle_edit_config(&mut app, InputEvent::NavigateRight)
.await
.unwrap();
handle_edit_config(&mut app, InputEvent::StageConfigEdit)
.await
.unwrap();
handle_edit_config(&mut app, InputEvent::SaveConfig)
.await
.unwrap();

assert_eq!(
Some("linux"),
app.state.config.target_kernel_tree().as_deref()
);
assert!(app.state.config_state.edit_config.is_none());
assert_eq!(
CurrentScreen::MailingListSelection,
app.state.navigation.current_screen
);

let raw = fs::read_to_string(home.join(DEFAULT_CONFIG_PATH_SUFFIX)).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed["target_kernel_tree"], "linux");
config.shutdown().await;
}

#[tokio::test]
async fn saving_with_a_dangling_target_unsets_it_instead_of_crashing() {
let (mut app, config, home) = app_with_kernel_trees(&["zebra"], Some("linux"));
assert_eq!(
Some("linux"),
app.state.config.target_kernel_tree().as_deref()
);
app.init_edit_config();

handle_edit_config(&mut app, InputEvent::SaveConfig)
.await
.unwrap();

assert!(app.state.config.target_kernel_tree().is_none());
let raw = fs::read_to_string(home.join(DEFAULT_CONFIG_PATH_SUFFIX)).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(parsed["target_kernel_tree"].is_null());
config.shutdown().await;
}
}
Loading
Loading