├── .gitignore ├── src ├── git_chain │ ├── mod.rs │ ├── core.rs │ └── operations.rs ├── error.rs ├── main.rs ├── types.rs ├── branch.rs ├── chain.rs └── cli.rs ├── Cargo.toml ├── .github ├── dependabot.yml └── workflows │ └── rust.yml ├── LICENSE ├── CHANGELOG.md ├── tests ├── git_config.rs ├── misc.rs ├── list.rs ├── prune.rs ├── setup.rs ├── backup.rs ├── push.rs ├── common │ └── mod.rs ├── fork_point_failure.rs ├── init.rs ├── merge_base_failures.rs └── pr.rs ├── Makefile └── CLAUDE.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /test_sandbox 3 | external/ -------------------------------------------------------------------------------- /src/git_chain/mod.rs: -------------------------------------------------------------------------------- 1 | use git2::Repository; 2 | 3 | pub struct GitChain { 4 | pub repo: Repository, 5 | pub executable_name: String, 6 | } 7 | 8 | // Re-export impl blocks 9 | mod core; 10 | mod merge; 11 | mod operations; 12 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "git-chain" 3 | version = "0.0.13" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | git2 = "0.20.2" 10 | clap = "2.33.3" 11 | colored = "2.1.0" 12 | between = "0.1.0" 13 | rand = "0.8.5" 14 | regex = "1.11.1" 15 | serde_json = "1.0.140" 16 | 17 | [dev-dependencies] 18 | assert_cmd = "2.0.16" 19 | console = "0.15.8" -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | time: "10:00" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alberto Leal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use git2::Error; 2 | 3 | // For API consistency, we create our own Error variants 4 | pub trait ErrorExt { 5 | #[allow(dead_code)] 6 | fn from_str(message: &str) -> Self; 7 | fn merge_conflict(branch: String, upstream: String, message: Option) -> Self; 8 | fn git_command_failed(command: String, status: i32, stdout: String, stderr: String) -> Self; 9 | } 10 | 11 | impl ErrorExt for Error { 12 | fn from_str(message: &str) -> Self { 13 | Error::from_str(message) 14 | } 15 | 16 | fn merge_conflict(branch: String, upstream: String, message: Option) -> Self { 17 | let mut error_msg = format!("Merge conflict between {} and {}", upstream, branch); 18 | if let Some(details) = message { 19 | error_msg.push('\n'); 20 | error_msg.push_str(&details); 21 | } 22 | Error::from_str(&error_msg) 23 | } 24 | 25 | fn git_command_failed(command: String, status: i32, stdout: String, stderr: String) -> Self { 26 | let error_msg = format!( 27 | "Git command failed: {}\nStatus: {}\nStdout: {}\nStderr: {}", 28 | command, status, stdout, stderr 29 | ); 30 | Error::from_str(&error_msg) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master, main ] 6 | pull_request: 7 | branches: [ master, main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | # Run the complete CI pipeline using the Makefile as source of truth 14 | ci: 15 | name: CI Pipeline 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install Rust stable 21 | uses: dtolnay/rust-toolchain@stable 22 | with: 23 | components: rustfmt, clippy 24 | 25 | - name: Cache dependencies 26 | uses: actions/cache@v3 27 | with: 28 | path: | 29 | ~/.cargo/registry 30 | ~/.cargo/git 31 | target/ 32 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 33 | 34 | - name: Run complete CI pipeline via Makefile 35 | run: make ci-local 36 | 37 | # Optional: Run on multiple Rust versions 38 | test-matrix: 39 | name: Test on ${{ matrix.rust }} 40 | runs-on: ubuntu-latest 41 | strategy: 42 | matrix: 43 | rust: 44 | - stable 45 | - beta 46 | - nightly 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - name: Install Rust ${{ matrix.rust }} 51 | uses: dtolnay/rust-toolchain@master 52 | with: 53 | toolchain: ${{ matrix.rust }} 54 | components: rustfmt, clippy 55 | 56 | - name: Cache dependencies 57 | uses: actions/cache@v3 58 | with: 59 | path: | 60 | ~/.cargo/registry 61 | ~/.cargo/git 62 | target/ 63 | key: ${{ runner.os }}-cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }} 64 | 65 | - name: Run tests 66 | run: cargo test 67 | continue-on-error: ${{ matrix.rust == 'nightly' }} -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsString; 2 | use std::process; 3 | 4 | use colored::*; 5 | 6 | mod branch; 7 | mod chain; 8 | mod cli; 9 | mod commands; 10 | mod error; 11 | mod git_chain; 12 | mod types; 13 | 14 | use cli::parse_arg_matches; 15 | use commands::run; 16 | 17 | // Re-export for use by other modules 18 | pub use branch::Branch; 19 | pub use chain::Chain; 20 | pub use git_chain::GitChain; 21 | 22 | fn main() { 23 | run_app(std::env::args_os()); 24 | } 25 | 26 | fn run_app(arguments: I) 27 | where 28 | I: IntoIterator, 29 | T: Into + Clone, 30 | { 31 | let arg_matches = parse_arg_matches(arguments); 32 | 33 | match run(arg_matches) { 34 | Ok(()) => {} 35 | Err(err) => { 36 | eprintln!("{} {}", "error:".red().bold(), err); 37 | process::exit(1); 38 | } 39 | } 40 | } 41 | 42 | pub fn executable_name() -> String { 43 | let name = std::env::current_exe() 44 | .expect("Cannot get the path of current executable.") 45 | .file_name() 46 | .expect("Cannot get the executable name.") 47 | .to_string_lossy() 48 | .into_owned(); 49 | if name.starts_with("git-") && name.len() > 4 { 50 | let tmp: Vec = name.split("git-").map(|x| x.to_string()).collect(); 51 | let git_cmd = &tmp[1]; 52 | return format!("git {}", git_cmd); 53 | } 54 | name 55 | } 56 | 57 | pub fn check_gh_cli_installed() -> Result<(), git2::Error> { 58 | let output = std::process::Command::new("gh").arg("--version").output(); 59 | match output { 60 | Ok(output) if output.status.success() => Ok(()), 61 | _ => { 62 | eprintln!("The GitHub CLI (gh) is not installed or not found in the PATH."); 63 | eprintln!("Please install it from https://cli.github.com/ and ensure it's available in your PATH."); 64 | process::exit(1); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.0.13] - 2025-11-05 11 | 12 | ### Improved 13 | - Enhanced error message when running git-chain outside a git repository 14 | - Replaced technical git2 error with clear, actionable message 15 | - Added helpful hints directing users to run 'git init' 16 | - Styled error output consistently with colored formatting (error: in red, hint: in yellow) 17 | - Mirrored Git's own error message style for better user familiarity 18 | 19 | ### Added 20 | - Added integration test for non-git repository edge case 21 | 22 | ## [0.0.12] - 2025-11-05 23 | 24 | ### Fixed 25 | - Fixed test failures when users have custom `init.defaultBranch` git configuration ([#47](https://github.com/dashed/git-chain/pull/47)) 26 | 27 | ## [0.0.11] - 2025-11-05 28 | 29 | ### Changed 30 | - Upgraded git2 dependency from 0.19.0 to 0.20.2 31 | - Updated libgit2-sys to 0.18.2+1.9.1 32 | 33 | ## [0.0.10] - 2025-11-05 34 | 35 | ### Fixed 36 | - Fixed help message to show correct argument order: `init ` ([#46](https://github.com/dashed/git-chain/pull/46)) 37 | - Fixed test assertion to match corrected help message 38 | - Fixed PR command --draft and --web flag interoperability issue with GitHub CLI 39 | - Fixed PR tests in GitHub Actions 40 | - Fixed rebase_no_forkpoint test 41 | - Fixed various merge test cases 42 | 43 | ### Added 44 | - Added `pr` subcommand for creating GitHub pull requests ([#40](https://github.com/dashed/git-chain/pull/40)) 45 | - Added support for `--pr` flag on `list` and `status` commands to show PR information 46 | - Added support for `--draft` flag when creating PRs 47 | - Added tests for PR functionality 48 | 49 | ### Changed 50 | - Improved merge commit information retrieval 51 | - Updated GitHub Actions workflow 52 | - Updated gitignore 53 | 54 | ## [0.0.9] - (Previous version) 55 | 56 | [unreleased]: https://github.com/dashed/git-chain/compare/v0.0.13...HEAD 57 | [0.0.13]: https://github.com/dashed/git-chain/compare/v0.0.12...v0.0.13 58 | [0.0.12]: https://github.com/dashed/git-chain/compare/v0.0.11...v0.0.12 59 | [0.0.11]: https://github.com/dashed/git-chain/compare/v0.0.10...v0.0.11 60 | [0.0.10]: https://github.com/dashed/git-chain/compare/v0.0.9...v0.0.10 61 | -------------------------------------------------------------------------------- /tests/git_config.rs: -------------------------------------------------------------------------------- 1 | use git2::ConfigLevel; 2 | 3 | #[path = "common/mod.rs"] 4 | pub mod common; 5 | 6 | use common::{ 7 | checkout_branch, commit_all, create_branch, create_new_file, delete_local_branch, 8 | first_commit_all, generate_path_to_repo, setup_git_repo, teardown_git_repo, 9 | }; 10 | 11 | #[test] 12 | fn deleted_branch_config_verification() { 13 | // This test verifies a git behaviour whereby deleting a branch will delete any and all configs whose keys begin with: branch. 14 | // Reference: https://github.com/git/git/blob/f443b226ca681d87a3a31e245a70e6bc2769123c/builtin/branch.c#L184-L191 15 | 16 | let repo_name = "deleted_branch_config_verification"; 17 | 18 | let repo = setup_git_repo(repo_name); 19 | 20 | let path_to_repo = generate_path_to_repo(repo_name); 21 | 22 | { 23 | // create new file 24 | create_new_file(&path_to_repo, "hello_world.txt", "Hello, world!"); 25 | 26 | // add first commit to master 27 | first_commit_all(&repo, "first commit"); 28 | }; 29 | 30 | // create and checkout new branch named some_branch 31 | let branch_name = { 32 | let branch_name = "some_branch"; 33 | create_branch(&repo, branch_name); 34 | checkout_branch(&repo, branch_name); 35 | branch_name 36 | }; 37 | 38 | { 39 | // create new file 40 | create_new_file(&path_to_repo, "file.txt", "contents"); 41 | 42 | // add commit to branch some_branch 43 | commit_all(&repo, "message"); 44 | }; 45 | 46 | // add custom config 47 | let repo_config = repo.config().unwrap(); 48 | let mut local_config = repo_config.open_level(ConfigLevel::Local).unwrap(); 49 | 50 | let config_key = format!("branch.{}.chain-name", branch_name); 51 | 52 | // verify config_key does not exist yet 53 | local_config 54 | .entries(None) 55 | .unwrap() 56 | .for_each(|entry| { 57 | assert_ne!(entry.name().unwrap(), config_key); 58 | }) 59 | .unwrap(); 60 | 61 | local_config.set_str(&config_key, "chain_name").unwrap(); 62 | 63 | let actual_value = local_config.get_string(&config_key).unwrap(); 64 | assert_eq!(actual_value, "chain_name"); 65 | 66 | // checkout master 67 | checkout_branch(&repo, "master"); 68 | 69 | // delete branch some_branch 70 | delete_local_branch(&repo, branch_name); 71 | 72 | // verify if local custom config is deleted 73 | local_config 74 | .entries(None) 75 | .unwrap() 76 | .for_each(|entry| { 77 | assert_ne!(entry.name().unwrap(), config_key); 78 | }) 79 | .unwrap(); 80 | 81 | teardown_git_repo(repo_name); 82 | } 83 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | // Merge options types 2 | #[derive(Debug, PartialEq, Clone, Copy)] 3 | pub enum SquashedMergeHandling { 4 | // Reset the branch to the parent branch 5 | Reset, 6 | 7 | // Skip merging the branch 8 | Skip, 9 | 10 | // Force a merge despite the squashed merge detection 11 | Merge, 12 | } 13 | 14 | #[derive(Debug, PartialEq, Clone, Copy)] 15 | pub enum ReportLevel { 16 | // Minimal reporting (just success/failure) 17 | Minimal, 18 | 19 | // Standard reporting (summary with counts) 20 | Standard, 21 | 22 | // Detailed reporting (all actions and their results) 23 | Detailed, 24 | } 25 | 26 | pub enum MergeResult { 27 | // Successfully merged with changes 28 | Success(String), // Contains the merge output message 29 | 30 | // Already up-to-date, no changes needed 31 | AlreadyUpToDate, 32 | 33 | // Merge conflict occurred 34 | Conflict(String), // Contains the conflict message 35 | } 36 | 37 | pub struct MergeOptions { 38 | // Skip the merge of the root branch into the first branch 39 | pub ignore_root: bool, 40 | 41 | // Git merge options passed to all merge operations 42 | pub merge_flags: Vec, 43 | 44 | // Whether to use fork point detection (more accurate but slower) 45 | pub use_fork_point: bool, 46 | 47 | // How to handle squashed merges (reset, skip, merge) 48 | pub squashed_merge_handling: SquashedMergeHandling, 49 | 50 | // Print verbose output 51 | pub verbose: bool, 52 | 53 | // Return to original branch after merging 54 | pub return_to_original: bool, 55 | 56 | // Use simple merge mode 57 | pub simple_mode: bool, 58 | 59 | // Level of detail in the final report 60 | pub report_level: ReportLevel, 61 | } 62 | 63 | impl Default for MergeOptions { 64 | fn default() -> Self { 65 | MergeOptions { 66 | ignore_root: false, 67 | merge_flags: vec![], 68 | use_fork_point: true, 69 | squashed_merge_handling: SquashedMergeHandling::Reset, 70 | verbose: false, 71 | return_to_original: true, 72 | simple_mode: false, 73 | report_level: ReportLevel::Standard, 74 | } 75 | } 76 | } 77 | 78 | pub enum BranchSearchResult { 79 | NotPartOfAnyChain, 80 | Branch(crate::Branch), 81 | } 82 | 83 | pub enum SortBranch { 84 | First, 85 | Last, 86 | Before(crate::Branch), 87 | After(crate::Branch), 88 | } 89 | 90 | // Structure to hold merge commit information 91 | #[derive(Debug)] 92 | pub struct MergeCommitInfo { 93 | pub message: Option, 94 | pub stats: Option, 95 | } 96 | 97 | #[derive(Debug)] 98 | pub struct MergeStats { 99 | pub files_changed: usize, 100 | pub insertions: usize, 101 | pub deletions: usize, 102 | } 103 | -------------------------------------------------------------------------------- /tests/misc.rs: -------------------------------------------------------------------------------- 1 | #[path = "common/mod.rs"] 2 | pub mod common; 3 | 4 | use std::fs; 5 | 6 | use common::{ 7 | create_new_file, first_commit_all, generate_path_to_repo, get_current_branch_name, 8 | run_test_bin_expect_err, setup_git_repo, teardown_git_repo, 9 | }; 10 | 11 | #[test] 12 | fn no_subcommand() { 13 | let repo_name = "no_subcommand"; 14 | let repo = setup_git_repo(repo_name); 15 | let path_to_repo = generate_path_to_repo(repo_name); 16 | 17 | { 18 | // create new file 19 | create_new_file(&path_to_repo, "hello_world.txt", "Hello, world!"); 20 | 21 | // add first commit to master 22 | first_commit_all(&repo, "first commit"); 23 | }; 24 | 25 | assert_eq!(&get_current_branch_name(&repo), "master"); 26 | 27 | let args: Vec = vec![]; 28 | let output = run_test_bin_expect_err(path_to_repo, args); 29 | assert!(String::from_utf8_lossy(&output.stdout).contains("On branch: master")); 30 | assert!( 31 | String::from_utf8_lossy(&output.stderr).contains("Branch is not part of any chain: master") 32 | ); 33 | 34 | teardown_git_repo(repo_name); 35 | } 36 | 37 | #[test] 38 | fn not_a_git_repo() { 39 | // Create a directory in the system temp location to avoid finding parent git repos 40 | let temp_dir = std::env::temp_dir(); 41 | let path_to_non_git_dir = temp_dir.join("git_chain_test_not_a_repo"); 42 | 43 | // Create a directory that is NOT a git repository 44 | fs::remove_dir_all(&path_to_non_git_dir).ok(); 45 | fs::create_dir_all(&path_to_non_git_dir).unwrap(); 46 | 47 | // Run git chain in the non-git directory 48 | let args: Vec = vec![]; 49 | let output = run_test_bin_expect_err(&path_to_non_git_dir, args); 50 | 51 | let stdout = String::from_utf8_lossy(&output.stdout); 52 | let stderr = String::from_utf8_lossy(&output.stderr); 53 | 54 | // Diagnostic printing 55 | println!("=== TEST DIAGNOSTICS ==="); 56 | println!("Test directory: {:?}", path_to_non_git_dir); 57 | println!("STDOUT: {}", stdout); 58 | println!("STDERR: {}", stderr); 59 | println!("EXIT STATUS: {}", output.status); 60 | println!("Is directory a git repo: false (intentional test condition)"); 61 | println!("======"); 62 | 63 | // Uncomment to stop test execution and inspect state with captured output 64 | // assert!(false, "DEBUG STOP: not_a_git_repo test section"); 65 | // assert!(false, "stdout: {}", stdout); 66 | // assert!(false, "stderr: {}", stderr); 67 | // assert!(false, "status code: {}", output.status.code().unwrap_or(0)); 68 | 69 | // Specific assertions based on expected behavior 70 | assert!( 71 | !output.status.success(), 72 | "Command should fail when run in non-git directory" 73 | ); 74 | assert!( 75 | stderr.contains("Not a git repository"), 76 | "Error message should mention 'Not a git repository', got: {}", 77 | stderr 78 | ); 79 | assert!( 80 | stderr.contains("This command must be run inside a git repository"), 81 | "Error message should provide helpful hint, got: {}", 82 | stderr 83 | ); 84 | 85 | // Clean up 86 | fs::remove_dir_all(&path_to_non_git_dir).ok(); 87 | } 88 | -------------------------------------------------------------------------------- /tests/list.rs: -------------------------------------------------------------------------------- 1 | #[path = "common/mod.rs"] 2 | pub mod common; 3 | 4 | use common::{ 5 | checkout_branch, commit_all, create_branch, create_new_file, first_commit_all, 6 | generate_path_to_repo, get_current_branch_name, run_test_bin_expect_ok, setup_git_repo, 7 | teardown_git_repo, 8 | }; 9 | 10 | #[test] 11 | fn list_subcommand() { 12 | let repo_name = "list_subcommand"; 13 | let repo = setup_git_repo(repo_name); 14 | let path_to_repo = generate_path_to_repo(repo_name); 15 | 16 | { 17 | // create new file 18 | create_new_file(&path_to_repo, "hello_world.txt", "Hello, world!"); 19 | 20 | // add first commit to master 21 | first_commit_all(&repo, "first commit"); 22 | }; 23 | 24 | assert_eq!(&get_current_branch_name(&repo), "master"); 25 | 26 | let args: Vec<&str> = vec!["list"]; 27 | let output = run_test_bin_expect_ok(&path_to_repo, args); 28 | 29 | assert_eq!( 30 | String::from_utf8_lossy(&output.stdout), 31 | r#" 32 | No chains to list. 33 | To initialize a chain for this branch, run git chain init 34 | "# 35 | .trim_start() 36 | ); 37 | 38 | // create and checkout new branch named not_part_of_any_chain 39 | { 40 | let branch_name = "not_part_of_any_chain"; 41 | create_branch(&repo, branch_name); 42 | checkout_branch(&repo, branch_name); 43 | }; 44 | 45 | { 46 | // create new file 47 | create_new_file(&path_to_repo, "not_part_of_any_chain.txt", "contents"); 48 | 49 | // add commit to branch not_part_of_any_chain 50 | commit_all(&repo, "message"); 51 | }; 52 | assert_eq!(&get_current_branch_name(&repo), "not_part_of_any_chain"); 53 | 54 | // create and checkout new branch named some_branch_1 55 | { 56 | checkout_branch(&repo, "master"); 57 | let branch_name = "some_branch_1"; 58 | create_branch(&repo, branch_name); 59 | checkout_branch(&repo, branch_name); 60 | }; 61 | 62 | { 63 | // create new file 64 | create_new_file(&path_to_repo, "file_1.txt", "contents 1"); 65 | 66 | // add commit to branch some_branch_1 67 | commit_all(&repo, "message"); 68 | }; 69 | 70 | // init subcommand with chain name, and use master as the root branch 71 | assert_eq!(&get_current_branch_name(&repo), "some_branch_1"); 72 | 73 | let args: Vec<&str> = vec!["init", "chain_name", "master"]; 74 | run_test_bin_expect_ok(&path_to_repo, args); 75 | 76 | let args: Vec<&str> = vec!["list"]; 77 | let output = run_test_bin_expect_ok(&path_to_repo, args); 78 | 79 | assert_eq!( 80 | String::from_utf8_lossy(&output.stdout), 81 | r#" 82 | chain_name 83 | ➜ some_branch_1 ⦁ 1 ahead 84 | master (root branch) 85 | "# 86 | .trim_start() 87 | ); 88 | 89 | // create and checkout new branch named some_branch_2 90 | { 91 | checkout_branch(&repo, "master"); 92 | let branch_name = "some_branch_2"; 93 | create_branch(&repo, branch_name); 94 | checkout_branch(&repo, branch_name); 95 | }; 96 | 97 | { 98 | // create new file 99 | create_new_file(&path_to_repo, "file_2.txt", "contents 2"); 100 | 101 | // add commit to branch some_branch_2 102 | commit_all(&repo, "message"); 103 | }; 104 | 105 | // init subcommand with chain name, and use master as the root branch 106 | assert_eq!(&get_current_branch_name(&repo), "some_branch_2"); 107 | 108 | let args: Vec<&str> = vec!["init", "chain_name_2", "master"]; 109 | run_test_bin_expect_ok(&path_to_repo, args); 110 | 111 | let args: Vec<&str> = vec!["list"]; 112 | let output = run_test_bin_expect_ok(&path_to_repo, args); 113 | 114 | assert_eq!( 115 | String::from_utf8_lossy(&output.stdout), 116 | r#" 117 | chain_name 118 | some_branch_1 ⦁ 1 ahead 119 | master (root branch) 120 | 121 | chain_name_2 122 | ➜ some_branch_2 ⦁ 1 ahead 123 | master (root branch) 124 | "# 125 | .trim_start() 126 | ); 127 | 128 | teardown_git_repo(repo_name); 129 | } 130 | -------------------------------------------------------------------------------- /tests/prune.rs: -------------------------------------------------------------------------------- 1 | #[path = "common/mod.rs"] 2 | pub mod common; 3 | 4 | use common::{ 5 | checkout_branch, commit_all, create_branch, create_new_file, first_commit_all, 6 | generate_path_to_repo, get_current_branch_name, run_git_command, run_test_bin_expect_ok, 7 | run_test_bin_for_rebase, setup_git_repo, teardown_git_repo, 8 | }; 9 | 10 | #[test] 11 | fn prune_subcommand_squashed_merged_branch() { 12 | let repo_name = "prune_subcommand_squashed_merged_branch"; 13 | let repo = setup_git_repo(repo_name); 14 | let path_to_repo = generate_path_to_repo(repo_name); 15 | 16 | { 17 | // create new file 18 | create_new_file(&path_to_repo, "hello_world.txt", "Hello, world!"); 19 | 20 | // add first commit to master 21 | first_commit_all(&repo, "first commit"); 22 | }; 23 | 24 | assert_eq!(&get_current_branch_name(&repo), "master"); 25 | 26 | // create and checkout new branch named some_branch_1 27 | { 28 | let branch_name = "some_branch_1"; 29 | create_branch(&repo, branch_name); 30 | checkout_branch(&repo, branch_name); 31 | }; 32 | 33 | { 34 | assert_eq!(&get_current_branch_name(&repo), "some_branch_1"); 35 | 36 | create_new_file(&path_to_repo, "file_1.txt", "contents 1"); 37 | commit_all(&repo, "message"); 38 | 39 | create_new_file(&path_to_repo, "file_1.txt", "contents 2"); 40 | commit_all(&repo, "message"); 41 | 42 | create_new_file(&path_to_repo, "file_1.txt", "contents 1"); 43 | commit_all(&repo, "message"); 44 | }; 45 | 46 | // create and checkout new branch named some_branch_2 47 | { 48 | let branch_name = "some_branch_2"; 49 | create_branch(&repo, branch_name); 50 | checkout_branch(&repo, branch_name); 51 | }; 52 | 53 | { 54 | assert_eq!(&get_current_branch_name(&repo), "some_branch_2"); 55 | 56 | // create new file 57 | create_new_file(&path_to_repo, "file_2.txt", "contents 2"); 58 | 59 | // add commit to branch some_branch_2 60 | commit_all(&repo, "message"); 61 | }; 62 | 63 | // run git chain setup 64 | let args: Vec<&str> = vec![ 65 | "setup", 66 | "chain_name", 67 | "master", 68 | "some_branch_1", 69 | "some_branch_2", 70 | ]; 71 | let output = run_test_bin_expect_ok(&path_to_repo, args); 72 | 73 | assert_eq!( 74 | String::from_utf8_lossy(&output.stdout), 75 | r#" 76 | 🔗 Succesfully set up chain: chain_name 77 | 78 | chain_name 79 | ➜ some_branch_2 ⦁ 1 ahead 80 | some_branch_1 ⦁ 3 ahead 81 | master (root branch) 82 | "# 83 | .trim_start() 84 | ); 85 | 86 | // squash and merge some_branch_1 onto master 87 | checkout_branch(&repo, "master"); 88 | run_git_command(&path_to_repo, vec!["merge", "--squash", "some_branch_1"]); 89 | commit_all(&repo, "squash merge"); 90 | 91 | // git chain rebase 92 | checkout_branch(&repo, "some_branch_1"); 93 | let args: Vec<&str> = vec!["rebase"]; 94 | let output = run_test_bin_for_rebase(&path_to_repo, args); 95 | 96 | assert!(String::from_utf8_lossy(&output.stdout) 97 | .contains("⚠️ Branch some_branch_1 is detected to be squashed and merged onto master.")); 98 | assert!(String::from_utf8_lossy(&output.stdout) 99 | .contains("Resetting branch some_branch_1 to master")); 100 | assert!(String::from_utf8_lossy(&output.stdout).contains("git reset --hard master")); 101 | assert!( 102 | String::from_utf8_lossy(&output.stdout).contains("Switching back to branch: some_branch_1") 103 | ); 104 | assert!(String::from_utf8_lossy(&output.stdout) 105 | .contains("🎉 Successfully rebased chain chain_name")); 106 | 107 | // git chain 108 | let args: Vec<&str> = vec![]; 109 | let output = run_test_bin_expect_ok(&path_to_repo, args); 110 | 111 | assert_eq!( 112 | String::from_utf8_lossy(&output.stdout), 113 | r#" 114 | On branch: some_branch_1 115 | 116 | chain_name 117 | some_branch_2 ⦁ 1 ahead 118 | ➜ some_branch_1 119 | master (root branch) 120 | "# 121 | .trim_start() 122 | ); 123 | 124 | // git chain prune 125 | let args: Vec<&str> = vec!["prune"]; 126 | let output = run_test_bin_expect_ok(&path_to_repo, args); 127 | 128 | assert_eq!( 129 | String::from_utf8_lossy(&output.stdout), 130 | r#" 131 | Removed the following branches from chain: chain_name 132 | 133 | some_branch_1 134 | 135 | Pruned 1 branches. 136 | "# 137 | .trim_start() 138 | ); 139 | 140 | // git chain 141 | checkout_branch(&repo, "some_branch_2"); 142 | let args: Vec<&str> = vec![]; 143 | let output = run_test_bin_expect_ok(&path_to_repo, args); 144 | 145 | assert_eq!( 146 | String::from_utf8_lossy(&output.stdout), 147 | r#" 148 | On branch: some_branch_2 149 | 150 | chain_name 151 | ➜ some_branch_2 ⦁ 1 ahead 152 | master (root branch) 153 | "# 154 | .trim_start() 155 | ); 156 | 157 | teardown_git_repo(repo_name); 158 | } 159 | -------------------------------------------------------------------------------- /tests/setup.rs: -------------------------------------------------------------------------------- 1 | #[path = "common/mod.rs"] 2 | pub mod common; 3 | 4 | use common::{ 5 | checkout_branch, commit_all, create_branch, create_new_file, first_commit_all, 6 | generate_path_to_repo, get_current_branch_name, run_test_bin_expect_ok, setup_git_repo, 7 | teardown_git_repo, 8 | }; 9 | 10 | #[test] 11 | fn setup_subcommand() { 12 | let repo_name = "setup_subcommand"; 13 | let repo = setup_git_repo(repo_name); 14 | let path_to_repo = generate_path_to_repo(repo_name); 15 | 16 | { 17 | // create new file 18 | create_new_file(&path_to_repo, "hello_world.txt", "Hello, world!"); 19 | 20 | // add first commit to master 21 | first_commit_all(&repo, "first commit"); 22 | }; 23 | 24 | assert_eq!(&get_current_branch_name(&repo), "master"); 25 | 26 | // create and checkout new branch named some_branch_1 27 | { 28 | let branch_name = "some_branch_1"; 29 | create_branch(&repo, branch_name); 30 | checkout_branch(&repo, branch_name); 31 | }; 32 | 33 | { 34 | assert_eq!(&get_current_branch_name(&repo), "some_branch_1"); 35 | 36 | // create new file 37 | create_new_file(&path_to_repo, "file_1.txt", "contents 1"); 38 | 39 | // add commit to branch some_branch_1 40 | commit_all(&repo, "message"); 41 | }; 42 | 43 | // create and checkout new branch named some_branch_2 44 | { 45 | let branch_name = "some_branch_2"; 46 | create_branch(&repo, branch_name); 47 | checkout_branch(&repo, branch_name); 48 | }; 49 | 50 | { 51 | assert_eq!(&get_current_branch_name(&repo), "some_branch_2"); 52 | 53 | // create new file 54 | create_new_file(&path_to_repo, "file_2.txt", "contents 2"); 55 | 56 | // add commit to branch some_branch_2 57 | commit_all(&repo, "message"); 58 | }; 59 | 60 | // create and checkout new branch named some_branch_3 61 | { 62 | let branch_name = "some_branch_3"; 63 | create_branch(&repo, branch_name); 64 | checkout_branch(&repo, branch_name); 65 | }; 66 | 67 | { 68 | assert_eq!(&get_current_branch_name(&repo), "some_branch_3"); 69 | 70 | // create new file 71 | create_new_file(&path_to_repo, "file_3.txt", "contents 3"); 72 | 73 | // add commit to branch some_branch_3 74 | commit_all(&repo, "message"); 75 | }; 76 | 77 | // create and checkout new branch named some_branch_2.5 78 | { 79 | checkout_branch(&repo, "some_branch_2"); 80 | let branch_name = "some_branch_2.5"; 81 | create_branch(&repo, branch_name); 82 | checkout_branch(&repo, branch_name); 83 | }; 84 | 85 | { 86 | assert_eq!(&get_current_branch_name(&repo), "some_branch_2.5"); 87 | 88 | // create new file 89 | create_new_file(&path_to_repo, "file_2.5.txt", "contents 2.5"); 90 | 91 | // add commit to branch some_branch_2.5 92 | commit_all(&repo, "message"); 93 | }; 94 | 95 | // create and checkout new branch named some_branch_1.5 96 | { 97 | checkout_branch(&repo, "some_branch_1"); 98 | let branch_name = "some_branch_1.5"; 99 | create_branch(&repo, branch_name); 100 | checkout_branch(&repo, branch_name); 101 | }; 102 | 103 | { 104 | assert_eq!(&get_current_branch_name(&repo), "some_branch_1.5"); 105 | 106 | // create new file 107 | create_new_file(&path_to_repo, "file_1.5.txt", "contents 1.5"); 108 | 109 | // add commit to branch some_branch_1.5 110 | commit_all(&repo, "message"); 111 | }; 112 | 113 | // create and checkout new branch named some_branch_0 114 | { 115 | checkout_branch(&repo, "master"); 116 | let branch_name = "some_branch_0"; 117 | create_branch(&repo, branch_name); 118 | checkout_branch(&repo, branch_name); 119 | }; 120 | 121 | { 122 | assert_eq!(&get_current_branch_name(&repo), "some_branch_0"); 123 | 124 | // create new file 125 | create_new_file(&path_to_repo, "file_0.txt", "contents 0"); 126 | 127 | // add commit to branch some_branch_0 128 | commit_all(&repo, "message"); 129 | }; 130 | 131 | assert_eq!(&get_current_branch_name(&repo), "some_branch_0"); 132 | 133 | // run git chain setup 134 | let args: Vec<&str> = vec![ 135 | "setup", 136 | "chain_name", 137 | "master", 138 | "some_branch_0", 139 | "some_branch_1", 140 | "some_branch_1.5", 141 | "some_branch_2", 142 | "some_branch_2.5", 143 | "some_branch_3", 144 | ]; 145 | let output = run_test_bin_expect_ok(&path_to_repo, args); 146 | 147 | assert_eq!( 148 | String::from_utf8_lossy(&output.stdout), 149 | r#" 150 | 🔗 Succesfully set up chain: chain_name 151 | 152 | chain_name 153 | some_branch_3 ⦁ 1 ahead ⦁ 1 behind 154 | some_branch_2.5 ⦁ 1 ahead 155 | some_branch_2 ⦁ 1 ahead ⦁ 1 behind 156 | some_branch_1.5 ⦁ 1 ahead 157 | some_branch_1 ⦁ 1 ahead ⦁ 1 behind 158 | ➜ some_branch_0 ⦁ 1 ahead 159 | master (root branch) 160 | "# 161 | .trim_start() 162 | ); 163 | 164 | // git chain 165 | let args: Vec<&str> = vec![]; 166 | let output = run_test_bin_expect_ok(&path_to_repo, args); 167 | 168 | assert_eq!( 169 | String::from_utf8_lossy(&output.stdout), 170 | r#" 171 | On branch: some_branch_0 172 | 173 | chain_name 174 | some_branch_3 ⦁ 1 ahead ⦁ 1 behind 175 | some_branch_2.5 ⦁ 1 ahead 176 | some_branch_2 ⦁ 1 ahead ⦁ 1 behind 177 | some_branch_1.5 ⦁ 1 ahead 178 | some_branch_1 ⦁ 1 ahead ⦁ 1 behind 179 | ➜ some_branch_0 ⦁ 1 ahead 180 | master (root branch) 181 | "# 182 | .trim_start() 183 | ); 184 | 185 | teardown_git_repo(repo_name); 186 | } 187 | -------------------------------------------------------------------------------- /tests/backup.rs: -------------------------------------------------------------------------------- 1 | #[path = "common/mod.rs"] 2 | pub mod common; 3 | 4 | use common::{ 5 | branch_equal, branch_exists, checkout_branch, commit_all, create_branch, create_new_file, 6 | first_commit_all, generate_path_to_repo, get_current_branch_name, run_test_bin_expect_ok, 7 | setup_git_repo, teardown_git_repo, 8 | }; 9 | 10 | fn backup_name(chain_name: &str, branch_name: &str) -> String { 11 | format!("backup-{}/{}", chain_name, branch_name) 12 | } 13 | 14 | #[test] 15 | fn backup_subcommand() { 16 | let repo_name = "backup_subcommand"; 17 | let repo = setup_git_repo(repo_name); 18 | let path_to_repo = generate_path_to_repo(repo_name); 19 | 20 | { 21 | // create new file 22 | create_new_file(&path_to_repo, "hello_world.txt", "Hello, world!"); 23 | 24 | // add first commit to master 25 | first_commit_all(&repo, "first commit"); 26 | }; 27 | 28 | assert_eq!(&get_current_branch_name(&repo), "master"); 29 | 30 | // create and checkout new branch named not_part_of_any_chain 31 | { 32 | let branch_name = "not_part_of_any_chain"; 33 | create_branch(&repo, branch_name); 34 | checkout_branch(&repo, branch_name); 35 | }; 36 | 37 | { 38 | // create new file 39 | create_new_file(&path_to_repo, "not_part_of_any_chain.txt", "contents"); 40 | 41 | // add commit to branch not_part_of_any_chain 42 | commit_all(&repo, "message"); 43 | }; 44 | assert_eq!(&get_current_branch_name(&repo), "not_part_of_any_chain"); 45 | 46 | // create and checkout new branch named some_branch_1 47 | { 48 | checkout_branch(&repo, "master"); 49 | let branch_name = "some_branch_1"; 50 | create_branch(&repo, branch_name); 51 | checkout_branch(&repo, branch_name); 52 | }; 53 | 54 | { 55 | // create new file 56 | create_new_file(&path_to_repo, "file_1.txt", "contents 1"); 57 | 58 | // add commit to branch some_branch_1 59 | commit_all(&repo, "message"); 60 | }; 61 | 62 | // init subcommand with chain name, and use master as the root branch 63 | assert_eq!(&get_current_branch_name(&repo), "some_branch_1"); 64 | 65 | let args: Vec<&str> = vec!["init", "chain_name", "master"]; 66 | run_test_bin_expect_ok(&path_to_repo, args); 67 | 68 | // create and checkout new branch named some_branch_2 69 | { 70 | checkout_branch(&repo, "master"); 71 | let branch_name = "some_branch_2"; 72 | create_branch(&repo, branch_name); 73 | checkout_branch(&repo, branch_name); 74 | }; 75 | 76 | { 77 | // create new file 78 | create_new_file(&path_to_repo, "file_2.txt", "contents 2"); 79 | 80 | // add commit to branch some_branch_2 81 | commit_all(&repo, "message"); 82 | }; 83 | 84 | // init subcommand with chain name, and use master as the root branch 85 | assert_eq!(&get_current_branch_name(&repo), "some_branch_2"); 86 | 87 | let args: Vec<&str> = vec!["init", "chain_name_2", "master"]; 88 | run_test_bin_expect_ok(&path_to_repo, args); 89 | 90 | // create and checkout new branch named some_branch_3 91 | { 92 | let branch_name = "some_branch_3"; 93 | create_branch(&repo, branch_name); 94 | checkout_branch(&repo, branch_name); 95 | }; 96 | 97 | { 98 | // create new file 99 | create_new_file(&path_to_repo, "file_3.txt", "contents 3"); 100 | 101 | // add commit to branch some_branch_3 102 | commit_all(&repo, "message"); 103 | }; 104 | 105 | // init subcommand with chain name, and use master as the root branch 106 | assert_eq!(&get_current_branch_name(&repo), "some_branch_3"); 107 | 108 | let args: Vec<&str> = vec!["init", "chain_name_2"]; 109 | run_test_bin_expect_ok(&path_to_repo, args); 110 | 111 | assert!(!branch_exists( 112 | &repo, 113 | &backup_name("chain_name_2", "some_branch_2") 114 | )); 115 | assert!(!branch_exists( 116 | &repo, 117 | &backup_name("chain_name_2", "some_branch_3") 118 | )); 119 | 120 | let args: Vec<&str> = vec!["backup"]; 121 | let output = run_test_bin_expect_ok(&path_to_repo, args); 122 | 123 | assert_eq!( 124 | String::from_utf8_lossy(&output.stdout), 125 | r#" 126 | 🎉 Successfully backed up chain: chain_name_2 127 | "# 128 | .trim_start() 129 | ); 130 | 131 | assert!(branch_exists( 132 | &repo, 133 | &backup_name("chain_name_2", "some_branch_2") 134 | )); 135 | assert!(branch_exists( 136 | &repo, 137 | &backup_name("chain_name_2", "some_branch_3") 138 | )); 139 | assert!(branch_equal( 140 | &repo, 141 | "some_branch_2", 142 | &backup_name("chain_name_2", "some_branch_2") 143 | )); 144 | assert!(branch_equal( 145 | &repo, 146 | "some_branch_3", 147 | &backup_name("chain_name_2", "some_branch_3") 148 | )); 149 | 150 | { 151 | assert_eq!(&get_current_branch_name(&repo), "some_branch_3"); 152 | // create new file 153 | create_new_file(&path_to_repo, "file_3.5.txt", "contents 3.5"); 154 | 155 | // add commit to branch some_branch_3 156 | commit_all(&repo, "message"); 157 | }; 158 | 159 | assert!(!branch_equal( 160 | &repo, 161 | "some_branch_3", 162 | &backup_name("chain_name_2", "some_branch_3") 163 | )); 164 | 165 | let args: Vec<&str> = vec!["backup"]; 166 | let output = run_test_bin_expect_ok(&path_to_repo, args); 167 | 168 | assert_eq!( 169 | String::from_utf8_lossy(&output.stdout), 170 | r#" 171 | 🎉 Successfully backed up chain: chain_name_2 172 | "# 173 | .trim_start() 174 | ); 175 | 176 | assert!(branch_exists( 177 | &repo, 178 | &backup_name("chain_name_2", "some_branch_2") 179 | )); 180 | assert!(branch_exists( 181 | &repo, 182 | &backup_name("chain_name_2", "some_branch_3") 183 | )); 184 | assert!(branch_equal( 185 | &repo, 186 | "some_branch_2", 187 | &backup_name("chain_name_2", "some_branch_2") 188 | )); 189 | assert!(branch_equal( 190 | &repo, 191 | "some_branch_3", 192 | &backup_name("chain_name_2", "some_branch_3") 193 | )); 194 | 195 | teardown_git_repo(repo_name); 196 | } 197 | -------------------------------------------------------------------------------- /tests/push.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | #[path = "common/mod.rs"] 4 | pub mod common; 5 | 6 | use common::{ 7 | checkout_branch, commit_all, create_branch, create_new_file, first_commit_all, 8 | generate_path_to_bare_repo, generate_path_to_repo, get_current_branch_name, run_git_command, 9 | run_test_bin_expect_ok, setup_git_bare_repo, setup_git_repo, teardown_git_bare_repo, 10 | teardown_git_repo, 11 | }; 12 | 13 | #[test] 14 | fn push_subcommand() { 15 | let repo_name = "push_subcommand"; 16 | let repo = setup_git_repo(repo_name); 17 | let _bare_repo = setup_git_bare_repo(repo_name); 18 | let path_to_repo = generate_path_to_repo(repo_name); 19 | 20 | let path_to_bare_repo = { 21 | let mut path_to_bare_repo_buf: PathBuf = generate_path_to_bare_repo(repo_name); 22 | if path_to_bare_repo_buf.is_relative() { 23 | path_to_bare_repo_buf = path_to_bare_repo_buf.canonicalize().unwrap(); 24 | } 25 | 26 | path_to_bare_repo_buf.to_str().unwrap().to_string() 27 | }; 28 | 29 | run_git_command( 30 | path_to_repo.clone(), 31 | vec!["remote", "add", "origin", &path_to_bare_repo], 32 | ); 33 | 34 | { 35 | // create new file 36 | create_new_file(&path_to_repo, "hello_world.txt", "Hello, world!"); 37 | 38 | // add first commit to master 39 | first_commit_all(&repo, "first commit"); 40 | }; 41 | 42 | assert_eq!(&get_current_branch_name(&repo), "master"); 43 | 44 | // create and checkout new branch named some_branch_1 45 | { 46 | let branch_name = "some_branch_1"; 47 | create_branch(&repo, branch_name); 48 | checkout_branch(&repo, branch_name); 49 | }; 50 | 51 | { 52 | assert_eq!(&get_current_branch_name(&repo), "some_branch_1"); 53 | 54 | create_new_file(&path_to_repo, "file_1.txt", "contents 1"); 55 | commit_all(&repo, "message"); 56 | }; 57 | 58 | // create and checkout new branch named some_branch_2 59 | { 60 | let branch_name = "some_branch_2"; 61 | create_branch(&repo, branch_name); 62 | checkout_branch(&repo, branch_name); 63 | }; 64 | 65 | { 66 | assert_eq!(&get_current_branch_name(&repo), "some_branch_2"); 67 | 68 | // create new file 69 | create_new_file(&path_to_repo, "file_2.txt", "contents 2"); 70 | 71 | // add commit to branch some_branch_2 72 | commit_all(&repo, "message"); 73 | }; 74 | 75 | // run git chain setup 76 | let args: Vec<&str> = vec![ 77 | "setup", 78 | "chain_name", 79 | "master", 80 | "some_branch_1", 81 | "some_branch_2", 82 | ]; 83 | let output = run_test_bin_expect_ok(&path_to_repo, args); 84 | 85 | assert_eq!( 86 | String::from_utf8_lossy(&output.stdout), 87 | r#" 88 | 🔗 Succesfully set up chain: chain_name 89 | 90 | chain_name 91 | ➜ some_branch_2 ⦁ 1 ahead 92 | some_branch_1 ⦁ 1 ahead 93 | master (root branch) 94 | "# 95 | .trim_start() 96 | ); 97 | 98 | // git chain push 99 | let args: Vec<&str> = vec!["push"]; 100 | let output = run_test_bin_expect_ok(&path_to_repo, args); 101 | 102 | assert_eq!( 103 | String::from_utf8_lossy(&output.stdout), 104 | r#" 105 | 🛑 Cannot push. Branch has no upstream: some_branch_1 106 | 🛑 Cannot push. Branch has no upstream: some_branch_2 107 | Pushed 0 branches. 108 | "# 109 | .trim_start() 110 | ); 111 | 112 | run_git_command( 113 | &path_to_repo, 114 | vec!["push", "--all", "--set-upstream", "origin"], 115 | ); 116 | 117 | // git chain push 118 | let args: Vec<&str> = vec!["push"]; 119 | let output = run_test_bin_expect_ok(&path_to_repo, args); 120 | 121 | assert_eq!( 122 | String::from_utf8_lossy(&output.stdout), 123 | r#" 124 | ✅ Pushed some_branch_1 125 | ✅ Pushed some_branch_2 126 | Pushed 2 branches. 127 | "# 128 | .trim_start() 129 | ); 130 | 131 | teardown_git_repo(repo_name); 132 | teardown_git_bare_repo(repo_name); 133 | } 134 | 135 | #[test] 136 | fn push_subcommand_force() { 137 | let repo_name = "push_subcommand_force"; 138 | let repo = setup_git_repo(repo_name); 139 | let _bare_repo = setup_git_bare_repo(repo_name); 140 | let path_to_repo = generate_path_to_repo(repo_name); 141 | 142 | let path_to_bare_repo = { 143 | let mut path_to_bare_repo_buf: PathBuf = generate_path_to_bare_repo(repo_name); 144 | if path_to_bare_repo_buf.is_relative() { 145 | path_to_bare_repo_buf = path_to_bare_repo_buf.canonicalize().unwrap(); 146 | } 147 | 148 | path_to_bare_repo_buf.to_str().unwrap().to_string() 149 | }; 150 | 151 | run_git_command( 152 | path_to_repo.clone(), 153 | vec!["remote", "add", "origin", &path_to_bare_repo], 154 | ); 155 | 156 | { 157 | // create new file 158 | create_new_file(&path_to_repo, "hello_world.txt", "Hello, world!"); 159 | 160 | // add first commit to master 161 | first_commit_all(&repo, "first commit"); 162 | }; 163 | 164 | assert_eq!(&get_current_branch_name(&repo), "master"); 165 | 166 | // create and checkout new branch named some_branch_1 167 | { 168 | let branch_name = "some_branch_1"; 169 | create_branch(&repo, branch_name); 170 | checkout_branch(&repo, branch_name); 171 | }; 172 | 173 | { 174 | assert_eq!(&get_current_branch_name(&repo), "some_branch_1"); 175 | 176 | create_new_file(&path_to_repo, "file_1.txt", "contents 1"); 177 | commit_all(&repo, "message"); 178 | }; 179 | 180 | // create and checkout new branch named some_branch_2 181 | { 182 | let branch_name = "some_branch_2"; 183 | create_branch(&repo, branch_name); 184 | checkout_branch(&repo, branch_name); 185 | }; 186 | 187 | { 188 | assert_eq!(&get_current_branch_name(&repo), "some_branch_2"); 189 | 190 | // create new file 191 | create_new_file(&path_to_repo, "file_2.txt", "contents 2"); 192 | 193 | // add commit to branch some_branch_2 194 | commit_all(&repo, "message"); 195 | }; 196 | 197 | // run git chain setup 198 | let args: Vec<&str> = vec![ 199 | "setup", 200 | "chain_name", 201 | "master", 202 | "some_branch_1", 203 | "some_branch_2", 204 | ]; 205 | let output = run_test_bin_expect_ok(&path_to_repo, args); 206 | 207 | assert_eq!( 208 | String::from_utf8_lossy(&output.stdout), 209 | r#" 210 | 🔗 Succesfully set up chain: chain_name 211 | 212 | chain_name 213 | ➜ some_branch_2 ⦁ 1 ahead 214 | some_branch_1 ⦁ 1 ahead 215 | master (root branch) 216 | "# 217 | .trim_start() 218 | ); 219 | 220 | // git chain push 221 | let args: Vec<&str> = vec!["push", "--force"]; 222 | let output = run_test_bin_expect_ok(&path_to_repo, args); 223 | 224 | assert_eq!( 225 | String::from_utf8_lossy(&output.stdout), 226 | r#" 227 | 🛑 Cannot push. Branch has no upstream: some_branch_1 228 | 🛑 Cannot push. Branch has no upstream: some_branch_2 229 | Pushed 0 branches. 230 | "# 231 | .trim_start() 232 | ); 233 | 234 | run_git_command( 235 | &path_to_repo, 236 | vec!["push", "--all", "--set-upstream", "origin"], 237 | ); 238 | 239 | // git chain push 240 | let args: Vec<&str> = vec!["push", "--force"]; 241 | let output = run_test_bin_expect_ok(&path_to_repo, args); 242 | 243 | assert_eq!( 244 | String::from_utf8_lossy(&output.stdout), 245 | r#" 246 | ✅ Force pushed some_branch_1 247 | ✅ Force pushed some_branch_2 248 | Pushed 2 branches. 249 | "# 250 | .trim_start() 251 | ); 252 | 253 | teardown_git_repo(repo_name); 254 | teardown_git_bare_repo(repo_name); 255 | } 256 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Git-Chain - Tool for Managing Git Branch Chains 2 | # Development Makefile 3 | 4 | .PHONY: help test test-sequential test-pr check clippy fmt fmt-check clean doc doc-open build release install-deps ci-local all 5 | 6 | # Default target 7 | .DEFAULT_GOAL := help 8 | 9 | # Colors for output 10 | BOLD := \033[1m 11 | RED := \033[31m 12 | GREEN := \033[32m 13 | YELLOW := \033[33m 14 | BLUE := \033[34m 15 | MAGENTA := \033[35m 16 | CYAN := \033[36m 17 | RESET := \033[0m 18 | 19 | help: ## Show this help message 20 | @echo "$(BOLD)Git-Chain - Tool for Managing Git Branch Chains$(RESET)" 21 | @echo "$(CYAN)Development Makefile$(RESET)" 22 | @echo "" 23 | @echo "$(BOLD)Usage:$(RESET)" 24 | @echo " make " 25 | @echo "" 26 | @echo "$(BOLD)Available targets:$(RESET)" 27 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " $(CYAN)%-20s$(RESET) %s\n", $$1, $$2}' $(MAKEFILE_LIST) 28 | @echo "" 29 | @echo "$(BOLD)Examples:$(RESET)" 30 | @echo " make build # Build the project" 31 | @echo " make test # Run all tests" 32 | @echo " make check # Quick compilation check" 33 | @echo " make ci-local # Run full CI pipeline locally" 34 | 35 | # === Building === 36 | 37 | build: ## Build the project in debug mode 38 | @echo "$(BOLD)$(GREEN)Building git-chain...$(RESET)" 39 | @cargo build 40 | 41 | release: ## Build the project in release mode 42 | @echo "$(BOLD)$(GREEN)Building git-chain (release)...$(RESET)" 43 | @cargo build --release 44 | 45 | install: release ## Install git-chain to cargo bin directory 46 | @echo "$(BOLD)$(GREEN)Installing git-chain...$(RESET)" 47 | @cargo install --path . 48 | @echo "$(BOLD)$(GREEN)✓ git-chain installed successfully!$(RESET)" 49 | 50 | # === Testing === 51 | 52 | test: ## Run all tests 53 | @echo "$(BOLD)$(GREEN)Running all tests...$(RESET)" 54 | @cargo test 55 | 56 | test-sequential: ## Run tests sequentially (avoids PATH conflicts) 57 | @echo "$(BOLD)$(GREEN)Running tests sequentially...$(RESET)" 58 | @cargo test -- --test-threads=1 59 | 60 | test-pr: ## Run PR tests only 61 | @echo "$(BOLD)$(GREEN)Running PR tests...$(RESET)" 62 | @cargo test --test pr 63 | 64 | test-merge: ## Run merge tests only 65 | @echo "$(BOLD)$(GREEN)Running merge tests...$(RESET)" 66 | @cargo test --test merge 67 | 68 | test-rebase: ## Run rebase tests only 69 | @echo "$(BOLD)$(GREEN)Running rebase tests...$(RESET)" 70 | @cargo test --test rebase 71 | 72 | test-specific: ## Run a specific test (use TEST=test_name) 73 | @echo "$(BOLD)$(GREEN)Running test: $(TEST)$(RESET)" 74 | @cargo test $(TEST) -- --nocapture 75 | 76 | # === Development === 77 | 78 | check: ## Quick compilation check 79 | @echo "$(BOLD)$(BLUE)Checking compilation...$(RESET)" 80 | @cargo check 81 | 82 | clippy: ## Run clippy lints 83 | @echo "$(BOLD)$(YELLOW)Running clippy...$(RESET)" 84 | @cargo clippy 85 | 86 | clippy-strict: ## Run clippy with all targets and strict warnings 87 | @echo "$(BOLD)$(YELLOW)Running clippy on all targets (strict)...$(RESET)" 88 | @cargo clippy --all-targets --all-features -- -D warnings 89 | 90 | clippy-fix: ## Run clippy and automatically fix issues 91 | @echo "$(BOLD)$(YELLOW)Running clippy with fixes...$(RESET)" 92 | @cargo clippy --fix --allow-dirty 93 | 94 | fmt: ## Format code 95 | @echo "$(BOLD)$(MAGENTA)Formatting code...$(RESET)" 96 | @cargo fmt 97 | 98 | fmt-check: ## Check code formatting without changing files 99 | @echo "$(BOLD)$(MAGENTA)Checking code formatting...$(RESET)" 100 | @cargo fmt -- --check 101 | 102 | # === Documentation === 103 | 104 | doc: ## Build documentation 105 | @echo "$(BOLD)$(CYAN)Building documentation...$(RESET)" 106 | @cargo doc --no-deps 107 | 108 | doc-open: ## Build and open documentation in browser 109 | @echo "$(BOLD)$(CYAN)Building and opening documentation...$(RESET)" 110 | @cargo doc --no-deps --open 111 | 112 | # === Utilities === 113 | 114 | clean: ## Clean build artifacts 115 | @echo "$(BOLD)$(RED)Cleaning build artifacts...$(RESET)" 116 | @cargo clean 117 | @rm -rf test_sandbox/ 118 | @echo "$(GREEN)✓ Clean completed$(RESET)" 119 | 120 | install-deps: ## Install development dependencies 121 | @echo "$(BOLD)$(BLUE)Installing development dependencies...$(RESET)" 122 | @rustup component add rustfmt clippy 123 | @echo "$(BOLD)$(BLUE)Checking for GitHub CLI...$(RESET)" 124 | @which gh >/dev/null 2>&1 || echo "$(YELLOW)⚠ GitHub CLI (gh) not found. Install from https://cli.github.com/$(RESET)" 125 | @echo "$(GREEN)✓ Development dependencies checked!$(RESET)" 126 | 127 | # === CI Pipeline === 128 | 129 | ci-local: ## Run the complete CI pipeline locally 130 | @echo "$(BOLD)$(CYAN)Running complete CI pipeline locally...$(RESET)" 131 | @echo "" 132 | @echo "$(BOLD)$(YELLOW)Step 1: Check formatting$(RESET)" 133 | @$(MAKE) fmt-check 134 | @echo "" 135 | @echo "$(BOLD)$(YELLOW)Step 2: Run clippy$(RESET)" 136 | @$(MAKE) clippy-strict 137 | @echo "" 138 | @echo "$(BOLD)$(YELLOW)Step 3: Run tests sequentially$(RESET)" 139 | @$(MAKE) test-sequential 140 | @echo "" 141 | @echo "$(BOLD)$(YELLOW)Step 4: Build documentation$(RESET)" 142 | @$(MAKE) doc 143 | @echo "" 144 | @echo "$(BOLD)$(YELLOW)Step 5: Build release$(RESET)" 145 | @$(MAKE) release 146 | @echo "" 147 | @echo "$(BOLD)$(GREEN)🎉 All CI checks passed!$(RESET)" 148 | 149 | # === Composite Targets === 150 | 151 | all: ## Run formatting, linting, tests, and build 152 | @echo "$(BOLD)$(CYAN)Running full development pipeline...$(RESET)" 153 | @$(MAKE) fmt 154 | @$(MAKE) clippy-strict 155 | @$(MAKE) test-sequential 156 | @$(MAKE) build 157 | @echo "$(BOLD)$(GREEN)✨ All tasks completed successfully!$(RESET)" 158 | 159 | quick: ## Quick development check (format + check) 160 | @echo "$(BOLD)$(CYAN)Quick development check...$(RESET)" 161 | @$(MAKE) fmt 162 | @$(MAKE) check 163 | @echo "$(BOLD)$(GREEN)✓ Quick check completed!$(RESET)" 164 | 165 | dev: ## Development workflow: format, check, build 166 | @echo "$(BOLD)$(CYAN)Development workflow...$(RESET)" 167 | @$(MAKE) fmt 168 | @$(MAKE) check 169 | @$(MAKE) build 170 | @echo "$(BOLD)$(GREEN)✓ Development build ready!$(RESET)" 171 | 172 | # === Git Chain Commands === 173 | 174 | chain-init: ## Initialize a new chain (use CHAIN=name BASE=branch) 175 | @echo "$(BOLD)$(CYAN)Initializing chain '$(CHAIN)' with base '$(BASE)'...$(RESET)" 176 | @cargo run -- init $(CHAIN) $(BASE) 177 | 178 | chain-list: ## List all chains 179 | @echo "$(BOLD)$(CYAN)Listing all chains...$(RESET)" 180 | @cargo run -- list 181 | 182 | chain-status: ## Show chain status 183 | @echo "$(BOLD)$(CYAN)Chain status...$(RESET)" 184 | @cargo run -- status 185 | 186 | # === Troubleshooting === 187 | 188 | debug-info: ## Show environment and toolchain information 189 | @echo "$(BOLD)$(CYAN)Environment Information:$(RESET)" 190 | @echo "$(YELLOW)Rust version:$(RESET)" 191 | @rustc --version 192 | @echo "$(YELLOW)Cargo version:$(RESET)" 193 | @cargo --version 194 | @echo "$(YELLOW)Toolchain:$(RESET)" 195 | @rustup show 196 | @echo "$(YELLOW)GitHub CLI version:$(RESET)" 197 | @gh --version 2>/dev/null || echo "$(RED)GitHub CLI not installed$(RESET)" 198 | @echo "$(YELLOW)Git version:$(RESET)" 199 | @git --version 200 | 201 | watch: ## Watch for changes and rebuild (requires cargo-watch) 202 | @echo "$(BOLD)$(CYAN)Watching for changes...$(RESET)" 203 | @cargo watch -x check -x test 204 | 205 | # === Release Preparation === 206 | 207 | pre-release: ## Prepare for release (full CI + clean) 208 | @echo "$(BOLD)$(MAGENTA)Preparing for release...$(RESET)" 209 | @$(MAKE) clean 210 | @$(MAKE) ci-local 211 | @echo "$(BOLD)$(GREEN)🚀 Ready for release!$(RESET)" 212 | 213 | bump-version: ## Bump version (use VERSION=0.1.0) 214 | @echo "$(BOLD)$(MAGENTA)Bumping version to $(VERSION)...$(RESET)" 215 | @sed -i '' 's/version = ".*"/version = "$(VERSION)"/' Cargo.toml 216 | @cargo check 217 | @echo "$(BOLD)$(GREEN)✓ Version bumped to $(VERSION)$(RESET)" 218 | @echo "$(YELLOW)⚠ Don't forget to update CHANGELOG.md and commit changes$(RESET)" 219 | @echo "$(YELLOW)⚠ Then run 'make tag-version' to create a git tag$(RESET)" 220 | 221 | tag-version: ## Create an annotated git tag for the current version 222 | @VERSION=$$(grep '^version = ' Cargo.toml | sed 's/version = "\(.*\)"/\1/'); \ 223 | echo "$(BOLD)$(MAGENTA)Creating tag v$$VERSION...$(RESET)"; \ 224 | if git rev-parse "v$$VERSION" >/dev/null 2>&1; then \ 225 | echo "$(RED)✗ Tag v$$VERSION already exists$(RESET)"; \ 226 | exit 1; \ 227 | fi; \ 228 | git tag -a "v$$VERSION" -m "Release version $$VERSION"; \ 229 | echo "$(BOLD)$(GREEN)✓ Created tag v$$VERSION$(RESET)"; \ 230 | echo "$(YELLOW)⚠ Push the tag with: git push origin v$$VERSION$(RESET)" 231 | 232 | create-release: ## Complete release workflow (bump, tag, and prepare) 233 | @echo "$(BOLD)$(CYAN)Starting release workflow...$(RESET)" 234 | @echo "" 235 | @if [ -z "$(VERSION)" ]; then \ 236 | echo "$(RED)Error: VERSION not specified$(RESET)"; \ 237 | echo "Usage: make create-release VERSION=0.1.0"; \ 238 | exit 1; \ 239 | fi 240 | @echo "$(BOLD)Step 1: Bump version$(RESET)" 241 | @$(MAKE) bump-version VERSION=$(VERSION) 242 | @echo "" 243 | @echo "$(BOLD)Step 2: Run CI checks$(RESET)" 244 | @$(MAKE) ci-local 245 | @echo "" 246 | @echo "$(BOLD)$(YELLOW)Manual steps required:$(RESET)" 247 | @echo " 1. Update CHANGELOG.md with release notes" 248 | @echo " 2. Review changes: git diff" 249 | @echo " 3. Commit: git add -A && git commit -m 'chore: Bump version to $(VERSION)'" 250 | @echo " 4. Create tag: make tag-version" 251 | @echo " 5. Push: git push && git push --tags" 252 | @echo "" 253 | @echo "$(BOLD)$(GREEN)Version bumped and tested. Complete manual steps above.$(RESET)" 254 | 255 | # === Testing Helpers === 256 | 257 | test-coverage: ## Generate test coverage report (requires cargo-tarpaulin) 258 | @echo "$(BOLD)$(CYAN)Generating test coverage report...$(RESET)" 259 | @cargo tarpaulin --out Html 260 | @echo "$(BOLD)$(GREEN)✓ Coverage report generated in tarpaulin-report.html$(RESET)" 261 | 262 | test-bench: ## Run benchmarks 263 | @echo "$(BOLD)$(CYAN)Running benchmarks...$(RESET)" 264 | @cargo bench 265 | 266 | # === PR Testing === 267 | 268 | test-pr-fix: ## Test the PR draft fix 269 | @echo "$(BOLD)$(CYAN)Testing PR draft functionality fix...$(RESET)" 270 | @cargo test test_pr_command_with_draft_flag -- --nocapture 271 | 272 | # === Integration Testing === 273 | 274 | integration-test: ## Run integration test in a temporary git repo 275 | @echo "$(BOLD)$(CYAN)Running integration test...$(RESET)" 276 | @./scripts/integration_test.sh || echo "$(YELLOW)Integration test script not found$(RESET)" 277 | -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsStr; 2 | use std::fs; 3 | use std::fs::OpenOptions; 4 | use std::io::{self, Write}; 5 | use std::path::{Path, PathBuf}; 6 | use std::process::{Command, Output}; 7 | 8 | use git2::{BranchType, IndexAddOption, ObjectType, Oid, Repository, RepositoryInitOptions}; 9 | 10 | pub fn generate_path_to_repo(repo_name: S) -> PathBuf 11 | where 12 | S: Into, 13 | { 14 | let repo_name: String = repo_name.into(); 15 | let test_fixture_path = Path::new("./test_sandbox/"); 16 | let path_to_repo = test_fixture_path.join(repo_name); 17 | assert!(path_to_repo.is_relative()); 18 | path_to_repo 19 | } 20 | 21 | #[allow(dead_code)] 22 | pub fn generate_path_to_bare_repo(repo_name: S) -> PathBuf 23 | where 24 | S: Into, 25 | { 26 | let repo_name: String = repo_name.into(); 27 | generate_path_to_repo(format!("bare_{}.git", repo_name)) 28 | } 29 | 30 | pub fn setup_git_repo(repo_name: S) -> Repository 31 | where 32 | S: Into, 33 | { 34 | let path_to_repo = generate_path_to_repo(repo_name); 35 | 36 | fs::remove_dir_all(&path_to_repo).ok(); 37 | fs::create_dir_all(&path_to_repo).unwrap(); 38 | 39 | let mut options = RepositoryInitOptions::new(); 40 | options.initial_head("master"); 41 | let repo = match Repository::init_opts(path_to_repo, &options) { 42 | Ok(repo) => repo, 43 | Err(err) => panic!("failed to init repo: {}", err), 44 | }; 45 | 46 | let mut config = repo.config().unwrap(); 47 | config.set_str("user.name", "name").unwrap(); 48 | config.set_str("user.email", "email").unwrap(); 49 | 50 | repo 51 | } 52 | 53 | #[allow(dead_code)] 54 | pub fn setup_git_bare_repo(repo_name: S) -> Repository 55 | where 56 | S: Into, 57 | { 58 | let path_to_bare_repo = generate_path_to_bare_repo(repo_name); 59 | 60 | fs::remove_dir_all(&path_to_bare_repo).ok(); 61 | fs::create_dir_all(&path_to_bare_repo).unwrap(); 62 | 63 | let repo = match Repository::init_bare(path_to_bare_repo) { 64 | Ok(repo) => repo, 65 | Err(err) => panic!("failed to init bare repo: {}", err), 66 | }; 67 | 68 | repo 69 | } 70 | 71 | pub fn teardown_git_repo(repo_name: S) 72 | where 73 | S: Into, 74 | { 75 | let path_to_repo = generate_path_to_repo(repo_name); 76 | fs::remove_dir_all(&path_to_repo).ok(); 77 | } 78 | 79 | #[allow(dead_code)] 80 | pub fn teardown_git_bare_repo(repo_name: S) 81 | where 82 | S: Into, 83 | { 84 | let path_to_repo = generate_path_to_bare_repo(repo_name); 85 | fs::remove_dir_all(&path_to_repo).ok(); 86 | } 87 | 88 | pub fn create_branch(repo: &Repository, branch_name: &str) { 89 | // create branch from HEAD 90 | let oid = repo.head().unwrap().target().unwrap(); 91 | let commit = repo.find_commit(oid).unwrap(); 92 | 93 | repo.branch(branch_name, &commit, false).unwrap(); 94 | } 95 | 96 | pub fn checkout_branch(repo: &Repository, branch_name: &str) { 97 | let obj = repo 98 | .revparse_single(&("refs/heads/".to_owned() + branch_name)) 99 | .unwrap(); 100 | 101 | repo.checkout_tree(&obj, None).unwrap(); 102 | 103 | repo.set_head(&("refs/heads/".to_owned() + branch_name)) 104 | .unwrap(); 105 | } 106 | 107 | #[allow(dead_code)] 108 | pub fn branch_exists(repo: &Repository, branch_name: &str) -> bool { 109 | repo.revparse_single(&("refs/heads/".to_owned() + branch_name)) 110 | .is_ok() 111 | } 112 | 113 | #[allow(dead_code)] 114 | pub fn branch_equal(repo: &Repository, branch_name: &str, other_branch: &str) -> bool { 115 | let obj = repo 116 | .revparse_single(&format!("{}^{{commit}}", branch_name)) 117 | .unwrap(); 118 | assert_eq!(obj.kind().unwrap(), ObjectType::Commit); 119 | 120 | let other_obj = repo 121 | .revparse_single(&format!("{}^{{commit}}", other_branch)) 122 | .unwrap(); 123 | assert_eq!(other_obj.kind().unwrap(), ObjectType::Commit); 124 | 125 | obj.id() == other_obj.id() 126 | } 127 | 128 | pub fn stage_everything(repo: &Repository) -> Oid { 129 | let mut index = repo.index().expect("cannot get the Index file"); 130 | index 131 | .add_all(["*"].iter(), IndexAddOption::DEFAULT, None) 132 | .unwrap(); 133 | index.write().unwrap(); 134 | 135 | let mut index = repo.index().unwrap(); 136 | // root_tree_oid 137 | index.write_tree().unwrap() 138 | } 139 | 140 | pub fn create_first_commit(repo: &Repository, root_tree_oid: Oid, message: &str) { 141 | let tree = repo.find_tree(root_tree_oid).unwrap(); 142 | 143 | let author = &repo.signature().unwrap(); 144 | let committer = &author; 145 | 146 | repo.commit(Some("HEAD"), author, committer, message, &tree, &[]) 147 | .unwrap(); 148 | } 149 | 150 | pub fn create_commit(repo: &Repository, root_tree_oid: Oid, message: &str) { 151 | let tree = repo.find_tree(root_tree_oid).unwrap(); 152 | let head_id = repo.refname_to_id("HEAD").unwrap(); 153 | let parent = repo.find_commit(head_id).unwrap(); 154 | 155 | let author = &repo.signature().unwrap(); 156 | let committer = &author; 157 | 158 | repo.commit(Some("HEAD"), author, committer, message, &tree, &[&parent]) 159 | .unwrap(); 160 | } 161 | 162 | pub fn first_commit_all(repo: &Repository, message: &str) { 163 | // HEAD should not resolve to anything prior to creating the first commit 164 | assert!(repo.head().is_err()); 165 | 166 | // stage all changes - git add -A * 167 | let root_tree_oid = stage_everything(repo); 168 | 169 | create_first_commit(repo, root_tree_oid, message); 170 | } 171 | 172 | pub fn commit_all(repo: &Repository, message: &str) { 173 | // stage all changes - git add -A * 174 | let root_tree_oid = stage_everything(repo); 175 | 176 | create_commit(repo, root_tree_oid, message); 177 | } 178 | 179 | #[allow(dead_code)] 180 | pub fn delete_local_branch(repo: &Repository, branch_name: &str) { 181 | let mut some_branch = repo.find_branch(branch_name, BranchType::Local).unwrap(); 182 | 183 | // Should not be able to delete branch_name if it is the current working tree 184 | assert!(!some_branch.is_head()); 185 | 186 | some_branch.delete().unwrap(); 187 | } 188 | 189 | #[allow(dead_code)] 190 | pub fn get_current_branch_name(repo: &Repository) -> String { 191 | let head = repo.head().unwrap(); 192 | head.shorthand().unwrap().to_string() 193 | } 194 | 195 | pub fn create_new_file(path_to_repo: &Path, file_name: &str, file_contents: &str) { 196 | let mut file = OpenOptions::new() 197 | .write(true) 198 | .create(true) 199 | .truncate(true) 200 | .open(path_to_repo.join(file_name)) 201 | .unwrap(); 202 | 203 | writeln!(file, "{}", file_contents).unwrap(); 204 | } 205 | 206 | #[allow(dead_code)] 207 | pub fn append_file(path_to_repo: &Path, file_name: &str, file_contents: &str) { 208 | let mut file = OpenOptions::new() 209 | .append(true) 210 | .open(path_to_repo.join(file_name)) 211 | .unwrap(); 212 | 213 | writeln!(file, "{}", file_contents).unwrap(); 214 | } 215 | 216 | pub fn run_test_bin>(current_dir: P, arguments: I) -> Output 217 | where 218 | I: IntoIterator, 219 | T: AsRef, 220 | { 221 | let mut current_dir_buf: PathBuf = current_dir.as_ref().into(); 222 | if current_dir_buf.is_relative() { 223 | current_dir_buf = current_dir_buf.canonicalize().unwrap(); 224 | } 225 | 226 | assert_cmd::Command::cargo_bin(env!("CARGO_PKG_NAME")) 227 | .expect("Failed to get git-chain") 228 | .current_dir(current_dir_buf) 229 | .args(arguments) 230 | .output() 231 | .expect("Failed to run git-chain") 232 | } 233 | 234 | pub fn run_test_bin_with_env>( 235 | current_dir: P, 236 | arguments: I, 237 | env_vars: E, 238 | ) -> Output 239 | where 240 | I: IntoIterator, 241 | T: AsRef, 242 | E: IntoIterator, 243 | K: AsRef, 244 | V: AsRef, 245 | { 246 | let mut current_dir_buf: PathBuf = current_dir.as_ref().into(); 247 | if current_dir_buf.is_relative() { 248 | current_dir_buf = current_dir_buf.canonicalize().unwrap(); 249 | } 250 | 251 | let mut cmd = 252 | assert_cmd::Command::cargo_bin(env!("CARGO_PKG_NAME")).expect("Failed to get git-chain"); 253 | 254 | cmd.current_dir(current_dir_buf) 255 | .args(arguments) 256 | .envs(env_vars); 257 | 258 | cmd.output().expect("Failed to run git-chain") 259 | } 260 | 261 | #[allow(dead_code)] 262 | pub fn run_test_bin_expect_err>(current_dir: P, arguments: I) -> Output 263 | where 264 | I: IntoIterator, 265 | T: AsRef, 266 | { 267 | let output = run_test_bin(current_dir, arguments); 268 | 269 | if output.status.success() { 270 | io::stdout().write_all(&output.stdout).unwrap(); 271 | io::stderr().write_all(&output.stderr).unwrap(); 272 | } 273 | 274 | assert!(!output.status.success(), "expect err"); 275 | 276 | output 277 | } 278 | 279 | pub fn run_test_bin_expect_ok>(current_dir: P, arguments: I) -> Output 280 | where 281 | I: IntoIterator, 282 | T: AsRef, 283 | { 284 | let output = run_test_bin(current_dir, arguments); 285 | 286 | if !output.status.success() { 287 | io::stdout().write_all(&output.stdout).unwrap(); 288 | io::stderr().write_all(&output.stderr).unwrap(); 289 | } 290 | 291 | assert!(output.status.success()); 292 | assert!(String::from_utf8_lossy(&output.stderr).is_empty()); 293 | 294 | output 295 | } 296 | 297 | #[allow(dead_code)] 298 | pub fn display_outputs(output: &Output) { 299 | io::stdout().write_all(&output.stdout).unwrap(); 300 | io::stderr().write_all(&output.stderr).unwrap(); 301 | } 302 | 303 | #[allow(dead_code)] 304 | pub fn run_git_command>(current_dir: P, arguments: I) -> Output 305 | where 306 | I: IntoIterator, 307 | T: AsRef, 308 | { 309 | let mut current_dir_buf: PathBuf = current_dir.as_ref().into(); 310 | if current_dir_buf.is_relative() { 311 | current_dir_buf = current_dir_buf.canonicalize().unwrap(); 312 | } 313 | 314 | let output = assert_cmd::Command::from_std(Command::new("git")) 315 | .current_dir(current_dir_buf) 316 | .args(arguments) 317 | .output() 318 | .expect("Failed to run git"); 319 | 320 | output 321 | } 322 | 323 | #[allow(dead_code)] 324 | pub fn run_test_bin_for_rebase>(current_dir: P, arguments: I) -> Output 325 | where 326 | I: IntoIterator, 327 | T: AsRef, 328 | { 329 | let output = run_test_bin(current_dir, arguments); 330 | 331 | if !output.status.success() { 332 | io::stdout().write_all(&output.stdout).unwrap(); 333 | io::stderr().write_all(&output.stderr).unwrap(); 334 | } 335 | 336 | assert!(output.status.success()); 337 | 338 | // https://git-scm.com/docs/git-rebase#_miscellaneous_differences 339 | // git rebase will output to both stdout and stderr. 340 | 341 | output 342 | } 343 | -------------------------------------------------------------------------------- /src/branch.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Write}; 2 | use std::iter::FromIterator; 3 | use std::process::Command; 4 | 5 | use between::Between; 6 | use colored::*; 7 | use git2::{BranchType, Error, ErrorCode}; 8 | use rand::Rng; 9 | 10 | use crate::types::*; 11 | use crate::{Chain, GitChain}; 12 | 13 | fn chain_name_key(branch_name: &str) -> String { 14 | format!("branch.{}.chain-name", branch_name) 15 | } 16 | 17 | fn chain_order_key(branch_name: &str) -> String { 18 | format!("branch.{}.chain-order", branch_name) 19 | } 20 | 21 | fn root_branch_key(branch_name: &str) -> String { 22 | format!("branch.{}.root-branch", branch_name) 23 | } 24 | 25 | fn generate_chain_order() -> String { 26 | let between = Between::init(); 27 | let chars = between.chars(); 28 | let chars_length = chars.len(); 29 | assert!(chars_length >= 3); 30 | let last_chars_index = chars_length - 1; 31 | 32 | // Use character that is not either between.low() or between.high(). 33 | // This guarantees that the next generated string sorts before or after the string generated in this function. 34 | let character_range = 1..=(last_chars_index - 1); 35 | let mut rng = rand::thread_rng(); 36 | 37 | let mut len = 5; 38 | let mut str: Vec = vec![]; 39 | 40 | while len >= 1 { 41 | let index: usize = rng.gen_range(character_range.clone()); 42 | let character_candidate = *chars.get(index).unwrap(); 43 | str.push(character_candidate); 44 | len -= 1; 45 | } 46 | 47 | String::from_iter(str) 48 | } 49 | 50 | fn generate_chain_order_after(chain_order: &str) -> Option { 51 | let between = Between::init(); 52 | between.after(chain_order) 53 | } 54 | 55 | fn generate_chain_order_before(chain_order: &str) -> Option { 56 | let between = Between::init(); 57 | between.before(chain_order) 58 | } 59 | 60 | fn generate_chain_order_between(before: &str, after: &str) -> Option { 61 | let between = Between::init(); 62 | between.between(before, after) 63 | } 64 | 65 | #[derive(Clone, PartialEq)] 66 | pub struct Branch { 67 | pub branch_name: String, 68 | pub chain_name: String, 69 | pub chain_order: String, 70 | pub root_branch: String, 71 | } 72 | 73 | impl Branch { 74 | pub fn delete_all_configs(git_chain: &GitChain, branch_name: &str) -> Result<(), Error> { 75 | git_chain.delete_git_config(&chain_name_key(branch_name))?; 76 | git_chain.delete_git_config(&chain_order_key(branch_name))?; 77 | git_chain.delete_git_config(&root_branch_key(branch_name))?; 78 | Ok(()) 79 | } 80 | 81 | pub fn remove_from_chain(self, git_chain: &GitChain) -> Result<(), Error> { 82 | Branch::delete_all_configs(git_chain, &self.branch_name) 83 | } 84 | 85 | pub fn get_branch_with_chain( 86 | git_chain: &GitChain, 87 | branch_name: &str, 88 | ) -> Result { 89 | let chain_name = git_chain.get_git_config(&chain_name_key(branch_name))?; 90 | let chain_order = git_chain.get_git_config(&chain_order_key(branch_name))?; 91 | let root_branch = git_chain.get_git_config(&root_branch_key(branch_name))?; 92 | 93 | if chain_name.is_none() 94 | || chain_order.is_none() 95 | || root_branch.is_none() 96 | || !git_chain.git_local_branch_exists(branch_name)? 97 | { 98 | Branch::delete_all_configs(git_chain, branch_name)?; 99 | return Ok(BranchSearchResult::NotPartOfAnyChain); 100 | } 101 | 102 | let branch = Branch { 103 | branch_name: branch_name.to_string(), 104 | chain_name: chain_name.unwrap(), 105 | chain_order: chain_order.unwrap(), 106 | root_branch: root_branch.unwrap(), 107 | }; 108 | 109 | Ok(BranchSearchResult::Branch(branch)) 110 | } 111 | 112 | fn generate_chain_order( 113 | git_chain: &GitChain, 114 | chain_name: &str, 115 | sort_option: &SortBranch, 116 | ) -> Result { 117 | let chain_order = if Chain::chain_exists(git_chain, chain_name)? { 118 | // invariant: a chain exists if and only if it has at least one branch. 119 | let chain = Chain::get_chain(git_chain, chain_name)?; 120 | assert!(!chain.branches.is_empty()); 121 | 122 | let maybe_chain_order = match sort_option { 123 | SortBranch::First => { 124 | let first_branch = chain.branches.first().unwrap(); 125 | generate_chain_order_before(&first_branch.chain_order) 126 | } 127 | SortBranch::Last => { 128 | let last_branch = chain.branches.last().unwrap(); 129 | generate_chain_order_after(&last_branch.chain_order) 130 | } 131 | SortBranch::Before(after_branch) => match chain.before(after_branch) { 132 | None => generate_chain_order_before(&after_branch.chain_order), 133 | Some(before_branch) => generate_chain_order_between( 134 | &before_branch.chain_order, 135 | &after_branch.chain_order, 136 | ), 137 | }, 138 | SortBranch::After(before_branch) => match chain.after(before_branch) { 139 | None => generate_chain_order_after(&before_branch.chain_order), 140 | Some(after_branch) => generate_chain_order_between( 141 | &before_branch.chain_order, 142 | &after_branch.chain_order, 143 | ), 144 | }, 145 | }; 146 | 147 | match maybe_chain_order { 148 | Some(chain_order) => chain_order, 149 | None => { 150 | let mut chain_order = generate_chain_order(); 151 | // last resort 152 | while chain.has_chain_order(&chain_order) { 153 | chain_order = generate_chain_order(); 154 | } 155 | chain_order 156 | } 157 | } 158 | } else { 159 | generate_chain_order() 160 | }; 161 | 162 | Ok(chain_order) 163 | } 164 | 165 | pub fn setup_branch( 166 | git_chain: &GitChain, 167 | chain_name: &str, 168 | root_branch: &str, 169 | branch_name: &str, 170 | sort_option: &SortBranch, 171 | ) -> Result<(), Error> { 172 | Branch::delete_all_configs(git_chain, branch_name)?; 173 | 174 | let chain_order = Branch::generate_chain_order(git_chain, chain_name, sort_option)?; 175 | git_chain.set_git_config(&chain_order_key(branch_name), &chain_order)?; 176 | git_chain.set_git_config(&root_branch_key(branch_name), root_branch)?; 177 | git_chain.set_git_config(&chain_name_key(branch_name), chain_name)?; 178 | 179 | Ok(()) 180 | } 181 | 182 | pub fn display_status(&self, git_chain: &GitChain, show_prs: bool) -> Result<(), Error> { 183 | let chain = Chain::get_chain(git_chain, &self.chain_name)?; 184 | 185 | let current_branch = git_chain.get_current_branch_name()?; 186 | 187 | chain.display_list(git_chain, ¤t_branch, show_prs)?; 188 | 189 | Ok(()) 190 | } 191 | 192 | pub fn change_root_branch( 193 | &self, 194 | git_chain: &GitChain, 195 | new_root_branch: &str, 196 | ) -> Result<(), Error> { 197 | git_chain.set_git_config(&root_branch_key(&self.branch_name), new_root_branch)?; 198 | Ok(()) 199 | } 200 | 201 | pub fn move_branch( 202 | &self, 203 | git_chain: &GitChain, 204 | chain_name: &str, 205 | sort_option: &SortBranch, 206 | ) -> Result<(), Error> { 207 | Branch::setup_branch( 208 | git_chain, 209 | chain_name, 210 | &self.root_branch, 211 | &self.branch_name, 212 | sort_option, 213 | )?; 214 | Ok(()) 215 | } 216 | 217 | pub fn backup(&self, git_chain: &GitChain) -> Result<(), Error> { 218 | let (object, _reference) = git_chain.repo.revparse_ext(&self.branch_name)?; 219 | let commit = git_chain.repo.find_commit(object.id())?; 220 | 221 | let backup_branch = format!("backup-{}/{}", self.chain_name, self.branch_name); 222 | 223 | git_chain.repo.branch(&backup_branch, &commit, true)?; 224 | 225 | Ok(()) 226 | } 227 | 228 | pub fn push(&self, git_chain: &GitChain, force_push: bool) -> Result { 229 | // get branch's upstream 230 | 231 | let branch = match git_chain 232 | .repo 233 | .find_branch(&self.branch_name, BranchType::Local) 234 | { 235 | Ok(branch) => branch, 236 | Err(e) => { 237 | if e.code() == ErrorCode::NotFound { 238 | // do nothing 239 | return Ok(false); 240 | } 241 | return Err(e); 242 | } 243 | }; 244 | 245 | match branch.upstream() { 246 | Ok(_remote_branch) => { 247 | let remote = git_chain 248 | .repo 249 | .branch_upstream_remote(branch.get().name().unwrap())?; 250 | let remote = remote.as_str().unwrap(); 251 | 252 | let output = if force_push { 253 | // git push --force-with-lease 254 | Command::new("git") 255 | .arg("push") 256 | .arg("--force-with-lease") 257 | .arg(remote) 258 | .arg(&self.branch_name) 259 | .output() 260 | .unwrap_or_else(|_| { 261 | panic!( 262 | "Unable to push branch to their upstream: {}", 263 | self.branch_name.bold() 264 | ) 265 | }) 266 | } else { 267 | // git push 268 | Command::new("git") 269 | .arg("push") 270 | .arg(remote) 271 | .arg(&self.branch_name) 272 | .output() 273 | .unwrap_or_else(|_| { 274 | panic!( 275 | "Unable to push branch to their upstream: {}", 276 | self.branch_name.bold() 277 | ) 278 | }) 279 | }; 280 | 281 | if output.status.success() { 282 | if force_push { 283 | println!("✅ Force pushed {}", self.branch_name.bold()); 284 | } else { 285 | println!("✅ Pushed {}", self.branch_name.bold()); 286 | } 287 | 288 | Ok(true) 289 | } else { 290 | io::stdout().write_all(&output.stdout).unwrap(); 291 | io::stderr().write_all(&output.stderr).unwrap(); 292 | println!("🛑 Unable to push {}", self.branch_name.bold()); 293 | Ok(false) 294 | } 295 | } 296 | Err(e) => { 297 | if e.code() == ErrorCode::NotFound { 298 | println!( 299 | "🛑 Cannot push. Branch has no upstream: {}", 300 | self.branch_name.bold() 301 | ); 302 | // do nothing 303 | return Ok(false); 304 | } 305 | Err(e) 306 | } 307 | } 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /tests/fork_point_failure.rs: -------------------------------------------------------------------------------- 1 | #[path = "common/mod.rs"] 2 | pub mod common; 3 | 4 | use common::{ 5 | create_new_file, first_commit_all, generate_path_to_repo, run_git_command, run_test_bin, 6 | run_test_bin_expect_ok, setup_git_repo, teardown_git_repo, 7 | }; 8 | 9 | use git2::RepositoryState; 10 | use std::path::Path; 11 | 12 | /// Helper function to run git-chain and check for error messages in the output 13 | /// This is useful when we want to verify error messages in stderr without expecting 14 | /// a non-zero exit code (since git-chain may handle some errors gracefully) 15 | fn run_and_check_for_error_messages>( 16 | current_dir: P, 17 | args: Vec<&str>, 18 | ) -> (String, String) { 19 | let output = run_test_bin(current_dir, args); 20 | let stdout = String::from_utf8_lossy(&output.stdout).to_string(); 21 | let stderr = String::from_utf8_lossy(&output.stderr).to_string(); 22 | 23 | println!("STDOUT: {}", stdout); 24 | println!("STDERR: {}", stderr); 25 | 26 | (stdout, stderr) 27 | } 28 | 29 | /// This test creates a scenario with completely unrelated Git branches. 30 | /// 31 | /// This simulates what can happen in real-world scenarios when: 32 | /// 1. Branch histories get completely rewritten (force pushed) 33 | /// 2. Git's reflog entries expire 34 | /// 3. Two branches that are supposed to be related end up having no common ancestor 35 | #[test] 36 | fn test_natural_forkpoint_loss() { 37 | let repo_name = "natural_forkpoint_loss_test"; 38 | let repo = setup_git_repo(repo_name); 39 | let path_to_repo = generate_path_to_repo(repo_name); 40 | 41 | // Create initial commit on master 42 | create_new_file(&path_to_repo, "init.txt", "Initial content"); 43 | first_commit_all(&repo, "Initial commit"); 44 | 45 | // Create an intentionally broken branch chain where branch1 and branch2 have no common ancestor 46 | 47 | // Branch1 - create normally from master 48 | run_git_command(&path_to_repo, vec!["checkout", "-b", "branch1"]); 49 | create_new_file(&path_to_repo, "branch1_file.txt", "Branch 1 content"); 50 | run_git_command(&path_to_repo, vec!["add", "branch1_file.txt"]); 51 | run_git_command(&path_to_repo, vec!["commit", "-m", "Branch 1 commit"]); 52 | 53 | // Branch2 - create as an orphan branch (with no relationship to master or branch1) 54 | run_git_command(&path_to_repo, vec!["checkout", "--orphan", "branch2"]); 55 | run_git_command(&path_to_repo, vec!["rm", "-rf", "."]); // Clear the working directory 56 | create_new_file(&path_to_repo, "branch2_file.txt", "Branch 2 content"); 57 | run_git_command(&path_to_repo, vec!["add", "branch2_file.txt"]); 58 | run_git_command(&path_to_repo, vec!["commit", "-m", "Branch 2 commit"]); 59 | 60 | // Branch3 - create from branch2 61 | run_git_command(&path_to_repo, vec!["checkout", "-b", "branch3"]); 62 | create_new_file(&path_to_repo, "branch3_file.txt", "Branch 3 content"); 63 | run_git_command(&path_to_repo, vec!["add", "branch3_file.txt"]); 64 | run_git_command(&path_to_repo, vec!["commit", "-m", "Branch 3 commit"]); 65 | 66 | // Set up a chain with these branches 67 | // Note: git-chain's setup command doesn't verify branch relationships at setup time 68 | run_test_bin_expect_ok( 69 | &path_to_repo, 70 | vec![ 71 | "setup", 72 | "test_chain", 73 | "master", 74 | "branch1", 75 | "branch2", 76 | "branch3", 77 | ], 78 | ); 79 | 80 | // Print the current branch structure 81 | println!("Branch Setup:"); 82 | let chain_branches = run_git_command(&path_to_repo, vec!["branch", "-v"]); 83 | println!("{}", String::from_utf8_lossy(&chain_branches.stdout)); 84 | 85 | // Confirm that branch1 and branch2 have no merge base 86 | println!("\nVerifying no merge base between branch1 and branch2:"); 87 | let merge_base_cmd = run_git_command(&path_to_repo, vec!["merge-base", "branch1", "branch2"]); 88 | println!( 89 | "stdout: {}", 90 | String::from_utf8_lossy(&merge_base_cmd.stdout) 91 | ); 92 | println!( 93 | "stderr: {}", 94 | String::from_utf8_lossy(&merge_base_cmd.stderr) 95 | ); 96 | 97 | // Also check that fork-point detection fails 98 | println!("\nFork-point detection between branch1 and branch2:"); 99 | let fork_point_cmd = run_git_command( 100 | &path_to_repo, 101 | vec!["merge-base", "--fork-point", "branch1", "branch2"], 102 | ); 103 | println!( 104 | "stdout: {}", 105 | String::from_utf8_lossy(&fork_point_cmd.stdout) 106 | ); 107 | println!( 108 | "stderr: {}", 109 | String::from_utf8_lossy(&fork_point_cmd.stderr) 110 | ); 111 | 112 | // Now try to rebase the chain - this should produce errors during fork-point detection 113 | println!("\nRunning git-chain rebase:"); 114 | let (stdout, stderr) = run_and_check_for_error_messages(&path_to_repo, vec!["rebase"]); 115 | 116 | // Check for error messages about missing fork points or merge bases 117 | let error_patterns = [ 118 | "no merge base found", 119 | "Unable to get forkpoint", 120 | "common ancestor", 121 | "failed to find", 122 | ]; 123 | 124 | let has_error_message = error_patterns 125 | .iter() 126 | .any(|pattern| stderr.contains(pattern) || stdout.contains(pattern)); 127 | 128 | assert!(has_error_message, 129 | "Expected output to contain error about missing merge base or fork point.\nStdout: {}\nStderr: {}", 130 | stdout, stderr); 131 | 132 | // Clean up any rebase in progress 133 | if repo.state() != RepositoryState::Clean { 134 | run_git_command(&path_to_repo, vec!["rebase", "--abort"]); 135 | } 136 | 137 | // Clean up test repository 138 | teardown_git_repo(repo_name); 139 | } 140 | 141 | /// This test creates a chain with completely unrelated branches to test edge cases. 142 | #[test] 143 | fn test_unable_to_get_forkpoint_error() { 144 | let repo_name = "forkpoint_error_test"; 145 | let repo = setup_git_repo(repo_name); 146 | let path_to_repo = generate_path_to_repo(repo_name); 147 | 148 | // Create initial commit on master 149 | create_new_file(&path_to_repo, "init.txt", "Initial content"); 150 | first_commit_all(&repo, "Initial commit"); 151 | 152 | // Create completely unrelated branches 153 | // Branch1 - create orphan branch with its own history 154 | run_git_command(&path_to_repo, vec!["checkout", "--orphan", "branch1"]); 155 | run_git_command(&path_to_repo, vec!["rm", "-rf", "."]); 156 | create_new_file(&path_to_repo, "branch1.txt", "Branch 1 content"); 157 | run_git_command(&path_to_repo, vec!["add", "branch1.txt"]); 158 | run_git_command(&path_to_repo, vec!["commit", "-m", "Branch 1 commit"]); 159 | 160 | // Branch2 - another orphan branch with different history 161 | run_git_command(&path_to_repo, vec!["checkout", "--orphan", "branch2"]); 162 | run_git_command(&path_to_repo, vec!["rm", "-rf", "."]); 163 | create_new_file(&path_to_repo, "branch2.txt", "Branch 2 content"); 164 | run_git_command(&path_to_repo, vec!["add", "branch2.txt"]); 165 | run_git_command(&path_to_repo, vec!["commit", "-m", "Branch 2 commit"]); 166 | 167 | // Set up a chain with these completely unrelated branches 168 | let args: Vec<&str> = vec!["setup", "unrelated_chain", "master", "branch1", "branch2"]; 169 | 170 | // Setup should succeed - git-chain doesn't verify branch relationships at setup time 171 | run_test_bin_expect_ok(&path_to_repo, args); 172 | 173 | // Run rebase with our unrelated branches 174 | println!("Running git-chain rebase with unrelated branches:"); 175 | let (stdout, stderr) = run_and_check_for_error_messages(&path_to_repo, vec!["rebase"]); 176 | 177 | // Check for error messages about missing fork points or merge bases 178 | let error_patterns = [ 179 | "no merge base found", 180 | "Unable to get forkpoint", 181 | "common ancestor", 182 | "failed to find", 183 | ]; 184 | 185 | let has_error_message = error_patterns 186 | .iter() 187 | .any(|pattern| stderr.contains(pattern) || stdout.contains(pattern)); 188 | 189 | assert!(has_error_message, 190 | "Expected output to contain error about missing merge base or fork point.\nStdout: {}\nStderr: {}", 191 | stdout, stderr); 192 | 193 | // Clean up test repo 194 | teardown_git_repo(repo_name); 195 | } 196 | 197 | /// Tests for a rebase conflict scenario. 198 | /// 199 | /// This test creates a situation where branches have conflicts that 200 | /// will cause the rebase to fail. This tests git-chain's ability to detect and 201 | /// report conflicts during the rebase process. 202 | #[test] 203 | fn test_rebase_conflict_error() { 204 | let repo_name = "rebase_conflict_error"; 205 | let repo = setup_git_repo(repo_name); 206 | let path_to_repo = generate_path_to_repo(repo_name); 207 | 208 | // Create initial commit on master 209 | create_new_file(&path_to_repo, "init.txt", "Initial content"); 210 | first_commit_all(&repo, "Initial commit"); 211 | 212 | // Create branch1 from master 213 | run_git_command(&path_to_repo, vec!["branch", "branch1"]); 214 | run_git_command(&path_to_repo, vec!["checkout", "branch1"]); 215 | create_new_file(&path_to_repo, "branch1.txt", "Branch 1 content"); 216 | run_git_command(&path_to_repo, vec!["add", "branch1.txt"]); 217 | run_git_command(&path_to_repo, vec!["commit", "-m", "Branch 1 commit"]); 218 | 219 | // Create branch2 from branch1 220 | run_git_command(&path_to_repo, vec!["branch", "branch2"]); 221 | run_git_command(&path_to_repo, vec!["checkout", "branch2"]); 222 | create_new_file(&path_to_repo, "branch2.txt", "Branch 2 content"); 223 | run_git_command(&path_to_repo, vec!["add", "branch2.txt"]); 224 | run_git_command(&path_to_repo, vec!["commit", "-m", "Branch 2 commit"]); 225 | 226 | // Set up a chain 227 | run_test_bin_expect_ok( 228 | &path_to_repo, 229 | vec!["setup", "test_chain", "master", "branch1", "branch2"], 230 | ); 231 | 232 | // Create a scenario where rebasing would create a conflict: 233 | // Both master and branch1 modify the same file in different ways 234 | run_git_command(&path_to_repo, vec!["checkout", "master"]); 235 | create_new_file(&path_to_repo, "conflict.txt", "Master content"); 236 | run_git_command(&path_to_repo, vec!["add", "conflict.txt"]); 237 | run_git_command(&path_to_repo, vec!["commit", "-m", "Add file on master"]); 238 | 239 | run_git_command(&path_to_repo, vec!["checkout", "branch1"]); 240 | create_new_file(&path_to_repo, "conflict.txt", "Branch1 content"); 241 | run_git_command(&path_to_repo, vec!["add", "conflict.txt"]); 242 | run_git_command( 243 | &path_to_repo, 244 | vec!["commit", "-m", "Add conflicting file on branch1"], 245 | ); 246 | 247 | // Try rebasing - this should fail due to conflict 248 | println!("Running git-chain rebase with conflicting changes:"); 249 | let (stdout, stderr) = run_and_check_for_error_messages(&path_to_repo, vec!["rebase"]); 250 | 251 | // We expect to see a message about resolving rebase conflicts 252 | let has_conflict_message = stderr.contains("conflict") 253 | || stderr.contains("error") 254 | || stderr.contains("Unable to") 255 | || stdout.contains("conflict") 256 | || stdout.contains("CONFLICT"); 257 | 258 | assert!( 259 | has_conflict_message, 260 | "Expected message about rebase conflict.\nStdout: {}\nStderr: {}", 261 | stdout, stderr 262 | ); 263 | 264 | // Clean up any rebase in progress 265 | if repo.state() != RepositoryState::Clean { 266 | run_git_command(&path_to_repo, vec!["rebase", "--abort"]); 267 | } 268 | 269 | teardown_git_repo(repo_name); 270 | } 271 | -------------------------------------------------------------------------------- /tests/init.rs: -------------------------------------------------------------------------------- 1 | #[path = "common/mod.rs"] 2 | pub mod common; 3 | 4 | use common::{ 5 | checkout_branch, commit_all, create_branch, create_new_file, first_commit_all, 6 | generate_path_to_repo, get_current_branch_name, run_test_bin_expect_err, 7 | run_test_bin_expect_ok, setup_git_repo, teardown_git_repo, 8 | }; 9 | use git2::ConfigLevel; 10 | 11 | #[test] 12 | fn init_subcommand() { 13 | let repo_name = "init_subcommand"; 14 | let repo = setup_git_repo(repo_name); 15 | let path_to_repo = generate_path_to_repo(repo_name); 16 | 17 | { 18 | // create new file 19 | create_new_file(&path_to_repo, "hello_world.txt", "Hello, world!"); 20 | 21 | // add first commit to master 22 | first_commit_all(&repo, "first commit"); 23 | }; 24 | 25 | assert_eq!(&get_current_branch_name(&repo), "master"); 26 | 27 | // init subcommand with no arguments 28 | let args: Vec<&str> = vec!["init"]; 29 | let output = run_test_bin_expect_err(&path_to_repo, args); 30 | 31 | assert!(String::from_utf8_lossy(&output.stdout).is_empty()); 32 | assert!(String::from_utf8_lossy(&output.stderr) 33 | .contains("The following required arguments were not provided")); 34 | assert!(String::from_utf8_lossy(&output.stderr).contains("")); 35 | 36 | // init subcommand with chain name, but no root branch 37 | let args: Vec<&str> = vec!["init", "chain_name"]; 38 | let output = run_test_bin_expect_err(&path_to_repo, args); 39 | 40 | assert!(String::from_utf8_lossy(&output.stdout).is_empty()); 41 | assert!(String::from_utf8_lossy(&output.stderr).contains("Please provide the root branch.")); 42 | 43 | // init subcommand with chain name, and use current branch as the root branch 44 | assert_eq!(&get_current_branch_name(&repo), "master"); 45 | 46 | let args: Vec<&str> = vec!["init", "chain_name", "master"]; 47 | let output = run_test_bin_expect_err(&path_to_repo, args); 48 | 49 | assert!(String::from_utf8_lossy(&output.stdout).is_empty()); 50 | assert!(String::from_utf8_lossy(&output.stderr) 51 | .contains("Current branch cannot be the root branch: master")); 52 | 53 | // create and checkout new branch named some_branch_1 54 | { 55 | let branch_name = "some_branch_1"; 56 | create_branch(&repo, branch_name); 57 | checkout_branch(&repo, branch_name); 58 | }; 59 | 60 | { 61 | // create new file 62 | create_new_file(&path_to_repo, "file_1.txt", "contents 1"); 63 | 64 | // add commit to branch some_branch_1 65 | commit_all(&repo, "message"); 66 | }; 67 | 68 | // init subcommand with chain name, and use master as the root branch 69 | assert_eq!(&get_current_branch_name(&repo), "some_branch_1"); 70 | 71 | let args: Vec<&str> = vec!["init", "chain_name", "master"]; 72 | let output = run_test_bin_expect_ok(&path_to_repo, args); 73 | 74 | assert_eq!( 75 | String::from_utf8_lossy(&output.stdout), 76 | r#" 77 | 🔗 Succesfully set up branch: some_branch_1 78 | 79 | chain_name 80 | ➜ some_branch_1 ⦁ 1 ahead 81 | master (root branch) 82 | "# 83 | .trim_start() 84 | ); 85 | 86 | // verify generated git config values 87 | { 88 | let repo_config = repo.config().unwrap(); 89 | let local_config = repo_config.open_level(ConfigLevel::Local).unwrap(); 90 | 91 | let branch_name = "some_branch_1"; 92 | let config_chain_name = format!("branch.{}.chain-name", branch_name); 93 | let config_chain_order = format!("branch.{}.chain-order", branch_name); 94 | 95 | let config_root_branch = format!("branch.{}.root-branch", branch_name); 96 | 97 | let count = { 98 | let mut iter = local_config.entries(Some(&config_chain_name)).unwrap(); 99 | let mut count = 0; 100 | while iter.next().is_some() { 101 | count += 1; 102 | } 103 | count 104 | }; 105 | 106 | assert!(count == 1); 107 | 108 | let mut configs = local_config.entries(Some(&config_chain_name)).unwrap(); 109 | let config_entry = configs.next().unwrap().unwrap(); 110 | let config_chain_name_value = config_entry.value().unwrap(); 111 | assert!(config_chain_name_value == "chain_name"); 112 | 113 | let count = { 114 | let mut iter = local_config.entries(Some(&config_chain_order)).unwrap(); 115 | let mut count = 0; 116 | while iter.next().is_some() { 117 | count += 1; 118 | } 119 | count 120 | }; 121 | 122 | assert!(count == 1); 123 | 124 | let mut configs = local_config.entries(Some(&config_chain_order)).unwrap(); 125 | let config_entry = configs.next().unwrap().unwrap(); 126 | let config_chain_order_value = config_entry.value().unwrap(); 127 | assert_eq!(config_chain_order_value.len(), 5); 128 | assert!(!config_chain_order_value.contains("!")); 129 | assert!(!config_chain_order_value.contains("~")); 130 | 131 | let count = { 132 | let mut iter = local_config.entries(Some(&config_root_branch)).unwrap(); 133 | let mut count = 0; 134 | while iter.next().is_some() { 135 | count += 1; 136 | } 137 | count 138 | }; 139 | 140 | assert!(count == 1); 141 | 142 | let mut configs = local_config.entries(Some(&config_root_branch)).unwrap(); 143 | let config_entry = configs.next().unwrap().unwrap(); 144 | let config_root_branch_value = config_entry.value().unwrap(); 145 | assert!(config_root_branch_value == "master"); 146 | }; 147 | 148 | // create and checkout new branch named some_branch_2 149 | { 150 | let branch_name = "some_branch_2"; 151 | create_branch(&repo, branch_name); 152 | checkout_branch(&repo, branch_name); 153 | }; 154 | 155 | { 156 | // create new file 157 | create_new_file(&path_to_repo, "file_2.txt", "contents 2"); 158 | 159 | // add commit to branch some_branch_2 160 | commit_all(&repo, "message"); 161 | }; 162 | 163 | // init subcommand with existing chain name, and use some_branch_1 as the root branch 164 | assert_eq!(&get_current_branch_name(&repo), "some_branch_2"); 165 | 166 | let args: Vec<&str> = vec!["init", "chain_name", "some_branch_1"]; 167 | let output = run_test_bin_expect_ok(&path_to_repo, args); 168 | 169 | assert_eq!( 170 | String::from_utf8_lossy(&output.stdout), 171 | r#" 172 | Using root branch master of chain chain_name instead of some_branch_1 173 | 🔗 Succesfully set up branch: some_branch_2 174 | 175 | chain_name 176 | ➜ some_branch_2 ⦁ 1 ahead 177 | some_branch_1 ⦁ 1 ahead 178 | master (root branch) 179 | "# 180 | .trim_start() 181 | ); 182 | 183 | // create and checkout new branch named some_branch_3 184 | { 185 | let branch_name = "some_branch_3"; 186 | create_branch(&repo, branch_name); 187 | checkout_branch(&repo, branch_name); 188 | }; 189 | 190 | { 191 | // create new file 192 | create_new_file(&path_to_repo, "file_3.txt", "contents 3"); 193 | 194 | // add commit to branch some_branch_3 195 | commit_all(&repo, "message"); 196 | }; 197 | 198 | // init subcommand with existing chain name without any explicit root branch 199 | assert_eq!(&get_current_branch_name(&repo), "some_branch_3"); 200 | 201 | let args: Vec<&str> = vec!["init", "chain_name"]; 202 | let output = run_test_bin_expect_ok(&path_to_repo, args); 203 | 204 | assert_eq!( 205 | String::from_utf8_lossy(&output.stdout), 206 | r#" 207 | 🔗 Succesfully set up branch: some_branch_3 208 | 209 | chain_name 210 | ➜ some_branch_3 ⦁ 1 ahead 211 | some_branch_2 ⦁ 1 ahead 212 | some_branch_1 ⦁ 1 ahead 213 | master (root branch) 214 | "# 215 | .trim_start() 216 | ); 217 | 218 | // create and checkout new branch named some_branch_2.5 219 | { 220 | checkout_branch(&repo, "some_branch_2"); 221 | let branch_name = "some_branch_2.5"; 222 | create_branch(&repo, branch_name); 223 | checkout_branch(&repo, branch_name); 224 | }; 225 | 226 | { 227 | // create new file 228 | create_new_file(&path_to_repo, "file_2.5.txt", "contents 2.5"); 229 | 230 | // add commit to branch some_branch_2.5 231 | commit_all(&repo, "message"); 232 | }; 233 | 234 | // Test option: --before=branch 235 | assert_eq!(&get_current_branch_name(&repo), "some_branch_2.5"); 236 | 237 | let args: Vec<&str> = vec!["init", "chain_name", "--before=some_branch_3"]; 238 | let output = run_test_bin_expect_ok(&path_to_repo, args); 239 | 240 | assert_eq!( 241 | String::from_utf8_lossy(&output.stdout), 242 | r#" 243 | 🔗 Succesfully set up branch: some_branch_2.5 244 | 245 | chain_name 246 | some_branch_3 ⦁ 1 ahead ⦁ 1 behind 247 | ➜ some_branch_2.5 ⦁ 1 ahead 248 | some_branch_2 ⦁ 1 ahead 249 | some_branch_1 ⦁ 1 ahead 250 | master (root branch) 251 | "# 252 | .trim_start() 253 | ); 254 | 255 | // create and checkout new branch named some_branch_1.5 256 | { 257 | checkout_branch(&repo, "some_branch_1"); 258 | let branch_name = "some_branch_1.5"; 259 | create_branch(&repo, branch_name); 260 | checkout_branch(&repo, branch_name); 261 | }; 262 | 263 | { 264 | // create new file 265 | create_new_file(&path_to_repo, "file_1.5.txt", "contents 1.5"); 266 | 267 | // add commit to branch some_branch_1.5 268 | commit_all(&repo, "message"); 269 | }; 270 | 271 | // Test option: --after=branch 272 | assert_eq!(&get_current_branch_name(&repo), "some_branch_1.5"); 273 | 274 | let args: Vec<&str> = vec!["init", "chain_name", "--after=some_branch_1"]; 275 | let output = run_test_bin_expect_ok(&path_to_repo, args); 276 | 277 | assert_eq!( 278 | String::from_utf8_lossy(&output.stdout), 279 | r#" 280 | 🔗 Succesfully set up branch: some_branch_1.5 281 | 282 | chain_name 283 | some_branch_3 ⦁ 1 ahead ⦁ 1 behind 284 | some_branch_2.5 ⦁ 1 ahead 285 | some_branch_2 ⦁ 1 ahead ⦁ 1 behind 286 | ➜ some_branch_1.5 ⦁ 1 ahead 287 | some_branch_1 ⦁ 1 ahead 288 | master (root branch) 289 | "# 290 | .trim_start() 291 | ); 292 | 293 | // create and checkout new branch named some_branch_0 294 | { 295 | checkout_branch(&repo, "master"); 296 | let branch_name = "some_branch_0"; 297 | create_branch(&repo, branch_name); 298 | checkout_branch(&repo, branch_name); 299 | }; 300 | 301 | { 302 | // create new file 303 | create_new_file(&path_to_repo, "file_0.txt", "contents 0"); 304 | 305 | // add commit to branch some_branch_0 306 | commit_all(&repo, "message"); 307 | }; 308 | 309 | // Test option: --first 310 | assert_eq!(&get_current_branch_name(&repo), "some_branch_0"); 311 | 312 | let args: Vec<&str> = vec!["init", "chain_name", "--first"]; 313 | let output = run_test_bin_expect_ok(&path_to_repo, args); 314 | 315 | assert_eq!( 316 | String::from_utf8_lossy(&output.stdout), 317 | r#" 318 | 🔗 Succesfully set up branch: some_branch_0 319 | 320 | chain_name 321 | some_branch_3 ⦁ 1 ahead ⦁ 1 behind 322 | some_branch_2.5 ⦁ 1 ahead 323 | some_branch_2 ⦁ 1 ahead ⦁ 1 behind 324 | some_branch_1.5 ⦁ 1 ahead 325 | some_branch_1 ⦁ 1 ahead ⦁ 1 behind 326 | ➜ some_branch_0 ⦁ 1 ahead 327 | master (root branch) 328 | "# 329 | .trim_start() 330 | ); 331 | 332 | // git chain 333 | let args: Vec<&str> = vec![]; 334 | let output = run_test_bin_expect_ok(&path_to_repo, args); 335 | 336 | assert_eq!( 337 | String::from_utf8_lossy(&output.stdout), 338 | r#" 339 | On branch: some_branch_0 340 | 341 | chain_name 342 | some_branch_3 ⦁ 1 ahead ⦁ 1 behind 343 | some_branch_2.5 ⦁ 1 ahead 344 | some_branch_2 ⦁ 1 ahead ⦁ 1 behind 345 | some_branch_1.5 ⦁ 1 ahead 346 | some_branch_1 ⦁ 1 ahead ⦁ 1 behind 347 | ➜ some_branch_0 ⦁ 1 ahead 348 | master (root branch) 349 | "# 350 | .trim_start() 351 | ); 352 | 353 | teardown_git_repo(repo_name); 354 | } 355 | -------------------------------------------------------------------------------- /src/git_chain/core.rs: -------------------------------------------------------------------------------- 1 | use std::process; 2 | 3 | use colored::*; 4 | use git2::{BranchType, Config, ConfigLevel, Error, ErrorClass, ErrorCode, ObjectType, Repository}; 5 | use regex::Regex; 6 | 7 | use super::GitChain; 8 | use crate::types::*; 9 | use crate::{executable_name, Branch, Chain}; 10 | 11 | impl GitChain { 12 | pub fn init() -> Result { 13 | let name_of_current_executable = executable_name(); 14 | 15 | let repo = match Repository::discover(".") { 16 | Ok(repo) => repo, 17 | Err(ref e) 18 | if e.class() == ErrorClass::Repository && e.code() == ErrorCode::NotFound => 19 | { 20 | eprintln!( 21 | "{} Not a git repository (or any of the parent directories)", 22 | "error:".red().bold() 23 | ); 24 | eprintln!( 25 | "\n{} This command must be run inside a git repository.", 26 | "hint:".yellow().bold() 27 | ); 28 | eprintln!( 29 | "{} Run {} to create a new git repository.", 30 | "hint:".yellow().bold(), 31 | "git init".bold() 32 | ); 33 | process::exit(1); 34 | } 35 | Err(e) => return Err(e), 36 | }; 37 | 38 | if repo.is_bare() { 39 | eprintln!( 40 | "Cannot run {} on bare git repository.", 41 | name_of_current_executable 42 | ); 43 | process::exit(1); 44 | } 45 | 46 | let git_chain = GitChain { 47 | repo, 48 | executable_name: name_of_current_executable, 49 | }; 50 | Ok(git_chain) 51 | } 52 | 53 | pub fn get_current_branch_name(&self) -> Result { 54 | let head = match self.repo.head() { 55 | Ok(head) => Some(head), 56 | Err(ref e) 57 | if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound => 58 | { 59 | None 60 | } 61 | Err(e) => return Err(e), 62 | }; 63 | 64 | let head = head.as_ref().and_then(|h| h.shorthand()); 65 | 66 | match head { 67 | Some(branch_name) => Ok(branch_name.to_string()), 68 | None => Err(Error::from_str("Unable to get current branch name.")), 69 | } 70 | } 71 | 72 | pub fn get_local_git_config(&self) -> Result { 73 | self.repo.config()?.open_level(ConfigLevel::Local) 74 | } 75 | 76 | pub fn get_git_config(&self, key: &str) -> Result, Error> { 77 | let local_config = self.get_local_git_config()?; 78 | match local_config.get_string(key) { 79 | Ok(value) => Ok(Some(value)), 80 | Err(ref e) if e.code() == ErrorCode::NotFound => Ok(None), 81 | Err(e) => Err(e), 82 | } 83 | } 84 | 85 | pub fn get_git_configs_matching_key( 86 | &self, 87 | regexp: &Regex, 88 | ) -> Result, Error> { 89 | let local_config = self.get_local_git_config()?; 90 | let mut entries = vec![]; 91 | 92 | local_config.entries(None)?.for_each(|entry| { 93 | if let Some(key) = entry.name() { 94 | if regexp.is_match(key) && entry.has_value() { 95 | let key = key.to_string(); 96 | let value = entry.value().unwrap().to_string(); 97 | entries.push((key, value)); 98 | } 99 | } 100 | })?; 101 | 102 | Ok(entries) 103 | } 104 | 105 | pub fn set_git_config(&self, key: &str, value: &str) -> Result<(), Error> { 106 | let mut local_config = self.get_local_git_config()?; 107 | local_config.set_str(key, value)?; 108 | Ok(()) 109 | } 110 | 111 | pub fn delete_git_config(&self, key: &str) -> Result<(), Error> { 112 | let mut local_config = self.get_local_git_config()?; 113 | match local_config.remove(key) { 114 | Ok(()) => Ok(()), 115 | Err(ref e) if e.code() == ErrorCode::NotFound => Ok(()), 116 | Err(e) => Err(e), 117 | } 118 | } 119 | 120 | pub fn checkout_branch(&self, branch_name: &str) -> Result<(), Error> { 121 | let (object, reference) = self.repo.revparse_ext(branch_name)?; 122 | 123 | // set working directory 124 | self.repo.checkout_tree(&object, None)?; 125 | 126 | // set HEAD to branch_name 127 | match reference { 128 | // ref_name is an actual reference like branches or tags 129 | Some(ref_name) => self.repo.set_head(ref_name.name().unwrap()), 130 | // this is a commit, not a reference 131 | None => self.repo.set_head_detached(object.id()), 132 | } 133 | .unwrap_or_else(|_| panic!("Failed to set HEAD to branch {}", branch_name)); 134 | 135 | Ok(()) 136 | } 137 | 138 | pub fn git_branch_exists(&self, branch_name: &str) -> Result { 139 | Ok(self.git_local_branch_exists(branch_name)? 140 | || self.git_remote_branch_exists(branch_name)?) 141 | } 142 | 143 | pub fn git_local_branch_exists(&self, branch_name: &str) -> Result { 144 | match self.repo.find_branch(branch_name, BranchType::Local) { 145 | Ok(_branch) => Ok(true), 146 | Err(ref e) if e.code() == ErrorCode::NotFound => Ok(false), 147 | Err(e) => Err(e), 148 | } 149 | } 150 | 151 | pub fn git_remote_branch_exists(&self, branch_name: &str) -> Result { 152 | match self.repo.find_branch(branch_name, BranchType::Remote) { 153 | Ok(_branch) => Ok(true), 154 | Err(ref e) if e.code() == ErrorCode::NotFound => Ok(false), 155 | Err(e) => Err(e), 156 | } 157 | } 158 | 159 | pub fn display_branch_not_part_of_chain_error(&self, branch_name: &str) { 160 | eprintln!("❌ Branch is not part of any chain: {}", branch_name.bold()); 161 | eprintln!( 162 | "To initialize a chain for this branch, run {} init ", 163 | self.executable_name 164 | ); 165 | } 166 | 167 | pub fn run_status(&self, show_prs: bool) -> Result<(), Error> { 168 | let branch_name = self.get_current_branch_name()?; 169 | println!("On branch: {}", branch_name.bold()); 170 | println!(); 171 | 172 | let results = Branch::get_branch_with_chain(self, &branch_name)?; 173 | 174 | match results { 175 | BranchSearchResult::NotPartOfAnyChain => { 176 | self.display_branch_not_part_of_chain_error(&branch_name); 177 | process::exit(1); 178 | } 179 | BranchSearchResult::Branch(branch) => { 180 | branch.display_status(self, show_prs)?; 181 | } 182 | } 183 | 184 | Ok(()) 185 | } 186 | 187 | pub fn init_chain( 188 | &self, 189 | chain_name: &str, 190 | root_branch: &str, 191 | branch_name: &str, 192 | sort_option: SortBranch, 193 | ) -> Result<(), Error> { 194 | let results = Branch::get_branch_with_chain(self, branch_name)?; 195 | 196 | match results { 197 | BranchSearchResult::NotPartOfAnyChain => { 198 | Branch::setup_branch(self, chain_name, root_branch, branch_name, &sort_option)?; 199 | 200 | match Branch::get_branch_with_chain(self, branch_name)? { 201 | BranchSearchResult::NotPartOfAnyChain => { 202 | eprintln!("Unable to set up chain for branch: {}", branch_name.bold()); 203 | process::exit(1); 204 | } 205 | BranchSearchResult::Branch(branch) => { 206 | println!("🔗 Succesfully set up branch: {}", branch_name.bold()); 207 | println!(); 208 | branch.display_status(self, false)?; 209 | } 210 | }; 211 | } 212 | BranchSearchResult::Branch(branch) => { 213 | eprintln!("❌ Unable to initialize branch to a chain.",); 214 | eprintln!(); 215 | eprintln!("Branch already part of a chain: {}", branch_name.bold()); 216 | eprintln!("It is part of the chain: {}", branch.chain_name.bold()); 217 | eprintln!("With root branch: {}", branch.root_branch.bold()); 218 | process::exit(1); 219 | } 220 | }; 221 | 222 | Ok(()) 223 | } 224 | 225 | pub fn remove_branch_from_chain(&self, branch_name: String) -> Result<(), Error> { 226 | let results = Branch::get_branch_with_chain(self, &branch_name)?; 227 | 228 | match results { 229 | BranchSearchResult::NotPartOfAnyChain => { 230 | Branch::delete_all_configs(self, &branch_name)?; 231 | 232 | println!( 233 | "Unable to remove branch from its chain: {}", 234 | branch_name.bold() 235 | ); 236 | println!("It is not part of any chain. Nothing to do."); 237 | } 238 | BranchSearchResult::Branch(branch) => { 239 | let chain_name = branch.chain_name.clone(); 240 | let root_branch = branch.root_branch.clone(); 241 | branch.remove_from_chain(self)?; 242 | 243 | println!( 244 | "Removed branch {} from chain {}", 245 | branch_name.bold(), 246 | chain_name.bold() 247 | ); 248 | println!("Its root branch was: {}", root_branch.bold()); 249 | } 250 | }; 251 | Ok(()) 252 | } 253 | 254 | pub fn list_chains(&self, current_branch: &str, show_prs: bool) -> Result<(), Error> { 255 | let list = Chain::get_all_chains(self)?; 256 | 257 | if list.is_empty() { 258 | println!("No chains to list."); 259 | println!( 260 | "To initialize a chain for this branch, run {} init ", 261 | self.executable_name 262 | ); 263 | return Ok(()); 264 | } 265 | 266 | for (index, chain) in list.iter().enumerate() { 267 | chain.display_list(self, current_branch, show_prs)?; 268 | 269 | if index != list.len() - 1 { 270 | println!(); 271 | } 272 | } 273 | 274 | Ok(()) 275 | } 276 | 277 | pub fn move_branch( 278 | &self, 279 | chain_name: &str, 280 | branch_name: &str, 281 | sort_option: &SortBranch, 282 | ) -> Result<(), Error> { 283 | match Branch::get_branch_with_chain(self, branch_name)? { 284 | BranchSearchResult::NotPartOfAnyChain => { 285 | self.display_branch_not_part_of_chain_error(branch_name); 286 | process::exit(1); 287 | } 288 | BranchSearchResult::Branch(branch) => { 289 | branch.move_branch(self, chain_name, sort_option)?; 290 | 291 | match Branch::get_branch_with_chain(self, &branch.branch_name)? { 292 | BranchSearchResult::NotPartOfAnyChain => { 293 | eprintln!("Unable to move branch: {}", branch.branch_name.bold()); 294 | process::exit(1); 295 | } 296 | BranchSearchResult::Branch(branch) => { 297 | println!("🔗 Succesfully moved branch: {}", branch.branch_name.bold()); 298 | println!(); 299 | branch.display_status(self, false)?; 300 | } 301 | }; 302 | } 303 | }; 304 | 305 | Ok(()) 306 | } 307 | 308 | pub fn get_commit_hash_of_head(&self) -> Result { 309 | let head = self.repo.head()?; 310 | let oid = head.target().unwrap(); 311 | let commit = self.repo.find_commit(oid).unwrap(); 312 | Ok(commit.id().to_string()) 313 | } 314 | 315 | pub fn get_tree_id_from_branch_name(&self, branch_name: &str) -> Result { 316 | match self 317 | .repo 318 | .revparse_single(&format!("{}^{{tree}}", branch_name)) 319 | { 320 | Ok(tree_object) => { 321 | assert_eq!(tree_object.kind().unwrap(), ObjectType::Tree); 322 | Ok(tree_object.id().to_string()) 323 | } 324 | Err(_err) => Err(Error::from_str(&format!( 325 | "Unable to get tree id of branch {}", 326 | branch_name.bold() 327 | ))), 328 | } 329 | } 330 | 331 | pub fn dirty_working_directory(&self) -> Result { 332 | // perform equivalent to git diff-index HEAD 333 | let obj = self.repo.revparse_single("HEAD")?; 334 | let tree = obj.peel(ObjectType::Tree)?; 335 | 336 | let diff = self 337 | .repo 338 | .diff_tree_to_workdir_with_index(tree.as_tree(), None)?; 339 | 340 | let diff_stats = diff.stats()?; 341 | let has_changes = diff_stats.files_changed() > 0 342 | || diff_stats.insertions() > 0 343 | || diff_stats.deletions() > 0; 344 | 345 | Ok(has_changes) 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /src/chain.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::process::{self, Command}; 3 | 4 | use colored::*; 5 | use git2::Error; 6 | use regex::Regex; 7 | 8 | use crate::types::*; 9 | use crate::{check_gh_cli_installed, Branch, GitChain}; 10 | 11 | #[derive(Clone)] 12 | pub struct Chain { 13 | pub name: String, 14 | pub root_branch: String, 15 | pub branches: Vec, 16 | } 17 | 18 | impl Chain { 19 | fn get_all_branch_configs(git_chain: &GitChain) -> Result, Error> { 20 | let key_regex = Regex::new(r"^branch\.(?P.+)\.chain-name$".trim()).unwrap(); 21 | git_chain.get_git_configs_matching_key(&key_regex) 22 | } 23 | 24 | pub fn get_all_chains(git_chain: &GitChain) -> Result, Error> { 25 | let entries = Chain::get_all_branch_configs(git_chain)?; 26 | 27 | let mut chains: HashMap = HashMap::new(); 28 | 29 | for (_key, chain_name) in entries { 30 | if chains.contains_key(&chain_name) { 31 | continue; 32 | } 33 | 34 | let chain = Chain::get_chain(git_chain, &chain_name)?; 35 | chains.insert(chain_name, chain); 36 | } 37 | 38 | let mut list: Vec = chains.values().cloned().collect(); 39 | list.sort_by_key(|c| c.name.clone()); 40 | Ok(list) 41 | } 42 | 43 | fn get_branches_for_chain( 44 | git_chain: &GitChain, 45 | chain_name: &str, 46 | ) -> Result, Error> { 47 | let key_regex = Regex::new(r"^branch\.(?P.+)\.chain-name$".trim()).unwrap(); 48 | let mut branches: Vec = vec![]; 49 | 50 | let entries = Chain::get_all_branch_configs(git_chain)?; 51 | for (key, value) in entries { 52 | if value != chain_name { 53 | continue; 54 | } 55 | 56 | let captures = key_regex.captures(&key).unwrap(); 57 | let branch_name = &captures["branch_name"]; 58 | 59 | let results = Branch::get_branch_with_chain(git_chain, branch_name)?; 60 | 61 | match results { 62 | BranchSearchResult::NotPartOfAnyChain => { 63 | // TODO: could this fail silently? 64 | eprintln!( 65 | "Branch not correctly set up as part of a chain: {}", 66 | branch_name.bold() 67 | ); 68 | process::exit(1); 69 | } 70 | BranchSearchResult::Branch(branch) => { 71 | branches.push(branch); 72 | } 73 | }; 74 | } 75 | 76 | Ok(branches) 77 | } 78 | 79 | pub fn chain_exists(git_chain: &GitChain, chain_name: &str) -> Result { 80 | let branches = Chain::get_branches_for_chain(git_chain, chain_name)?; 81 | Ok(!branches.is_empty()) 82 | } 83 | 84 | pub fn get_chain(git_chain: &GitChain, chain_name: &str) -> Result { 85 | let mut branches = Chain::get_branches_for_chain(git_chain, chain_name)?; 86 | 87 | if branches.is_empty() { 88 | return Err(Error::from_str(&format!( 89 | "Unable to get branches attached to chain: {}", 90 | chain_name 91 | ))); 92 | } 93 | 94 | // TODO: ensure all branches have the same root 95 | 96 | branches.sort_by_key(|b| b.chain_order.clone()); 97 | 98 | // use first branch as the source of the root branch 99 | let root_branch = branches[0].root_branch.clone(); 100 | 101 | let chain = Chain { 102 | name: chain_name.to_string(), 103 | root_branch, 104 | branches, 105 | }; 106 | 107 | Ok(chain) 108 | } 109 | 110 | pub fn has_chain_order(&self, chain_order: &str) -> bool { 111 | for branch in &self.branches { 112 | if branch.chain_order == chain_order { 113 | return true; 114 | } 115 | } 116 | false 117 | } 118 | 119 | fn display_ahead_behind( 120 | &self, 121 | git_chain: &GitChain, 122 | upstream: &str, 123 | branch: &str, 124 | ) -> Result { 125 | let (upstream_obj, _reference) = git_chain.repo.revparse_ext(upstream)?; 126 | let (branch_obj, _reference) = git_chain.repo.revparse_ext(branch)?; 127 | 128 | let ahead_behind = git_chain 129 | .repo 130 | .graph_ahead_behind(branch_obj.id(), upstream_obj.id())?; 131 | 132 | let status = match ahead_behind { 133 | (0, 0) => "".to_string(), 134 | (ahead, 0) => { 135 | format!("{} ahead", ahead) 136 | } 137 | (0, behind) => { 138 | format!("{} behind", behind) 139 | } 140 | (ahead, behind) => { 141 | format!("{} ahead ⦁ {} behind", ahead, behind) 142 | } 143 | }; 144 | 145 | Ok(status) 146 | } 147 | 148 | pub fn display_list( 149 | &self, 150 | git_chain: &GitChain, 151 | current_branch: &str, 152 | show_prs: bool, 153 | ) -> Result<(), Error> { 154 | println!("{}", self.name); 155 | 156 | let mut branches = self.branches.clone(); 157 | branches.reverse(); 158 | 159 | for (index, branch) in branches.iter().enumerate() { 160 | let (marker, branch_name) = if branch.branch_name == current_branch { 161 | ("➜ ", branch.branch_name.bold().to_string()) 162 | } else { 163 | ("", branch.branch_name.clone()) 164 | }; 165 | 166 | let upstream = if index == branches.len() - 1 { 167 | &self.root_branch 168 | } else { 169 | &branches[index + 1].branch_name 170 | }; 171 | 172 | let ahead_behind_status = 173 | self.display_ahead_behind(git_chain, upstream, &branch.branch_name)?; 174 | 175 | let mut status_line = if ahead_behind_status.is_empty() { 176 | format!("{:>6}{}", marker, branch_name) 177 | } else { 178 | format!("{:>6}{} ⦁ {}", marker, branch_name, ahead_behind_status) 179 | }; 180 | 181 | if show_prs && check_gh_cli_installed().is_ok() { 182 | // Check for open pull requests for each branch 183 | let output = Command::new("gh") 184 | .arg("pr") 185 | .arg("list") 186 | .arg("--state") 187 | .arg("all") 188 | .arg("--head") 189 | .arg(&branch.branch_name) 190 | .arg("--json") 191 | .arg("url,state") 192 | .output(); 193 | 194 | match output { 195 | Ok(output) if output.status.success() => { 196 | let stdout = String::from_utf8_lossy(&output.stdout); 197 | let pr_objects: Vec = 198 | serde_json::from_str(&stdout).unwrap_or_default(); 199 | let pr_details: Vec = pr_objects 200 | .iter() 201 | .filter_map(|pr| { 202 | let url = pr.get("url").and_then(|url| url.as_str()); 203 | let state = pr.get("state").and_then(|state| state.as_str()); 204 | match (url, state) { 205 | (Some(url), Some(state)) => { 206 | let colored_state = match state { 207 | "MERGED" => "Merged".purple().to_string(), 208 | "OPEN" => "Open".green().to_string(), 209 | "CLOSED" => "Closed".red().to_string(), 210 | _ => state.to_string(), 211 | }; 212 | Some(format!("{} [{}]", url, colored_state)) 213 | } 214 | _ => None, 215 | } 216 | }) 217 | .collect(); 218 | 219 | if !pr_details.is_empty() { 220 | let pr_list = pr_details.join("; "); 221 | status_line.push_str(&format!(" ({})", pr_list)); 222 | } 223 | } 224 | _ => { 225 | eprintln!( 226 | " Failed to retrieve PRs for branch {}.", 227 | branch.branch_name.bold() 228 | ); 229 | } 230 | } 231 | } 232 | 233 | println!("{}", status_line.trim_end()); 234 | } 235 | 236 | if self.root_branch == current_branch { 237 | println!("{:>6}{} (root branch)", "➜ ", self.root_branch.bold()); 238 | } else { 239 | println!("{:>6}{} (root branch)", "", self.root_branch); 240 | }; 241 | 242 | Ok(()) 243 | } 244 | 245 | pub fn before(&self, needle_branch: &Branch) -> Option { 246 | if self.branches.is_empty() { 247 | return None; 248 | } 249 | 250 | let maybe_index = self.branches.iter().position(|b| b == needle_branch); 251 | 252 | match maybe_index { 253 | None => None, 254 | Some(index) => { 255 | if index > 0 { 256 | let before_branch = self.branches[index - 1].clone(); 257 | return Some(before_branch); 258 | } 259 | None 260 | } 261 | } 262 | } 263 | 264 | pub fn after(&self, needle_branch: &Branch) -> Option { 265 | if self.branches.is_empty() { 266 | return None; 267 | } 268 | 269 | let maybe_index = self.branches.iter().position(|b| b == needle_branch); 270 | 271 | match maybe_index { 272 | None => None, 273 | Some(index) => { 274 | if index == (self.branches.len() - 1) { 275 | return None; 276 | } 277 | let after_branch = self.branches[index + 1].clone(); 278 | Some(after_branch) 279 | } 280 | } 281 | } 282 | 283 | pub fn change_root_branch( 284 | &self, 285 | git_chain: &GitChain, 286 | new_root_branch: &str, 287 | ) -> Result<(), Error> { 288 | // verify that none of the branches of the chain are equal to new_root_branch 289 | for branch in &self.branches { 290 | if new_root_branch == branch.branch_name { 291 | eprintln!( 292 | "Unable to update the root branch for the branches in the chain: {}", 293 | self.name.bold() 294 | ); 295 | eprintln!( 296 | "Branch cannot be the root branch: {}", 297 | branch.branch_name.bold() 298 | ); 299 | process::exit(1); 300 | } 301 | } 302 | 303 | for branch in &self.branches { 304 | branch.change_root_branch(git_chain, new_root_branch)?; 305 | } 306 | 307 | Ok(()) 308 | } 309 | 310 | pub fn delete(self, git_chain: &GitChain) -> Result, Error> { 311 | let mut deleted_branches: Vec = vec![]; 312 | for branch in self.branches { 313 | deleted_branches.push(branch.branch_name.clone()); 314 | branch.remove_from_chain(git_chain)?; 315 | } 316 | 317 | Ok(deleted_branches) 318 | } 319 | 320 | pub fn backup(&self, git_chain: &GitChain) -> Result<(), Error> { 321 | for branch in &self.branches { 322 | branch.backup(git_chain)?; 323 | } 324 | Ok(()) 325 | } 326 | 327 | pub fn push(&self, git_chain: &GitChain, force_push: bool) -> Result { 328 | let mut num_of_pushes = 0; 329 | for branch in &self.branches { 330 | if branch.push(git_chain, force_push)? { 331 | num_of_pushes += 1; 332 | } 333 | } 334 | Ok(num_of_pushes) 335 | } 336 | 337 | pub fn prune(&self, git_chain: &GitChain, dry_run: bool) -> Result, Error> { 338 | let mut pruned_branches = vec![]; 339 | for branch in self.branches.clone() { 340 | // branch is an ancestor of the root branch if: 341 | // - it is the root branch, or 342 | // - the branch is a commit that occurs before the root branch. 343 | if git_chain.is_ancestor(&branch.branch_name, &self.root_branch)? { 344 | let branch_name = branch.branch_name.clone(); 345 | 346 | if !dry_run { 347 | branch.remove_from_chain(git_chain)?; 348 | } 349 | 350 | pruned_branches.push(branch_name); 351 | } 352 | } 353 | Ok(pruned_branches) 354 | } 355 | 356 | pub fn rename(self, git_chain: &GitChain, new_chain_name: &str) -> Result<(), Error> { 357 | // invariant: new_chain_name chain does not exist 358 | assert!(!Chain::chain_exists(git_chain, new_chain_name)?); 359 | 360 | for branch in self.branches { 361 | Branch::setup_branch( 362 | git_chain, 363 | new_chain_name, 364 | &branch.root_branch, 365 | &branch.branch_name, 366 | &SortBranch::Last, 367 | )?; 368 | } 369 | Ok(()) 370 | } 371 | } 372 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Git-Chain Development Guidelines 2 | 3 | ## Build, Test, Lint Commands 4 | - Build: `cargo build --release` 5 | - Run all tests: `cargo test` 6 | - Run a specific test: `cargo test test_name` 7 | - Run tests in a specific file: `cargo test --test backup` 8 | - Check for errors without running tests: `cargo check` 9 | - Format code: `cargo fmt` 10 | - Check for linting issues: `cargo clippy` 11 | 12 | ## Code Style Guidelines 13 | - **Formatting**: Follow standard Rust style with 4-space indentation 14 | - **Imports**: Group imports by std, external crates, then local modules 15 | - **Naming**: Use snake_case for variables/functions, CamelCase for types/structs 16 | - **Error Handling**: Use Result types with descriptive error messages 17 | - **Tests**: Create integration tests in the tests/ directory 18 | - Write separate assertions instead of combining with OR conditions 19 | - For example, use: 20 | ```rust 21 | assert!(output.status.success()); 22 | assert!(stdout.contains("Expected message")); 23 | ``` 24 | Instead of: 25 | ```rust 26 | assert!(output.status.success() || stdout.contains("Expected message")); 27 | ``` 28 | - **Documentation**: Document all public functions with doc comments 29 | - **Git Workflow**: Create focused commits with descriptive messages 30 | - **Comments**: Explain complex operations, not obvious functionality 31 | 32 | 33 | ## Test Writing Guidelines 34 | 35 | ### Important Rules for Writing Tests 36 | 37 | 1. **Avoid OR conditions in assertions** 38 | 39 | Please avoid using the OR operator (`||`) in assertions, as it creates test conditions that may evaluate differently depending on the order of execution. 40 | 41 | ❌ **Avoid this pattern**: 42 | ```rust 43 | assert!(!output.status.success() || stdout.contains("Merge conflicts:"), 44 | "Expected either a non-zero exit code or conflict message in output"); 45 | ``` 46 | 47 | ✅ **Use this pattern instead**: 48 | ```rust 49 | assert!(!output.status.success(), 50 | "Expected command to fail but it succeeded"); 51 | assert!(stdout.contains("Merge conflicts:"), 52 | "Expected output to contain conflict message"); 53 | ``` 54 | 55 | 2. **Avoid conditional assertions** 56 | 57 | Never use `if/else` blocks to conditionally execute different assertions. This makes test logic difficult to follow and can hide issues. 58 | 59 | ❌ **Avoid this pattern**: 60 | ```rust 61 | if !output.status.success() { 62 | assert!(true, "Merge failed as expected due to conflicts"); 63 | } else { 64 | assert!(stdout.contains("Merge conflicts:"), "Expected output to contain conflict message"); 65 | } 66 | ``` 67 | 68 | ✅ **Use this pattern instead**: 69 | ```rust 70 | assert!(!output.status.success(), "Merge failed as expected due to conflicts"); 71 | assert!(stdout.contains("Merge conflicts:"), "Expected output to contain conflict message"); 72 | ``` 73 | 74 | 3. **Always check stdout, stderr, and status separately** 75 | 76 | When testing command output, always check stdout, stderr, and exit status with separate assertions. This makes failures more specific and easier to debug. 77 | 78 | ✅ **Recommended pattern**: 79 | ```rust 80 | // Print debug information 81 | println!("STDOUT: {}", stdout); 82 | println!("STDERR: {}", stderr); 83 | println!("STATUS: {}", output.status.success()); 84 | 85 | // Separate assertions with detailed error messages 86 | assert!(output.status.success(), "Command failed unexpectedly"); 87 | assert!(stdout.contains("Expected text"), "stdout should contain expected text but got: {}", stdout); 88 | assert!(stderr.is_empty(), "stderr should be empty but got: {}", stderr); 89 | ``` 90 | 91 | 4. **Include detailed error messages** 92 | 93 | Always include descriptive error messages in assertions, and where relevant, show the actual values that failed the assertion. 94 | 95 | ✅ **Example**: 96 | ```rust 97 | assert!( 98 | stdout.contains("Successfully merged"), 99 | "stdout should indicate successful merge but got: {}", 100 | stdout 101 | ); 102 | ``` 103 | 104 | 5. **Use diagnostic printing with corresponding assertions** 105 | 106 | For complex tests, use diagnostic printing to show exactly what's being tested, but always accompany diagnostics with corresponding assertions. Never print diagnostic information without also asserting on the conditions being diagnosed. 107 | 108 | ❌ **Avoid this pattern** (diagnostics without assertions): 109 | ```rust 110 | // Only printing diagnostics without asserting 111 | println!("Contains 'expected term' in stdout: {}", stdout.contains("expected term")); 112 | println!("Command succeeded: {}", output.status.success()); 113 | ``` 114 | 115 | ✅ **Recommended pattern**: 116 | ```rust 117 | // Print key test conditions clearly 118 | println!("Contains 'expected term' in stdout: {}", stdout.contains("expected term")); 119 | println!("Contains 'expected term' in stderr: {}", stderr.contains("expected term")); 120 | 121 | // Print expected vs. observed behavior 122 | println!("EXPECTED BEHAVIOR: Command should fail with an error message"); 123 | println!("OBSERVED: Command {} with message: {}", 124 | if output.status.success() { "succeeded" } else { "failed" }, 125 | if !stderr.is_empty() { &stderr } else { "none" }); 126 | 127 | // Always assert on the conditions you're diagnosing 128 | assert!(!output.status.success(), "Command should have failed"); 129 | assert!(stdout.contains("expected term"), "Expected term should be in stdout"); 130 | assert!(!stderr.is_empty(), "Error message should be present in stderr"); 131 | ``` 132 | 133 | For every diagnostic print, there should be a corresponding assertion. This includes: 134 | - Exit status (output.status.success()) 135 | - Standard output content (stdout) 136 | - Standard error content (stderr) 137 | - Any other conditions that are critical to the test 138 | 139 | 6. **Include commented debug assertions with captured output** 140 | 141 | Add commented-out assertions that print variable values when failing. This technique captures and displays the exact content of variables when test execution stops, making debugging much easier. 142 | 143 | ✅ **Example**: 144 | ```rust 145 | // Uncomment to stop test execution and debug this test case 146 | // assert!(false, "DEBUG STOP: Test section name"); 147 | // assert!(false, "stdout: {}", stdout); 148 | // assert!(false, "stderr: {}", stderr); 149 | // assert!(false, "status code: {}", output.status.code().unwrap_or(0)); 150 | // assert!(false, "git branch output: {}", git_branch_output); 151 | 152 | // Regular assertions follow 153 | assert!(output.status.success(), "Command should succeed"); 154 | ``` 155 | 156 | This technique is especially useful because: 157 | - The output is formatted directly in the error message 158 | - Multi-line outputs are preserved in the test failure message 159 | - You can capture the exact state at failure time 160 | - Variables are evaluated at exactly that point in execution 161 | - It works better than println!() when output is interleaved 162 | 163 | The goal is to create tests that are: 164 | - Clear about what they're testing 165 | - Provide specific feedback when they fail 166 | - Evaluate all conditions regardless of short-circuit evaluation 167 | - Are easy to debug when something goes wrong 168 | - Include enough diagnostic information to understand behavior 169 | 170 | ## Test, Debug, Edit Loop 171 | 172 | When developing or updating tests, follow this systematic approach to ensure all conditions are properly tested. VERY IMPORTANT: You must complete this entire loop for one test before moving to the next test. 173 | 174 | ### Step-by-Step Process 175 | 176 | 1. **Analyze the test** - Understand what behavior or condition the test should verify 177 | ```rust 178 | // First review the test to understand its purpose 179 | // Example test to verify merge fails with uncommitted changes 180 | ``` 181 | 182 | 2. **Add diagnostic printing** - Insert detailed diagnostics that reveal current state 183 | ```rust 184 | // Print relevant state and conditions 185 | println!("=== TEST DIAGNOSTICS ==="); 186 | println!("STDOUT: {}", stdout); 187 | println!("STDERR: {}", stderr); 188 | println!("EXIT STATUS: {}", output.status); 189 | println!("Has uncommitted changes: {}", has_uncommitted_changes); 190 | println!("Current branch: {}", current_branch); 191 | println!("======"); 192 | ``` 193 | 194 | 3. **Insert debug breaks with captured output** - Add assertions that stop execution and display output 195 | ```rust 196 | // Uncomment to stop test execution and inspect state with captured output 197 | // assert!(false, "DEBUG STOP: uncommitted_changes test section"); 198 | // assert!(false, "stdout: {}", stdout); 199 | // assert!(false, "stderr: {}", stderr); 200 | // assert!(false, "status code: {}", output.status.code().unwrap_or(0)); 201 | ``` 202 | 203 | 4. **Run the specific test** - Execute only the test you're working on 204 | ``` 205 | cargo test test_merge_with_uncommitted_changes -- --nocapture 206 | ``` 207 | Note: The `--nocapture` flag ensures println! output is displayed 208 | 209 | 5. **Review diagnostics** - Analyze the output to determine correct assertions 210 | 211 | 6. **Add appropriate assertions** - Create specific assertions based on diagnostics 212 | ```rust 213 | // Add assertions that precisely test expected conditions 214 | assert!(!output.status.success(), "Command should fail with uncommitted changes"); 215 | assert!(stderr.contains("uncommitted changes"), 216 | "Error message should mention uncommitted changes, got: {}", stderr); 217 | ``` 218 | 219 | 7. **Comment out the debug break** - Remove or comment out the debug assertion 220 | 221 | 8. **Run the test again** - Verify assertions work as expected 222 | ``` 223 | cargo test test_merge_with_uncommitted_changes 224 | ``` 225 | 226 | 9. **Refine as needed** - Adjust the assertions for better specificity and clarity 227 | 228 | 10. **VERIFY TEST PASSES** - Make sure the test passes before moving to the next test 229 | ``` 230 | cargo test test_merge_with_uncommitted_changes 231 | ``` 232 | 233 | ### Important: Always Follow Complete Loop 234 | 235 | You MUST follow this complete "Test, Debug, Edit" Loop for EACH test before moving on to the next test: 236 | 237 | 1. Start with one specific test 238 | 2. Add diagnostics and assertions following the guidelines 239 | 3. Run the test to verify it works correctly 240 | 4. Debug and fix any issues until the test passes 241 | 5. Only after the test passes, move on to the next test 242 | 243 | This ensures that each test is thoroughly improved and validated before proceeding to the next one. 244 | 245 | ### Practical Examples 246 | 247 | **Example 1: Testing error conditions** 248 | ```rust 249 | // Test that merge fails with uncommitted changes 250 | #[test] 251 | fn test_merge_with_uncommitted_changes() { 252 | let repo = setup_test_repo(); 253 | // Create uncommitted change 254 | write_to_file(&repo, "file.txt", "modified content"); 255 | 256 | let output = run_command(&repo, "chain", &["merge", "feature"]); 257 | 258 | // Diagnostic printing 259 | println!("Has uncommitted changes: true (intentional test condition)"); 260 | println!("STDOUT: {}", output.stdout); 261 | println!("STDERR: {}", output.stderr); 262 | println!("EXIT STATUS: {}", output.status.code().unwrap_or(0)); 263 | 264 | // Debug breaks with captured output (uncomment for debugging) 265 | // assert!(false, "DEBUG STOP: Checking uncommitted changes behavior"); 266 | // assert!(false, "stdout: {}", output.stdout); 267 | // assert!(false, "stderr: {}", output.stderr); 268 | // assert!(false, "status code: {}", output.status.code().unwrap_or(0)); 269 | 270 | // Specific assertions based on diagnostics 271 | assert!(!output.status.success(), 272 | "Command should fail with uncommitted changes"); 273 | assert!(output.stderr.contains("uncommitted changes"), 274 | "Error message should mention uncommitted changes, got: {}", 275 | output.stderr); 276 | } 277 | ``` 278 | 279 | **Example 2: Testing success conditions** 280 | ```rust 281 | // Test that merge succeeds with the right conditions 282 | #[test] 283 | fn test_successful_merge() { 284 | let repo = setup_test_repo(); 285 | // Setup branches for merge 286 | 287 | let output = run_command(&repo, "chain", &["merge", "feature"]); 288 | 289 | // Diagnostic printing 290 | println!("STDOUT: {}", output.stdout); 291 | println!("STDERR: {}", output.stderr); 292 | println!("EXIT STATUS: {}", output.status.code().unwrap_or(0)); 293 | 294 | // Specific assertions 295 | assert!(output.status.success(), 296 | "Merge command should succeed, got exit code: {}", 297 | output.status.code().unwrap_or(0)); 298 | assert!(output.stdout.contains("Successfully merged"), 299 | "Output should indicate successful merge, got: {}", 300 | output.stdout); 301 | } 302 | ``` 303 | 304 | ### Benefits 305 | 306 | This approach helps you: 307 | - Understand exactly what the code is doing under test conditions 308 | - See all relevant output before deciding on appropriate assertions 309 | - Create precise assertions that check specific conditions 310 | - Provide detailed diagnostics that make test failures more informative 311 | - Systematically avoid conditional logic or OR operators in tests 312 | - Build up comprehensive test coverage iteratively 313 | - Easily debug tests when they fail 314 | 315 | ### Pro Tips 316 | 317 | 1. **Use assert!(false, ...) for superior output capture** 318 | ```rust 319 | // This technique displays output better than println! 320 | assert!(false, "stdout: {}", stdout); 321 | assert!(false, "stderr: {}", stderr); 322 | ``` 323 | - Preserves all whitespace and formatting in the output 324 | - Works better for multi-line output than println! 325 | - Can capture multiple variables in a single failure point 326 | - Displays the output directly in test failure messages 327 | 328 | 2. **Keep diagnostic assertions in the code but commented out** 329 | ```rust 330 | // Keep these for future debugging (commented out) 331 | // assert!(false, "branch name: {}", branch_name); 332 | // assert!(false, "commit message: {}", commit_message); 333 | ``` 334 | 335 | 3. **Add context variables to capture key test state** 336 | ```rust 337 | // Capture state in variables for both printing and assertions 338 | let has_conflict = stdout.contains("CONFLICT"); 339 | let is_on_branch = !current_branch.is_empty(); 340 | 341 | // Use in both diagnostics and assertions 342 | println!("Has conflict: {}", has_conflict); 343 | assert!(!has_conflict, "Merge should not have conflicts"); 344 | ``` 345 | 346 | Remember to leave diagnostic printing and commented debug assertions in place even after the test is working. This makes future debugging much easier when tests start failing after code changes. 347 | 348 | ## External Resources 349 | 350 | The project includes the following external repositories in the `external/` directory for reference: 351 | 352 | 1. **git-scm.com** - The official Git website source code, containing documentation and examples of Git usage 353 | 2. **git** - The actual Git source code repository, which includes: 354 | - Official Git implementation in C 355 | - Git documentation in AsciiDoc format 356 | - Command definitions and implementations 357 | - Core Git functionality code 358 | - Test suites 359 | 3. **git2-rs** - The Rust bindings for libgit2, which includes: 360 | - A safe Rust API for libgit2 functionality 361 | - Direct access to Git repositories, objects, and operations 362 | - Support for Git worktrees and other advanced features 363 | - Examples demonstrating Git operations in Rust 364 | 4. **clap** - A Rust command line argument parsing library used in git-chain: 365 | - Declarative interface for defining command-line arguments 366 | - Robust error handling and help message generation 367 | - Support for subcommands, flags, options, and positional arguments 368 | - Type conversion and validation capabilities 369 | 370 | These repositories are useful for understanding Git internals and implementing Git functionality in Rust. Use git2-rs when working with Git internals from Rust code, especially for operations related to worktrees, repositories, and references. The git source code is valuable for understanding the original C implementation of Git commands. The clap library is essential for understanding the command-line interface implementation in git-chain. -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsString; 2 | 3 | use clap::{App, Arg, ArgMatches, SubCommand}; 4 | 5 | use crate::executable_name; 6 | 7 | pub fn parse_arg_matches<'a, I, T>(arguments: I) -> ArgMatches<'a> 8 | where 9 | I: IntoIterator, 10 | T: Into + Clone, 11 | { 12 | let init_subcommand = SubCommand::with_name("init") 13 | .about("Initialize the current branch to a chain.") 14 | .arg( 15 | Arg::with_name("before") 16 | .short("b") 17 | .long("before") 18 | .value_name("branch_name") 19 | .help("Sort current branch before another branch.") 20 | .conflicts_with("after") 21 | .conflicts_with("first") 22 | .takes_value(true), 23 | ) 24 | .arg( 25 | Arg::with_name("after") 26 | .short("a") 27 | .long("after") 28 | .value_name("branch_name") 29 | .help("Sort current branch after another branch.") 30 | .conflicts_with("before") 31 | .conflicts_with("first") 32 | .takes_value(true), 33 | ) 34 | .arg( 35 | Arg::with_name("first") 36 | .short("f") 37 | .long("first") 38 | .help("Sort current branch as the first branch of the chain.") 39 | .conflicts_with("before") 40 | .conflicts_with("after") 41 | .takes_value(false), 42 | ) 43 | .arg( 44 | Arg::with_name("chain_name") 45 | .help("The name of the chain.") 46 | .required(true) 47 | .index(1), 48 | ) 49 | .arg( 50 | Arg::with_name("root_branch") 51 | .help("The root branch which the chain of branches will merge into.") 52 | .required(false) 53 | .index(2), 54 | ); 55 | 56 | let remove_subcommand = SubCommand::with_name("remove") 57 | .about("Remove current branch from its chain.") 58 | .arg( 59 | Arg::with_name("chain_name") 60 | .short("c") 61 | .long("chain") 62 | .value_name("chain_name") 63 | .help("Delete chain by removing all of its branches.") 64 | .takes_value(true), 65 | ); 66 | 67 | let move_subcommand = SubCommand::with_name("move") 68 | .about("Move current branch or chain.") 69 | .arg( 70 | Arg::with_name("before") 71 | .short("b") 72 | .long("before") 73 | .value_name("branch_name") 74 | .help("Sort current branch before another branch.") 75 | .conflicts_with("after") 76 | .takes_value(true), 77 | ) 78 | .arg( 79 | Arg::with_name("after") 80 | .short("a") 81 | .long("after") 82 | .value_name("branch_name") 83 | .help("Sort current branch after another branch.") 84 | .conflicts_with("before") 85 | .takes_value(true), 86 | ) 87 | .arg( 88 | Arg::with_name("root") 89 | .short("r") 90 | .long("root") 91 | .value_name("root_branch") 92 | .help("Set root branch of current branch and the chain it is a part of.") 93 | .takes_value(true), 94 | ) 95 | .arg( 96 | Arg::with_name("chain_name") 97 | .short("c") 98 | .long("chain") 99 | .value_name("chain_name") 100 | .help("Move current branch to another chain.") 101 | .conflicts_with("root") 102 | .takes_value(true), 103 | ); 104 | 105 | let rebase_subcommand = SubCommand::with_name("rebase") 106 | .about("Rebase all branches for the current chain.") 107 | .arg( 108 | Arg::with_name("step") 109 | .short("s") 110 | .long("step") 111 | .value_name("step") 112 | .help("Stop at the first rebase.") 113 | .takes_value(false), 114 | ) 115 | .arg( 116 | Arg::with_name("ignore_root") 117 | .short("i") 118 | .long("ignore-root") 119 | .value_name("ignore_root") 120 | .help("Rebase each branch of the chain except for the first branch.") 121 | .takes_value(false), 122 | ); 123 | 124 | let push_subcommand = SubCommand::with_name("push") 125 | .about("Push all branches of the current chain to their upstreams.") 126 | .arg( 127 | Arg::with_name("force") 128 | .short("f") 129 | .long("force") 130 | .value_name("force") 131 | .help("Push branches with --force-with-lease") 132 | .takes_value(false), 133 | ); 134 | 135 | let prune_subcommand = SubCommand::with_name("prune") 136 | .about("Prune any branches of the current chain that are ancestors of the root branch.") 137 | .arg( 138 | Arg::with_name("dry_run") 139 | .short("d") 140 | .long("dry-run") 141 | .value_name("dry_run") 142 | .help("Output branches that will be pruned.") 143 | .takes_value(false), 144 | ); 145 | 146 | let rename_subcommand = SubCommand::with_name("rename") 147 | .about("Rename current chain.") 148 | .arg( 149 | Arg::with_name("chain_name") 150 | .help("The new name of the chain.") 151 | .required(true) 152 | .index(1), 153 | ); 154 | 155 | let setup_subcommand = SubCommand::with_name("setup") 156 | .about("Set up a chain.") 157 | .arg( 158 | Arg::with_name("chain_name") 159 | .help("The new name of the chain.") 160 | .required(true) 161 | .index(1), 162 | ) 163 | .arg( 164 | Arg::with_name("root_branch") 165 | .help("The root branch which the chain of branches will merge into.") 166 | .required(true) 167 | .index(2), 168 | ) 169 | .arg( 170 | Arg::with_name("branch") 171 | .help("A branch to add to the chain") 172 | .required(true) 173 | .multiple(true) 174 | .index(3), 175 | ); 176 | 177 | let pr_subcommand = SubCommand::with_name("pr") 178 | .about("Create a pull request for each branch in the current chain using the GitHub CLI.") 179 | .arg( 180 | Arg::with_name("draft") 181 | .short("d") 182 | .long("draft") 183 | .value_name("draft") 184 | .help("Create pull requests as drafts") 185 | .takes_value(false), 186 | ); 187 | 188 | let status_subcommand = SubCommand::with_name("status") 189 | .about("Display the status of the current branch and its chain.") 190 | .arg( 191 | Arg::with_name("pr") 192 | .short("p") 193 | .long("pr") 194 | .help("Show open pull requests for the branch") 195 | .takes_value(false), 196 | ); 197 | 198 | let list_subcommand = SubCommand::with_name("list").about("List all chains.").arg( 199 | Arg::with_name("pr") 200 | .short("p") 201 | .long("pr") 202 | .help("Show open pull requests for each branch in the chains") 203 | .takes_value(false), 204 | ); 205 | 206 | // Merge with comprehensive options 207 | let merge_subcommand = SubCommand::with_name("merge") 208 | .about("Cascade merges through the branch chain by merging each parent branch into its child branch, preserving commit history.") 209 | .arg( 210 | Arg::with_name("ignore_root") 211 | .short("i") 212 | .long("ignore-root") 213 | .help("Don't merge the root branch into the first branch") 214 | .takes_value(false), 215 | ) 216 | .arg( 217 | Arg::with_name("verbose") 218 | .short("v") 219 | .long("verbose") 220 | .help("Provides detailed output during merging process") 221 | .takes_value(false), 222 | ) 223 | .arg( 224 | Arg::with_name("simple") 225 | .short("s") 226 | .long("simple") 227 | .help("Use simple merge mode") 228 | .takes_value(false), 229 | ) 230 | .arg( 231 | Arg::with_name("no_report") 232 | .short("n") 233 | .long("no-report") 234 | .help("Suppress the merge summary report") 235 | .takes_value(false), 236 | ) 237 | .arg( 238 | Arg::with_name("detailed_report") 239 | .short("d") 240 | .long("detailed-report") 241 | .help("Show a more detailed merge report") 242 | .takes_value(false), 243 | ) 244 | .arg( 245 | Arg::with_name("fork_point") 246 | .short("f") 247 | .long("fork-point") 248 | .help("Use git merge-base --fork-point for finding common ancestors [default]") 249 | .takes_value(false), 250 | ) 251 | .arg( 252 | Arg::with_name("no_fork_point") 253 | .long("no-fork-point") 254 | .help("Don't use fork-point detection, use regular merge-base") 255 | .takes_value(false), 256 | ) 257 | .arg( 258 | Arg::with_name("stay") 259 | .long("stay") 260 | .help("Don't return to the original branch after merging") 261 | .takes_value(false), 262 | ) 263 | .arg( 264 | Arg::with_name("squashed_merge") 265 | .long("squashed-merge") 266 | .help("How to handle squashed merges [default: reset]") 267 | .possible_values(&["reset", "skip", "merge"]) 268 | .default_value("reset") 269 | .takes_value(true), 270 | ) 271 | .arg( 272 | Arg::with_name("chain") 273 | .long("chain") 274 | .help("Specify a chain to merge other than the current one") 275 | .takes_value(true), 276 | ) 277 | .arg( 278 | Arg::with_name("report_level") 279 | .long("report-level") 280 | .help("Set the detail level for the merge report [default: standard]") 281 | .possible_values(&["minimal", "standard", "detailed"]) 282 | .default_value("standard") 283 | .takes_value(true), 284 | ) 285 | .arg( 286 | Arg::with_name("ff") 287 | .long("ff") 288 | .help("Allow fast-forward merges [default]") 289 | .takes_value(false), 290 | ) 291 | .arg( 292 | Arg::with_name("no_ff") 293 | .long("no-ff") 294 | .help("Create a merge commit even when fast-forward is possible") 295 | .takes_value(false), 296 | ) 297 | .arg( 298 | Arg::with_name("ff_only") 299 | .long("ff-only") 300 | .help("Only allow fast-forward merges") 301 | .takes_value(false), 302 | ) 303 | .arg( 304 | Arg::with_name("squash") 305 | .long("squash") 306 | .help("Create a single commit instead of doing a merge") 307 | .takes_value(false), 308 | ) 309 | .arg( 310 | Arg::with_name("strategy") 311 | .long("strategy") 312 | .help("Use the specified merge strategy (passed directly to 'git merge' as --strategy=)") 313 | .long_help( 314 | "Use the specified merge strategy. The value is passed directly to 'git merge' as '--strategy='. 315 | For the most up-to-date and complete information, refer to your Git version's 316 | documentation with 'git merge --help' or 'man git-merge'. 317 | 318 | Available strategies: 319 | 320 | ort (default for single branch): 321 | The default strategy from Git 2.33.0. Performs a 3-way merge algorithm. 322 | Detects and handles renames. Creates a merged tree of common ancestors 323 | when multiple common ancestors exist. 324 | 325 | recursive: 326 | Previous default strategy. Similar to 'ort' but with support for 327 | additional options like patience and diff-algorithm. Uses a 3-way 328 | merge algorithm and can detect and handle renames. 329 | 330 | resolve: 331 | Only resolves two heads using a 3-way merge algorithm. Tries to 332 | detect criss-cross merge ambiguities but doesn't handle renames. 333 | 334 | octopus: 335 | Default strategy when merging more than two branches. Refuses to do 336 | complex merges requiring manual resolution. 337 | 338 | ours: 339 | Resolves any number of heads, but the resulting tree is always that 340 | of the current branch, ignoring all changes from other branches. 341 | 342 | subtree: 343 | Modified 'ort' strategy. When merging trees A and B, if B corresponds 344 | to a subtree of A, B is adjusted to match A's tree structure.") 345 | .possible_values(&["ort", "recursive", "resolve", "octopus", "ours", "subtree"]) 346 | .takes_value(true), 347 | ) 348 | .arg( 349 | Arg::with_name("strategy_option") 350 | .long("strategy-option") 351 | .help("Pass merge strategy specific option (passed directly to 'git merge' as --strategy-option=