Skip to content

Commit

Permalink
clone-repo: Display clone repo progress (#139)
Browse files Browse the repository at this point in the history
  • Loading branch information
junglerobba authored Dec 8, 2024
1 parent 164c609 commit 643a30b
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 36 deletions.
69 changes: 34 additions & 35 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
use std::{
collections::HashMap,
env::current_dir,
fs::canonicalize,
path::{Path, PathBuf},
};
use std::{collections::HashMap, env::current_dir, fs::canonicalize, path::PathBuf};

use crate::{
configs::{Config, SearchDirectory, SessionSortOrderConfig},
clone::git_clone,
configs::{CloneRepoSwitchConfig, Config, SearchDirectory, SessionSortOrderConfig},
dirty_paths::DirtyUtf8Path,
execute_command, get_single_selection,
marks::{marks_command, MarksCommand},
Expand All @@ -18,7 +14,7 @@ use crate::{
use clap::{Args, Parser, Subcommand};
use clap_complete::{ArgValueCandidates, CompletionCandidate};
use error_stack::ResultExt;
use git2::{build::RepoBuilder, FetchOptions, RemoteCallbacks, Repository};
use git2::Repository;
use ratatui::style::Color;

#[derive(Debug, Parser)]
Expand Down Expand Up @@ -109,6 +105,13 @@ pub struct ConfigCommand {
#[arg(long, value_name = "Alphabetical | LastAttached")]
/// Set the sort order of the sessions in the switch command
session_sort_order: Option<SessionSortOrderConfig>,
#[arg(long, value_name = "Always | Never | Foreground", verbatim_doc_comment)]
/// Whether to automatically switch to the new session after the `clone-repo` command finishes
/// `Always` will always switch tmux to the new session
/// `Never` will always create the new session in the background
/// When set to `Foreground`, the new session will only be opened in the background if the active
/// tmux session has changed since starting the clone process (for long clone processes on larger repos)
clone_repo_switch: Option<CloneRepoSwitchConfig>,
}

#[derive(Debug, Args)]
Expand Down Expand Up @@ -432,6 +435,10 @@ fn config_command(args: &ConfigCommand, mut config: Config) -> Result<()> {
config.session_sort_order = Some(order.to_owned());
}

if let Some(switch) = &args.clone_repo_switch {
config.clone_repo_switch = Some(switch.to_owned());
}

config.save().change_context(TmsError::ConfigError)?;
println!("Configuration has been stored");
Ok(())
Expand Down Expand Up @@ -654,10 +661,26 @@ fn clone_repo_command(args: &CloneRepoCommand, config: Config, tmux: &Tmux) -> R
let repo_name = repo_name.trim_end_matches(".git");
path.push(repo_name);

let previous_session = tmux.current_session("#{session_name}");

println!("Cloning into '{repo_name}'...");
let repo = git_clone(&args.repository, &path)?;

let mut session_name = repo_name.to_string();

let switch_config = config
.clone_repo_switch
.unwrap_or(CloneRepoSwitchConfig::Always);

let switch = match switch_config {
CloneRepoSwitchConfig::Always => true,
CloneRepoSwitchConfig::Never => false,
CloneRepoSwitchConfig::Foreground => {
let active_session = tmux.current_session("#{session_name}");
previous_session == active_session
}
};

if tmux.session_exists(&session_name) {
session_name = format!(
"{}/{}",
Expand All @@ -672,37 +695,13 @@ fn clone_repo_command(args: &CloneRepoCommand, config: Config, tmux: &Tmux) -> R

tmux.new_session(Some(&session_name), Some(&path.display().to_string()));
tmux.set_up_tmux_env(&repo, &session_name)?;
tmux.switch_to_session(&session_name);
if switch {
tmux.switch_to_session(&session_name);
}

Ok(())
}

fn git_clone(repo: &str, target: &Path) -> Result<Repository> {
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(git_credentials_callback);
let mut fo = FetchOptions::new();
fo.remote_callbacks(callbacks);
let mut builder = RepoBuilder::new();
builder.fetch_options(fo);

builder
.clone(repo, target)
.change_context(TmsError::GitError)
}

fn git_credentials_callback(
user: &str,
user_from_url: Option<&str>,
_cred: git2::CredentialType,
) -> std::result::Result<git2::Cred, git2::Error> {
let user = match user_from_url {
Some(user) => user,
None => user,
};

git2::Cred::ssh_key_from_agent(user)
}

fn init_repo_command(args: &InitRepoCommand, config: Config, tmux: &Tmux) -> Result<()> {
let Some(mut path) = pick_search_path(&config, tmux)? else {
return Ok(());
Expand Down
159 changes: 159 additions & 0 deletions src/clone.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
use std::{
fmt::Display,
io::{stdout, Stdout, Write},
path::Path,
time::{Duration, Instant},
};

use crate::{error::TmsError, Result};

use crossterm::{cursor, terminal, ExecutableCommand};
use error_stack::ResultExt;
use git2::{build::RepoBuilder, FetchOptions, Progress, RemoteCallbacks, Repository};

const UPDATE_INTERVAL: Duration = Duration::from_millis(300);

struct Rate(usize);

impl Display for Rate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.0 > 1024 * 1024 {
let rate = self.0 as f64 / 1024.0 / 1024.0;
write!(f, "{:.2} MB/s", rate)
} else {
let rate = self.0 as f64 / 1024.0;
write!(f, "{:.2} kB/s", rate)
}
}
}

struct CloneSnapshot {
time: Instant,
bytes_transferred: usize,
stdout: Stdout,
lines: u16,
}

impl CloneSnapshot {
pub fn new() -> Self {
let stdout = stdout();
Self {
time: Instant::now(),
bytes_transferred: 0,
stdout,
lines: 0,
}
}

pub fn update(&mut self, progress: &Progress) -> Result<()> {
let now = Instant::now();
let difference = now.duration_since(self.time);
if difference < UPDATE_INTERVAL {
return Ok(());
}

let transferred = progress.received_bytes() - self.bytes_transferred;
let rate = Rate(transferred / (difference.as_millis() as usize) * 1000);

let network_pct = (100 * progress.received_objects()) / progress.total_objects();
let index_pct = (100 * progress.indexed_objects()) / progress.total_objects();

let total = (network_pct + index_pct) / 2;

if self.lines > 0 {
self.stdout
.execute(cursor::MoveUp(self.lines))
.change_context(TmsError::IoError)?;
self.stdout
.execute(terminal::Clear(terminal::ClearType::FromCursorDown))
.change_context(TmsError::IoError)?;
}

let mut lines = 0;

if network_pct < 100 {
writeln!(
self.stdout,
"Received {:3}% ({:5}/{:5})",
network_pct,
progress.received_objects(),
progress.total_objects(),
)
.change_context(TmsError::IoError)?;
lines += 1
}

if index_pct < 100 {
writeln!(
self.stdout,
"Indexed {:3}% ({:5}/{:5})",
index_pct,
progress.indexed_objects(),
progress.total_objects(),
)
.change_context(TmsError::IoError)?;
lines += 1;
}

if network_pct < 100 {
writeln!(self.stdout, "{} ", rate).change_context(TmsError::IoError)?;
lines += 1;
}

if progress.total_objects() > 0 && progress.received_objects() == progress.total_objects() {
let delta_pct = (100 * progress.indexed_deltas()) / progress.total_deltas();
writeln!(
self.stdout,
"Resolving deltas {:3}% ({:5}/{:5})",
delta_pct,
progress.indexed_deltas(),
progress.total_deltas()
)
.change_context(TmsError::IoError)?;
lines += 1;
}
write!(self.stdout, "{:3}% ", total).change_context(TmsError::IoError)?;
for _ in 0..(total / 3) {
write!(self.stdout, "█").change_context(TmsError::IoError)?;
}
writeln!(self.stdout).change_context(TmsError::IoError)?;
lines += 1;
self.time = Instant::now();
self.bytes_transferred = progress.received_bytes();
self.lines = lines;

Ok(())
}
}

pub fn git_clone(repo: &str, target: &Path) -> Result<Repository> {
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(git_credentials_callback);

let mut state = CloneSnapshot::new();
callbacks.transfer_progress(move |progress| {
state.update(&progress).ok();
true
});
let mut fo = FetchOptions::new();
fo.remote_callbacks(callbacks);
let mut builder = RepoBuilder::new();
builder.fetch_options(fo);

builder
.clone(repo, target)
.change_context(TmsError::GitError)
}

fn git_credentials_callback(
user: &str,
user_from_url: Option<&str>,
_cred: git2::CredentialType,
) -> std::result::Result<git2::Cred, git2::Error> {
let user = match user_from_url {
Some(user) => user,
None => user,
};

git2::Cred::ssh_key_from_agent(user)
}
24 changes: 24 additions & 0 deletions src/configs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub struct Config {
pub bookmarks: Option<Vec<String>>,
pub session_configs: Option<HashMap<String, SessionConfig>>,
pub marks: Option<HashMap<String, String>>,
pub clone_repo_switch: Option<CloneRepoSwitchConfig>,
}

impl Config {
Expand Down Expand Up @@ -368,6 +369,29 @@ impl ValueEnum for SessionSortOrderConfig {
}
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub enum CloneRepoSwitchConfig {
Always,
Never,
Foreground,
}

impl ValueEnum for CloneRepoSwitchConfig {
fn value_variants<'a>() -> &'a [Self] {
&[Self::Always, Self::Never, Self::Foreground]
}

fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
match self {
CloneRepoSwitchConfig::Always => Some(clap::builder::PossibleValue::new("Always")),
CloneRepoSwitchConfig::Never => Some(clap::builder::PossibleValue::new("Never")),
CloneRepoSwitchConfig::Foreground => {
Some(clap::builder::PossibleValue::new("Foreground"))
}
}
}
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct SessionConfig {
pub create_script: Option<PathBuf>,
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod cli;
mod clone;
pub mod configs;
pub mod dirty_paths;
pub mod error;
Expand Down
11 changes: 11 additions & 0 deletions src/tmux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ impl Tmux {
Tmux::stdout_to_string(output)
}

pub fn current_session(&self, format: &str) -> String {
let output = self.execute_tmux_command(&[
"list-sessions",
"-F",
format,
"-f",
"#{session_attached}",
]);
Tmux::stdout_to_string(output)
}

pub fn kill_session(&self, session: &str) -> process::Output {
self.execute_tmux_command(&["kill-session", "-t", session])
}
Expand Down
7 changes: 6 additions & 1 deletion tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ use pretty_assertions::assert_eq;
use ratatui::style::Color;
use std::{fs, str::FromStr};
use tempfile::tempdir;
use tms::configs::{Config, PickerColorConfig, SearchDirectory, SessionSortOrderConfig};
use tms::configs::{
CloneRepoSwitchConfig, Config, PickerColorConfig, SearchDirectory, SessionSortOrderConfig,
};

#[test]
fn tms_fails_with_missing_config() -> anyhow::Result<()> {
Expand Down Expand Up @@ -64,6 +66,7 @@ fn tms_config() -> anyhow::Result<()> {
bookmarks: None,
session_configs: None,
marks: None,
clone_repo_switch: Some(CloneRepoSwitchConfig::Always),
};

let mut tms = Command::cargo_bin("tms")?;
Expand Down Expand Up @@ -99,6 +102,8 @@ fn tms_config() -> anyhow::Result<()> {
&picker_info_color.to_string(),
"--picker-prompt-color",
&picker_prompt_color.to_string(),
"--clone-repo-switch",
"Always",
]);

tms.assert().success().code(0);
Expand Down

0 comments on commit 643a30b

Please sign in to comment.