diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ca0eaac2..3973514518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added +* new `--only-this-dir` (`-x`) flag to restrict the status, log and files tabs to the directory gitui is started in, while still opening the repository it belongs to * support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) ### Changed diff --git a/asyncgit/src/status.rs b/asyncgit/src/status.rs index 9728c6f33e..ad8644c768 100644 --- a/asyncgit/src/status.rs +++ b/asyncgit/src/status.rs @@ -49,6 +49,8 @@ pub struct AsyncStatus { sender: Sender, pending: Arc, repo: RepoPath, + /// limits the status to this repo relative path (see `PathScope`) + pathspec: Option, /// Counter that increments after each completed fetch. generation: Arc, } @@ -58,9 +60,11 @@ impl AsyncStatus { pub fn new( repo: RepoPath, sender: Sender, + pathspec: Option, ) -> Self { Self { repo, + pathspec, current: Arc::new(Mutex::new(Request(0, None))), last: Arc::new(Mutex::new(Status::default())), sender, @@ -119,6 +123,7 @@ impl AsyncStatus { let status_type = params.status_type; let config = params.config; let repo = self.repo.clone(); + let pathspec = self.pathspec.clone(); self.pending.fetch_add(1, Ordering::Relaxed); @@ -127,6 +132,7 @@ impl AsyncStatus { &repo, status_type, config, + pathspec.as_deref(), hash_request, &arc_current, &arc_last, @@ -151,11 +157,13 @@ impl AsyncStatus { repo: &RepoPath, status_type: StatusType, config: Option, + pathspec: Option<&str>, hash_request: u64, arc_current: &Arc>>, arc_last: &Arc>, ) -> Result<()> { - let res = Self::get_status(repo, status_type, config)?; + let res = + Self::get_status(repo, status_type, config, pathspec)?; log::trace!( "status fetched: {hash_request} (type: {status_type:?})", ); @@ -179,12 +187,14 @@ impl AsyncStatus { repo: &RepoPath, status_type: StatusType, config: Option, + pathspec: Option<&str>, ) -> Result { Ok(Status { items: sync::status::get_status( repo, status_type, config, + pathspec, )?, }) } diff --git a/asyncgit/src/sync/diff.rs b/asyncgit/src/sync/diff.rs index c13fc476c7..b3f3ba2f21 100644 --- a/asyncgit/src/sync/diff.rs +++ b/asyncgit/src/sync/diff.rs @@ -531,8 +531,9 @@ mod tests { .unwrap(); } - let res = get_status(repo_path, StatusType::WorkingDir, None) - .unwrap(); + let res = + get_status(repo_path, StatusType::WorkingDir, None, None) + .unwrap(); assert_eq!(res.len(), 1); assert_eq!(res[0].path, "bar.txt"); diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2cd358d065..f9e2503fe2 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -241,10 +241,10 @@ pub mod tests { /// helper returning amount of files with changes in the (wd,stage) pub fn get_statuses(repo_path: &RepoPath) -> (usize, usize) { ( - get_status(repo_path, StatusType::WorkingDir, None) + get_status(repo_path, StatusType::WorkingDir, None, None) .unwrap() .len(), - get_status(repo_path, StatusType::Stage, None) + get_status(repo_path, StatusType::Stage, None, None) .unwrap() .len(), ) diff --git a/asyncgit/src/sync/reset.rs b/asyncgit/src/sync/reset.rs index 4142255954..b4069c7236 100644 --- a/asyncgit/src/sync/reset.rs +++ b/asyncgit/src/sync/reset.rs @@ -107,8 +107,9 @@ mod tests { let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into(); - let res = get_status(repo_path, StatusType::WorkingDir, None) - .unwrap(); + let res = + get_status(repo_path, StatusType::WorkingDir, None, None) + .unwrap(); assert_eq!(res.len(), 0); let file_path = root.join("bar.txt"); diff --git a/asyncgit/src/sync/status.rs b/asyncgit/src/sync/status.rs index 8afdf2dd09..e99dd3a988 100644 --- a/asyncgit/src/sync/status.rs +++ b/asyncgit/src/sync/status.rs @@ -168,10 +168,14 @@ impl From for gix::status::UntrackedFiles { } /// guarantees sorting +/// +/// `pathspec` limits the result to a path relative to the root of the +/// repository, `None` covers the whole repository pub fn get_status( repo_path: &RepoPath, status_type: StatusType, show_untracked: Option, + pathspec: Option<&str>, ) -> Result> { scope_time!("get_status"); @@ -195,11 +199,18 @@ pub fn get_status( .status(gix::progress::Discard)? .untracked_files(show_untracked.into()); + // `gix` resolves patterns relative to the current directory, + // `:(top)` makes them relative to the root of the repository, + // which is what `pathspec` is + let patterns: Vec = pathspec + .map(|pathspec| vec![format!(":(top){pathspec}").into()]) + .unwrap_or_default(); + let mut res = Vec::new(); match status_type { StatusType::WorkingDir => { - let iter = status.into_index_worktree_iter(Vec::new())?; + let iter = status.into_index_worktree_iter(patterns)?; for item in iter { let Ok(item) = item else { @@ -227,7 +238,7 @@ pub fn get_status( let mut pathspec = repo.pathspec( false, /* empty patterns match prefix */ - None::<&str>, + patterns.iter(), true, /* inherit ignore case */ &gix::index::State::new(repo.object_hash()), gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping @@ -255,7 +266,7 @@ pub fn get_status( )?; } StatusType::Both => { - let iter = status.into_iter(Vec::new())?; + let iter = status.into_iter(patterns)?; for item in iter { let item = item?; @@ -300,16 +311,103 @@ mod tests { use super::*; use crate::{ sync::{ - commit, stage_add_file, + commit, stage_add_all, stage_add_file, status::{get_status, StatusType}, tests::{repo_init, repo_init_bare}, RepoPath, }, StatusItem, StatusItemType, }; - use std::{fs::File, io::Write, path::Path}; + use std::{ + fs::{self, File}, + io::Write, + path::Path, + }; use tempfile::TempDir; + fn paths(items: &[StatusItem]) -> Vec<&str> { + items.iter().map(|i| i.path.as_str()).collect() + } + + #[test] + fn test_status_pathspec() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: &RepoPath = + &root.as_os_str().to_str().unwrap().into(); + + fs::create_dir(root.join("sub")).unwrap(); + File::create(root.join("sub/a.txt")) + .unwrap() + .write_all(b"a") + .unwrap(); + File::create(root.join("b.txt")) + .unwrap() + .write_all(b"b") + .unwrap(); + + let all = get_status( + repo_path, + StatusType::WorkingDir, + Some(ShowUntrackedFilesConfig::All), + None, + ) + .unwrap(); + assert_eq!(paths(&all), vec!["b.txt", "sub/a.txt"]); + + let scoped = get_status( + repo_path, + StatusType::WorkingDir, + Some(ShowUntrackedFilesConfig::All), + Some("sub"), + ) + .unwrap(); + assert_eq!(paths(&scoped), vec!["sub/a.txt"]); + + stage_add_all(repo_path, "*", None).unwrap(); + + let scoped = get_status( + repo_path, + StatusType::Stage, + Some(ShowUntrackedFilesConfig::All), + Some("sub"), + ) + .unwrap(); + assert_eq!(paths(&scoped), vec!["sub/a.txt"]); + + let scoped = get_status( + repo_path, + StatusType::Both, + Some(ShowUntrackedFilesConfig::All), + Some("sub"), + ) + .unwrap(); + assert_eq!(paths(&scoped), vec!["sub/a.txt"]); + } + + /// a pathspec that matches nothing is not the same as no pathspec + #[test] + fn test_status_pathspec_without_match() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: &RepoPath = + &root.as_os_str().to_str().unwrap().into(); + + File::create(root.join("b.txt")) + .unwrap() + .write_all(b"b") + .unwrap(); + + let scoped = get_status( + repo_path, + StatusType::WorkingDir, + Some(ShowUntrackedFilesConfig::All), + Some("sub"), + ) + .unwrap(); + assert!(scoped.is_empty()); + } + #[test] fn test_discard_status() { let file_path = Path::new("README.md"); @@ -327,14 +425,14 @@ mod tests { writeln!(file, "Test for discard_status").unwrap(); let statuses = - get_status(repo_path, StatusType::WorkingDir, None) + get_status(repo_path, StatusType::WorkingDir, None, None) .unwrap(); assert_eq!(statuses.len(), 1); discard_status(repo_path).unwrap(); let statuses = - get_status(repo_path, StatusType::WorkingDir, None) + get_status(repo_path, StatusType::WorkingDir, None, None) .unwrap(); assert_eq!(statuses.len(), 0); } @@ -356,9 +454,13 @@ mod tests { workdir: separate_workdir.path().into(), }; - let status = - get_status(&repo_path, StatusType::WorkingDir, None) - .unwrap(); + let status = get_status( + &repo_path, + StatusType::WorkingDir, + None, + None, + ) + .unwrap(); assert_eq!( status, diff --git a/asyncgit/src/sync/utils.rs b/asyncgit/src/sync/utils.rs index 148e29baa9..98579bb63d 100644 --- a/asyncgit/src/sync/utils.rs +++ b/asyncgit/src/sync/utils.rs @@ -323,7 +323,7 @@ mod tests { &root.as_os_str().to_str().unwrap().into(); let status_count = |s: StatusType| -> usize { - get_status(repo_path, s, None).unwrap().len() + get_status(repo_path, s, None, None).unwrap().len() }; fs::create_dir_all(root.join("a/d"))?; @@ -422,7 +422,7 @@ mod tests { &root.as_os_str().to_str().unwrap().into(); let status_count = |s: StatusType| -> usize { - get_status(repo_path, s, None).unwrap().len() + get_status(repo_path, s, None, None).unwrap().len() }; let full_path = &root.join(file_path); @@ -457,7 +457,7 @@ mod tests { &root.as_os_str().to_str().unwrap().into(); let status_count = |s: StatusType| -> usize { - get_status(repo_path, s, None).unwrap().len() + get_status(repo_path, s, None, None).unwrap().len() }; let sub = &root.join("sub"); diff --git a/src/app.rs b/src/app.rs index ddb157af3f..d687019f8b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -26,6 +26,7 @@ use crate::{ Action, AppTabs, InternalEvent, NeedsUpdate, Queue, StackablePopupOpen, }, + scope::PathScope, setup_popups, strings::{self, ellipsis_trim_start, order}, tabs::{FilesTab, Revlog, StashList, Stashing, Status}, @@ -126,6 +127,8 @@ pub struct Environment { pub theme: SharedTheme, pub key_config: SharedKeyConfig, pub repo: RepoPathRef, + /// the directory of the repository the ui is restricted to + pub scope: PathScope, pub options: SharedOptions, pub sender_git: Sender, pub sender_app: Sender, @@ -141,6 +144,7 @@ impl Environment { theme: Default::default(), key_config: Default::default(), repo: RefCell::new(RepoPath::Path(Default::default())), + scope: PathScope::everything(), options: Rc::new(RefCell::new(Options::test_env())), sender_git: unbounded().0, sender_app: unbounded().0, @@ -154,6 +158,7 @@ impl App { #[allow(clippy::too_many_lines)] pub fn new( cliargs: CliArgs, + scope: PathScope, sender_git: Sender, sender_app: Sender, input: Input, @@ -163,8 +168,15 @@ impl App { let repo = RefCell::new(cliargs.repo_path.clone()); log::trace!("open repo at: {repo:?}"); - let repo_path_text = - repo_work_dir(&repo.borrow()).unwrap_or_default(); + let repo_path_text = scope.path().map_or_else( + || repo_work_dir(&repo.borrow()).unwrap_or_default(), + |scope| { + format!( + "{} [{scope}]", + repo_work_dir(&repo.borrow()).unwrap_or_default() + ) + }, + ); let env = Environment { queue: Queue::new(), @@ -172,6 +184,7 @@ impl App { key_config: Rc::new(key_config), options: Options::new(repo.clone()), repo, + scope, sender_git, sender_app, }; diff --git a/src/args.rs b/src/args.rs index 22c6cc8d92..12601dbfbe 100644 --- a/src/args.rs +++ b/src/args.rs @@ -19,6 +19,7 @@ const THEME_FLAG_ID: &str = "theme"; const WORKDIR_FLAG_ID: &str = "workdir"; const FILE_FLAG_ID: &str = "file"; const GIT_DIR_FLAG_ID: &str = "directory"; +const ONLY_THIS_DIR_FLAG_ID: &str = "only-this-dir"; const WATCHER_FLAG_ID: &str = "watcher"; const KEY_BINDINGS_FLAG_ID: &str = "key_bindings"; const KEY_SYMBOLS_FLAG_ID: &str = "key_symbols"; @@ -30,6 +31,7 @@ pub struct CliArgs { pub theme: PathBuf, pub select_file: Option, pub repo_path: RepoPath, + pub only_this_dir: bool, pub notify_watcher: bool, pub key_bindings_path: Option, pub key_symbols_path: Option, @@ -68,6 +70,8 @@ pub fn process_cmdline() -> Result { RepoPath::Path(gitdir) }; + let only_this_dir = arg_matches.get_flag(ONLY_THIS_DIR_FLAG_ID); + let arg_theme = arg_matches .get_one::(THEME_FLAG_ID) .map_or_else(|| PathBuf::from(DEFAULT_THEME), PathBuf::from); @@ -96,6 +100,7 @@ pub fn process_cmdline() -> Result { theme, select_file, repo_path, + only_this_dir, notify_watcher, key_bindings_path, key_symbols_path, @@ -182,6 +187,13 @@ fn app() -> ClapApp { .env("GIT_DIR") .num_args(1), ) + .arg( + Arg::new(ONLY_THIS_DIR_FLAG_ID) + .help("Restrict the status, log and files tabs to the current directory (the repository is still opened from any parent directory)") + .short('x') + .long("only-this-dir") + .action(clap::ArgAction::SetTrue), + ) .arg( Arg::new(WORKDIR_FLAG_ID) .help("Set the working directory") @@ -238,7 +250,30 @@ pub fn get_app_config_path() -> Result { Ok(path) } -#[test] -fn verify_app() { - app().debug_assert(); +#[cfg(test)] +mod tests { + use super::{app, ONLY_THIS_DIR_FLAG_ID}; + + #[test] + fn verify_app() { + app().debug_assert(); + } + + #[test] + fn test_only_this_dir_flag() { + let matches = app() + .try_get_matches_from(["gitui"]) + .expect("no args must parse"); + assert!(!matches.get_flag(ONLY_THIS_DIR_FLAG_ID)); + + let matches = app() + .try_get_matches_from(["gitui", "--only-this-dir"]) + .expect("`--only-this-dir` must parse"); + assert!(matches.get_flag(ONLY_THIS_DIR_FLAG_ID)); + + let matches = app() + .try_get_matches_from(["gitui", "-x"]) + .expect("`-x` must parse"); + assert!(matches.get_flag(ONLY_THIS_DIR_FLAG_ID)); + } } diff --git a/src/components/changes.rs b/src/components/changes.rs index 74f11e581a..24227903e0 100644 --- a/src/components/changes.rs +++ b/src/components/changes.rs @@ -9,6 +9,7 @@ use crate::{ keys::{key_match, SharedKeyConfig}, options::SharedOptions, queue::{Action, InternalEvent, NeedsUpdate, Queue, ResetItem}, + scope::PathScope, strings, try_or_popup, }; use anyhow::Result; @@ -23,6 +24,7 @@ use std::path::Path; /// pub struct ChangesComponent { repo: RepoPathRef, + scope: PathScope, files: StatusTreeComponent, is_working_dir: bool, queue: Queue, @@ -45,6 +47,7 @@ impl ChangesComponent { key_config: env.key_config.clone(), options: env.options.clone(), repo: env.repo.clone(), + scope: env.scope.clone(), } } @@ -131,7 +134,11 @@ impl ChangesComponent { fn index_add_all(&self) -> Result<()> { let config = self.options.borrow().status_show_untracked(); - sync::stage_add_all(&self.repo.borrow(), "*", config)?; + sync::stage_add_all( + &self.repo.borrow(), + self.scope.pathspec(), + config, + )?; self.queue.push(InternalEvent::Update(NeedsUpdate::ALL)); @@ -139,7 +146,10 @@ impl ChangesComponent { } fn stage_remove_all(&self) -> Result<()> { - sync::reset_stage(&self.repo.borrow(), "*")?; + sync::reset_stage( + &self.repo.borrow(), + self.scope.pathspec(), + )?; self.queue.push(InternalEvent::Update(NeedsUpdate::ALL)); diff --git a/src/components/revision_files.rs b/src/components/revision_files.rs index 1e15ec086f..189fe1e5d5 100644 --- a/src/components/revision_files.rs +++ b/src/components/revision_files.rs @@ -8,6 +8,7 @@ use crate::{ keys::{key_match, SharedKeyConfig}, popups::{BlameFileOpen, FileRevOpen}, queue::{InternalEvent, Queue, StackablePopupOpen}, + scope::PathScope, strings::{self, order, symbol}, try_or_popup, ui::{self, common_nav, style::SharedTheme}, @@ -44,6 +45,7 @@ enum Focus { pub struct RevisionFilesComponent { repo: RepoPathRef, + scope: PathScope, queue: Queue, theme: SharedTheme, //TODO: store TreeFiles in `tree` @@ -79,6 +81,7 @@ impl RevisionFilesComponent { focus: Focus::Tree, key_config: env.key_config.clone(), repo: env.repo.clone(), + scope: env.scope.clone(), select_file, visible: false, } @@ -131,6 +134,10 @@ impl RevisionFilesComponent { .is_some_and(|commit| commit.id == result.commit) { if let Ok(last) = result.result { + let last: Vec = last + .into_iter() + .filter(|f| self.scope.contains(&f.path)) + .collect(); let filenames: Vec<&Path> = last .iter() .map(|f| f.path.as_path()) diff --git a/src/gitui.rs b/src/gitui.rs index 03d73b11c1..4b3a4fb159 100644 --- a/src/gitui.rs +++ b/src/gitui.rs @@ -14,6 +14,7 @@ use crate::{ draw, input::{Input, InputEvent, InputState}, keys::KeyConfig, + scope::PathScope, select_event, spinner::Spinner, ui::style::Theme, @@ -34,6 +35,7 @@ pub struct Gitui { impl Gitui { pub(crate) fn new( cliargs: CliArgs, + scope: PathScope, theme: Theme, key_config: &KeyConfig, updater: Updater, @@ -56,6 +58,7 @@ impl Gitui { let app = App::new( cliargs, + scope, tx_git, tx_app, input.clone(), @@ -205,6 +208,7 @@ impl Gitui { mod tests { use std::path::PathBuf; + use crate::scope::PathScope; use asyncgit::{sync::RepoPath, AsyncGitNotification}; use crossterm::event::{KeyCode, KeyModifiers}; use git2_testing::repo_init_suffix; @@ -241,6 +245,7 @@ mod tests { theme: PathBuf::from("theme.ron"), select_file: None, repo_path: path, + only_this_dir: false, notify_watcher: false, key_bindings_path: None, key_symbols_path: None, @@ -249,9 +254,14 @@ mod tests { let theme = Theme::init(&PathBuf::new()); let key_config = KeyConfig::default(); - let mut gitui = - Gitui::new(cliargs, theme, &key_config, Updater::Ticker) - .unwrap(); + let mut gitui = Gitui::new( + cliargs, + PathScope::everything(), + theme, + &key_config, + Updater::Ticker, + ) + .unwrap(); let mut terminal = Terminal::new(TestBackend::new(90, 12)).unwrap(); diff --git a/src/main.rs b/src/main.rs index fd662950a2..1366d43126 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,6 +73,7 @@ mod options; mod popup_stack; mod popups; mod queue; +mod scope; mod spinner; mod string_utils; mod strings; @@ -84,7 +85,7 @@ use crate::{ app::App, args::{process_cmdline, CliArgs}, }; -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, bail, Context, Result}; use app::QuitState; use asyncgit::{sync::RepoPath, AsyncGitNotification}; use backtrace::Backtrace; @@ -100,6 +101,7 @@ use gitui::Gitui; use input::InputEvent; use keys::KeyConfig; use ratatui::backend::CrosstermBackend; +use scope::PathScope; use scopeguard::defer; use std::{ io::{self, Stdout}, @@ -166,6 +168,8 @@ fn main() -> Result<()> { asyncgit::register_tracing_logging(); ensure_valid_path(&cliargs.repo_path)?; + let mut scope = repo_scope(&cliargs)?; + let key_config = KeyConfig::init( cliargs.key_bindings_path.as_ref(), cliargs.key_symbols_path.as_ref(), @@ -196,6 +200,7 @@ fn main() -> Result<()> { let quit_state = run_app( app_start, args.clone(), + scope.clone(), theme.clone(), &key_config, updater, @@ -206,12 +211,16 @@ fn main() -> Result<()> { QuitState::OpenSubmodule(p) => { args = CliArgs { repo_path: p, + only_this_dir: false, select_file: None, theme: args.theme, notify_watcher: args.notify_watcher, key_bindings_path: args.key_bindings_path, key_symbols_path: args.key_symbols_path, - } + }; + // the submodule is a repository of its own, so the + // directory we started in says nothing about it + scope = PathScope::everything(); } _ => break, } @@ -223,12 +232,14 @@ fn main() -> Result<()> { fn run_app( app_start: Instant, cliargs: CliArgs, + scope: PathScope, theme: Theme, key_config: &KeyConfig, updater: Updater, terminal: &mut Terminal, ) -> Result { - let mut gitui = Gitui::new(cliargs, theme, key_config, updater)?; + let mut gitui = + Gitui::new(cliargs, scope, theme, key_config, updater)?; log::trace!("app start: {} ms", app_start.elapsed().as_millis()); @@ -273,6 +284,16 @@ fn draw( Ok(()) } +/// the part of the repository gitui is restricted to +fn repo_scope(cliargs: &CliArgs) -> Result { + if cliargs.only_this_dir { + PathScope::current_dir(&cliargs.repo_path) + .context("--only-this-dir") + } else { + Ok(PathScope::everything()) + } +} + fn ensure_valid_path(repo_path: &RepoPath) -> Result<()> { match asyncgit::sync::repo_open_error(repo_path) { Some(e) => { diff --git a/src/popups/branchlist.rs b/src/popups/branchlist.rs index fa66ffffad..3d593fa8b3 100644 --- a/src/popups/branchlist.rs +++ b/src/popups/branchlist.rs @@ -586,6 +586,7 @@ impl BranchListPopup { &self.repo.borrow(), StatusType::WorkingDir, None, + None, ) .expect("Could not get status"); diff --git a/src/scope.rs b/src/scope.rs new file mode 100644 index 0000000000..34dee481bf --- /dev/null +++ b/src/scope.rs @@ -0,0 +1,117 @@ +use anyhow::{anyhow, Result}; +use asyncgit::sync::{utils::repo_work_dir, RepoPath}; +use std::{env, path::Path}; + +/// Restricts what gitui shows to a single directory of the repository. +/// +/// The path is relative to the work dir of the repository, uses `/` as +/// separator and has no trailing one. That way it doubles as a git +/// pathspec (`sub/dir`) and as a prefix of the repo relative paths git +/// reports for files. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PathScope(Option); + +impl PathScope { + /// covers the whole repository + pub const fn everything() -> Self { + Self(None) + } + + /// covers the current working directory only + /// + /// fails if the current directory is not inside the work dir of + /// the repository (for example for a bare repository) + pub fn current_dir(repo_path: &RepoPath) -> Result { + let workdir = repo_work_dir(repo_path).map_err(|e| { + anyhow!("repository has no work dir to scope to: {e}") + })?; + let workdir = Path::new(&workdir).canonicalize()?; + let current_dir = env::current_dir()?.canonicalize()?; + + let relative = + current_dir.strip_prefix(&workdir).map_err(|_| { + anyhow!( + "current directory `{}` is not inside the repository `{}`", + current_dir.display(), + workdir.display() + ) + })?; + + Ok(Self::from_relative_path(relative)) + } + + fn from_relative_path(path: &Path) -> Self { + let path = path + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + + if path.is_empty() { + Self::everything() + } else { + Self(Some(path)) + } + } + + /// the scoped directory, `None` if the whole repository is shown + pub fn path(&self) -> Option<&str> { + self.0.as_deref() + } + + /// pathspec for git operations that would otherwise run on the + /// whole repository + pub fn pathspec(&self) -> &str { + self.0.as_deref().unwrap_or("*") + } + + /// is the repo relative `path` inside the scope? + /// + /// a leading `./` is accepted because that is how paths of a + /// commit tree are reported + pub fn contains(&self, path: &Path) -> bool { + self.0.as_deref().is_none_or(|scope| { + path.strip_prefix(".").unwrap_or(path).starts_with(scope) + }) + } +} + +#[cfg(test)] +mod tests { + use super::PathScope; + use std::path::Path; + + #[test] + fn test_everything_contains_all() { + let scope = PathScope::everything(); + + assert_eq!(scope.path(), None); + assert_eq!(scope.pathspec(), "*"); + assert!(scope.contains(Path::new("foo.txt"))); + assert!(scope.contains(Path::new("sub/foo.txt"))); + } + + #[test] + fn test_scope_contains_only_children() { + let scope = + PathScope::from_relative_path(Path::new("sub/dir")); + + assert_eq!(scope.path(), Some("sub/dir")); + assert_eq!(scope.pathspec(), "sub/dir"); + assert!(scope.contains(Path::new("sub/dir/foo.txt"))); + assert!(scope.contains(Path::new("./sub/dir/foo.txt"))); + assert!(!scope.contains(Path::new("./sub/foo.txt"))); + assert!(scope.contains(Path::new("sub/dir/deeper/foo.txt"))); + assert!(!scope.contains(Path::new("sub/dirt/foo.txt"))); + assert!(!scope.contains(Path::new("sub/foo.txt"))); + assert!(!scope.contains(Path::new("foo.txt"))); + } + + #[test] + fn test_empty_relative_path_is_everything() { + assert_eq!( + PathScope::from_relative_path(Path::new("")), + PathScope::everything() + ); + } +} diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index 9200c93f00..3d13fd1514 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -16,8 +16,8 @@ use anyhow::Result; use asyncgit::{ asyncjob::AsyncSingleJob, sync::{ - self, filter_commit_by_search, CommitId, LogFilterSearch, - LogFilterSearchOptions, RepoPathRef, + self, diff_contains_file, filter_commit_by_search, CommitId, + LogFilterSearch, LogFilterSearchOptions, RepoPathRef, }, AsyncBranchesJob, AsyncCommitFilterJob, AsyncGitNotification, AsyncLog, AsyncTags, CommitFilesParams, FetchStatus, @@ -90,7 +90,9 @@ impl Revlog { git_log: AsyncLog::new( env.repo.borrow().clone(), &env.sender_git, - None, + env.scope + .path() + .map(|scope| diff_contains_file(scope.into())), ), search: LogSearch::Off, git_tags: AsyncTags::new( diff --git a/src/tabs/stashing.rs b/src/tabs/stashing.rs index 4f04e9ebc6..a76f3c7b82 100644 --- a/src/tabs/stashing.rs +++ b/src/tabs/stashing.rs @@ -62,6 +62,8 @@ impl Stashing { git_status: AsyncStatus::new( env.repo.borrow().clone(), env.sender_git.clone(), + // stashing always affects the whole repository + None, ), queue: env.queue.clone(), key_config: env.key_config.clone(), diff --git a/src/tabs/status.rs b/src/tabs/status.rs index 135cf18e4e..17f6bd4f63 100644 --- a/src/tabs/status.rs +++ b/src/tabs/status.rs @@ -187,10 +187,12 @@ impl Status { git_status_workdir: AsyncStatus::new( repo_clone.clone(), env.sender_git.clone(), + env.scope.path().map(String::from), ), git_status_stage: AsyncStatus::new( repo_clone, env.sender_git.clone(), + env.scope.path().map(String::from), ), git_action_executed: false, git_branch_state: None,