diff --git a/README.md b/README.md index 1682c78..986213e 100644 --- a/README.md +++ b/README.md @@ -702,6 +702,37 @@ after the fact using `split`: - Git 2.0+ - Rust (for building from source) +## Windows support + +git-surgeon compiles and runs on Windows. All commands work except `update`, which +is gated behind CI release artifacts that don't yet include Windows targets. + +The following changes were needed for Windows compatibility: + +- **`sed` removed from `split`**: The `split` command previously used `sed -i.bak` + as a `GIT_SEQUENCE_EDITOR` to mark commits for editing during rebase. On Windows, + `sed` is not available. Replaced with the existing `_edit-todo` self-invocation + pattern (a new `edit-mark` mode in `edit_todo()`), eliminating the shell + dependency entirely. This also fixes `move` and `fixup` on Windows, which use + the same `_edit-todo` mechanism and had a latent bug: Windows backslash paths in + `GIT_SEQUENCE_EDITOR` were mangled by Git for Windows' `/bin/sh`. Fixed by + normalizing paths to forward slashes. + +- **Unix-only `chmod` guarded**: `std::os::unix::fs::PermissionsExt` and + `from_mode(0o755)` in the binary replacement logic are now behind `#[cfg(unix)]`. + Windows determines executability by file extension, not permission bits. + +- **SHA-256 checksum on Windows**: The checksum verification in `update` uses + `sha256sum` (Linux) / `shasum` (macOS). Neither exists on Windows. Added a + `certutil -hashfile` path for Windows (built into the OS). + +- **Cache directory**: The update cache path was hardcoded to `~/.cache/`, which + is not conventional on Windows. Switched to `dirs::cache_dir()`, which returns + `%LOCALAPPDATA%` on Windows and `~/.cache` on Unix. + +- **Platform targets**: Added `windows-amd64` and `windows-arm64` to the release + artifact suffix mapping. + ## Related projects - [workmux](https://github.com/raine/workmux) — Git worktrees + tmux windows for diff --git a/src/hunk.rs b/src/hunk.rs index 331994d..d71be63 100644 --- a/src/hunk.rs +++ b/src/hunk.rs @@ -735,6 +735,26 @@ pub fn edit_todo(file: &str, sources: &[String], target: &str, mode: &str) -> Re lines.insert(target_idx + 1 + j, line); } } + "edit-mark" => { + // Change "pick " to "edit " in the todo + let mut found = false; + for line in &mut lines { + let trimmed = line.trim().to_string(); + if !trimmed.starts_with('#') + && let Some(sha) = trimmed.split_whitespace().nth(1) + && (sha.starts_with(target_short) || target.starts_with(sha)) + { + if let Some(rest) = line.strip_prefix("pick ") { + *line = format!("edit {}", rest); + found = true; + break; + } + } + } + if !found { + anyhow::bail!("target commit {} not found in todo", target_short); + } + } "move" | "move-before" => { if sources.len() != 1 { anyhow::bail!("move mode requires exactly one source commit"); @@ -882,10 +902,11 @@ pub fn move_commit( }; let exe = std::env::current_exe().context("failed to get current executable path")?; + let exe = exe.to_string_lossy().replace('\\', "/"); let editor = format!( "{} _edit-todo --source {} --target {} --mode {}", - exe.display(), + exe, source_sha, editor_target, editor_mode @@ -1022,6 +1043,7 @@ pub fn fixup(target: &str, sources: &[String]) -> Result<()> { } else { // Rebase path: use custom todo editor to mark all sources as fixup let exe = std::env::current_exe().context("failed to get current executable path")?; + let exe = exe.to_string_lossy().replace('\\', "/"); // Check if target is root commit let is_root = Command::new("git") @@ -1037,7 +1059,7 @@ pub fn fixup(target: &str, sources: &[String]) -> Result<()> { .collect(); let editor = format!( "{} _edit-todo{} --target {}", - exe.display(), + exe, source_args, target_sha ); @@ -1707,9 +1729,13 @@ fn start_rebase_at_commit(target_sha: &str) -> Result<()> { .unwrap_or(false); // We need a custom sequence editor that marks the target commit as "edit" - let short_sha = &target_sha[..7.min(target_sha.len())]; - // Use sed to change "pick " to "edit " for the target commit - let sed_script = format!("s/^pick {} /edit {} /", short_sha, short_sha); + let exe = std::env::current_exe().context("failed to determine current executable")?; + let exe = exe.to_string_lossy().replace('\\', "/"); + let editor = format!( + "{} _edit-todo --target {} --mode edit-mark", + exe, + target_sha + ); let mut rebase_cmd = Command::new("git"); rebase_cmd.args(["rebase", "-i", "--autostash"]); @@ -1718,10 +1744,7 @@ fn start_rebase_at_commit(target_sha: &str) -> Result<()> { } else { rebase_cmd.arg(format!("{}~1", target_sha)); } - rebase_cmd.env( - "GIT_SEQUENCE_EDITOR", - format!("sed -i.bak '{}'", sed_script), - ); + rebase_cmd.env("GIT_SEQUENCE_EDITOR", &editor); let output = rebase_cmd.output().context("failed to start rebase")?; if !output.status.success() { diff --git a/src/update.rs b/src/update.rs index 29e97d2..7233dff 100644 --- a/src/update.rs +++ b/src/update.rs @@ -16,6 +16,8 @@ fn platform_suffix() -> Result<&'static str> { ("macos", "x86_64") => Ok("darwin-amd64"), ("linux", "x86_64") => Ok("linux-amd64"), ("linux", "aarch64") => Ok("linux-arm64"), + ("windows", "x86_64") => Ok("windows-amd64"), + ("windows", "aarch64") => Ok("windows-arm64"), (os, arch) => bail!("Unsupported platform: {os}/{arch}"), } } @@ -84,32 +86,59 @@ fn extract_tar(archive: &std::path::Path, dest: &std::path::Path) -> Result<()> /// Compute SHA-256 hash of a file using system tools. fn sha256_of(path: &std::path::Path) -> Result { - // Try sha256sum first (common on Linux) - if let Ok(output) = Command::new("sha256sum").arg(path).output() - && output.status.success() + #[cfg(windows)] { + let output = Command::new("certutil") + .args(["-hashfile", &path.to_string_lossy(), "SHA256"]) + .output() + .context("certutil not found. Cannot verify checksum.")?; + + if !output.status.success() { + bail!("Checksum command failed"); + } + let out = String::from_utf8_lossy(&output.stdout); - if let Some(hash) = out.split_whitespace().next() { - return Ok(hash.to_string()); + // certutil output format: + // SHA256 Hash of file : + // + // CertUtil: -hashfile command completed successfully. + let hash_line = out.lines().nth(1).context("Could not parse certutil output")?; + let hash = hash_line.split_whitespace().collect::>().join(""); + if hash.is_empty() { + anyhow::bail!("Could not parse checksum output"); } + return Ok(hash); } - // Fall back to shasum -a 256 (macOS) - let output = Command::new("shasum") - .args(["-a", "256"]) - .arg(path) - .output() - .context("Neither sha256sum nor shasum found. Cannot verify checksum.")?; + #[cfg(not(windows))] + { + // Try sha256sum first (common on Linux) + if let Ok(output) = Command::new("sha256sum").arg(path).output() + && output.status.success() + { + let out = String::from_utf8_lossy(&output.stdout); + if let Some(hash) = out.split_whitespace().next() { + return Ok(hash.to_string()); + } + } - if !output.status.success() { - bail!("Checksum command failed"); - } + // Fall back to shasum -a 256 (macOS) + let output = Command::new("shasum") + .args(["-a", "256"]) + .arg(path) + .output() + .context("Neither sha256sum nor shasum found. Cannot verify checksum.")?; - let out = String::from_utf8_lossy(&output.stdout); - out.split_whitespace() - .next() - .map(|s| s.to_string()) - .context("Could not parse checksum output") + if !output.status.success() { + bail!("Checksum command failed"); + } + + let out = String::from_utf8_lossy(&output.stdout); + out.split_whitespace() + .next() + .map(|s| s.to_string()) + .context("Could not parse checksum output") + } } /// Verify SHA-256 checksum of a file against the expected checksum line. @@ -129,8 +158,6 @@ fn verify_checksum(file: &std::path::Path, expected_line: &str) -> Result<()> { /// Replace the current binary with the new one, with rollback on failure. fn replace_binary(new_binary: &std::path::Path, current_exe: &std::path::Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - let exe_dir = current_exe .parent() .context("Could not determine binary directory")?; @@ -138,7 +165,12 @@ fn replace_binary(new_binary: &std::path::Path, current_exe: &std::path::Path) - // Copy to destination directory to avoid EXDEV (cross-device rename) let staged = exe_dir.join(format!(".{BINARY_NAME}.new")); std::fs::copy(new_binary, &staged).context("Failed to copy new binary to install directory")?; - std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?; + } // Rename current -> .old, then staged -> current let backup = exe_dir.join(format!(".{BINARY_NAME}.old")); @@ -199,6 +231,12 @@ fn do_update( } pub fn run() -> Result<()> { + #[cfg(windows)] + bail!( + "Self-update is not yet supported on Windows. \ + Download the latest release from https://github.com/{REPO}/releases/latest" + ); + let current_exe = std::env::current_exe().context("Could not determine current executable path")?; @@ -243,8 +281,7 @@ struct UpdateCache { } fn update_cache_path() -> Option { - let home = dirs::home_dir()?; - let dir = home.join(".cache").join(BINARY_NAME); + let dir = dirs::cache_dir()?.join(BINARY_NAME); std::fs::create_dir_all(&dir).ok()?; Some(dir.join("update_check.json")) } @@ -400,7 +437,7 @@ mod tests { #[test] fn test_platform_suffix_current() { let suffix = platform_suffix().unwrap(); - assert!(["darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64"].contains(&suffix)); + assert!(["darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64", "windows-arm64"].contains(&suffix)); } #[test]