feat: add import from PowerShell history (#2864)

This adds an `atuin import powershell` command.

Of course, it is related to #2543 but I'm submitting it as a separate PR
since the code is self-contained and simple enough, and the feature
could be useful on its own.

/cc @ajn142 who [requested
it](https://github.com/atuinsh/atuin/issues/84#issuecomment-3091692807).

## Checks
- [x] I am happy for maintainers to push small adjustments to this PR,
to speed up the review cycle
- [x] I have checked that there are no existing pull requests for the
same thing
This commit is contained in:
Lucas Trzesniewski
2025-10-20 20:27:59 +02:00
committed by GitHub
parent 10ab94372b
commit 755bd34090
3 changed files with 221 additions and 6 deletions

View File

@@ -12,6 +12,7 @@ pub mod bash;
pub mod fish;
pub mod nu;
pub mod nu_histdb;
pub mod powershell;
pub mod replxx;
pub mod resh;
pub mod xonsh;

View File

@@ -0,0 +1,202 @@
use async_trait::async_trait;
use directories::BaseDirs;
use eyre::{Result, eyre};
use std::path::PathBuf;
use time::{Duration, OffsetDateTime};
use super::{Importer, Loader, count_lines, unix_byte_lines};
use crate::history::History;
use crate::import::read_to_end;
#[derive(Debug)]
pub struct PowerShell {
bytes: Vec<u8>,
line_count: Option<usize>,
}
fn get_history_path() -> Result<PathBuf> {
let base = BaseDirs::new().ok_or_else(|| eyre!("could not determine data directory"))?;
// The command line history in PowerShell is maintained by the PSReadLine module:
// https://learn.microsoft.com/en-us/powershell/module/psreadline/about/about_psreadline#command-history
//
// > PSReadLine maintains a history file containing all the commands and data you've entered from the command line.
// > The history files are a file named `$($Host.Name)_history.txt`.
// > On Windows systems the history file is stored at `$Env:APPDATA\Microsoft\Windows\PowerShell\PSReadLine`.
// > On non-Windows systems, the history files are stored at `$Env:XDG_DATA_HOME/powershell/PSReadLine`
// > or `$Env:HOME/.local/share/powershell/PSReadLine`.
let dir = if cfg!(windows) {
base.data_dir()
.join("Microsoft")
.join("Windows")
.join("PowerShell")
.join("PSReadLine")
} else {
std::env::var("XDG_DATA_HOME")
.map_or_else(
|_| base.home_dir().join(".local").join("share"),
PathBuf::from,
)
.join("powershell")
.join("PSReadLine")
};
// The history is stored in a file named `$($Host.Name)_history.txt`.
// For the default console host shipped by Microsoft,`$Host.Name` is `ConsoleHost`:
// https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.host.pshost.name#remarks
let file = dir.join("ConsoleHost_history.txt");
if file.is_file() {
Ok(file)
} else {
Err(eyre!("Could not find history file: {}", file.display()))
}
}
#[async_trait]
impl Importer for PowerShell {
const NAME: &'static str = "PowerShell";
async fn new() -> Result<Self> {
let bytes = read_to_end(get_history_path()?)?;
Ok(Self {
bytes,
line_count: None,
})
}
async fn entries(&mut self) -> Result<usize> {
// Commands can be split over multiple lines,
// but this is only used for a progress bar, and multi-line commands
// should be quite rare, so this is not an issue in practice.
if self.line_count.is_none() {
self.line_count = Some(count_lines(&self.bytes));
}
Ok(self.line_count.unwrap())
}
async fn load(mut self, h: &mut impl Loader) -> Result<()> {
let line_count = self.entries().await?;
let start = OffsetDateTime::now_utc() - Duration::milliseconds(line_count as i64);
let mut counter = 0;
let mut iter = unix_byte_lines(&self.bytes);
while let Some(s) = iter.next() {
let Ok(s) = read_line(s) else {
continue; // We can skip past things like invalid utf8
};
let mut cmd = s.to_string();
// Multi-line commands end with a backtick, append the following lines.
while cmd.ends_with('`') {
cmd.pop();
let Some(next) = iter.next() else {
break;
};
let Ok(next) = read_line(next) else {
break;
};
cmd.push('\n');
cmd.push_str(next);
}
if cmd.is_empty() {
continue;
}
let offset = Duration::milliseconds(counter);
counter += 1;
let entry = History::import().timestamp(start + offset).command(cmd);
h.push(entry.build().into()).await?;
}
Ok(())
}
}
fn read_line(s: &[u8]) -> Result<&str> {
let s = str::from_utf8(s)?;
// History is stored in CRLF on Windows, normalize the input to LF on all platforms.
let s = s.strip_suffix('\r').unwrap_or(s);
Ok(s)
}
#[cfg(test)]
mod test {
use super::*;
use crate::import::tests::TestLoader;
use itertools::assert_equal;
const INPUT: &str = r#"cargo install atuin
cargo update
echo "first line`
second line`
`
last line"
echo foo
echo bar
echo baz
"#;
const EXPECTED: &[&str] = &[
"cargo install atuin",
"cargo update",
"echo \"first line\nsecond line\n\nlast line\"",
"echo foo",
"echo bar",
"echo baz",
];
#[tokio::test]
async fn test_import() {
let loader = import(INPUT).await;
let actual = loader.buf.iter().map(|h| h.command.clone());
let expected = EXPECTED.iter().map(|s| s.to_string());
assert_equal(actual, expected);
}
#[tokio::test]
async fn test_crlf() {
let input = INPUT.replace("\n", "\r\n");
let loader = import(input.as_str()).await;
let actual = loader.buf.iter().map(|h| h.command.clone());
let expected = EXPECTED.iter().map(|s| s.to_string());
assert_equal(actual, expected);
}
#[tokio::test]
async fn test_timestamps() {
let loader = import(INPUT).await;
let mut prev = loader.buf.first().unwrap().timestamp;
for current in loader.buf.iter().skip(1).map(|h| h.timestamp) {
assert!(current > prev);
prev = current;
}
}
async fn import(input: &str) -> TestLoader {
let powershell = PowerShell {
bytes: input.as_bytes().to_vec(),
line_count: None,
};
let mut loader = TestLoader::default();
powershell.load(&mut loader).await.unwrap();
loader
}
}

View File

@@ -9,8 +9,9 @@ use atuin_client::{
database::Database,
history::History,
import::{
Importer, Loader, bash::Bash, fish::Fish, nu::Nu, nu_histdb::NuHistDb, replxx::Replxx,
resh::Resh, xonsh::Xonsh, xonsh_sqlite::XonshSqlite, zsh::Zsh, zsh_histdb::ZshHistDb,
Importer, Loader, bash::Bash, fish::Fish, nu::Nu, nu_histdb::NuHistDb,
powershell::PowerShell, replxx::Replxx, resh::Resh, xonsh::Xonsh,
xonsh_sqlite::XonshSqlite, zsh::Zsh, zsh_histdb::ZshHistDb,
},
};
@@ -40,6 +41,8 @@ pub enum Cmd {
Xonsh,
/// Import history from xonsh sqlite db
XonshSqlite,
/// Import history from the powershell history file
Powershell,
}
const BATCH_SIZE: usize = 100;
@@ -58,10 +61,15 @@ impl Cmd {
match self {
Self::Auto => {
if cfg!(windows) {
println!(
"This feature does not work on windows. Please run atuin import <SHELL>. To view a list of shells, run atuin import."
);
return Ok(());
return if env::var("PSModulePath").is_ok() {
println!("Detected PowerShell");
import::<PowerShell, DB>(db).await
} else {
println!("Could not detect the current shell.");
println!("Please run atuin import <SHELL>.");
println!("To view a list of shells, run atuin import.");
Ok(())
};
}
// $XONSH_HISTORY_BACKEND isn't always set, but $XONSH_HISTORY_FILE is
@@ -103,6 +111,9 @@ impl Cmd {
println!("Detected Nushell");
import::<Nu, DB>(db).await
}
} else if shell.ends_with("/pwsh") {
println!("Detected PowerShell");
import::<PowerShell, DB>(db).await
} else {
println!("cannot import {shell} history");
Ok(())
@@ -119,6 +130,7 @@ impl Cmd {
Self::NuHistDb => import::<NuHistDb, DB>(db).await,
Self::Xonsh => import::<Xonsh, DB>(db).await,
Self::XonshSqlite => import::<XonshSqlite, DB>(db).await,
Self::Powershell => import::<PowerShell, DB>(db).await,
}
}
}