Skip to content
Open
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 32 additions & 9 deletions src/hunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target>" to "edit <target>" 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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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
);
Expand Down Expand Up @@ -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 <sha>" to "edit <sha>" 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"]);
Expand All @@ -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() {
Expand Down
87 changes: 62 additions & 25 deletions src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
}
}
Expand Down Expand Up @@ -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<String> {
// 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 <path>:
// <hash bytes with spaces>
// 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::<Vec<_>>().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.
Expand All @@ -129,16 +158,19 @@ 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")?;

// 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"));
Expand Down Expand Up @@ -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")?;

Expand Down Expand Up @@ -243,8 +281,7 @@ struct UpdateCache {
}

fn update_cache_path() -> Option<std::path::PathBuf> {
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"))
}
Expand Down Expand Up @@ -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]
Expand Down