use clap::Subcommand;
use std::process::Command;
#[derive(Subcommand)]
pub enum SourceCommands {
Push {
#[arg(long)]
branch: Option<String>,
#[arg(long, default_value = "main")]
base: String,
},
Pull {
#[arg(long, default_value = "main")]
branch: String,
},
Branches,
Delete {
branch: String,
},
}
pub fn create_bundle(branch: &str, base: &str) -> Result<Vec<u8>, String> {
let id = std::process::id();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = std::env::temp_dir().join(format!(
"nk-bundle-{}-{}-{}.bundle",
branch.replace('/', "-"),
id,
ts
));
let base_exists = Command::new("git")
.args(["rev-parse", "--verify", base])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let bundle_result = if base_exists {
Command::new("git")
.args([
"bundle",
"create",
tmp.to_str().expect("temp path"),
&format!("{base}..{branch}"),
])
.output()
} else {
Command::new("git")
.args(["bundle", "create", tmp.to_str().expect("temp path"), branch])
.output()
};
let output = bundle_result.map_err(|e| format!("git bundle create failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Refusing to create empty bundle") {
return Err("nothing to push (branch is up-to-date with base)".to_string());
}
return Err(format!("git bundle create failed: {stderr}"));
}
let data = std::fs::read(&tmp).map_err(|e| format!("failed to read bundle file: {e}"))?;
let _ = std::fs::remove_file(&tmp);
Ok(data)
}
pub fn apply_bundle(data: &[u8], branch: &str) -> Result<String, String> {
let id = std::process::id();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = std::env::temp_dir().join(format!(
"nk-pull-{}-{}-{}.bundle",
branch.replace('/', "-"),
id,
ts
));
std::fs::write(&tmp, data).map_err(|e| format!("failed to write bundle: {e}"))?;
let verify = Command::new("git")
.args(["bundle", "verify", tmp.to_str().expect("temp path")])
.output()
.map_err(|e| format!("git bundle verify failed: {e}"))?;
if !verify.status.success() {
let stderr = String::from_utf8_lossy(&verify.stderr);
let _ = std::fs::remove_file(&tmp);
return Err(format!("bundle verification failed: {stderr}"));
}
let fetch = Command::new("git")
.args([
"fetch",
tmp.to_str().expect("temp path"),
&format!("{branch}:{branch}"),
])
.output()
.map_err(|e| format!("git fetch from bundle failed: {e}"))?;
let _ = std::fs::remove_file(&tmp);
if !fetch.status.success() {
let stderr = String::from_utf8_lossy(&fetch.stderr);
if stderr.contains("non-fast-forward") {
return Err(format!(
"conflict: {branch} has diverged (non-fast-forward). Rebase or merge manually."
));
}
return Err(format!("git fetch failed: {stderr}"));
}
let stdout = String::from_utf8_lossy(&fetch.stdout);
Ok(format!("Pulled {branch} successfully\n{stdout}"))
}
pub fn current_branch() -> Result<String, String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.map_err(|e| format!("git rev-parse failed: {e}"))?;
if !output.status.success() {
return Err("not in a git repository".to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
struct TestCli {
#[command(subcommand)]
command: SourceCommands,
}
#[test]
fn test_parse_push_default() {
let cli = TestCli::parse_from(["test", "push"]);
match cli.command {
SourceCommands::Push { branch, base } => {
assert!(branch.is_none());
assert_eq!(base, "main");
}
_ => panic!("expected Push"),
}
}
#[test]
fn test_parse_push_with_branch() {
let cli = TestCli::parse_from(["test", "push", "--branch", "feature/foo"]);
match cli.command {
SourceCommands::Push { branch, .. } => {
assert_eq!(branch, Some("feature/foo".to_string()));
}
_ => panic!("expected Push"),
}
}
#[test]
fn test_parse_pull_default() {
let cli = TestCli::parse_from(["test", "pull"]);
match cli.command {
SourceCommands::Pull { branch } => {
assert_eq!(branch, "main");
}
_ => panic!("expected Pull"),
}
}
#[test]
fn test_parse_pull_branch() {
let cli = TestCli::parse_from(["test", "pull", "--branch", "expedition/foo"]);
match cli.command {
SourceCommands::Pull { branch } => {
assert_eq!(branch, "expedition/foo");
}
_ => panic!("expected Pull"),
}
}
#[test]
fn test_parse_branches() {
let cli = TestCli::parse_from(["test", "branches"]);
assert!(matches!(cli.command, SourceCommands::Branches));
}
#[test]
fn test_parse_delete() {
let cli = TestCli::parse_from(["test", "delete", "old-branch"]);
match cli.command {
SourceCommands::Delete { branch } => {
assert_eq!(branch, "old-branch");
}
_ => panic!("expected Delete"),
}
}
#[test]
fn test_current_branch_in_git_repo() {
let branch = current_branch();
assert!(branch.is_ok());
assert!(!branch.unwrap().is_empty());
}
fn init_temp_repo() -> tempfile::TempDir {
let dir = tempfile::TempDir::new().expect("create temp dir");
let path = dir.path();
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.expect("git init");
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.expect("git config email");
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()
.expect("git config name");
std::fs::write(path.join("README.md"), "# Test repo\n").expect("write readme");
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.expect("git add");
Command::new("git")
.args(["commit", "-m", "initial commit"])
.current_dir(path)
.output()
.expect("git commit");
dir
}
fn add_commit(dir: &std::path::Path, filename: &str, content: &str, message: &str) {
std::fs::write(dir.join(filename), content).expect("write file");
Command::new("git")
.args(["add", "."])
.current_dir(dir)
.output()
.expect("git add");
Command::new("git")
.args(["commit", "-m", message])
.current_dir(dir)
.output()
.expect("git commit");
}
fn create_bundle_in(
dir: &std::path::Path,
branch: &str,
base: &str,
) -> Result<Vec<u8>, String> {
let id = std::process::id();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = std::env::temp_dir().join(format!(
"nk-test-{}-{}-{}.bundle",
branch.replace('/', "-"),
id,
ts
));
let base_exists = Command::new("git")
.args(["-C", &dir.to_string_lossy(), "rev-parse", "--verify", base])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let output = if base_exists {
Command::new("git")
.args([
"-C",
&dir.to_string_lossy(),
"bundle",
"create",
tmp.to_str().expect("tmp"),
&format!("{base}..{branch}"),
])
.output()
} else {
Command::new("git")
.args([
"-C",
&dir.to_string_lossy(),
"bundle",
"create",
tmp.to_str().expect("tmp"),
branch,
])
.output()
}
.map_err(|e| format!("git bundle create: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Refusing to create empty bundle") {
return Err("nothing to push".to_string());
}
return Err(format!("git bundle create failed: {stderr}"));
}
let data = std::fs::read(&tmp).map_err(|e| format!("read: {e}"))?;
let _ = std::fs::remove_file(&tmp);
Ok(data)
}
fn apply_bundle_in(dir: &std::path::Path, data: &[u8], branch: &str) -> Result<String, String> {
let id = std::process::id();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = std::env::temp_dir().join(format!("nk-test-pull-{}-{}.bundle", id, ts));
std::fs::write(&tmp, data).map_err(|e| format!("write: {e}"))?;
let fetch = Command::new("git")
.args([
"-C",
&dir.to_string_lossy(),
"fetch",
tmp.to_str().expect("tmp"),
&format!("{branch}:{branch}"),
])
.output()
.map_err(|e| format!("git fetch: {e}"))?;
let _ = std::fs::remove_file(&tmp);
if !fetch.status.success() {
let stderr = String::from_utf8_lossy(&fetch.stderr);
if stderr.contains("non-fast-forward") {
return Err(format!("conflict: non-fast-forward on {branch}"));
}
return Err(format!("git fetch failed: {stderr}"));
}
Ok(format!("Pulled {branch}"))
}
#[test]
fn test_create_bundle_produces_bytes() {
let repo = init_temp_repo();
let path = repo.path();
Command::new("git")
.args(["checkout", "-b", "feature"])
.current_dir(path)
.output()
.expect("checkout");
add_commit(path, "feature.txt", "hello", "add feature");
let result = create_bundle_in(path, "feature", "main");
assert!(result.is_ok(), "create_bundle failed: {:?}", result.err());
let data = result.unwrap();
assert!(!data.is_empty());
assert!(data.len() > 50, "bundle too small: {} bytes", data.len());
}
#[test]
fn test_create_bundle_nothing_to_push() {
let repo = init_temp_repo();
let result = create_bundle_in(repo.path(), "main", "main");
assert!(result.is_err());
assert!(result.unwrap_err().contains("nothing to push"));
}
#[test]
fn test_bundle_round_trip() {
let src = init_temp_repo();
Command::new("git")
.args(["checkout", "-b", "feature"])
.current_dir(src.path())
.output()
.expect("checkout");
add_commit(src.path(), "new.rs", "fn main() {}", "add new file");
let bundle_data = create_bundle_in(src.path(), "feature", "main").expect("create bundle");
let dst = init_temp_repo();
let result = apply_bundle_in(dst.path(), &bundle_data, "feature");
assert!(result.is_ok(), "apply_bundle failed: {:?}", result.err());
let branches = Command::new("git")
.args(["branch", "--list", "feature"])
.current_dir(dst.path())
.output()
.expect("git branch");
assert!(
String::from_utf8_lossy(&branches.stdout).contains("feature"),
"feature branch should exist in dst"
);
}
#[test]
fn test_apply_bundle_conflict_detection() {
let src = init_temp_repo();
Command::new("git")
.args(["checkout", "-b", "feature"])
.current_dir(src.path())
.output()
.expect("checkout");
add_commit(src.path(), "file.txt", "src version", "src commit");
let bundle_data = create_bundle_in(src.path(), "feature", "main").expect("bundle");
let dst = init_temp_repo();
Command::new("git")
.args(["checkout", "-b", "feature"])
.current_dir(dst.path())
.output()
.expect("checkout");
add_commit(dst.path(), "file.txt", "dst version", "dst commit");
Command::new("git")
.args(["checkout", "main"])
.current_dir(dst.path())
.output()
.expect("checkout main");
let result = apply_bundle_in(dst.path(), &bundle_data, "feature");
assert!(result.is_err(), "should detect conflict");
let err = result.unwrap_err();
assert!(
err.contains("conflict") || err.contains("non-fast-forward"),
"error should mention conflict: {err}"
);
}
}