From 7b5c3d543d198a18884c990d540f5debc8a4d8d5 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Mon, 26 Apr 2021 11:50:31 +0100 Subject: Support bash, resolves #3 --- README.md | 43 ++++++++-- atuin-client/src/import.rs | 176 ---------------------------------------- atuin-client/src/import/bash.rs | 79 ++++++++++++++++++ atuin-client/src/import/mod.rs | 15 ++++ atuin-client/src/import/zsh.rs | 167 ++++++++++++++++++++++++++++++++++++++ atuin-client/src/sync.rs | 2 + diesel.toml | 5 -- src/command/import.rs | 69 +++++++++++++++- src/command/init.rs | 25 ++++-- src/command/mod.rs | 4 +- src/shell/atuin.bash | 30 +++++++ src/shell/atuin.zsh | 2 +- 12 files changed, 417 insertions(+), 200 deletions(-) delete mode 100644 atuin-client/src/import.rs create mode 100644 atuin-client/src/import/bash.rs create mode 100644 atuin-client/src/import/mod.rs create mode 100644 atuin-client/src/import/zsh.rs delete mode 100644 diesel.toml create mode 100644 src/shell/atuin.bash diff --git a/README.md b/README.md index 1533e3c8..ff431fc5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@

Atuin

-Magical shell history

@@ -30,6 +29,7 @@ ## Supported Shells - zsh +- bash # Quickstart @@ -43,12 +43,15 @@ atuin sync ## Install -### AUR +### Script (recommended) -Atuin is available on the [AUR](https://aur.archlinux.org/packages/atuin/) +The install script will help you through the setup, ensuring your shell is +properly configured. It will also use one of the below methods, preferring the +system package manager where possible (AUR, homebrew, etc etc). ``` -yay -S atuin # or your AUR helper of choice +# do not run this as root, root will be asked for if required +curl https://github.com/ellie/atuin/blob/main/install.sh | sh ``` ### With cargo @@ -60,6 +63,14 @@ toolchain, then you can run: cargo install atuin ``` +### AUR + +Atuin is available on the [AUR](https://aur.archlinux.org/packages/atuin/) + +``` +yay -S atuin # or your AUR helper of choice +``` + ### From source ``` @@ -68,15 +79,31 @@ cd atuin cargo install --path . ``` -### Shell plugin +## Shell plugin + +Once the binary is installed, the shell plugin requires installing. If you use +the install script, this should all be done for you! + +### zsh + +``` +echo 'eval "$(atuin init zsh)"' >> ~/.zshrc +``` + +### bash -Once the binary is installed, the shell plugin requires installing. Add +We need to setup some hooks, so first install bash-preexec: ``` -eval "$(atuin init)" +curl https://raw.githubusercontent.com/rcaloras/bash-preexec/master/bash-preexec.sh -o ~/.bash-preexec.sh +echo '[[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh' >> ~/.bashrc ``` -to your `.zshrc` +Then setup Atuin + +``` +echo 'eval "$(atuin init bash)"' >> ~/.bashrc +``` ## ...what's with the name? diff --git a/atuin-client/src/import.rs b/atuin-client/src/import.rs deleted file mode 100644 index 3b0b2a69..00000000 --- a/atuin-client/src/import.rs +++ /dev/null @@ -1,176 +0,0 @@ -// import old shell history! -// automatically hoover up all that we can find - -use std::io::{BufRead, BufReader, Seek, SeekFrom}; -use std::{fs::File, path::Path}; - -use chrono::prelude::*; -use chrono::Utc; -use eyre::{eyre, Result}; -use itertools::Itertools; - -use super::history::History; - -#[derive(Debug)] -pub struct Zsh { - file: BufReader, - - pub loc: u64, - pub counter: i64, -} - -// this could probably be sped up -fn count_lines(buf: &mut BufReader) -> Result { - let lines = buf.lines().count(); - buf.seek(SeekFrom::Start(0))?; - - Ok(lines) -} - -impl Zsh { - pub fn new(path: impl AsRef) -> Result { - let file = File::open(path)?; - let mut buf = BufReader::new(file); - let loc = count_lines(&mut buf)?; - - Ok(Self { - file: buf, - loc: loc as u64, - counter: 0, - }) - } -} - -fn parse_extended(line: &str, counter: i64) -> History { - let line = line.replacen(": ", "", 2); - let (time, duration) = line.splitn(2, ':').collect_tuple().unwrap(); - let (duration, command) = duration.splitn(2, ';').collect_tuple().unwrap(); - - let time = time - .parse::() - .unwrap_or_else(|_| chrono::Utc::now().timestamp()); - - let offset = chrono::Duration::milliseconds(counter); - let time = Utc.timestamp(time, 0); - let time = time + offset; - - let duration = duration.parse::().map_or(-1, |t| t * 1_000_000_000); - - // use nanos, because why the hell not? we won't display them. - History::new( - time, - command.trim_end().to_string(), - String::from("unknown"), - 0, // assume 0, we have no way of knowing :( - duration, - None, - None, - ) -} - -impl Zsh { - fn read_line(&mut self) -> Option> { - let mut line = String::new(); - - match self.file.read_line(&mut line) { - Ok(0) => None, - Ok(_) => Some(Ok(line)), - Err(e) => Some(Err(eyre!("failed to read line: {}", e))), // we can skip past things like invalid utf8 - } - } -} - -impl Iterator for Zsh { - type Item = Result; - - fn next(&mut self) -> Option { - // ZSH extended history records the timestamp + command duration - // These lines begin with : - // So, if the line begins with :, parse it. Otherwise it's just - // the command - let line = self.read_line()?; - - if let Err(e) = line { - return Some(Err(e)); // :( - } - - let mut line = line.unwrap(); - - while line.ends_with("\\\n") { - let next_line = self.read_line()?; - - if next_line.is_err() { - // There's a chance that the last line of a command has invalid - // characters, the only safe thing to do is break :/ - // usually just invalid utf8 or smth - // however, we really need to avoid missing history, so it's - // better to have some items that should have been part of - // something else, than to miss things. So break. - break; - } - - line.push_str(next_line.unwrap().as_str()); - } - - // We have to handle the case where a line has escaped newlines. - // Keep reading until we have a non-escaped newline - - let extended = line.starts_with(':'); - - if extended { - self.counter += 1; - Some(Ok(parse_extended(line.as_str(), self.counter))) - } else { - let time = chrono::Utc::now(); - let offset = chrono::Duration::seconds(self.counter); - let time = time - offset; - - self.counter += 1; - - Some(Ok(History::new( - time, - line.trim_end().to_string(), - String::from("unknown"), - -1, - -1, - None, - None, - ))) - } - } -} - -#[cfg(test)] -mod test { - use chrono::prelude::*; - use chrono::Utc; - - use super::parse_extended; - - #[test] - fn test_parse_extended_simple() { - let parsed = parse_extended(": 1613322469:0;cargo install atuin", 0); - - assert_eq!(parsed.command, "cargo install atuin"); - assert_eq!(parsed.duration, 0); - assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); - - let parsed = parse_extended(": 1613322469:10;cargo install atuin;cargo update", 0); - - assert_eq!(parsed.command, "cargo install atuin;cargo update"); - assert_eq!(parsed.duration, 10_000_000_000); - assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); - - let parsed = parse_extended(": 1613322469:10;cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷", 0); - - assert_eq!(parsed.command, "cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷"); - assert_eq!(parsed.duration, 10_000_000_000); - assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); - - let parsed = parse_extended(": 1613322469:10;cargo install \\n atuin\n", 0); - - assert_eq!(parsed.command, "cargo install \\n atuin"); - assert_eq!(parsed.duration, 10_000_000_000); - assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); - } -} diff --git a/atuin-client/src/import/bash.rs b/atuin-client/src/import/bash.rs new file mode 100644 index 00000000..d5fbef46 --- /dev/null +++ b/atuin-client/src/import/bash.rs @@ -0,0 +1,79 @@ +use std::io::{BufRead, BufReader}; +use std::{fs::File, path::Path}; + +use eyre::{eyre, Result}; + +use super::count_lines; +use crate::history::History; + +#[derive(Debug)] +pub struct Bash { + file: BufReader, + + pub loc: u64, + pub counter: i64, +} + +impl Bash { + pub fn new(path: impl AsRef) -> Result { + let file = File::open(path)?; + let mut buf = BufReader::new(file); + let loc = count_lines(&mut buf)?; + + Ok(Self { + file: buf, + loc: loc as u64, + counter: 0, + }) + } + + fn read_line(&mut self) -> Option> { + let mut line = String::new(); + + match self.file.read_line(&mut line) { + Ok(0) => None, + Ok(_) => Some(Ok(line)), + Err(e) => Some(Err(eyre!("failed to read line: {}", e))), // we can skip past things like invalid utf8 + } + } +} + +impl Iterator for Bash { + type Item = Result; + + fn next(&mut self) -> Option { + let line = self.read_line()?; + + if let Err(e) = line { + return Some(Err(e)); // :( + } + + let mut line = line.unwrap(); + + while line.ends_with("\\\n") { + let next_line = self.read_line()?; + + if next_line.is_err() { + break; + } + + line.push_str(next_line.unwrap().as_str()); + } + + let time = chrono::Utc::now(); + let offset = chrono::Duration::seconds(self.counter); + let time = time - offset; + + self.counter += 1; + + Some(Ok(History::new( + time, + line.trim_end().to_string(), + String::from("unknown"), + -1, + -1, + None, + None, + ))) + } +} diff --git a/atuin-client/src/import/mod.rs b/atuin-client/src/import/mod.rs new file mode 100644 index 00000000..3f8ea355 --- /dev/null +++ b/atuin-client/src/import/mod.rs @@ -0,0 +1,15 @@ +use std::fs::File; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; + +use eyre::Result; + +pub mod bash; +pub mod zsh; + +// this could probably be sped up +fn count_lines(buf: &mut BufReader) -> Result { + let lines = buf.lines().count(); + buf.seek(SeekFrom::Start(0))?; + + Ok(lines) +} diff --git a/atuin-client/src/import/zsh.rs b/atuin-client/src/import/zsh.rs new file mode 100644 index 00000000..46e9af63 --- /dev/null +++ b/atuin-client/src/import/zsh.rs @@ -0,0 +1,167 @@ +// import old shell history! +// automatically hoover up all that we can find + +use std::io::{BufRead, BufReader}; +use std::{fs::File, path::Path}; + +use chrono::prelude::*; +use chrono::Utc; +use eyre::{eyre, Result}; +use itertools::Itertools; + +use super::count_lines; +use crate::history::History; + +#[derive(Debug)] +pub struct Zsh { + file: BufReader, + + pub loc: u64, + pub counter: i64, +} + +impl Zsh { + pub fn new(path: impl AsRef) -> Result { + let file = File::open(path)?; + let mut buf = BufReader::new(file); + let loc = count_lines(&mut buf)?; + + Ok(Self { + file: buf, + loc: loc as u64, + counter: 0, + }) + } + + fn read_line(&mut self) -> Option> { + let mut line = String::new(); + + match self.file.read_line(&mut line) { + Ok(0) => None, + Ok(_) => Some(Ok(line)), + Err(e) => Some(Err(eyre!("failed to read line: {}", e))), // we can skip past things like invalid utf8 + } + } +} + +impl Iterator for Zsh { + type Item = Result; + + fn next(&mut self) -> Option { + // ZSH extended history records the timestamp + command duration + // These lines begin with : + // So, if the line begins with :, parse it. Otherwise it's just + // the command + let line = self.read_line()?; + + if let Err(e) = line { + return Some(Err(e)); // :( + } + + let mut line = line.unwrap(); + + while line.ends_with("\\\n") { + let next_line = self.read_line()?; + + if next_line.is_err() { + // There's a chance that the last line of a command has invalid + // characters, the only safe thing to do is break :/ + // usually just invalid utf8 or smth + // however, we really need to avoid missing history, so it's + // better to have some items that should have been part of + // something else, than to miss things. So break. + break; + } + + line.push_str(next_line.unwrap().as_str()); + } + + // We have to handle the case where a line has escaped newlines. + // Keep reading until we have a non-escaped newline + + let extended = line.starts_with(':'); + + if extended { + self.counter += 1; + Some(Ok(parse_extended(line.as_str(), self.counter))) + } else { + let time = chrono::Utc::now(); + let offset = chrono::Duration::seconds(self.counter); + let time = time - offset; + + self.counter += 1; + + Some(Ok(History::new( + time, + line.trim_end().to_string(), + String::from("unknown"), + -1, + -1, + None, + None, + ))) + } + } +} + +fn parse_extended(line: &str, counter: i64) -> History { + let line = line.replacen(": ", "", 2); + let (time, duration) = line.splitn(2, ':').collect_tuple().unwrap(); + let (duration, command) = duration.splitn(2, ';').collect_tuple().unwrap(); + + let time = time + .parse::() + .unwrap_or_else(|_| chrono::Utc::now().timestamp()); + + let offset = chrono::Duration::milliseconds(counter); + let time = Utc.timestamp(time, 0); + let time = time + offset; + + let duration = duration.parse::().map_or(-1, |t| t * 1_000_000_000); + + // use nanos, because why the hell not? we won't display them. + History::new( + time, + command.trim_end().to_string(), + String::from("unknown"), + 0, // assume 0, we have no way of knowing :( + duration, + None, + None, + ) +} + +#[cfg(test)] +mod test { + use chrono::prelude::*; + use chrono::Utc; + + use super::parse_extended; + + #[test] + fn test_parse_extended_simple() { + let parsed = parse_extended(": 1613322469:0;cargo install atuin", 0); + + assert_eq!(parsed.command, "cargo install atuin"); + assert_eq!(parsed.duration, 0); + assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); + + let parsed = parse_extended(": 1613322469:10;cargo install atuin;cargo update", 0); + + assert_eq!(parsed.command, "cargo install atuin;cargo update"); + assert_eq!(parsed.duration, 10_000_000_000); + assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); + + let parsed = parse_extended(": 1613322469:10;cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷", 0); + + assert_eq!(parsed.command, "cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷"); + assert_eq!(parsed.duration, 10_000_000_000); + assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); + + let parsed = parse_extended(": 1613322469:10;cargo install \\n atuin\n", 0); + + assert_eq!(parsed.command, "cargo install \\n atuin"); + assert_eq!(parsed.duration, 10_000_000_000); + assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); + } +} diff --git a/atuin-client/src/sync.rs b/atuin-client/src/sync.rs index 813c2ed4..5c6405df 100644 --- a/atuin-client/src/sync.rs +++ b/atuin-client/src/sync.rs @@ -123,6 +123,8 @@ async fn sync_upload( client.post_history(&buffer).await?; cursor = buffer.last().unwrap().timestamp; remote_count = client.count().await?; + + debug!("upload cursor: {:?}", cursor); } Ok(()) diff --git a/diesel.toml b/diesel.toml deleted file mode 100644 index 92267c82..00000000 --- a/diesel.toml +++ /dev/null @@ -1,5 +0,0 @@ -# For documentation on how to configure this file, -# see diesel.rs/guides/configuring-diesel-cli - -[print_schema] -file = "src/schema.rs" diff --git a/src/command/import.rs b/src/command/import.rs index 931e7af4..09df5839 100644 --- a/src/command/import.rs +++ b/src/command/import.rs @@ -7,7 +7,7 @@ use structopt::StructOpt; use atuin_client::database::Database; use atuin_client::history::History; -use atuin_client::import::Zsh; +use atuin_client::import::{bash::Bash, zsh::Zsh}; use indicatif::ProgressBar; #[derive(StructOpt)] @@ -23,11 +23,17 @@ pub enum Cmd { aliases=&["z", "zs"], )] Zsh, + + #[structopt( + about="import history from the bash history file", + aliases=&["b", "ba", "bas"], + )] + Bash, } impl Cmd { pub async fn run(&self, db: &mut (impl Database + Send + Sync)) -> Result<()> { - println!(" A'Tuin "); + println!(" Atuin "); println!("======================"); println!(" \u{1f30d} "); println!(" \u{1f418}\u{1f418}\u{1f418}\u{1f418} "); @@ -49,6 +55,7 @@ impl Cmd { } Self::Zsh => import_zsh(db).await, + Self::Bash => import_bash(db).await, } } } @@ -120,3 +127,61 @@ async fn import_zsh(db: &mut (impl Database + Send + Sync)) -> Result<()> { Ok(()) } + +// TODO: don't just copy paste this lol +async fn import_bash(db: &mut (impl Database + Send + Sync)) -> Result<()> { + // oh-my-zsh sets HISTFILE=~/.zhistory + // zsh has no default value for this var, but uses ~/.zhistory. + // we could maybe be smarter about this in the future :) + + let histpath = env::var("HISTFILE"); + + let histpath = if let Ok(p) = histpath { + let histpath = PathBuf::from(p); + + if !histpath.exists() { + return Err(eyre!( + "Could not find history file {:?}. try updating $HISTFILE", + histpath + )); + } + + histpath + } else { + let user_dirs = UserDirs::new().unwrap(); + let home_dir = user_dirs.home_dir(); + + home_dir.join(".bash_history") + }; + + let bash = Bash::new(histpath)?; + + let progress = ProgressBar::new(bash.loc); + + let buf_size = 100; + let mut buf = Vec::::with_capacity(buf_size); + + for i in bash + .filter_map(Result::ok) + .filter(|x| !x.command.trim().is_empty()) + { + buf.push(i); + + if buf.len() == buf_size { + db.save_bulk(&buf).await?; + progress.inc(buf.len() as u64); + + buf.clear(); + } + } + + if !buf.is_empty() { + db.save_bulk(&buf).await?; + progress.inc(buf.len() as u64); + } + + progress.finish(); + println!("Import complete!"); + + Ok(()) +} diff --git a/src/command/init.rs b/src/command/init.rs index 022021d0..ed1555a9 100644 --- a/src/command/init.rs +++ b/src/command/init.rs @@ -1,19 +1,32 @@ use std::env; use eyre::{eyre, Result}; +use structopt::StructOpt; + +#[derive(StructOpt)] +pub enum Cmd { + #[structopt(about = "zsh setup")] + Zsh, + #[structopt(about = "bash setup")] + Bash, +} fn init_zsh() { let full = include_str!("../shell/atuin.zsh"); println!("{}", full); } -pub fn init() -> Result<()> { - let shell = env::var("SHELL")?; +fn init_bash() { + let full = include_str!("../shell/atuin.bash"); + println!("{}", full); +} - if shell.ends_with("zsh") { - init_zsh(); +impl Cmd { + pub fn run(&self) -> Result<()> { + match self { + Self::Zsh => init_zsh(), + Self::Bash => init_bash(), + } Ok(()) - } else { - Err(eyre!("Could not detect shell, or shell unsupported")) } } diff --git a/src/command/mod.rs b/src/command/mod.rs index 78e6402e..b16aae4d 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -37,7 +37,7 @@ pub enum AtuinCmd { Stats(stats::Cmd), #[structopt(about = "output shell setup")] - Init, + Init(init::Cmd), #[structopt(about = "generates a UUID")] Uuid, @@ -101,7 +101,7 @@ impl AtuinCmd { Self::Import(import) => import.run(&mut db).await, Self::Server(server) => server.run(&server_settings).await, Self::Stats(stats) => stats.run(&mut db, &client_settings).await, - Self::Init => init::init(), + Self::Init(init) => init.run(), Self::Search { cwd, exit, diff --git a/src/shell/atuin.bash b/src/shell/atuin.bash new file mode 100644 index 00000000..43de3640 --- /dev/null +++ b/src/shell/atuin.bash @@ -0,0 +1,30 @@ +_atuin_preexec() { + id=$(atuin history start "$1") + export ATUIN_HISTORY_ID="$id" +} + +_atuin_precmd() { + local EXIT="$?" + + [[ -z "${ATUIN_HISTORY_ID}" ]] && return + + + (RUST_LOG=error atuin history end $ATUIN_HISTORY_ID --exit $EXIT &) > /dev/null 2>&1 +} + + +__atuin_history () +{ + tput rmkx + HISTORY="$(RUST_LOG=error atuin search -i $BUFFER 3>&1 1>&2 2>&3)" + tput smkx + + READLINE_LINE=${HISTORY} + READLINE_POINT=${#READLINE_LINE} +} + + +preexec_functions+=(_atuin_preexec) +precmd_functions+=(_atuin_precmd) + +bind -x '"\C-r": __atuin_history' diff --git a/src/shell/atuin.zsh b/src/shell/atuin.zsh index cdef5e54..6a24de50 100644 --- a/src/shell/atuin.zsh +++ b/src/shell/atuin.zsh @@ -6,7 +6,7 @@ export ATUIN_HISTORY="atuin history list" export ATUIN_BINDKEYS="true" _atuin_preexec(){ - id=$(atuin history start $1) + id=$(atuin history start "$1") export ATUIN_HISTORY_ID="$id" } -- cgit v1.3.1