From 29f231990dde86f4b5ec82b34d27f49d4ce9df0a Mon Sep 17 00:00:00 2001 From: Alexandre Veyrenc <24861865+aveyrenc@users.noreply.github.com> Date: Tue, 2 Dec 2025 09:45:51 +0100 Subject: [PATCH 1/2] feat: add fast-forward functionality --- src/cli.rs | 4 + src/gitinfo/mod.rs | 27 ++++++- src/gitinfo/repoinfo.rs | 24 ++++-- src/interactive/mod.rs | 2 +- src/printer.rs | 5 +- src/tests/gitinfo_test.rs | 4 + src/tests/integration_test.rs | 75 +++++++++++++++++++ src/tests/printer_test.rs | 17 +++++ ...atuses__tests__cli_test__git-statuses.snap | 4 + src/tests/util_test.rs | 2 + 10 files changed, 155 insertions(+), 9 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 8cd20a0..acf5a14 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -38,6 +38,9 @@ pub struct Args { /// Note: This may take a while for large repositories. #[arg(short, long)] pub fetch: bool, + /// Run a fast-forward merge after fetching + #[arg(short = 'F', long = "ff")] + pub fast_forward: bool, /// Print a legend explaining the color codes and statuses used in the output #[arg(short, long)] pub legend: bool, @@ -118,6 +121,7 @@ impl Args { &repo_name, self.remote, self.fetch, + self.fast_forward, &self.dir, ) { repos.write().push(repo); diff --git a/src/gitinfo/mod.rs b/src/gitinfo/mod.rs index 9657289..ad29d35 100644 --- a/src/gitinfo/mod.rs +++ b/src/gitinfo/mod.rs @@ -3,7 +3,7 @@ use std::{ process::Command, }; -use git2::{Repository, StatusOptions}; +use git2::{Branch, Repository, StatusOptions}; use crate::gitinfo::status::Status; @@ -198,6 +198,31 @@ pub fn fetch_origin(repo: &Repository) -> anyhow::Result<()> { Ok(()) } +//Executes a fast-forward merge to update local checkout +pub fn merge_ff(repo: &Repository) -> anyhow::Result { + let head = repo.head()?; + + if head.is_branch() { + let branch = Branch::wrap(head); + let upstream = branch.upstream()?; + let upstream_head_commit = repo.reference_to_annotated_commit(upstream.get())?; + + // If fast-forward merge is possible and the user doesn't explicitly forbids it, let's proceed + if let Ok((merge_analysis, merge_pref)) = repo.merge_analysis(&[&upstream_head_commit]) + && merge_analysis.is_fast_forward() + && !merge_pref.is_no_fast_forward() + { + let upstream_head_commit_id = upstream_head_commit.id(); + repo.checkout_tree(&repo.find_object(upstream_head_commit_id, None)?, None)?; + repo.head()? + .set_target(upstream_head_commit_id, "updated by git-statuses")?; + return Ok(true); + } + } + + Ok(false) +} + /// Checks if the current branch is unpushed or has unpushed commits. /// Returns `true` if the branch is not published or ahead of its remote. pub fn get_branch_push_status(repo: &Repository) -> Status { diff --git a/src/gitinfo/repoinfo.rs b/src/gitinfo/repoinfo.rs index 54ff595..5586756 100644 --- a/src/gitinfo/repoinfo.rs +++ b/src/gitinfo/repoinfo.rs @@ -29,6 +29,8 @@ pub struct RepoInfo { pub stash_count: usize, /// True if the current branch has no upstream (local-only). pub is_local_only: bool, + /// True if the repository was fast-forwarded + pub fast_forwarded: bool, /// relative path from the starting directory pub repo_path: String, } @@ -54,9 +56,10 @@ impl RepoInfo { name: &str, show_remote: bool, fetch: bool, + merge: bool, dir: &Path, ) -> anyhow::Result { - if fetch { + if fetch || merge { // Attempt to fetch from origin, ignoring errors gitinfo::fetch_origin(repo)?; } @@ -82,6 +85,12 @@ impl RepoInfo { } else { repo_path_relative }; + let fast_forwarded = if merge { + // Fast-forward merge + gitinfo::merge_ff(repo)? + } else { + false + }; Ok(Self { name, @@ -95,6 +104,7 @@ impl RepoInfo { path, stash_count, is_local_only, + fast_forwarded, repo_path: repo_path_relative.display().to_string(), }) } @@ -113,12 +123,14 @@ impl RepoInfo { /// Formats the status with stash information if stashes are present. /// # Returns /// A formatted string showing status and stash count if present. - pub fn format_status_with_stash(&self) -> String { - let status_str = self.status.to_string(); + pub fn format_status_with_stash_and_ff(&self) -> String { + let mut status_str = self.status.to_string(); if self.stash_count > 0 { - format!("{status_str} ({}*)", self.stash_count) - } else { - status_str + status_str = format!("{status_str} ({}*)", self.stash_count); + } + if self.fast_forwarded { + status_str = format!("{status_str} ↑↑"); } + status_str } } diff --git a/src/interactive/mod.rs b/src/interactive/mod.rs index bc501c9..3eb87e9 100644 --- a/src/interactive/mod.rs +++ b/src/interactive/mod.rs @@ -83,7 +83,7 @@ fn draw_repository_list_ui( Cell::from(repo.branch.clone()), Cell::from(repo.format_local_status()), Cell::from(repo.commits.to_string()), - Cell::from(repo.format_status_with_stash()).style(status_style), + Cell::from(repo.format_status_with_stash_and_ff()).style(status_style), ]; if args.remote { diff --git a/src/printer.rs b/src/printer.rs index c3ff994..b472238 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -56,7 +56,7 @@ pub fn repositories_table(repos: &mut [RepoInfo], args: &Args) { Cell::new(&repo.branch), Cell::new(repo.format_local_status()), Cell::new(repo.commits), - Cell::new(repo.format_status_with_stash()).fg(repo.status.comfy_color()), + Cell::new(repo.format_status_with_stash_and_ff()).fg(repo.status.comfy_color()), ]; if args.remote { row.push(Cell::new(repo.remote_url.as_deref().unwrap_or("-"))); @@ -92,6 +92,7 @@ pub fn legend(condensed: bool) { println!("{table}"); println!("The counts in brackets indicate the number of changed files."); println!("The counts in brackets with an asterisk (*) indicate the number of stashes."); + println!("↑↑ indicates that the repository was fast-forwarded"); } /// Prints a summary of the repository scan (total, clean, dirty, unpushed). @@ -109,6 +110,7 @@ pub fn summary(repos: &[RepoInfo], failed: usize) { let unpushed = repos.iter().filter(|r| r.has_unpushed).count(); let with_stashes = repos.iter().filter(|r| r.stash_count > 0).count(); let local_only = repos.iter().filter(|r| r.is_local_only).count(); + let fast_forwarded = repos.iter().filter(|r| r.fast_forwarded).count(); println!("\nSummary:"); println!(" Total repositories: {total}"); println!(" Clean: {clean}"); @@ -116,6 +118,7 @@ pub fn summary(repos: &[RepoInfo], failed: usize) { println!(" With unpushed: {unpushed}"); println!(" With stashes: {with_stashes}"); println!(" Local-only branches: {local_only}"); + println!(" Fast-forwarded: {fast_forwarded}"); if failed > 0 { println!(" Failed to process: {failed}"); } diff --git a/src/tests/gitinfo_test.rs b/src/tests/gitinfo_test.rs index 3ed2fc0..89635be 100644 --- a/src/tests/gitinfo_test.rs +++ b/src/tests/gitinfo_test.rs @@ -128,6 +128,7 @@ fn test_repo_info_new_with_and_without_remote() { "tmp", false, false, + false, &PathBuf::from("/path/to/repo"), ); info.unwrap(); @@ -137,6 +138,7 @@ fn test_repo_info_new_with_and_without_remote() { "tmp", true, false, + false, &PathBuf::from("/path/to/repo"), ); info_remote.unwrap(); @@ -260,6 +262,7 @@ fn test_repo_info_includes_stash_and_local_status() { "test", false, false, + false, &PathBuf::from("/path/to/repo"), ) .unwrap(); @@ -449,6 +452,7 @@ fn test_get_repo_name_from_url() { "fallback-name", false, false, + false, &PathBuf::from("/path/to/repo"), ) .unwrap(); diff --git a/src/tests/integration_test.rs b/src/tests/integration_test.rs index 4773215..4ae64eb 100644 --- a/src/tests/integration_test.rs +++ b/src/tests/integration_test.rs @@ -260,3 +260,78 @@ fn test_integration_repository_with_remote() { Some("https://github.com/example/test-repo.git".to_owned()) ); } + +#[test] +fn test_integration_repository_fast_forward() { + let remote_temp_dir = TempDir::new().unwrap(); + let local_temp_dir = TempDir::new().unwrap(); + + let remote_repo_name = "remote-repo"; + let remote_repo_path = remote_temp_dir.path().join(remote_repo_name); + let remote_url = format!("file://{}", remote_repo_path.display()); + + // Create repository faking remote + let remote_repo = create_git_repo_with_commit(remote_temp_dir.path(), remote_repo_name); + + // Create git repository, clone from remote + let _local_repo = + Repository::clone(&remote_url, local_temp_dir.path().join("local-repo")).unwrap(); + + // Test that the clone was NOT fast-forwarded + let args = Args { + dir: local_temp_dir.path().to_path_buf(), + fast_forward: true, + ..Default::default() + }; + + let (repos, failed) = args.find_repositories(); + + assert_eq!(repos.len(), 1); + assert_eq!(failed.len(), 0); + assert!(!repos[0].fast_forwarded); + + // Add a commit to remote + let file_path = remote_repo_path.join("dummy.md"); + fs::write(&file_path, "# Second commit\n").unwrap(); + + let mut index = remote_repo.index().unwrap(); + index.add_path(Path::new("dummy.md")).unwrap(); + index.write().unwrap(); + + let tree_id = index.write_tree().unwrap(); + let tree = remote_repo.find_tree(tree_id).unwrap(); + let sig = remote_repo.signature().unwrap(); + let head = remote_repo.head().unwrap(); + let head_annotated_commit = remote_repo.reference_to_annotated_commit(&head).unwrap(); + let head_commit_id = head_annotated_commit.id(); + let head_object = remote_repo.find_object(head_commit_id, None).unwrap(); + let head_commit = head_object.into_commit().unwrap(); + remote_repo + .commit( + Some("HEAD"), + &sig, + &sig, + "Second commit", + &tree, + &[&head_commit], + ) + .unwrap(); + + // Test that the clone was fast-forwarded + let (repos, failed) = args.find_repositories(); + + assert_eq!(repos.len(), 1); + assert_eq!(failed.len(), 0); + assert_eq!(repos[0].commits, 1); + assert_eq!(repos[0].behind, 1); + assert!(repos[0].fast_forwarded); + + // Test that the clone is now up to date and doesn't need fast-forward + let (repos, failed) = args.find_repositories(); + + assert_eq!(repos.len(), 1); + assert_eq!(failed.len(), 0); + assert_eq!(repos[0].commits, 2); + assert_eq!(repos[0].behind, 0); + assert!(!repos[0].fast_forwarded); +} diff --git a/src/tests/printer_test.rs b/src/tests/printer_test.rs index eac0089..501119f 100644 --- a/src/tests/printer_test.rs +++ b/src/tests/printer_test.rs @@ -31,6 +31,7 @@ fn test_repositories_table_with_data() { path: PathBuf::from("/path/to/repo1"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "repo1".to_owned(), }]; let args = Args { @@ -64,6 +65,7 @@ fn test_repositories_table_with_stashes_and_local_only() { path: PathBuf::from("/path/to/repo-with-stash"), stash_count: 2, is_local_only: true, + fast_forwarded: false, repo_path: "repo-with-stash".to_owned(), }, RepoInfo { @@ -78,6 +80,7 @@ fn test_repositories_table_with_stashes_and_local_only() { path: PathBuf::from("/path/to/repo-with-upstream"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "repo-with-upstream".to_owned(), }, ]; @@ -104,6 +107,7 @@ fn test_repositories_table_with_path_option() { path: PathBuf::from("/very/long/path/to/repository"), stash_count: 0, is_local_only: true, + fast_forwarded: false, repo_path: "test-repo".to_owned(), }]; let args = Args { @@ -130,6 +134,7 @@ fn test_repositories_table_condensed_layout() { path: PathBuf::from("/path/to/repo"), stash_count: 1, is_local_only: false, + fast_forwarded: false, repo_path: "repo".to_owned(), }]; let args = Args { @@ -159,6 +164,7 @@ fn test_repositories_table_non_clean_filter() { path: PathBuf::from("/path/to/clean"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "clean-repo".to_owned(), }, RepoInfo { @@ -173,6 +179,7 @@ fn test_repositories_table_non_clean_filter() { path: PathBuf::from("/path/to/dirty"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "dirty-repo".to_owned(), }, ]; @@ -201,6 +208,7 @@ fn test_repositories_table_sorting() { path: PathBuf::from("/path/to/zebra"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "zebra-repo".to_owned(), }, RepoInfo { @@ -215,6 +223,7 @@ fn test_repositories_table_sorting() { path: PathBuf::from("/path/to/alpha"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "Alpha-Repo".to_owned(), }, RepoInfo { @@ -229,6 +238,7 @@ fn test_repositories_table_sorting() { path: PathBuf::from("/path/to/beta"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "beta-repo".to_owned(), }, ]; @@ -259,6 +269,7 @@ fn test_repositories_table_various_statuses() { path: PathBuf::from("/path/to/rebase"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "rebase-repo".to_owned(), }, RepoInfo { @@ -273,6 +284,7 @@ fn test_repositories_table_various_statuses() { path: PathBuf::from("/path/to/cherry"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "cherry-repo".to_owned(), }, RepoInfo { @@ -287,6 +299,7 @@ fn test_repositories_table_various_statuses() { path: PathBuf::from("/path/to/bisect"), stash_count: 1, is_local_only: false, + fast_forwarded: false, repo_path: "bisect-repo".to_owned(), }, ]; @@ -320,6 +333,7 @@ fn test_summary_comprehensive() { path: PathBuf::from("/path/to/clean1"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "clean1".to_owned(), }, RepoInfo { @@ -334,6 +348,7 @@ fn test_summary_comprehensive() { path: PathBuf::from("/path/to/clean2"), stash_count: 1, // has stash is_local_only: true, // local only + fast_forwarded: false, repo_path: "clean2".to_owned(), }, RepoInfo { @@ -348,6 +363,7 @@ fn test_summary_comprehensive() { path: PathBuf::from("/path/to/dirty"), stash_count: 2, // has stashes is_local_only: false, + fast_forwarded: false, repo_path: "dirty".to_owned(), }, ]; @@ -404,6 +420,7 @@ fn test_summary_edge_cases() { path: PathBuf::from("/path/to/unknown"), stash_count: 0, is_local_only: true, + fast_forwarded: false, repo_path: "unknown-status".to_owned(), }]; summary(&edge_repos, 0); diff --git a/src/tests/snapshots/git_statuses__tests__cli_test__git-statuses.snap b/src/tests/snapshots/git_statuses__tests__cli_test__git-statuses.snap index 24c88d3..9f9437e 100644 --- a/src/tests/snapshots/git_statuses__tests__cli_test__git-statuses.snap +++ b/src/tests/snapshots/git_statuses__tests__cli_test__git-statuses.snap @@ -1,5 +1,6 @@ --- source: src/tests/cli_test.rs +assertion_line: 34 expression: help_text --- A tool to display git repository statuses in a table format @@ -30,6 +31,9 @@ Options: -f, --fetch Run a fetch before scanning to update the repository state Note: This may take a while for large repositories + -F, --ff + Run a fast-forward merge after fetching + -l, --legend Print a legend explaining the color codes and statuses used in the output diff --git a/src/tests/util_test.rs b/src/tests/util_test.rs index 21e3098..91429ac 100644 --- a/src/tests/util_test.rs +++ b/src/tests/util_test.rs @@ -40,6 +40,7 @@ fn test_print_repositories_and_summary() { path: PathBuf::from("/path/to/dummy"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "dummy".to_owned(), }; let args = Args { @@ -82,6 +83,7 @@ fn test_print_repositories_with_remote() { path: PathBuf::from("/path/to/dummy"), stash_count: 0, is_local_only: false, + fast_forwarded: false, repo_path: "dummy".to_owned(), }; let args = Args { From 9503ab177ddfb0c687a1d7672995c1cf9b123ea5 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 8 Dec 2025 21:24:50 +0100 Subject: [PATCH 2/2] doc: Update doc comments --- src/gitinfo/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gitinfo/mod.rs b/src/gitinfo/mod.rs index ad29d35..9372811 100644 --- a/src/gitinfo/mod.rs +++ b/src/gitinfo/mod.rs @@ -198,7 +198,7 @@ pub fn fetch_origin(repo: &Repository) -> anyhow::Result<()> { Ok(()) } -//Executes a fast-forward merge to update local checkout +/// Executes a fast-forward merge to update local checkout pub fn merge_ff(repo: &Repository) -> anyhow::Result { let head = repo.head()?;