Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -118,6 +121,7 @@ impl Args {
&repo_name,
self.remote,
self.fetch,
self.fast_forward,
&self.dir,
) {
repos.write().push(repo);
Expand Down
27 changes: 26 additions & 1 deletion src/gitinfo/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::{
process::Command,
};

use git2::{Repository, StatusOptions};
use git2::{Branch, Repository, StatusOptions};

use crate::gitinfo::status::Status;

Expand Down Expand Up @@ -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<bool> {
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 {
Expand Down
24 changes: 18 additions & 6 deletions src/gitinfo/repoinfo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -54,9 +56,10 @@ impl RepoInfo {
name: &str,
show_remote: bool,
fetch: bool,
merge: bool,
dir: &Path,
) -> anyhow::Result<Self> {
if fetch {
if fetch || merge {
// Attempt to fetch from origin, ignoring errors
gitinfo::fetch_origin(repo)?;
}
Expand All @@ -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,
Expand All @@ -95,6 +104,7 @@ impl RepoInfo {
path,
stash_count,
is_local_only,
fast_forwarded,
repo_path: repo_path_relative.display().to_string(),
})
}
Expand All @@ -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
}
}
2 changes: 1 addition & 1 deletion src/interactive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion src/printer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("-")));
Expand Down Expand Up @@ -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).
Expand All @@ -109,13 +110,15 @@ 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}");
println!(" With changes: {dirty}");
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}");
}
Expand Down
4 changes: 4 additions & 0 deletions src/tests/gitinfo_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ fn test_repo_info_new_with_and_without_remote() {
"tmp",
false,
false,
false,
&PathBuf::from("/path/to/repo"),
);
info.unwrap();
Expand All @@ -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();
Expand Down Expand Up @@ -260,6 +262,7 @@ fn test_repo_info_includes_stash_and_local_status() {
"test",
false,
false,
false,
&PathBuf::from("/path/to/repo"),
)
.unwrap();
Expand Down Expand Up @@ -449,6 +452,7 @@ fn test_get_repo_name_from_url() {
"fallback-name",
false,
false,
false,
&PathBuf::from("/path/to/repo"),
)
.unwrap();
Expand Down
75 changes: 75 additions & 0 deletions src/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading