From af0ee69dab747830f2405460a3044ab65e370407 Mon Sep 17 00:00:00 2001 From: Christian Schilling Date: Wed, 24 Sep 2025 22:13:35 +0200 Subject: [PATCH] Add josh cli tool Change: add-cli --- .dockerignore | 1 + .github/dependabot.yml | 1 + Cargo.lock | 17 + Cargo.toml | 2 + josh-cli/Cargo.toml | 23 + josh-cli/src/bin/josh.rs | 1022 +++++++++++++++++++++++++++++ josh-core/Cargo.toml | 2 +- josh-core/src/changes.rs | 200 ++++++ josh-core/src/lib.rs | 1 + josh-proxy/src/lib.rs | 193 +----- tester.sh | 2 +- tests/cli/clone-http.t | 87 +++ tests/cli/clone-main-branch.t | 138 ++++ tests/cli/clone-ssh.t | 97 +++ tests/cli/clone.t | 96 +++ tests/cli/empty-filter.t | 197 ++++++ tests/cli/fetch.t | 110 ++++ tests/cli/filter.t | 68 ++ tests/cli/keep-trivial-merges.t | 70 ++ tests/cli/pull-multiple-remotes.t | 99 +++ tests/cli/pull.t | 91 +++ tests/cli/push.t | 65 ++ tests/cli/push_stacked.t | 131 ++++ tests/cli/push_stacked_split.t | 162 +++++ tests/cli/remote-refspecs.t | 60 ++ 25 files changed, 2753 insertions(+), 182 deletions(-) create mode 100644 josh-cli/Cargo.toml create mode 100644 josh-cli/src/bin/josh.rs create mode 100644 josh-core/src/changes.rs create mode 100644 tests/cli/clone-http.t create mode 100644 tests/cli/clone-main-branch.t create mode 100644 tests/cli/clone-ssh.t create mode 100644 tests/cli/clone.t create mode 100644 tests/cli/empty-filter.t create mode 100644 tests/cli/fetch.t create mode 100644 tests/cli/filter.t create mode 100644 tests/cli/keep-trivial-merges.t create mode 100644 tests/cli/pull-multiple-remotes.t create mode 100644 tests/cli/pull.t create mode 100644 tests/cli/push.t create mode 100644 tests/cli/push_stacked.t create mode 100644 tests/cli/push_stacked_split.t create mode 100644 tests/cli/remote-refspecs.t diff --git a/.dockerignore b/.dockerignore index 6c63e368b..bc1043327 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,7 @@ !hyper_cgi !hyper-reverse-proxy !josh-core +!josh-cli !josh-filter !josh-proxy !josh-rpc diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8c9e11ab6..b20c1f5e2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,7 @@ updates: - "/" - "hyper_cgi" - "josh-core" + - "josh-cli" - "josh-filter" - "josh-graphql" - "josh-proxy" diff --git a/Cargo.lock b/Cargo.lock index 4278e759e..b5c3199ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1879,6 +1879,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "josh-cli" +version = "22.4.15" +dependencies = [ + "clap", + "defer", + "env_logger", + "git2", + "josh", + "josh-graphql", + "josh-templates", + "juniper", + "log", + "rs_tracing", + "serde_json", +] + [[package]] name = "josh-filter" version = "22.4.15" diff --git a/Cargo.toml b/Cargo.toml index 27214c778..b7fe3fc1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "hyper_cgi", "hyper-reverse-proxy", "josh-core", + "josh-cli", "josh-filter", "josh-graphql", "josh-proxy", @@ -22,6 +23,7 @@ codegen-units = 1 base64 = "0.22.1" defer = "0.2.1" env_logger = "0.11.5" +log = "0.4.28" futures = "0.3.31" gix = { version = "0.73.0", default-features = false } hyper-reverse-proxy = { path = "hyper-reverse-proxy", version = "0.0.1" } diff --git a/josh-cli/Cargo.toml b/josh-cli/Cargo.toml new file mode 100644 index 000000000..d152b24a9 --- /dev/null +++ b/josh-cli/Cargo.toml @@ -0,0 +1,23 @@ +[package] +authors = ["Christian Schilling "] +description = "Josh CLI" +edition = "2024" +keywords = ["git", "monorepo", "workflow", "scm"] +license-file = "LICENSE" +name = "josh-cli" +readme = "../README.md" +repository = "https://github.com/josh-project/josh" +version = "22.4.15" + +[dependencies] +josh = { path = "../josh-core" } +josh-graphql = { path = "../josh-graphql" } +josh-templates = { path = "../josh-templates" } +env_logger = { workspace = true } +log = { workspace = true } +serde_json = { workspace = true } +defer = { workspace = true } +clap = { workspace = true } +rs_tracing = { workspace = true } +juniper = { workspace = true } +git2 = { workspace = true } diff --git a/josh-cli/src/bin/josh.rs b/josh-cli/src/bin/josh.rs new file mode 100644 index 000000000..c4a72ed8c --- /dev/null +++ b/josh-cli/src/bin/josh.rs @@ -0,0 +1,1022 @@ +#![warn(unused_extern_crates)] + +use clap::Parser; +use josh::changes::{PushMode, build_to_push}; +use josh::shell::Shell; +use log::debug; +use std::io::IsTerminal; +use std::process::{Command as ProcessCommand, Stdio}; + +/// Spawn a git command directly to the terminal so users can see progress +/// Falls back to captured output if not in a TTY environment +fn spawn_git_command( + cwd: &std::path::Path, + args: &[&str], + env: &[(&str, &str)], +) -> Result> { + debug!("spawn_git_command: {:?}", args); + let mut command = ProcessCommand::new("git"); + command.current_dir(cwd).args(args); + + // Add environment variables + for (key, value) in env { + command.env(key, value); + } + + // Check if we're in a TTY environment + let is_tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); + + if is_tty { + // In TTY: inherit stdio so users can see progress + command + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + let status = command.status()?; + Ok(status.code().unwrap_or(1)) + } else { + // Not in TTY: capture output and print stderr (for tests, CI, etc.) + // Use the same approach as josh::shell::Shell for consistency + let output = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("failed to execute git command: {}", e))?; + + // Print stderr if there's any output + if !output.stderr.is_empty() { + let output_str = String::from_utf8_lossy(&output.stderr); + let output_str = if let Ok(testtmp) = std::env::var("TESTTMP") { + //println!("TESTTMP {:?}", testtmp); + output_str.replace(&testtmp, "$TESTTMP") + } else { + output_str.to_string() + }; + eprintln!("{}", output_str); + } + + Ok(output.status.code().unwrap_or(1)) + } +} + +#[derive(Debug, clap::Parser)] +#[command(name = "josh", version, about = "Josh: Git projections & sync tooling", long_about = None)] +pub struct Cli { + /// Subcommand to run + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, clap::Subcommand)] +pub enum Command { + /// Clone a repository with optional projection/filtering + Clone(CloneArgs), + + /// Fetch from a remote (like `git fetch`) with projection-aware options + Fetch(FetchArgs), + + /// Fetch & integrate from a remote (like `git pull`) with projection-aware options + Pull(PullArgs), + + /// Push refs to a remote (like `git push`) with projection-aware options + Push(PushArgs), + + /// Add a remote with optional projection/filtering (like `git remote add`) + Remote(RemoteArgs), + + /// Apply filtering to existing refs (like `josh fetch` but without fetching) + Filter(FilterArgs), +} + +#[derive(Debug, clap::Parser)] +pub struct CloneArgs { + /// Remote repository URL + #[arg()] + pub url: String, + + /// Workspace/projection identifier or path to spec + #[arg()] + pub filter: String, + + /// Checkout directory + #[arg()] + pub out: std::path::PathBuf, + + /// Branch or ref to clone + #[arg(short = 'b', long = "branch", default_value = "HEAD")] + pub branch: String, + + /// Keep trivial merges (don't append :prune=trivial-merge to filters) + #[arg(long = "keep-trivial-merges")] + pub keep_trivial_merges: bool, +} + +#[derive(Debug, clap::Parser)] +pub struct PullArgs { + /// Remote name (or URL) to pull from + #[arg(short = 'r', long = "remote", default_value = "origin")] + pub remote: String, + + /// Ref to pull (branch, tag, or commit-ish) + #[arg(short = 'R', long = "ref", default_value = "HEAD")] + pub rref: String, + + /// Prune tracking refs no longer on the remote + #[arg(long = "prune", action = clap::ArgAction::SetTrue)] + pub prune: bool, + + /// Fast-forward only (fail if merge needed) + #[arg(long = "ff-only", action = clap::ArgAction::SetTrue)] + pub ff_only: bool, + + /// Rebase the current branch on top of the upstream branch + #[arg(long = "rebase", action = clap::ArgAction::SetTrue)] + pub rebase: bool, + + /// Automatically stash local changes before rebase + #[arg(long = "autostash", action = clap::ArgAction::SetTrue)] + pub autostash: bool, +} + +#[derive(Debug, clap::Parser)] +pub struct FetchArgs { + /// Remote name (or URL) to fetch from + #[arg(short = 'r', long = "remote", default_value = "origin")] + pub remote: String, + + /// Ref to fetch (branch, tag, or commit-ish) + #[arg(short = 'R', long = "ref", default_value = "HEAD")] + pub rref: String, + + /// Prune tracking refs no longer on the remote + #[arg(long = "prune", action = clap::ArgAction::SetTrue)] + pub prune: bool, +} + +#[derive(Debug, clap::Parser)] +pub struct PushArgs { + /// Remote name (or URL) to push to + #[arg(short = 'r', long = "remote", default_value = "origin")] + pub remote: String, + + /// One or more refspecs to push (e.g. main, HEAD:refs/heads/main) + #[arg(short = 'R', long = "ref")] + pub refspecs: Vec, + + /// Force update (non-fast-forward) + #[arg(short = 'f', long = "force", action = clap::ArgAction::SetTrue)] + pub force: bool, + + /// Atomic push (all-or-nothing if server supports it) + #[arg(long = "atomic", action = clap::ArgAction::SetTrue)] + pub atomic: bool, + + /// Dry run (don't actually update remote) + #[arg(long = "dry-run", action = clap::ArgAction::SetTrue)] + pub dry_run: bool, + + /// Use split mode for pushing (defaults to normal mode) + #[arg(long = "split", action = clap::ArgAction::SetTrue)] + pub split: bool, + + /// Use stack mode for pushing (defaults to normal mode) + #[arg(long = "stack", action = clap::ArgAction::SetTrue)] + pub stack: bool, +} + +#[derive(Debug, clap::Parser)] +pub struct RemoteArgs { + /// Remote subcommand + #[command(subcommand)] + pub command: RemoteCommand, +} + +#[derive(Debug, clap::Subcommand)] +pub enum RemoteCommand { + /// Add a remote with optional projection/filtering + Add(RemoteAddArgs), +} + +#[derive(Debug, clap::Parser)] +pub struct RemoteAddArgs { + /// Remote name + #[arg()] + pub name: String, + + /// Remote repository URL + #[arg()] + pub url: String, + + /// Workspace/projection identifier or path to spec + #[arg()] + pub filter: String, + + /// Keep trivial merges (don't append :prune=trivial-merge to filters) + #[arg(long = "keep-trivial-merges")] + pub keep_trivial_merges: bool, +} + +#[derive(Debug, clap::Parser)] +pub struct FilterArgs { + /// Remote name to apply filtering to + #[arg()] + pub remote: String, +} + +fn main() { + env_logger::init(); + let cli = Cli::parse(); + + match &cli.command { + Command::Clone(args) => { + if let Err(e) = handle_clone(args) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + Command::Fetch(args) => { + if let Err(e) = handle_fetch(args) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + Command::Pull(args) => { + if let Err(e) = handle_pull(args) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + Command::Push(args) => { + if let Err(e) = handle_push(args) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + Command::Remote(args) => { + if let Err(e) = handle_remote(args) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + Command::Filter(args) => { + if let Err(e) = handle_filter(args) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + } +} + +/// Apply josh filtering to all remote refs and update local refs +fn apply_josh_filtering( + repo_shell: &Shell, + filter: &str, + remote_name: &str, +) -> Result<(), Box> { + // Change to the repository directory + let original_dir = std::env::current_dir()?; + std::env::set_current_dir(repo_shell.cwd.as_path())?; + + // Use josh API directly instead of calling josh-filter binary + let filterobj = + josh::filter::parse(filter).map_err(|e| format!("Failed to parse filter: {}", e.0))?; + + josh::cache_sled::sled_load(&repo_shell.cwd.as_path().join(".git")).unwrap(); + + let cache = std::sync::Arc::new( + josh::cache_stack::CacheStack::new() + .with_backend(josh::cache_sled::SledCacheBackend::default()) + .with_backend( + josh::cache_notes::NotesCacheBackend::new(&repo_shell.cwd.as_path()) + .map_err(|e| format!("Failed to create NotesCacheBackend: {}", e.0))?, + ), + ); + + // Open Josh transaction + let transaction = josh::cache::TransactionContext::from_env(cache.clone()) + .map_err(|e| format!("Failed TransactionContext::from_env: {}", e.0))? + .open(None) + .map_err(|e| format!("Failed TransactionContext::open: {}", e.0))?; + + let repo = transaction.repo(); + + // Get all remote refs from refs/josh/remotes/{remote_name}/* + let mut input_refs = Vec::new(); + let josh_remotes = repo.references_glob(&format!("refs/josh/remotes/{}/*", remote_name))?; + + for reference in josh_remotes { + let reference = reference?; + if let Some(target) = reference.target() { + let ref_name = reference.name().unwrap().to_string(); + input_refs.push((ref_name, target)); + } + } + + if input_refs.is_empty() { + return Err("No remote references found".into()); + } + + // Apply the filter to all remote refs + let (updated_refs, errors) = + josh::filter_refs(&transaction, filterobj, &input_refs, josh::filter::empty()); + + // Check for errors + for error in errors { + return Err(format!("josh filter error: {}", error.1.0).into()); + } + + // Second pass: create all references + for (original_ref, filtered_oid) in updated_refs { + // Check if the filtered result is empty (zero OID indicates empty result) + if filtered_oid == git2::Oid::zero() { + // Skip creating references for empty filtered results + continue; + } + + // Extract branch name from refs/josh/remotes/{remote_name}/branch_name + let branch_name = original_ref + .strip_prefix(&format!("refs/josh/remotes/{}/", remote_name)) + .ok_or("Invalid josh remote reference")?; + + // Create filtered reference in josh/filtered namespace + let filtered_ref = format!( + "refs/namespaces/josh-{}/refs/heads/{}", + remote_name, branch_name + ); + repo.reference(&filtered_ref, filtered_oid, true, "josh filter") + .map_err(|e| format!("Failed to create filtered reference: {}", e))?; + } + + // Fetch the filtered refs to create standard remote refs + let path_env = std::env::var("PATH").unwrap_or_default(); + let exit_code = spawn_git_command( + repo_shell.cwd.as_path(), + &["fetch", remote_name], + &[("PATH", &path_env)], + )?; + + if exit_code != 0 { + return Err(format!("Failed to fetch filtered refs: exit code {}", exit_code).into()); + } + + // Restore the original directory + std::env::set_current_dir(original_dir)?; + Ok(()) +} + +fn handle_clone(args: &CloneArgs) -> Result<(), Box> { + // Use the provided output directory + let output_dir = args.out.clone(); + + // Create the output directory first + std::fs::create_dir_all(&output_dir)?; + + // Initialize a new git repository inside the directory using git2 + let _repo = git2::Repository::init(&output_dir) + .map_err(|e| format!("Failed to initialize git repository: {}", e))?; + + // Change to the repository directory and add the remote using handle_remote_add + let original_dir = std::env::current_dir()?; + std::env::set_current_dir(&output_dir)?; + + // Make the URL absolute if it's a relative path (for local repositories) + let absolute_url = if args.url.starts_with("http") || args.url.starts_with("ssh://") { + args.url.clone() + } else { + // For local paths, make them absolute relative to the original directory + let absolute_path = if args.url.starts_with('/') { + // Already absolute + args.url.clone() + } else { + // Relative to original directory + original_dir.join(&args.url).to_string_lossy().to_string() + }; + absolute_path + }; + + // Use handle_remote_add to add the remote with the filter + let remote_add_args = RemoteAddArgs { + name: "origin".to_string(), + url: absolute_url, + filter: args.filter.clone(), + keep_trivial_merges: args.keep_trivial_merges, + }; + handle_remote_add(&remote_add_args)?; + + // Create FetchArgs from CloneArgs + let fetch_args = FetchArgs { + remote: "origin".to_string(), + rref: args.branch.clone(), + prune: false, + }; + + // Use handle_fetch to do the actual fetching and filtering + handle_fetch(&fetch_args)?; + + // Get the default branch name from the remote HEAD symref + let default_branch = if args.branch == "HEAD" { + // Read the remote HEAD symref to get the default branch + let head_ref = format!("refs/remotes/origin/HEAD"); + let repo = git2::Repository::open_from_env() + .map_err(|e| format!("Not in a git repository: {}", e))?; + + let head_reference = repo + .find_reference(&head_ref) + .map_err(|e| format!("Failed to find remote HEAD reference {}: {}", head_ref, e))?; + + let symref_target = head_reference + .symbolic_target() + .ok_or("Remote HEAD reference is not a symbolic reference")?; + + // Extract branch name from symref target (e.g., "refs/remotes/origin/master" -> "master") + let branch_name = symref_target + .strip_prefix("refs/remotes/origin/") + .ok_or_else(|| format!("Invalid symref target format: {}", symref_target))?; + + branch_name.to_string() + } else { + args.branch.clone() + }; + + // Checkout the default branch + let path_env = std::env::var("PATH").unwrap_or_default(); + let repo_shell = Shell { + cwd: std::env::current_dir()?, + }; + + let exit_code = spawn_git_command( + repo_shell.cwd.as_path(), + &[ + "checkout", + "-b", + &default_branch, + &format!("origin/{}", default_branch), + ], + &[("PATH", &path_env)], + )?; + + if exit_code != 0 { + return Err(format!( + "Failed to checkout branch {}: exit code {}", + default_branch, exit_code + ) + .into()); + } + + // Set up upstream tracking for the branch + let exit_code = spawn_git_command( + repo_shell.cwd.as_path(), + &[ + "branch", + "--set-upstream-to", + &format!("origin/{}", default_branch), + &default_branch, + ], + &[("PATH", &path_env)], + )?; + + if exit_code != 0 { + return Err(format!( + "Failed to set upstream for branch {}: exit code {}", + default_branch, exit_code + ) + .into()); + } + + // Restore the original directory + std::env::set_current_dir(original_dir)?; + + println!("Cloned repository to: {}", output_dir.display()); + Ok(()) +} + +fn handle_pull(args: &PullArgs) -> Result<(), Box> { + // Check if we're in a git repository + let _repo = + git2::Repository::open_from_env().map_err(|e| format!("Not in a git repository: {}", e))?; + + // Create FetchArgs from PullArgs + let fetch_args = FetchArgs { + remote: args.remote.clone(), + rref: args.rref.clone(), + prune: args.prune, + }; + + // Use handle_fetch to do the actual fetching and filtering + handle_fetch(&fetch_args)?; + + // Get current working directory for shell commands + let current_dir = std::env::current_dir()?; + let repo_shell = Shell { + cwd: current_dir.clone(), + }; + let path_env = std::env::var("PATH").unwrap_or_default(); + + // Now use actual git pull to integrate the changes + let mut git_cmd = vec!["git", "pull"]; + + // Add flags based on arguments + if args.rebase { + git_cmd.push("--rebase"); + } + + if args.autostash { + git_cmd.push("--autostash"); + } + + // Add the remote and branch in the format: git pull {remote} {remote}/{branch} + //let remote_branch = format!("{}/{}", args.remote, default_branch); + git_cmd.push(&args.remote); + //git_cmd.push(&remote_branch); + + // Execute the git pull command + let exit_code = spawn_git_command( + repo_shell.cwd.as_path(), + &git_cmd[1..], // Skip "git" since spawn_git_command adds it + &[("PATH", &path_env)], + )?; + + if exit_code != 0 { + return Err(format!("git pull failed with exit code: {}", exit_code).into()); + } + + println!("Pulled from remote: {}", args.remote); + Ok(()) +} + +fn handle_fetch(args: &FetchArgs) -> Result<(), Box> { + // Check if we're in a git repository + let repo = + git2::Repository::open_from_env().map_err(|e| format!("Not in a git repository: {}", e))?; + + // Get current working directory (should be inside a git repository) + let current_dir = std::env::current_dir()?; + + // Create shell for the current repository directory + let repo_shell = Shell { + cwd: current_dir.clone(), + }; + + // Get PATH environment variable for shell commands + let path_env = std::env::var("PATH").unwrap_or_default(); + + // Read the remote URL from josh-remote config + let config = repo + .config() + .map_err(|e| format!("Failed to get git config: {}", e))?; + + let remote_url = config + .get_string(&format!("josh-remote.{}.url", args.remote)) + .map_err(|e| format!("Failed to get remote URL for '{}': {}", args.remote, e))?; + + let refspec = config + .get_string(&format!("josh-remote.{}.fetch", args.remote)) + .map_err(|e| format!("Failed to get refspec for '{}': {}", args.remote, e))?; + + // First, fetch unfiltered refs to refs/josh/remotes/* + let exit_code = spawn_git_command( + repo_shell.cwd.as_path(), + &["fetch", &remote_url, &refspec], + &[("PATH", &path_env)], + )?; + + if exit_code != 0 { + return Err(format!( + "git fetch to josh/remotes failed with exit code: {}", + exit_code + ) + .into()); + } + + // Set up remote HEAD reference using git ls-remote + // This is the proper way to get the default branch from the remote + let head_ref = format!("refs/remotes/{}/HEAD", args.remote); + + // Use git ls-remote --symref to get the default branch + let exit_code = spawn_git_command( + repo_shell.cwd.as_path(), + &["ls-remote", "--symref", &remote_url, "HEAD"], + &[("PATH", &path_env)], + )?; + + if exit_code == 0 { + // Parse the output to get the default branch name + // Output format: "ref: refs/heads/main\t" + let output = std::process::Command::new("git") + .args(&["ls-remote", "--symref", &remote_url, "HEAD"]) + .current_dir(repo_shell.cwd.as_path()) + .output()?; + + if output.status.success() { + let output_str = String::from_utf8_lossy(&output.stdout); + if let Some(line) = output_str.lines().next() { + if let Some(symref_part) = line.split('\t').next() { + if symref_part.starts_with("ref: refs/heads/") { + let default_branch = &symref_part[16..]; // Remove "ref: refs/heads/" + let default_branch_ref = + format!("refs/remotes/{}/{}", args.remote, default_branch); + + // Create the symbolic reference + let _ = repo.reference_symbolic( + &head_ref, + &default_branch_ref, + true, + "josh remote HEAD", + ); + let _ = repo.reference_symbolic( + &format!("refs/namespaces/josh-{}/{}", args.remote, "HEAD"), + &format!("refs/heads/{}", default_branch), + true, + "josh remote HEAD", + ); + } + } + } + } + } + + // Apply josh filtering using handle_filter_internal (without messages) + let filter_args = FilterArgs { + remote: args.remote.clone(), + }; + handle_filter_internal(&filter_args, false)?; + // Note: fetch doesn't checkout, it just updates the refs + + println!("Fetched from remote: {}", args.remote); + Ok(()) +} + +fn handle_push(args: &PushArgs) -> Result<(), Box> { + // Get current working directory (should be inside a git repository) + let current_dir = std::env::current_dir()?; + + // Create shell for the current repository directory + let repo_shell = Shell { + cwd: current_dir.clone(), + }; + + // Read filter from git config for the specific remote + let repo = + git2::Repository::open_from_env().map_err(|e| format!("Not in a git repository: {}", e))?; + let config = repo + .config() + .map_err(|e| format!("Failed to get git config: {}", e))?; + + // Step 2: Apply reverse filtering and push to actual remote + let filter_str = config + .get_string(&format!("josh-remote.{}.filter", args.remote)) + .map_err(|e| format!("Failed to read filter from git config: {}", e))?; + + // Parse the filter using Josh API + let filter = + josh::filter::parse(&filter_str).map_err(|e| format!("Failed to parse filter: {}", e.0))?; + + josh::cache_sled::sled_load(&repo_shell.cwd.as_path()).unwrap(); + let cache = std::sync::Arc::new( + josh::cache_stack::CacheStack::new() + .with_backend(josh::cache_sled::SledCacheBackend::default()) + .with_backend( + josh::cache_notes::NotesCacheBackend::new(&repo_shell.cwd.as_path()) + .map_err(|e| format!("Failed to create NotesCacheBackend: {}", e.0))?, + ), + ); + + // Open Josh transaction + let transaction = josh::cache::TransactionContext::from_env(cache.clone()) + .map_err(|e| format!("Failed TransactionContext::from_env: {}", e.0))? + .open(None) + .map_err(|e| format!("Failed TransactionContext::open: {}", e.0))?; + + // Get the remote URL from josh-remote config + let remote_url = config + .get_string(&format!("josh-remote.{}.url", args.remote)) + .map_err(|e| format!("Failed to get remote URL for '{}': {}", args.remote, e))?; + + // If no refspecs provided, push the current branch + let refspecs = if args.refspecs.is_empty() { + // Get the current branch name + let head = repo + .head() + .map_err(|e| format!("Failed to get HEAD: {}", e))?; + + let current_branch = head + .shorthand() + .ok_or("Failed to get current branch name")?; + + vec![current_branch.to_string()] + } else { + args.refspecs.clone() + }; + + // For each refspec, we need to: + // 1. Get the current commit of the local ref + // 2. Use Josh API to unapply the filter + // 3. Push the unfiltered result to the remote + + for refspec in &refspecs { + let (local_ref, remote_ref) = if let Some(colon_pos) = refspec.find(':') { + let local = &refspec[..colon_pos]; + let remote = &refspec[colon_pos + 1..]; + (local.to_string(), remote.to_string()) + } else { + // If no colon, push local ref to remote with same name + (refspec.clone(), refspec.clone()) + }; + + // Get the current commit of the local ref + let local_commit = repo + .resolve_reference_from_short_name(&local_ref) + .map_err(|e| format!("Failed to resolve local ref '{}': {}", local_ref, e))? + .target() + .ok_or("Failed to get target of local ref")?; + + // Get the original target (the base commit that was filtered) + // We need to find the original commit in the unfiltered repository + // that corresponds to the current filtered commit + // Use josh/remotes references which contain the unfiltered commits + let josh_remote_ref = format!("refs/josh/remotes/{}/{}", args.remote, remote_ref); + let original_target = if let Ok(remote_reference) = repo.find_reference(&josh_remote_ref) { + // If we have a josh remote reference, use its target (this is the unfiltered commit) + remote_reference.target().unwrap_or(git2::Oid::zero()) + } else { + // If no josh remote reference, this is a new push + git2::Oid::zero() + }; + + // Get the old filtered oid by applying the filter to the original remote ref + // before we push to the namespace + let josh_remote_ref = format!("refs/josh/remotes/{}/{}", args.remote, remote_ref); + let old_filtered_oid = + if let Ok(josh_remote_reference) = repo.find_reference(&josh_remote_ref) { + let josh_remote_oid = josh_remote_reference.target().unwrap_or(git2::Oid::zero()); + + // Apply the filter to the josh remote ref to get the old filtered oid + let (filtered_oids, errors) = josh::filter_refs( + &transaction, + filter, + &[(josh_remote_ref.clone(), josh_remote_oid)], + josh::filter::empty(), + ); + + // Check for errors + for error in errors { + return Err(format!("josh filter error: {}", error.1.0).into()); + } + + if let Some((_, filtered_oid)) = filtered_oids.first() { + *filtered_oid + } else { + git2::Oid::zero() + } + } else { + // If no josh remote reference, this is a new push + git2::Oid::zero() + }; + + debug!("old_filtered_oid: {:?}", old_filtered_oid); + debug!("original_target: {:?}", original_target); + + // Set push mode based on the flags + let push_mode = if args.split { + PushMode::Split + } else if args.stack { + PushMode::Stack + } else { + PushMode::Normal + }; + + // Get author email from git config + let author = config.get_string("user.email").unwrap_or_default(); + + let mut changes: Option> = + if push_mode == PushMode::Stack || push_mode == PushMode::Split { + Some(vec![]) + } else { + None + }; + + // Use Josh API to unapply the filter + let unfiltered_oid = josh::history::unapply_filter( + &transaction, + filter, + original_target, + old_filtered_oid, + local_commit, + false, // keep_orphans + None, // reparent_orphans + &mut changes, // change_ids + ) + .map_err(|e| format!("Failed to unapply filter: {}", e.0))?; + + // Define variables needed for build_to_push + let baseref = remote_ref.clone(); + let oid_to_push = unfiltered_oid; + let old = original_target; + + debug!("unfiltered_oid: {:?}", unfiltered_oid); + + let to_push = build_to_push( + transaction.repo(), + changes, + push_mode, + &baseref, + &author, + remote_ref, + oid_to_push, + old, + ) + .map_err(|e| format!("Failed to build to push: {}", e.0))?; + + debug!("to_push: {:?}", to_push); + + // Get PATH environment variable for shell commands + let path_env = std::env::var("PATH").unwrap_or_default(); + + // Process each entry in to_push (similar to josh-proxy) + for (refname, oid, _) in to_push { + // Build git push command + let mut git_push_cmd = vec!["git", "push"]; + + if args.force || push_mode == PushMode::Split { + git_push_cmd.push("--force"); + } + + if args.atomic { + git_push_cmd.push("--atomic"); + } + + if args.dry_run { + git_push_cmd.push("--dry-run"); + } + + // Determine the target remote URL + let target_remote = remote_url.clone(); + + // Create refspec: oid:refname + let push_refspec = format!("{}:{}", oid, refname); + + git_push_cmd.push(&target_remote); + git_push_cmd.push(&push_refspec); + + // Use direct spawn so users can see git push progress + let exit_code = spawn_git_command( + repo_shell.cwd.as_path(), + &git_push_cmd[1..], // Skip "git" since spawn_git_command adds it + &[("PATH", &path_env)], + )?; + + if exit_code != 0 { + return Err( + format!("git push failed for {}: exit code {}", refname, exit_code).into(), + ); + } + + println!("Pushed {} to {}/{}", oid, args.remote, refname); + } + } + + Ok(()) +} + +fn handle_remote(args: &RemoteArgs) -> Result<(), Box> { + match &args.command { + RemoteCommand::Add(add_args) => handle_remote_add(add_args), + } +} + +fn handle_remote_add(args: &RemoteAddArgs) -> Result<(), Box> { + // Check if we're in a git repository + let repo = + git2::Repository::open_from_env().map_err(|e| format!("Not in a git repository: {}", e))?; + + // Store the remote information in josh-remote config instead of adding a git remote + let remote_path = if args.url.starts_with("http") || args.url.starts_with("ssh://") { + args.url.clone() + } else { + // For local paths, make them absolute + std::fs::canonicalize(&args.url) + .map_err(|e| format!("Failed to resolve path {}: {}", args.url, e))? + .to_string_lossy() + .to_string() + }; + + // Store the filter in git config per remote + // Append ":prune=trivial-merge" to all filters unless --keep-trivial-merges flag is set + let filter_to_store = if args.keep_trivial_merges { + args.filter.clone() + } else { + format!("{}:prune=trivial-merge", args.filter) + }; + + let mut config = repo + .config() + .map_err(|e| format!("Failed to get git config: {}", e))?; + + // Store remote URL in josh-remote section + config + .set_str(&format!("josh-remote.{}.url", args.name), &remote_path) + .map_err(|e| format!("Failed to store remote URL in git config: {}", e))?; + + // Store filter in josh-remote section + config + .set_str( + &format!("josh-remote.{}.filter", args.name), + &filter_to_store, + ) + .map_err(|e| format!("Failed to store filter in git config: {}", e))?; + + // Store refspec in josh-remote section (for unfiltered refs) + let refspec = format!("refs/heads/*:refs/josh/remotes/{}/*", args.name); + config + .set_str(&format!("josh-remote.{}.fetch", args.name), &refspec) + .map_err(|e| format!("Failed to store refspec in git config: {}", e))?; + + // Set up a git remote that points to "." with a refspec to fetch filtered refs + let path_env = std::env::var("PATH").unwrap_or_default(); + let current_dir = std::env::current_dir()?; + let repo_shell = Shell { + cwd: current_dir.clone(), + }; + + // Add remote pointing to current directory + let exit_code = spawn_git_command( + repo_shell.cwd.as_path(), + &[ + "remote", + "add", + &args.name, + &format!("file://{}", repo_shell.cwd.to_string_lossy()), + ], + &[("PATH", &path_env)], + )?; + + if exit_code != 0 { + return Err(format!("Failed to add git remote: exit code {}", exit_code).into()); + } + + // Set up namespace configuration for the remote + let namespace = format!("josh-{}", args.name); + let uploadpack_cmd = format!("env GIT_NAMESPACE={} git upload-pack", namespace); + + let exit_code = spawn_git_command( + repo_shell.cwd.as_path(), + &[ + "config", + &format!("remote.{}.uploadpack", args.name), + &uploadpack_cmd, + ], + &[("PATH", &path_env)], + )?; + + if exit_code != 0 { + return Err(format!("Failed to set remote uploadpack: exit code {}", exit_code).into()); + } + + println!( + "Added remote '{}' with filter '{}'", + args.name, filter_to_store + ); + + Ok(()) +} + +/// Handle the `josh filter` command - apply filtering to existing refs without fetching +fn handle_filter(args: &FilterArgs) -> Result<(), Box> { + handle_filter_internal(args, true) +} + +/// Internal filter function that can be called from other handlers +fn handle_filter_internal( + args: &FilterArgs, + print_messages: bool, +) -> Result<(), Box> { + let repo = git2::Repository::open_from_env()?; + let repo_shell = Shell { + cwd: repo.path().parent().unwrap().to_path_buf(), + }; + + // Read the filter from git config for this remote + let config = repo + .config() + .map_err(|e| format!("Failed to get git config: {}", e))?; + + let filter_key = format!("josh-remote.{}.filter", args.remote); + let filter = config + .get_string(&filter_key) + .map_err(|e| format!("No filter configured for remote '{}': {}", args.remote, e))?; + + if print_messages { + println!("Applying filter '{}' to remote '{}'", filter, args.remote); + } + + // Apply josh filtering (this is the same as in handle_fetch but without the git fetch step) + apply_josh_filtering(&repo_shell, &filter, &args.remote)?; + + if print_messages { + println!("Applied filter to remote: {}", args.remote); + } + + Ok(()) +} diff --git a/josh-core/Cargo.toml b/josh-core/Cargo.toml index 4ff678be8..b49548c76 100644 --- a/josh-core/Cargo.toml +++ b/josh-core/Cargo.toml @@ -21,7 +21,7 @@ hex = { workspace = true } indoc = "2.0.7" itertools = "0.14.0" lazy_static = { workspace = true } -log = "0.4.28" +log = { workspace = true } percent-encoding = "2.3.1" pest = "2.8.3" pest_derive = "2.8.3" diff --git a/josh-core/src/changes.rs b/josh-core/src/changes.rs new file mode 100644 index 000000000..88ad99417 --- /dev/null +++ b/josh-core/src/changes.rs @@ -0,0 +1,200 @@ +use super::*; + +#[derive(PartialEq, Clone, Copy, Debug)] +pub enum PushMode { + Normal, + Review, + Stack, + Split, +} + +pub fn baseref_and_options(refname: &str) -> JoshResult<(String, String, Vec, PushMode)> { + let mut split = refname.splitn(2, '%'); + let push_to = split.next().ok_or(josh_error("no next"))?.to_owned(); + + let options = if let Some(options) = split.next() { + options.split(',').map(|x| x.to_string()).collect() + } else { + vec![] + }; + + let mut baseref = push_to.to_owned(); + let mut push_mode = PushMode::Normal; + + if baseref.starts_with("refs/for") { + push_mode = PushMode::Review; + baseref = baseref.replacen("refs/for", "refs/heads", 1) + } + if baseref.starts_with("refs/drafts") { + push_mode = PushMode::Review; + baseref = baseref.replacen("refs/drafts", "refs/heads", 1) + } + if baseref.starts_with("refs/stack/for") { + push_mode = PushMode::Stack; + baseref = baseref.replacen("refs/stack/for", "refs/heads", 1) + } + if baseref.starts_with("refs/split/for") { + push_mode = PushMode::Split; + baseref = baseref.replacen("refs/split/for", "refs/heads", 1) + } + Ok((baseref, push_to, options, push_mode)) +} + +fn split_changes( + repo: &git2::Repository, + changes: &mut Vec<(String, git2::Oid, String)>, + base: git2::Oid, +) -> JoshResult<()> { + if base == git2::Oid::zero() { + return Ok(()); + } + + let commits: Vec = changes + .iter() + .map(|(_, commit, _)| Ok(repo.find_commit(*commit)?)) + .collect::>>()?; + + let mut trees: Vec = commits + .iter() + .map(|commit| Ok(commit.tree()?)) + .collect::>>()?; + + trees.insert(0, repo.find_commit(base)?.tree()?); + + let diffs: Vec = (1..trees.len()) + .map(|i| Ok(repo.diff_tree_to_tree(Some(&trees[i - 1]), Some(&trees[i]), None)?)) + .collect::>>()?; + + let mut moved = std::collections::HashSet::new(); + let mut bases = vec![base]; + for _ in 0..changes.len() { + let mut new_bases = vec![]; + for base in bases.iter() { + for i in 0..diffs.len() { + if moved.contains(&i) { + continue; + } + let diff = &diffs[i]; + let parent = repo.find_commit(*base)?; + if let Ok(mut index) = repo.apply_to_tree(&parent.tree()?, diff, None) { + moved.insert(i); + let new_tree = repo.find_tree(index.write_tree_to(repo)?)?; + let new_commit = history::rewrite_commit( + repo, + &repo.find_commit(changes[i].1)?, + &[&parent], + filter::Apply::from_tree(new_tree), + false, + )?; + changes[i].1 = new_commit; + new_bases.push(new_commit); + } + if moved.len() == changes.len() { + return Ok(()); + } + } + } + bases = new_bases; + } + + Ok(()) +} + +pub fn changes_to_refs( + baseref: &str, + change_author: &str, + changes: Vec, +) -> JoshResult> { + let mut seen = vec![]; + let mut changes = changes; + changes.retain(|change| change.author == change_author); + if !change_author.contains('@') { + return Err(josh_error( + "Push option 'author' needs to be set to a valid email address", + )); + }; + + for change in changes.iter() { + if let Some(id) = &change.id { + if id.contains('@') { + return Err(josh_error("Change id must not contain '@'")); + } + if seen.contains(&id) { + return Err(josh_error(&format!( + "rejecting to push {:?} with duplicate label", + change.commit + ))); + } + seen.push(id); + } else { + return Err(josh_error(&format!( + "rejecting to push {:?} without id", + change.commit + ))); + } + } + + Ok(changes + .into_iter() + .filter(|change| change.id.is_some()) + .map(|change| { + ( + format!( + "refs/heads/@changes/{}/{}/{}", + baseref.replacen("refs/heads/", "", 1), + change.author, + change.id.as_ref().unwrap_or(&"".to_string()), + ), + change.commit, + change + .id + .as_ref() + .unwrap_or(&"JOSH_PUSH".to_string()) + .to_string(), + ) + }) + .collect()) +} + +pub fn build_to_push( + repo: &git2::Repository, + changes: Option>, + push_mode: PushMode, + baseref: &str, + author: &str, + ref_with_options: String, + oid_to_push: git2::Oid, + old: git2::Oid, +) -> JoshResult> { + if let Some(changes) = changes { + let mut v = vec![]; + let mut refs = changes_to_refs(baseref, author, changes)?; + v.append(&mut refs); + if push_mode == PushMode::Split { + split_changes(repo, &mut v, old)?; + } + if push_mode == PushMode::Review { + v.push(( + ref_with_options.clone(), + oid_to_push, + "JOSH_PUSH".to_string(), + )); + } + v.push(( + format!( + "refs/heads/@heads/{}/{}", + baseref.replacen("refs/heads/", "", 1), + author, + ), + oid_to_push, + baseref.replacen("refs/heads/", "", 1), + )); + Ok(v) + } else { + Ok(vec![( + ref_with_options, + oid_to_push, + "JOSH_PUSH".to_string(), + )]) + } +} diff --git a/josh-core/src/lib.rs b/josh-core/src/lib.rs index 834dcd15d..c5e018383 100644 --- a/josh-core/src/lib.rs +++ b/josh-core/src/lib.rs @@ -21,6 +21,7 @@ pub mod cache; pub mod cache_notes; pub mod cache_sled; pub mod cache_stack; +pub mod changes; pub mod filter; pub mod history; pub mod housekeeping; diff --git a/josh-proxy/src/lib.rs b/josh-proxy/src/lib.rs index bff4f7ef1..b78f120a0 100644 --- a/josh-proxy/src/lib.rs +++ b/josh-proxy/src/lib.rs @@ -14,13 +14,7 @@ use josh::{JoshError, JoshResult, josh_error}; use std::fs; use std::path::PathBuf; -#[derive(PartialEq)] -enum PushMode { - Normal, - Review, - Stack, - Split, -} +use josh::changes::PushMode; #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)] pub struct Ref { @@ -61,37 +55,7 @@ pub fn refs_locking(refs: Vec<(String, git2::Oid)>, meta: &MetaConfig) -> Vec<(S output } -fn baseref_and_options(refname: &str) -> josh::JoshResult<(String, String, Vec, PushMode)> { - let mut split = refname.splitn(2, '%'); - let push_to = split.next().ok_or(josh::josh_error("no next"))?.to_owned(); - - let options = if let Some(options) = split.next() { - options.split(',').map(|x| x.to_string()).collect() - } else { - vec![] - }; - - let mut baseref = push_to.to_owned(); - let mut push_mode = PushMode::Normal; - - if baseref.starts_with("refs/for") { - push_mode = PushMode::Review; - baseref = baseref.replacen("refs/for", "refs/heads", 1) - } - if baseref.starts_with("refs/drafts") { - push_mode = PushMode::Review; - baseref = baseref.replacen("refs/drafts", "refs/heads", 1) - } - if baseref.starts_with("refs/stack/for") { - push_mode = PushMode::Stack; - baseref = baseref.replacen("refs/stack/for", "refs/heads", 1) - } - if baseref.starts_with("refs/split/for") { - push_mode = PushMode::Split; - baseref = baseref.replacen("refs/split/for", "refs/heads", 1) - } - Ok((baseref, push_to, options, push_mode)) -} +use josh::changes::baseref_and_options; #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] pub enum RemoteAuth { @@ -314,31 +278,16 @@ pub fn process_repo_update(repo_update: RepoUpdate) -> josh::JoshResult push_to }; - let to_push = if let Some(changes) = changes { - let mut v = vec![]; - v.append(&mut changes_to_refs(&baseref, &author, changes)?); - - if push_mode == PushMode::Split { - split_changes(transaction.repo(), &mut v, old)?; - } - - if push_mode == PushMode::Review { - v.push((ref_with_options, oid_to_push, "JOSH_PUSH".to_string())); - } - - v.push(( - format!( - "refs/heads/@heads/{}/{}", - baseref.replacen("refs/heads/", "", 1), - author, - ), - oid_to_push, - baseref.replacen("refs/heads/", "", 1), - )); - v - } else { - vec![(ref_with_options, oid_to_push, "JOSH_PUSH".to_string())] - }; + let to_push = build_to_push( + transaction.repo(), + changes, + push_mode, + &baseref, + &author, + ref_with_options, + oid_to_push, + old, + )?; let mut resp = vec![]; @@ -411,68 +360,7 @@ pub fn process_repo_update(repo_update: RepoUpdate) -> josh::JoshResult Ok("".to_string()) } -fn split_changes( - repo: &git2::Repository, - changes: &mut Vec<(String, git2::Oid, String)>, - base: git2::Oid, -) -> josh::JoshResult<()> { - if base == git2::Oid::zero() { - return Ok(()); - } - - let commits: Vec = changes - .iter() - .map(|(_, commit, _)| repo.find_commit(*commit).unwrap()) - .collect(); - - let mut trees: Vec = commits - .iter() - .map(|commit| commit.tree().unwrap()) - .collect(); - - trees.insert(0, repo.find_commit(base)?.tree()?); - - let diffs: Vec = (1..trees.len()) - .map(|i| { - repo.diff_tree_to_tree(Some(&trees[i - 1]), Some(&trees[i]), None) - .unwrap() - }) - .collect(); - - let mut moved = std::collections::HashSet::new(); - let mut bases = vec![base]; - for _ in 0..changes.len() { - let mut new_bases = vec![]; - for base in bases.iter() { - for i in 0..diffs.len() { - if moved.contains(&i) { - continue; - } - let diff = &diffs[i]; - let parent = repo.find_commit(*base)?; - if let Ok(mut index) = repo.apply_to_tree(&parent.tree()?, diff, None) { - moved.insert(i); - let new_tree = repo.find_tree(index.write_tree_to(repo)?)?; - let new_commit = josh::history::rewrite_commit( - repo, - &repo.find_commit(changes[i].1)?, - &[&parent], - josh::filter::Apply::from_tree(new_tree), - false, - )?; - changes[i].1 = new_commit; - new_bases.push(new_commit); - } - if moved.len() == changes.len() { - return Ok(()); - } - } - } - bases = new_bases; - } - - Ok(()) -} +use josh::changes::build_to_push; pub fn push_head_url( repo: &git2::Repository, @@ -864,61 +752,6 @@ impl Drop for TmpGitNamespace { } } -fn changes_to_refs( - baseref: &str, - change_author: &str, - changes: Vec, -) -> josh::JoshResult> { - let mut seen = vec![]; - let mut changes = changes; - changes.retain(|change| change.author == change_author); - if !change_author.contains('@') { - return Err(josh::josh_error( - "Push option 'author' needs to be set to a valid email address", - )); - }; - - for change in changes.iter() { - if let Some(id) = &change.id { - if id.contains('@') { - return Err(josh::josh_error("Change id must not contain '@'")); - } - if seen.contains(&id) { - return Err(josh::josh_error(&format!( - "rejecting to push {:?} with duplicate label", - change.commit - ))); - } - seen.push(id); - } else { - return Err(josh::josh_error(&format!( - "rejecting to push {:?} without id", - change.commit - ))); - } - } - - Ok(changes - .iter() - .map(|change| { - ( - format!( - "refs/heads/@changes/{}/{}/{}", - baseref.replacen("refs/heads/", "", 1), - change.author, - change.id.as_ref().unwrap_or(&"".to_string()), - ), - change.commit, - change - .id - .as_ref() - .unwrap_or(&"JOSH_PUSH".to_string()) - .to_string(), - ) - }) - .collect()) -} - fn proxy_commit_signature<'a>() -> josh::JoshResult> { Ok(if let Ok(time) = std::env::var("JOSH_COMMIT_TIME") { git2::Signature::new( diff --git a/tester.sh b/tester.sh index 19e47a0a1..829a4a50f 100755 --- a/tester.sh +++ b/tester.sh @@ -47,7 +47,7 @@ cargo build --workspace --exclude josh-ui --features hyper_cgi/test-server sh run-tests.sh ${TESTS} EOF -docker run -it --rm \ +docker run --rm \ --workdir "$(pwd)" \ --volume "$(pwd)":"$(pwd)" \ --volume cache:/opt/cache \ diff --git a/tests/cli/clone-http.t b/tests/cli/clone-http.t new file mode 100644 index 000000000..fce4c27af --- /dev/null +++ b/tests/cli/clone-http.t @@ -0,0 +1,87 @@ + $ . ${TESTDIR}/../proxy/setup_test_env.sh + $ export RUST_LOG=error + $ cd ${TESTTMP} + +Create a test repo and push it to the existing real_repo.git + + $ git clone -q http://localhost:8001/real_repo.git + warning: You appear to have cloned an empty repository. + $ cd real_repo + $ mkdir -p subdir + $ echo test > test1 + $ echo test > subdir/test2 + $ git add test1 subdir/test2 + $ git commit -q -m "test" + $ git push -q + $ cd .. + +Test josh clone via HTTP (no filter) + + $ josh clone http://127.0.0.1:8001/real_repo.git :/ repo1-clone-josh + Added remote 'origin' with filter ':/:prune=trivial-merge' + From http://127.0.0.1:8001/real_repo + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/repo1-clone-josh + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: repo1-clone-josh + + $ cd repo1-clone-josh + $ ls + subdir + test1 + $ cat test1 + test + $ cat subdir/test2 + test + $ cd .. + +Test josh clone via HTTP (with filter) + + $ josh clone http://127.0.0.1:8001/real_repo.git :/subdir repo1-clone-josh-filtered + Added remote 'origin' with filter ':/subdir:prune=trivial-merge' + From http://127.0.0.1:8001/real_repo + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/repo1-clone-josh-filtered + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: repo1-clone-josh-filtered + + $ cd repo1-clone-josh-filtered + $ ls + test2 + $ cat test2 + test + $ cd .. + +Test josh clone via HTTP (with explicit filter argument) + + $ josh clone http://127.0.0.1:8001/real_repo.git :/subdir repo1-clone-josh-explicit + Added remote 'origin' with filter ':/subdir:prune=trivial-merge' + From http://127.0.0.1:8001/real_repo + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/repo1-clone-josh-explicit + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: repo1-clone-josh-explicit + + $ cd repo1-clone-josh-explicit + $ ls + test2 + $ cat test2 + test + $ cd .. + + diff --git a/tests/cli/clone-main-branch.t b/tests/cli/clone-main-branch.t new file mode 100644 index 000000000..5def641d4 --- /dev/null +++ b/tests/cli/clone-main-branch.t @@ -0,0 +1,138 @@ + $ export TESTTMP=${PWD} + +# Create a test repository with "main" as the default branch + $ mkdir -p remote + $ cd remote + $ git init -q -b main + +# Create main branch with content + $ mkdir -p sub1 sub2 + $ echo "file1" > sub1/file1 + $ echo "file2" > sub1/file2 + $ echo "file3" > sub2/file3 + $ git add . + $ git commit -m "Initial commit" + [main (root-commit) c8050c5] Initial commit + 3 files changed, 3 insertions(+) + create mode 100644 sub1/file1 + create mode 100644 sub1/file2 + create mode 100644 sub2/file3 + +# Create another branch + $ git checkout -b feature-branch + Switched to a new branch 'feature-branch' + $ echo "feature content" > sub1/feature.txt + $ git add . + $ git commit -m "Feature branch commit" + [feature-branch 72f7018] Feature branch commit + 1 file changed, 1 insertion(+) + create mode 100644 sub1/feature.txt + + $ git checkout main + Switched to branch 'main' + $ cd .. + +# Create a bare repository for cloning + $ git clone --bare remote remote.git + Cloning into bare repository 'remote.git'... + done. + $ cd ${TESTTMP} + + $ which git + /opt/git-install/bin/git + +Test josh clone with main branch as default + + $ cat remote.git/HEAD + ref: refs/heads/main + + $ git ls-remote --symref remote.git HEAD + ref: refs/heads/main\tHEAD (esc) + c8050c5d4fc5f431e684ca501a7ff3db5aa47103\tHEAD (esc) + + $ josh clone remote.git :/sub1 filtered-repo + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + From $TESTTMP/remote + * [new branch] feature-branch -> refs/josh/remotes/origin/feature-branch + * [new branch] main -> refs/josh/remotes/origin/main + + From file://$TESTTMP/filtered-repo + * [new branch] feature-branch -> origin/feature-branch + * [new branch] main -> origin/main + + Fetched from remote: origin + Switched to a new branch 'main' + + Cloned repository to: filtered-repo + + $ cat filtered-repo/.git/HEAD + ref: refs/heads/main + $ cat filtered-repo/.git/refs/remotes/origin/HEAD + ref: refs/remotes/origin/main + + $ cd filtered-repo + $ find .git | grep HEAD + .git/refs/remotes/origin/HEAD + .git/refs/namespaces/josh-origin/HEAD + .git/FETCH_HEAD + .git/logs/HEAD + .git/HEAD + $ git symbolic-ref refs/remotes/origin/HEAD + refs/remotes/origin/main + +# Check that we have the main branch with filtered content + $ git branch -a + * main + remotes/origin/HEAD -> origin/main + remotes/origin/feature-branch + remotes/origin/main + +# Check that main branch has filtered content + $ git checkout main + Already on 'main' + Your branch is up to date with 'origin/main'. + $ ls + file1 + file2 + +# Check that feature-branch has filtered content + $ git checkout feature-branch + branch 'feature-branch' set up to track 'origin/feature-branch'. + Switched to a new branch 'feature-branch' + $ ls + feature.txt + file1 + file2 + +# Check the reference structure + $ tree .git/refs + .git/refs + |-- heads + | |-- feature-branch + | `-- main + |-- josh + | |-- 24 + | | `-- 0 + | | |-- 9d5b5e98dceaf62470a7569949757c9643632621 + | | `-- d14715b1358e12e9fb4132036e06049fd1ddf88f + | `-- remotes + | `-- origin + | |-- feature-branch + | `-- main + |-- namespaces + | `-- josh-origin + | |-- HEAD + | `-- refs + | `-- heads + | |-- feature-branch + | `-- main + |-- remotes + | `-- origin + | |-- HEAD + | |-- feature-branch + | `-- main + `-- tags + + 14 directories, 12 files + + $ cd .. diff --git a/tests/cli/clone-ssh.t b/tests/cli/clone-ssh.t new file mode 100644 index 000000000..394297580 --- /dev/null +++ b/tests/cli/clone-ssh.t @@ -0,0 +1,97 @@ + $ export JOSH_TEST_SSH=1 + $ . ${TESTDIR}/../proxy/setup_test_env.sh + $ export RUST_LOG=error + + $ export GIT_SSH_COMMAND="ssh -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=publickey -o ForwardAgent=no" + +Create a bare repo where we will push + + $ mkdir repo1-bare.git + $ cd repo1-bare.git + $ git init -q --bare + $ cd .. + +Create a test repo and push it to bare repo on filesystem + + $ mkdir repo1 + $ cd repo1 + $ git init -q + $ mkdir -p subdir + $ echo test > test1 + $ echo test > subdir/test2 + $ git add test1 subdir/test2 + $ git commit -q -m "test" + $ git remote add origin $(pwd)/../repo1-bare.git + $ git push -q origin master + $ cd .. + +Test josh clone via SSH (no filter) + + $ josh clone ssh://git@127.0.0.1:9001/$(pwd)/repo1-bare.git :/ repo1-clone-josh + Added remote 'origin' with filter ':/:prune=trivial-merge' + From ssh://127.0.0.1:9001/$TESTTMP/repo1-bare + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/repo1-clone-josh + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: repo1-clone-josh + + $ cd repo1-clone-josh + $ ls + subdir + test1 + $ cat test1 + test + $ cat subdir/test2 + test + $ cd .. + +Test josh clone via SSH (with filter) + + $ josh clone ssh://git@127.0.0.1:9001/$(pwd)/repo1-bare.git :/subdir repo1-clone-josh-filtered + Added remote 'origin' with filter ':/subdir:prune=trivial-merge' + From ssh://127.0.0.1:9001/$TESTTMP/repo1-bare + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/repo1-clone-josh-filtered + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: repo1-clone-josh-filtered + + $ cd repo1-clone-josh-filtered + $ ls + test2 + $ cat test2 + test + $ cd .. + +Test josh clone via SSH (with explicit filter argument) + + $ josh clone ssh://git@127.0.0.1:9001/$(pwd)/repo1-bare.git :/subdir repo1-clone-josh-explicit + Added remote 'origin' with filter ':/subdir:prune=trivial-merge' + From ssh://127.0.0.1:9001/$TESTTMP/repo1-bare + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/repo1-clone-josh-explicit + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: repo1-clone-josh-explicit + + $ cd repo1-clone-josh-explicit + $ ls + test2 + $ cat test2 + test + $ cd .. + + diff --git a/tests/cli/clone.t b/tests/cli/clone.t new file mode 100644 index 000000000..8fa101cec --- /dev/null +++ b/tests/cli/clone.t @@ -0,0 +1,96 @@ + $ export TESTTMP=${PWD} + + + $ cd ${TESTTMP} + $ mkdir remote + $ cd remote + $ git init -q libs 1> /dev/null + $ cd libs + + $ mkdir sub1 + $ echo contents1 > sub1/file1 + $ git add sub1 + $ git commit -m "add file1" 1> /dev/null + + $ echo contents2 > sub1/file2 + $ git add sub1 + $ git commit -m "add file2" 1> /dev/null + + + $ mkdir sub2 + $ echo contents1 > sub2/file3 + $ git add sub2 + $ git commit -m "add file3" 1> /dev/null + $ git branch feature + + $ cd ${TESTTMP} + + $ which git + /opt/git-install/bin/git + + $ josh clone remote/libs :/sub1 libs + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + From $TESTTMP/remote/libs + * [new branch] feature -> refs/josh/remotes/origin/feature + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/libs + * [new branch] feature -> origin/feature + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: libs + + $ cd libs + + + $ tree .git/refs + .git/refs + |-- heads + | `-- master + |-- josh + | |-- 24 + | | `-- 0 + | | |-- 9d5b5e98dceaf62470a7569949757c9643632621 + | | `-- d14715b1358e12e9fb4132036e06049fd1ddf88f + | `-- remotes + | `-- origin + | |-- feature + | `-- master + |-- namespaces + | `-- josh-origin + | |-- HEAD + | `-- refs + | `-- heads + | |-- feature + | `-- master + |-- remotes + | `-- origin + | |-- HEAD + | |-- feature + | `-- master + `-- tags + + 14 directories, 11 files + + $ tree + . + |-- file1 + `-- file2 + + 1 directory, 2 files + + $ git checkout feature + branch 'feature' set up to track 'origin/feature'. + Switched to a new branch 'feature' + + $ tree + . + |-- file1 + `-- file2 + + 1 directory, 2 files + + diff --git a/tests/cli/empty-filter.t b/tests/cli/empty-filter.t new file mode 100644 index 000000000..a7a3b1347 --- /dev/null +++ b/tests/cli/empty-filter.t @@ -0,0 +1,197 @@ + $ export TESTTMP=${PWD} + +# Create a test repository with multiple branches + $ mkdir -p remote + $ cd remote + $ git init -q + $ git config user.name "Test User" + $ git config user.email "test@example.com" + +# Create main branch with content + $ mkdir -p sub1 sub2 + $ echo "file1" > sub1/file1 + $ echo "file2" > sub1/file2 + $ echo "file3" > sub2/file3 + $ git add . + $ git commit -m "Initial commit" + [master (root-commit) c8050c5] Initial commit + 3 files changed, 3 insertions(+) + create mode 100644 sub1/file1 + create mode 100644 sub1/file2 + create mode 100644 sub2/file3 + +# Create a branch that will be empty when filtered (never had sub1 content) + $ git checkout --orphan truly-empty-branch + Switched to a new branch 'truly-empty-branch' + $ git rm -rf . + rm 'sub1/file1' + rm 'sub1/file2' + rm 'sub2/file3' + $ mkdir -p other-dir + $ echo "other content" > other-dir/file.txt + $ git add . + $ git commit -m "Truly empty branch - never had sub1" + [truly-empty-branch (root-commit) 0907dcd] Truly empty branch - never had sub1 + 1 file changed, 1 insertion(+) + create mode 100644 other-dir/file.txt + +# Add another commit to the truly empty branch + $ echo "more other content" > other-dir/another-file.txt + $ git add . + $ git commit -m "Another truly empty branch commit" + [truly-empty-branch 89922be] Another truly empty branch commit + 1 file changed, 1 insertion(+) + create mode 100644 other-dir/another-file.txt + +# Create a branch that has mixed history - some commits with content, some without + $ git checkout master + Switched to branch 'master' + $ git checkout -b mixed-branch + Switched to a new branch 'mixed-branch' +# First commit has content that matches filter + $ echo "mixed content" > sub1/mixed-file.txt + $ git add . + $ git commit -m "Mixed branch - has content" + [mixed-branch 58b3b63] Mixed branch - has content + 1 file changed, 1 insertion(+) + create mode 100644 sub1/mixed-file.txt + +# Second commit removes the content (becomes empty when filtered) + $ git rm sub1/mixed-file.txt + rm 'sub1/mixed-file.txt' + $ mkdir -p other-dir + $ echo "other content" > other-dir/file.txt + $ git add . + $ git commit -m "Mixed branch - no matching content" + [mixed-branch 7a854d2] Mixed branch - no matching content + 2 files changed, 1 insertion(+), 1 deletion(-) + create mode 100644 other-dir/file.txt + delete mode 100644 sub1/mixed-file.txt + +# Third commit adds content again + $ echo "more mixed content" > sub1/another-mixed-file.txt + $ git add . + $ git commit -m "Mixed branch - has content again" + [mixed-branch 51276d8] Mixed branch - has content again + 1 file changed, 1 insertion(+) + create mode 100644 sub1/another-mixed-file.txt + +# Create another branch with content that will be filtered + $ git checkout master + Switched to branch 'master' + $ git checkout -b content-branch + Switched to a new branch 'content-branch' + $ echo "newfile" > sub1/newfile + $ git add . + $ git commit -m "Content branch commit" + [content-branch d589567] Content branch commit + 1 file changed, 1 insertion(+) + create mode 100644 sub1/newfile + + $ git checkout master + Switched to branch 'master' + $ cd .. + +# Create a bare repository for cloning + $ git clone --bare remote remote.git + Cloning into bare repository 'remote.git'... + done. + $ cd ${TESTTMP} + + $ which git + /opt/git-install/bin/git + +Test josh clone with filter that results in empty tree for some branches + + $ josh clone remote.git :/sub1 filtered-repo + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + From $TESTTMP/remote + * [new branch] content-branch -> refs/josh/remotes/origin/content-branch + * [new branch] master -> refs/josh/remotes/origin/master + * [new branch] mixed-branch -> refs/josh/remotes/origin/mixed-branch + * [new branch] truly-empty-branch -> refs/josh/remotes/origin/truly-empty-branch + + From file://$TESTTMP/filtered-repo + * [new branch] content-branch -> origin/content-branch + * [new branch] master -> origin/master + * [new branch] mixed-branch -> origin/mixed-branch + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: filtered-repo + + $ cd filtered-repo + +# Check that we have the main branch with filtered content + $ git branch -a + * master + remotes/origin/HEAD -> origin/master + remotes/origin/content-branch + remotes/origin/master + remotes/origin/mixed-branch + +# Check that master branch has filtered content + $ git checkout master + Already on 'master' + Your branch is up to date with 'origin/master'. + $ ls + file1 + file2 + +# Check that content-branch has filtered content + $ git checkout content-branch + branch 'content-branch' set up to track 'origin/content-branch'. + Switched to a new branch 'content-branch' + $ ls + file1 + file2 + newfile + +# Check that mixed-branch has filtered content (should exist because it has some commits with content) + $ git checkout mixed-branch + branch 'mixed-branch' set up to track 'origin/mixed-branch'. + Switched to a new branch 'mixed-branch' + $ ls + another-mixed-file.txt + file1 + file2 + +# Check that truly-empty-branch should not have a filtered reference +# (it should not exist as a local branch since ALL commits result in empty tree when filtered) + $ tree .git/refs + .git/refs + |-- heads + | |-- content-branch + | |-- master + | `-- mixed-branch + |-- josh + | |-- 24 + | | `-- 0 + | | |-- 9d5b5e98dceaf62470a7569949757c9643632621 + | | `-- d14715b1358e12e9fb4132036e06049fd1ddf88f + | `-- remotes + | `-- origin + | |-- content-branch + | |-- master + | |-- mixed-branch + | `-- truly-empty-branch + |-- namespaces + | `-- josh-origin + | |-- HEAD + | `-- refs + | `-- heads + | |-- content-branch + | |-- master + | `-- mixed-branch + |-- remotes + | `-- origin + | |-- HEAD + | |-- content-branch + | |-- master + | `-- mixed-branch + `-- tags + + 14 directories, 17 files + + $ cd .. diff --git a/tests/cli/fetch.t b/tests/cli/fetch.t new file mode 100644 index 000000000..e6165b18f --- /dev/null +++ b/tests/cli/fetch.t @@ -0,0 +1,110 @@ + $ export TESTTMP=${PWD} + + + $ cd ${TESTTMP} + $ mkdir remote + $ cd remote + $ git init -q libs 1> /dev/null + $ cd libs + + $ mkdir sub1 + $ echo contents1 > sub1/file1 + $ git add sub1 + $ git commit -m "add file1" 1> /dev/null + + $ echo contents2 > sub1/file2 + $ git add sub1 + $ git commit -m "add file2" 1> /dev/null + + $ cd ${TESTTMP} + + $ which git + /opt/git-install/bin/git + + $ josh clone ${TESTTMP}/remote/libs :/sub1 libs + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + From $TESTTMP/remote/libs + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/libs + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: libs + + $ cd libs + + $ tree + . + |-- file1 + `-- file2 + + 1 directory, 2 files + + $ echo newfile > newfile + $ git add newfile + $ git commit -m "add newfile" 1> /dev/null + + $ cd ${TESTTMP}/remote/libs + $ echo remote_newfile > sub1/remote_newfile + $ git add sub1 + $ git commit -m "add remote_newfile" 1> /dev/null + + $ cd ${TESTTMP}/libs + + $ josh fetch + From $TESTTMP/remote/libs + 81b10fb..0956fb2 master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/libs + d8388f5..61e377b master -> origin/master + + Fetched from remote: origin + + $ tree + . + |-- file1 + |-- file2 + `-- newfile + + 1 directory, 3 files + + $ git log --oneline + 6a6f932 add newfile + d8388f5 add file2 + 0b4cf6c add file1 + + $ git log --oneline origin/master + 61e377b add remote_newfile + d8388f5 add file2 + 0b4cf6c add file1 + + $ git checkout origin/master + Note: switching to 'origin/master'. + + You are in 'detached HEAD' state. You can look around, make experimental + changes and commit them, and you can discard any commits you make in this + state without impacting any branches by switching back to a branch. + + If you want to create a new branch to retain commits you create, you may + do so (now or later) by using -c with the switch command. Example: + + git switch -c + + Or undo this operation with: + + git switch - + + Turn off this advice by setting config variable advice.detachedHead to false + + HEAD is now at 61e377b add remote_newfile + + $ tree + . + |-- file1 + |-- file2 + `-- remote_newfile + + 1 directory, 3 files diff --git a/tests/cli/filter.t b/tests/cli/filter.t new file mode 100644 index 000000000..b76a1d4b5 --- /dev/null +++ b/tests/cli/filter.t @@ -0,0 +1,68 @@ + $ export TESTTMP=${PWD} + + $ cd ${TESTTMP} + $ mkdir remote + $ cd remote + $ git init -q libs 1> /dev/null + $ cd libs + + $ mkdir sub1 + $ echo contents1 > sub1/file1 + $ echo contents2 > sub1/file2 + $ git add sub1 + $ git commit -m "add files" 1> /dev/null + + $ cd ${TESTTMP} + + $ which git + /opt/git-install/bin/git + +Test josh filter command - apply filtering without fetching + + $ josh clone ${TESTTMP}/remote/libs :/sub1 filtered-repo + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + From $TESTTMP/remote/libs + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/filtered-repo + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: filtered-repo + + $ cd filtered-repo + + $ ls + file1 + file2 + + $ git log --oneline + 1432d42 add files + + $ git config josh-remote.origin.filter + :/sub1:prune=trivial-merge + + $ josh filter origin + Applying filter ':/sub1:prune=trivial-merge' to remote 'origin' + Applied filter to remote: origin + + $ git log --oneline + 1432d42 add files + + $ cd .. + +Test josh filter with non-existent remote + + $ mkdir test-repo + $ cd test-repo + $ git init -q + + $ josh filter nonexistent + Error: No filter configured for remote 'nonexistent': config value 'josh-remote.nonexistent.filter' was not found; class=Config (7); code=NotFound (-3) + [1] + + $ cd .. + + diff --git a/tests/cli/keep-trivial-merges.t b/tests/cli/keep-trivial-merges.t new file mode 100644 index 000000000..87892e50c --- /dev/null +++ b/tests/cli/keep-trivial-merges.t @@ -0,0 +1,70 @@ + $ export TESTTMP=${PWD} + + $ cd ${TESTTMP} + $ mkdir remote + $ cd remote + $ git init -q libs 1> /dev/null + $ cd libs + + $ mkdir sub1 + $ echo contents1 > sub1/file1 + $ git add sub1 + $ git commit -m "add file1" 1> /dev/null + + $ echo contents2 > sub1/file2 + $ git add sub1 + $ git commit -m "add file2" 1> /dev/null + + $ mkdir sub2 + $ echo contents1 > sub2/file3 + $ git add sub2 + $ git commit -m "add file3" 1> /dev/null + $ git branch feature + + $ cd ${TESTTMP} + + $ which git + /opt/git-install/bin/git + +Test josh clone with --keep-trivial-merges flag + + $ josh clone remote/libs :/sub1 libs --keep-trivial-merges + Added remote 'origin' with filter ':/sub1' + From $TESTTMP/remote/libs + * [new branch] feature -> refs/josh/remotes/origin/feature + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/libs + * [new branch] feature -> origin/feature + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: libs + + $ cd libs + + $ tree + . + |-- file1 + `-- file2 + + 1 directory, 2 files + + $ cd .. + +Test josh remote add with --keep-trivial-merges flag + + $ mkdir test-repo + $ cd test-repo + $ git init -q + $ josh remote add origin ${TESTTMP}/remote/libs :/sub1 --keep-trivial-merges + Added remote 'origin' with filter ':/sub1' + + $ git config josh-remote.origin.filter + :/sub1 + + $ cd .. + + diff --git a/tests/cli/pull-multiple-remotes.t b/tests/cli/pull-multiple-remotes.t new file mode 100644 index 000000000..b52a8b930 --- /dev/null +++ b/tests/cli/pull-multiple-remotes.t @@ -0,0 +1,99 @@ + $ export TESTTMP=${PWD} + + + $ cd ${TESTTMP} + $ mkdir remote1 remote2 + $ cd remote1 + $ git init -q libs 1> /dev/null + $ cd libs + + $ mkdir sub1 + $ echo contents1 > sub1/file1 + $ git add sub1 + $ git commit -m "add file1" 1> /dev/null + + $ echo contents2 > sub1/file2 + $ git add sub1 + $ git commit -m "add file2" 1> /dev/null + + $ mkdir sub2 + $ echo contents3 > sub2/file3 + $ git add sub2 + $ git commit -m "add file3" 1> /dev/null + + $ cd ${TESTTMP}/remote2 + $ git init -q libs 1> /dev/null + $ cd libs + + $ mkdir sub1 + $ echo different1 > sub1/file1 + $ git add sub1 + $ git commit -m "add different file1" 1> /dev/null + + $ mkdir sub2 + $ echo different2 > sub2/file3 + $ git add sub2 + $ git commit -m "add different file3" 1> /dev/null + + $ cd ${TESTTMP} + + $ which git + /opt/git-install/bin/git + + $ josh clone ${TESTTMP}/remote1/libs :/sub1 libs + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + From $TESTTMP/remote1/libs + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/libs + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: libs + + $ cd libs + + $ tree + . + |-- file1 + `-- file2 + + 1 directory, 2 files + + $ josh remote add remote2 ${TESTTMP}/remote2/libs :/sub2 + Added remote 'remote2' with filter ':/sub2:prune=trivial-merge' + + $ josh pull --remote remote2 + From $TESTTMP/remote2/libs + * [new branch] master -> refs/josh/remotes/remote2/master + + From file://$TESTTMP/libs + * [new branch] master -> remote2/master + + Fetched from remote: remote2 + You asked to pull from the remote 'remote2', but did not specify + a branch. Because this is not the default configured remote + for your current branch, you must specify a branch on the command line. + + Error: git pull failed with exit code: 1 + [1] + + $ tree + . + |-- file1 + `-- file2 + + 1 directory, 2 files + + $ josh pull + Fetched from remote: origin + Pulled from remote: origin + + $ tree + . + |-- file1 + `-- file2 + + 1 directory, 2 files diff --git a/tests/cli/pull.t b/tests/cli/pull.t new file mode 100644 index 000000000..2b78eb211 --- /dev/null +++ b/tests/cli/pull.t @@ -0,0 +1,91 @@ + $ export TESTTMP=${PWD} + + + $ cd ${TESTTMP} + $ mkdir remote + $ cd remote + $ git init -q libs 1> /dev/null + $ cd libs + + $ mkdir sub1 + $ echo contents1 > sub1/file1 + $ git add sub1 + $ git commit -m "add file1" 1> /dev/null + + $ echo contents2 > sub1/file2 + $ git add sub1 + $ git commit -m "add file2" 1> /dev/null + + + $ mkdir sub2 + $ echo contents1 > sub2/file3 + $ git add sub2 + $ git commit -m "add file3" 1> /dev/null + $ git branch feature + + $ cd ${TESTTMP} + + $ which git + /opt/git-install/bin/git + + $ josh clone ${TESTTMP}/remote/libs :/sub1 libs + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + From $TESTTMP/remote/libs + * [new branch] feature -> refs/josh/remotes/origin/feature + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/libs + * [new branch] feature -> origin/feature + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: libs + + $ cd libs + + $ tree + . + |-- file1 + `-- file2 + + 1 directory, 2 files + + $ cd ${TESTTMP}/remote/libs + + $ echo new_content > sub1/newfile + $ git add sub1 + $ git commit -m "add newfile" 1> /dev/null + + $ cd ${TESTTMP}/libs + + $ josh pull + From $TESTTMP/remote/libs + 667a912..2c470be master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/libs + d8388f5..0974639 master -> origin/master + + Fetched from remote: origin + Pulled from remote: origin + + $ tree + . + |-- file1 + |-- file2 + `-- newfile + + 1 directory, 3 files + + $ git checkout feature + branch 'feature' set up to track 'origin/feature'. + Switched to a new branch 'feature' + + $ tree + . + |-- file1 + `-- file2 + + 1 directory, 2 files + diff --git a/tests/cli/push.t b/tests/cli/push.t new file mode 100644 index 000000000..a917bc42c --- /dev/null +++ b/tests/cli/push.t @@ -0,0 +1,65 @@ +Setup + + $ export TESTTMP=${PWD} + +Create a test repository with some content + + $ mkdir remote + $ cd remote + $ git init -q --bare + $ cd .. + + $ mkdir local + $ cd local + $ git init -q + $ mkdir -p sub1 + $ echo "file1 content" > sub1/file1 + $ echo "file2 content" > sub1/file2 + $ git add sub1 + $ git commit -q -m "add files" + $ git remote add origin ${TESTTMP}/remote + $ git push -q origin master + $ cd .. + +Clone with josh filter + + $ josh clone ${TESTTMP}/remote :/sub1 filtered + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + From $TESTTMP/remote + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/filtered + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: filtered + $ cd filtered + $ tree + . + |-- file1 + `-- file2 + + 1 directory, 2 files + +Make a change in the filtered repository + + $ echo "modified content" > file1 + $ git add file1 + $ git commit -q -m "modify file1" + +Push the change back + + $ josh push + To $TESTTMP/remote + bd8c97c..6cd75eb 6cd75ebe1f882bd362eeb6f1199b9540552ac413 -> master + + Pushed 6cd75ebe1f882bd362eeb6f1199b9540552ac413 to origin/master + +Verify the change was pushed to the original repository + + $ cd ${TESTTMP}/local + $ git pull -q --rebase origin master + $ cat sub1/file1 + modified content \ No newline at end of file diff --git a/tests/cli/push_stacked.t b/tests/cli/push_stacked.t new file mode 100644 index 000000000..b40d5753f --- /dev/null +++ b/tests/cli/push_stacked.t @@ -0,0 +1,131 @@ +Setup + + $ export TESTTMP=${PWD} + +Create a test repository with some content + + $ mkdir remote + $ cd remote + $ git init -q --bare + $ cd .. + + $ mkdir local + $ cd local + $ git init -q + $ mkdir -p sub1 + $ echo "file1 content" > sub1/file1 + $ echo "before" > file7 + $ git add . + $ git commit -q -m "add file1" + $ git remote add origin ${TESTTMP}/remote + $ git push -q origin master + $ cd .. + +Clone with josh filter + + $ josh clone ${TESTTMP}/remote :/sub1 filtered + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + From $TESTTMP/remote + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/filtered + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: filtered + $ cd filtered + $ tree + . + `-- file1 + + 1 directory, 1 file + +Make changes with Change-Id for stacked changes + + $ echo "contents2" > file2 + $ git add file2 + $ git commit -q -m "Change-Id: 1234" + $ echo "contents2" > file7 + $ git add file7 + $ git commit -q -m "Change-Id: foo7" + $ git log --decorate --graph --pretty="%s %d" + * Change-Id: foo7 (HEAD -> master) + * Change-Id: 1234 + * add file1 (origin/master, origin/HEAD) + +Set up git config for author + + $ git config user.email "josh@example.com" + $ git config user.name "Josh Test" + +Push with stacked changes (should create multiple refs) + + $ git ls-remote . + da80e49d24d110866ce2ec7a5c21112696fd165b\tHEAD (esc) + da80e49d24d110866ce2ec7a5c21112696fd165b\trefs/heads/master (esc) + 725a17751b9dc03b1696fb894d0643c5b6f0397d\trefs/josh/24/0/9d5b5e98dceaf62470a7569949757c9643632621 (esc) + 030ef005644909d7f6320dcd99684a36860fb7d9\trefs/josh/24/0/d14715b1358e12e9fb4132036e06049fd1ddf88f (esc) + 6ed6c1ca90cb15fe4edf8d133f0e2e44562aa77d\trefs/josh/remotes/origin/master (esc) + da80e49d24d110866ce2ec7a5c21112696fd165b\trefs/namespaces/josh-origin/HEAD (esc) + 5f2928c89c4dcc7f5a8c59ef65734a83620cefee\trefs/namespaces/josh-origin/refs/heads/master (esc) + 5f2928c89c4dcc7f5a8c59ef65734a83620cefee\trefs/remotes/origin/HEAD (esc) + 5f2928c89c4dcc7f5a8c59ef65734a83620cefee\trefs/remotes/origin/master (esc) + $ josh push --stack + To $TESTTMP/remote + * [new branch] c61c37f4a3d5eb447f41dde15620eee1a181d60b -> @changes/master/josh@example.com/1234 + + Pushed c61c37f4a3d5eb447f41dde15620eee1a181d60b to origin/refs/heads/@changes/master/josh@example.com/1234 + To $TESTTMP/remote + * [new branch] 2cbfa8cb8d9a9f1de029fcba547a6e56c742733f -> @changes/master/josh@example.com/foo7 + + Pushed 2cbfa8cb8d9a9f1de029fcba547a6e56c742733f to origin/refs/heads/@changes/master/josh@example.com/foo7 + To $TESTTMP/remote + * [new branch] 2cbfa8cb8d9a9f1de029fcba547a6e56c742733f -> @heads/master/josh@example.com + + Pushed 2cbfa8cb8d9a9f1de029fcba547a6e56c742733f to origin/refs/heads/@heads/master/josh@example.com + $ git ls-remote . + da80e49d24d110866ce2ec7a5c21112696fd165b\tHEAD (esc) + da80e49d24d110866ce2ec7a5c21112696fd165b\trefs/heads/master (esc) + 725a17751b9dc03b1696fb894d0643c5b6f0397d\trefs/josh/24/0/9d5b5e98dceaf62470a7569949757c9643632621 (esc) + 030ef005644909d7f6320dcd99684a36860fb7d9\trefs/josh/24/0/d14715b1358e12e9fb4132036e06049fd1ddf88f (esc) + 6ed6c1ca90cb15fe4edf8d133f0e2e44562aa77d\trefs/josh/remotes/origin/master (esc) + da80e49d24d110866ce2ec7a5c21112696fd165b\trefs/namespaces/josh-origin/HEAD (esc) + 5f2928c89c4dcc7f5a8c59ef65734a83620cefee\trefs/namespaces/josh-origin/refs/heads/master (esc) + 5f2928c89c4dcc7f5a8c59ef65734a83620cefee\trefs/remotes/origin/HEAD (esc) + 5f2928c89c4dcc7f5a8c59ef65734a83620cefee\trefs/remotes/origin/master (esc) + +Verify the refs were created in the remote + + $ cd ${TESTTMP}/remote + $ git ls-remote . + 6ed6c1ca90cb15fe4edf8d133f0e2e44562aa77d\tHEAD (esc) + c61c37f4a3d5eb447f41dde15620eee1a181d60b\trefs/heads/@changes/master/josh@example.com/1234 (esc) + 2cbfa8cb8d9a9f1de029fcba547a6e56c742733f\trefs/heads/@changes/master/josh@example.com/foo7 (esc) + 2cbfa8cb8d9a9f1de029fcba547a6e56c742733f\trefs/heads/@heads/master/josh@example.com (esc) + 6ed6c1ca90cb15fe4edf8d133f0e2e44562aa77d\trefs/heads/master (esc) + +Test normal push (without --split) - create a new commit + + $ cd ${TESTTMP}/filtered + $ echo "contents3" > file2 + $ git add file2 + $ git commit -q -m "add file3" -m "Change-Id: 1235" + $ git log --graph --pretty=%s:%H + * add file3:746bd987ef4122f2e6175f81a025ab335cf51b27 + * Change-Id: foo7:da80e49d24d110866ce2ec7a5c21112696fd165b + * Change-Id: 1234:43d6fcc9e7a81452d7343c78c0102f76027717fb + * add file1:5f2928c89c4dcc7f5a8c59ef65734a83620cefee + $ josh push + To $TESTTMP/remote + 6ed6c1c..d3e371f d3e371f8c637c91b59e05aae1066cf0adbe0da93 -> master + + Pushed d3e371f8c637c91b59e05aae1066cf0adbe0da93 to origin/master + +Verify normal push worked + + $ cd ${TESTTMP}/local + $ git pull -q --rebase origin master + $ cat sub1/file2 + contents3 diff --git a/tests/cli/push_stacked_split.t b/tests/cli/push_stacked_split.t new file mode 100644 index 000000000..e57d76660 --- /dev/null +++ b/tests/cli/push_stacked_split.t @@ -0,0 +1,162 @@ +Setup + + $ export TESTTMP=${PWD} + +Create a test repository with some content + + $ mkdir remote + $ cd remote + $ git init -q --bare + $ cd .. + + $ mkdir local + $ cd local + $ git init -q + $ mkdir -p sub1 + $ echo "file1 content" > sub1/file1 + $ echo "before" > file7 + $ git add . + $ git commit -q -m "add file1" + $ git remote add origin ${TESTTMP}/remote + $ git push -q origin master + $ cd .. + +Clone with josh filter + + $ josh clone ${TESTTMP}/remote :/sub1 filtered + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + From $TESTTMP/remote + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/filtered + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: filtered + $ cd filtered + $ tree + . + `-- file1 + + 1 directory, 1 file + +Make multiple changes with Change-Ids for split testing + + $ echo "contents2" > file2 + $ git add file2 + $ git commit -q -m "Change-Id: 1234" + $ echo "contents2" > file7 + $ git add file7 + $ git commit -q -m "Change-Id: foo7" + $ echo "contents3" > file2 + $ git add file2 + $ git commit -q -m "Change-Id: 1235" + $ git log --decorate --graph --pretty="%s %d" + * Change-Id: 1235 (HEAD -> master) + * Change-Id: foo7 + * Change-Id: 1234 + * add file1 (origin/master, origin/HEAD) + +Set up git config for author + + $ git config user.email "josh@example.com" + $ git config user.name "Josh Test" + +Push with split mode (should create multiple refs for each change) + + $ josh push --split + To $TESTTMP/remote + * [new branch] c61c37f4a3d5eb447f41dde15620eee1a181d60b -> @changes/master/josh@example.com/1234 + + Pushed c61c37f4a3d5eb447f41dde15620eee1a181d60b to origin/refs/heads/@changes/master/josh@example.com/1234 + To $TESTTMP/remote + * [new branch] c1b55ea7e5f27f82d3565c1f5d64113adf635c2c -> @changes/master/josh@example.com/foo7 + + Pushed c1b55ea7e5f27f82d3565c1f5d64113adf635c2c to origin/refs/heads/@changes/master/josh@example.com/foo7 + To $TESTTMP/remote + * [new branch] ef7c3c85ad4c5875f308003d42a6e11d9b14aeb9 -> @changes/master/josh@example.com/1235 + + Pushed ef7c3c85ad4c5875f308003d42a6e11d9b14aeb9 to origin/refs/heads/@changes/master/josh@example.com/1235 + To $TESTTMP/remote + * [new branch] 02796cbb12e05f3be9f16c82e4d26542af7e700c -> @heads/master/josh@example.com + + Pushed 02796cbb12e05f3be9f16c82e4d26542af7e700c to origin/refs/heads/@heads/master/josh@example.com + +Verify the refs were created in the remote + + $ cd ${TESTTMP}/remote + $ git ls-remote . | grep "@" | sort + 02796cbb12e05f3be9f16c82e4d26542af7e700c\trefs/heads/@heads/master/josh@example.com (esc) + c1b55ea7e5f27f82d3565c1f5d64113adf635c2c\trefs/heads/@changes/master/josh@example.com/foo7 (esc) + c61c37f4a3d5eb447f41dde15620eee1a181d60b\trefs/heads/@changes/master/josh@example.com/1234 (esc) + ef7c3c85ad4c5875f308003d42a6e11d9b14aeb9\trefs/heads/@changes/master/josh@example.com/1235 (esc) + + $ git log --all --decorate --graph --pretty="%s %d %H" + * Change-Id: 1235 (@changes/master/josh@example.com/1235) ef7c3c85ad4c5875f308003d42a6e11d9b14aeb9 + | * Change-Id: foo7 (@changes/master/josh@example.com/foo7) c1b55ea7e5f27f82d3565c1f5d64113adf635c2c + | | * Change-Id: 1235 (@heads/master/josh@example.com) 02796cbb12e05f3be9f16c82e4d26542af7e700c + | | * Change-Id: foo7 2cbfa8cb8d9a9f1de029fcba547a6e56c742733f + | |/ + |/| + * | Change-Id: 1234 (@changes/master/josh@example.com/1234) c61c37f4a3d5eb447f41dde15620eee1a181d60b + |/ + * add file1 (HEAD -> master) 6ed6c1ca90cb15fe4edf8d133f0e2e44562aa77d + +Test that we can fetch the split refs back + + $ cd ${TESTTMP}/filtered + $ josh fetch + From $TESTTMP/remote + * [new branch] @changes/master/josh@example.com/1234 -> refs/josh/remotes/origin/@changes/master/josh@example.com/1234 + * [new branch] @changes/master/josh@example.com/1235 -> refs/josh/remotes/origin/@changes/master/josh@example.com/1235 + * [new branch] @changes/master/josh@example.com/foo7 -> refs/josh/remotes/origin/@changes/master/josh@example.com/foo7 + * [new branch] @heads/master/josh@example.com -> refs/josh/remotes/origin/@heads/master/josh@example.com + + From file://$TESTTMP/filtered + * [new branch] @changes/master/josh@example.com/1234 -> origin/@changes/master/josh@example.com/1234 + * [new branch] @changes/master/josh@example.com/1235 -> origin/@changes/master/josh@example.com/1235 + * [new branch] @changes/master/josh@example.com/foo7 -> origin/@changes/master/josh@example.com/foo7 + * [new branch] @heads/master/josh@example.com -> origin/@heads/master/josh@example.com + + Fetched from remote: origin + + $ git log --all --decorate --graph --pretty="%s %d %H" + * Change-Id: 1235 (HEAD -> master, origin/@heads/master/josh@example.com) 3faa5b51d4600be54a2b32e84697e7b32a781a03 + * Change-Id: foo7 da80e49d24d110866ce2ec7a5c21112696fd165b + | * Change-Id: 1235 ef7c3c85ad4c5875f308003d42a6e11d9b14aeb9 + | | * Change-Id: foo7 c1b55ea7e5f27f82d3565c1f5d64113adf635c2c + | | | * Change-Id: 1235 02796cbb12e05f3be9f16c82e4d26542af7e700c + | | | * Change-Id: foo7 2cbfa8cb8d9a9f1de029fcba547a6e56c742733f + | | |/ + | |/| + | * | Change-Id: 1234 c61c37f4a3d5eb447f41dde15620eee1a181d60b + | |/ + | * add file1 6ed6c1ca90cb15fe4edf8d133f0e2e44562aa77d + | * Change-Id: 1235 (origin/@changes/master/josh@example.com/1235) 96da92a9021ee186e1e9dd82305ddebfd1153ed5 + |/ + * Change-Id: 1234 (origin/@changes/master/josh@example.com/1234) 43d6fcc9e7a81452d7343c78c0102f76027717fb + | * Change-Id: foo7 (origin/@changes/master/josh@example.com/foo7) ecb19ea4b4fbfb6afff253ec719909e80a480a18 + |/ + * add file1 (origin/master, origin/HEAD) 5f2928c89c4dcc7f5a8c59ef65734a83620cefee + * Notes added by 'git_note_create' from libgit2 725a17751b9dc03b1696fb894d0643c5b6f0397d + * Notes added by 'git_note_create' from libgit2 030ef005644909d7f6320dcd99684a36860fb7d9 + +Test normal push still works + + $ echo "contents4" > file2 + $ git add file2 + $ git commit -q -m "add file4" -m "Change-Id: 1236" + $ josh push + To $TESTTMP/remote + 6ed6c1c..84f0380 84f0380f63011c5432945683f8f79426cc6bd180 -> master + + Pushed 84f0380f63011c5432945683f8f79426cc6bd180 to origin/master + +Verify normal push worked + + $ cd ${TESTTMP}/local + $ git pull -q --rebase origin master + $ cat sub1/file2 + contents4 diff --git a/tests/cli/remote-refspecs.t b/tests/cli/remote-refspecs.t new file mode 100644 index 000000000..79be91e46 --- /dev/null +++ b/tests/cli/remote-refspecs.t @@ -0,0 +1,60 @@ + $ export TESTTMP=${PWD} + + $ cd ${TESTTMP} + $ mkdir remote + $ cd remote + $ git init -q libs 1> /dev/null + $ cd libs + + $ mkdir sub1 + $ echo contents1 > sub1/file1 + $ git add sub1 + $ git commit -m "add file1" 1> /dev/null + + $ cd ${TESTTMP} + + $ which git + /opt/git-install/bin/git + +Test that josh remote add sets up proper refspecs + + $ mkdir test-repo + $ cd test-repo + $ git init -q + $ josh remote add origin ${TESTTMP}/remote/libs :/sub1 + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + + $ git config --get-all remote.origin.fetch + +refs/heads/*:refs/remotes/origin/* + + $ git config josh-remote.origin.filter + :/sub1:prune=trivial-merge + + $ cd .. + +Test that josh clone also sets up proper refspecs + + $ josh clone ${TESTTMP}/remote/libs :/sub1 cloned-repo + Added remote 'origin' with filter ':/sub1:prune=trivial-merge' + From $TESTTMP/remote/libs + * [new branch] master -> refs/josh/remotes/origin/master + + From file://$TESTTMP/cloned-repo + * [new branch] master -> origin/master + + Fetched from remote: origin + Already on 'master' + + Cloned repository to: cloned-repo + + $ cd cloned-repo + + $ git config --get-all remote.origin.fetch + +refs/heads/*:refs/remotes/origin/* + + $ git config josh-remote.origin.filter + :/sub1:prune=trivial-merge + + $ cd .. + +