feat: add chooser file mechanism for shell directory switching
Some checks failed
continuous-integration/drone/push Build encountered an error

Replace stdout-based path capture with a temporary chooser file that the
shell wrapper reads after gitnow exits. Commands that select a directory
write to the file; commands that don't (e.g. project delete) leave it
empty, so the shell only cd's when appropriate. The chooser file path
can be set via --chooser-file flag or GITNOW_CHOOSER_FILE env var.
This commit is contained in:
2026-03-20 14:38:48 +01:00
parent 681993c379
commit b5394a6b26
6 changed files with 92 additions and 34 deletions

View File

@@ -4,19 +4,30 @@ function git-now {
nohup gitnow update > /dev/null 2>&1 &
)
# Find the repository of choice
choice=$(gitnow "$@" --no-shell)
if [[ $? -ne 0 ]]; then
return $?
# Create a temporary chooser file
local chooser_file
chooser_file="$(mktemp)"
# Run gitnow with the chooser file
GITNOW_CHOOSER_FILE="$chooser_file" gitnow "$@"
local exit_code=$?
if [[ $exit_code -ne 0 ]]; then
rm -f "$chooser_file"
return $exit_code
fi
# Enter local repository path
cd "$(echo "$choice" | tail --lines 1)"
# If the chooser file has content, cd to the chosen path
if [[ -s "$chooser_file" ]]; then
local target
target="$(cat "$chooser_file")"
rm -f "$chooser_file"
cd "$target"
else
rm -f "$chooser_file"
fi
}
function gn {
git-now "$@"
if [[ $? -ne 0 ]]; then
return $?
fi
}

View File

@@ -0,0 +1,34 @@
use std::path::{Path, PathBuf};
/// Manages an optional chooser file that the shell wrapper reads after gitnow
/// exits. When active, the selected directory path is written to the file
/// instead of being printed to stdout.
#[derive(Debug, Default)]
pub struct Chooser {
path: Option<PathBuf>,
}
impl Chooser {
pub fn new(path: PathBuf) -> Self {
Self { path: Some(path) }
}
/// Returns `true` when a chooser file has been configured.
pub fn is_active(&self) -> bool {
self.path.is_some()
}
/// Write `dir` to the chooser file. If no chooser file is configured the
/// path is printed to stdout (preserving the old `--no-shell` behaviour).
pub fn set(&self, dir: &Path) -> anyhow::Result<()> {
match &self.path {
Some(chooser_path) => {
std::fs::write(chooser_path, dir.display().to_string())?;
}
None => {
println!("{}", dir.display());
}
}
Ok(())
}
}

View File

@@ -5,6 +5,7 @@ use std::sync::Arc;
use crate::{
app::App,
cache::load_repositories,
chooser::Chooser,
custom_command::CustomCommandApp,
interactive::{InteractiveApp, Searchable},
shell::ShellApp,
@@ -256,16 +257,16 @@ fn select_project(
// --- Command implementations ---
impl ProjectCommand {
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
pub async fn execute(&mut self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
match self.command.take() {
Some(ProjectSubcommand::Create(mut create)) => create.execute(app).await,
Some(ProjectSubcommand::Create(mut create)) => create.execute(app, chooser).await,
Some(ProjectSubcommand::Add(mut add)) => add.execute(app).await,
Some(ProjectSubcommand::Delete(mut delete)) => delete.execute(app).await,
None => self.open_existing(app).await,
None => self.open_existing(app, chooser).await,
}
}
async fn open_existing(&self, app: &'static App) -> anyhow::Result<()> {
async fn open_existing(&self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
let projects_dir = get_projects_dir(app);
let projects = list_subdirectories(&projects_dir)?;
@@ -301,10 +302,10 @@ impl ProjectCommand {
.ok_or(anyhow::anyhow!("no project selected"))?,
};
if !self.no_shell {
if !self.no_shell && !chooser.is_active() {
app.shell().spawn_shell_at(&project.path).await?;
} else {
println!("{}", project.path.display());
chooser.set(&project.path)?;
}
Ok(())
@@ -312,7 +313,7 @@ impl ProjectCommand {
}
impl ProjectCreateCommand {
async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
async fn execute(&mut self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
let name = match self.name.take() {
Some(n) => n,
None => {
@@ -399,10 +400,10 @@ impl ProjectCreateCommand {
selected_repos.len()
);
if !self.no_shell {
if !self.no_shell && !chooser.is_active() {
app.shell().spawn_shell_at(&project_path).await?;
} else {
println!("{}", project_path.display());
chooser.set(&project_path)?;
}
Ok(())

View File

@@ -3,6 +3,7 @@ use std::{collections::BTreeMap, io::IsTerminal};
use crate::{
app::App,
cache::{load_repositories, CacheApp},
chooser::Chooser,
components::inline_command::InlineCommand,
custom_command::CustomCommandApp,
fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp},
@@ -31,6 +32,7 @@ impl RootCommand {
shell: bool,
force_refresh: bool,
force_cache_update: bool,
chooser: &Chooser,
) -> anyhow::Result<()> {
tracing::debug!("executing");
@@ -117,16 +119,7 @@ impl RootCommand {
self.app.shell().spawn_shell(&repo).await?;
} else {
tracing::info!("skipping shell for repo: {}", &repo.to_rel_path().display());
println!(
"{}",
self.app
.config
.settings
.projects
.directory
.join(repo.to_rel_path())
.display()
);
chooser.set(&self.app.config.settings.projects.directory.join(repo.to_rel_path()))?;
}
Ok(())

View File

@@ -3,6 +3,7 @@ use std::io::IsTerminal;
use crate::{
app::App,
cache::load_repositories,
chooser::Chooser,
components::inline_command::InlineCommand,
fuzzy_matcher::FuzzyMatcherApp,
interactive::{InteractiveApp, StringItem},
@@ -32,7 +33,7 @@ pub struct WorktreeCommand {
}
impl WorktreeCommand {
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
pub async fn execute(&mut self, app: &'static App, chooser: &Chooser) -> anyhow::Result<()> {
// Step 1: Load repositories
let repositories = load_repositories(app, !self.no_cache).await?;
@@ -142,10 +143,10 @@ impl WorktreeCommand {
}
// Step 7: Enter shell or print path
if !self.no_shell {
if !self.no_shell && !chooser.is_active() {
app.shell().spawn_shell_at(&worktree_path).await?;
} else {
println!("{}", worktree_path.display());
chooser.set(&worktree_path)?;
}
Ok(())

View File

@@ -13,6 +13,7 @@ use tracing_subscriber::EnvFilter;
mod app;
mod cache;
mod cache_codec;
pub mod chooser;
mod commands;
mod components;
mod config;
@@ -48,6 +49,12 @@ struct Command {
#[arg(long = "no-shell", default_value = "false")]
no_shell: bool,
/// Path to a chooser file; if set, the selected directory path is written
/// to this file instead of spawning a shell or printing to stdout.
/// Can also be set via the GITNOW_CHOOSER_FILE environment variable.
#[arg(long = "chooser-file", global = true, env = "GITNOW_CHOOSER_FILE")]
chooser_file: Option<PathBuf>,
#[arg(long = "force-refresh", default_value = "false")]
force_refresh: bool,
@@ -96,6 +103,16 @@ async fn main() -> anyhow::Result<()> {
let app = app::App::new_static(config).await?;
// When a chooser file is provided, it implies --no-shell behaviour:
// the selected path is written to the file and no interactive shell is
// spawned. The calling shell wrapper is responsible for reading the
// file and changing directory.
let chooser = cli
.chooser_file
.map(chooser::Chooser::new)
.unwrap_or_default();
let no_shell = cli.no_shell || chooser.is_active();
match cli.command {
Some(cmd) => match cmd {
Commands::Init(mut shell) => {
@@ -108,10 +125,10 @@ async fn main() -> anyhow::Result<()> {
clone.execute(app).await?;
}
Commands::Worktree(mut wt) => {
wt.execute(app).await?;
wt.execute(app, &chooser).await?;
}
Commands::Project(mut project) => {
project.execute(app).await?;
project.execute(app, &chooser).await?;
}
},
None => {
@@ -120,9 +137,10 @@ async fn main() -> anyhow::Result<()> {
cli.search.as_ref(),
!cli.no_cache,
!cli.no_clone,
!cli.no_shell,
!no_shell,
cli.force_refresh,
cli.force_cache_update,
&chooser,
)
.await?;
}