Add staged files (so random files don't get removed)

This commit is contained in:
2026-03-03 11:16:22 +00:00
parent c0a71bc285
commit 399a72f380
5 changed files with 141 additions and 28 deletions

46
src/add.rs Normal file
View File

@@ -0,0 +1,46 @@
use crate::error::CommitError;
use crate::utils::{is_descendant_of_current_dir, is_file_in_dir};
use crate::TOUR_DIR;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
pub const STAGED_PATH: &str = "./.tour/staged";
pub fn add(files: Vec<PathBuf>) -> Result<(), CommitError> {
let tour_dir = Path::new(TOUR_DIR);
for file in &files {
if !is_descendant_of_current_dir(file)? {
return Err(CommitError::NotADescendantOfCurrentDir(file.clone()));
}
if is_file_in_dir(file, tour_dir)? {
return Err(CommitError::InsideTourDir(file.clone()));
}
}
let mut staged = OpenOptions::new()
.append(true)
.create(true)
.open(STAGED_PATH)?;
for file in &files {
writeln!(staged, "{}", file.display())?;
println!("staged: {}", file.display());
}
Ok(())
}
pub fn get_staged() -> Result<Vec<PathBuf>, std::io::Error> {
let content = fs::read_to_string(STAGED_PATH).unwrap_or_default();
Ok(content
.lines()
.filter(|l| !l.is_empty())
.map(PathBuf::from)
.collect())
}
pub fn clear_staged() -> Result<(), std::io::Error> {
fs::write(STAGED_PATH, "")
}

View File

@@ -1,3 +1,4 @@
use crate::add::{clear_staged, get_staged};
use crate::error::CommitError; use crate::error::CommitError;
use crate::utils::{copy_path, is_descendant_of_current_dir, is_file_in_dir}; use crate::utils::{copy_path, is_descendant_of_current_dir, is_file_in_dir};
use crate::TOUR_DIR; use crate::TOUR_DIR;
@@ -7,6 +8,16 @@ use std::path::{Path, PathBuf};
pub fn commit(files: Vec<PathBuf>, message: String) -> Result<(), CommitError> { pub fn commit(files: Vec<PathBuf>, message: String) -> Result<(), CommitError> {
let tour_dir = Path::new(TOUR_DIR); let tour_dir = Path::new(TOUR_DIR);
let files = if files.is_empty() {
let staged = get_staged()?;
if staged.is_empty() {
return Err(CommitError::NothingToCommit);
}
staged
} else {
files
};
for file in &files { for file in &files {
if !is_descendant_of_current_dir(file)? { if !is_descendant_of_current_dir(file)? {
return Err(CommitError::NotADescendantOfCurrentDir(file.clone())); return Err(CommitError::NotADescendantOfCurrentDir(file.clone()));
@@ -30,6 +41,7 @@ pub fn commit(files: Vec<PathBuf>, message: String) -> Result<(), CommitError> {
} }
fs::write(step_dir.join("message"), &message)?; fs::write(step_dir.join("message"), &message)?;
clear_staged()?;
crate::info::update_last_modified()?; crate::info::update_last_modified()?;
println!("Step {}: {}", step_num, message); println!("Step {}: {}", step_num, message);

View File

@@ -5,6 +5,7 @@ use std::path::PathBuf;
pub enum CommitError { pub enum CommitError {
NotADescendantOfCurrentDir(PathBuf), NotADescendantOfCurrentDir(PathBuf),
InsideTourDir(PathBuf), InsideTourDir(PathBuf),
NothingToCommit,
Io(io::Error), Io(io::Error),
} }
@@ -17,6 +18,9 @@ impl std::fmt::Display for CommitError {
Self::InsideTourDir(path) => { Self::InsideTourDir(path) => {
write!(f, "File {:?} is inside a .tour directory, which is not allowed.", path) write!(f, "File {:?} is inside a .tour directory, which is not allowed.", path)
} }
Self::NothingToCommit => {
write!(f, "Nothing to commit. Use `tour add <files>` to stage files first.")
}
Self::Io(e) => write!(f, "IO error: {}", e), Self::Io(e) => write!(f, "IO error: {}", e),
} }
} }

View File

@@ -2,19 +2,20 @@ use clap::{Parser, Subcommand};
use std::error::Error; use std::error::Error;
use std::path::PathBuf; use std::path::PathBuf;
mod add;
mod commit; mod commit;
mod end; mod end;
mod error; mod error;
mod info;
mod init; mod init;
mod step; mod step;
mod utils; mod utils;
mod info;
const TOUR_DIR: &str = "./.tour"; const TOUR_DIR: &str = "./.tour";
const SESSION_PATH: &str = "./.tour/session"; const SESSION_PATH: &str = "./.tour/session";
#[derive(Parser)] #[derive(Parser)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None, disable_help_subcommand = true)]
struct Args { struct Args {
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, command: Option<Commands>,
@@ -25,6 +26,11 @@ enum Commands {
// Create a new tour // Create a new tour
Init, Init,
// Stage files for the next commit
Add {
files: Vec<PathBuf>,
},
// Add steps to the tour // Add steps to the tour
Commit { Commit {
files: Vec<PathBuf>, files: Vec<PathBuf>,
@@ -61,12 +67,40 @@ enum Commands {
Start, Start,
Info, Info,
// Show help
Help,
}
fn help() {
println!(
"\
\x1b[1mtour\x1b[0m — create and navigate code tutorials as a series of snapshots
\x1b[1mAUTHOR WORKFLOW\x1b[0m
tour init Set up a new tour in the current directory
tour add <files...> Stage files for the next commit
tour commit [-m <msg>] Commit staged files as a new step
tour commit <files...> -m <msg> Stage and commit files in one step
tour end -m <msg> Finalise the tour
\x1b[1mREADER WORKFLOW\x1b[0m
tour start Load the first step
tour next [n] Advance n steps (default 1)
tour prev [n] Go back n steps (default 1)
tour step <n> Jump to step n
\x1b[1mOTHER\x1b[0m
tour info Show tour metadata
tour help Show this help message"
);
} }
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
let args = Args::parse(); let args = Args::parse();
match args.command { match args.command {
Some(Commands::Init) => crate::init::init()?, Some(Commands::Init) => crate::init::init()?,
Some(Commands::Add { files }) => crate::add::add(files)?,
Some(Commands::Commit { files, message }) => crate::commit::commit(files, message)?, Some(Commands::Commit { files, message }) => crate::commit::commit(files, message)?,
Some(Commands::End { message }) => crate::end::end(message)?, Some(Commands::End { message }) => crate::end::end(message)?,
Some(Commands::Next { n }) => crate::step::next(n)?, Some(Commands::Next { n }) => crate::step::next(n)?,
@@ -74,7 +108,7 @@ fn main() -> Result<(), Box<dyn Error>> {
Some(Commands::Step { n }) => crate::step::step_n(n)?, Some(Commands::Step { n }) => crate::step::step_n(n)?,
Some(Commands::Start) => crate::step::step_n(0)?, Some(Commands::Start) => crate::step::step_n(0)?,
Some(Commands::Info) => crate::info::info()?, Some(Commands::Info) => crate::info::info()?,
_ => println!("command not found"), Some(Commands::Help) | None => help(),
} }
Ok(()) Ok(())
} }

View File

@@ -1,7 +1,7 @@
use crate::utils::get_tour_step; use crate::utils::get_tour_step;
use crate::SESSION_PATH; use crate::SESSION_PATH;
use crate::TOUR_DIR; use crate::TOUR_DIR;
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::fs; use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -62,19 +62,14 @@ fn step(delta: i32) -> Result<(), io::Error> {
let new_step = new_step as u32; let new_step = new_step as u32;
let cwd = std::env::current_dir()?; let cwd = std::env::current_dir()?;
let old_files = snapshot_files(&cwd)?; let tracked = get_tracked_files()?;
let old_files = snapshot_tracked_files(&cwd, &tracked)?;
// Clear CWD except .tour/ // Remove only tracked files from CWD
for entry in fs::read_dir(&cwd)? { for relative in &tracked {
let entry = entry?; let full = cwd.join(relative);
if entry.file_name() == ".tour" { if full.is_file() {
continue; fs::remove_file(&full)?;
}
let path = entry.path();
if path.is_dir() {
fs::remove_dir_all(&path)?;
} else {
fs::remove_file(&path)?;
} }
} }
@@ -91,7 +86,7 @@ fn step(delta: i32) -> Result<(), io::Error> {
// Persist the new step // Persist the new step
fs::write(SESSION_PATH, format!("STEP={}", new_step))?; fs::write(SESSION_PATH, format!("STEP={}", new_step))?;
let new_files = snapshot_files(&cwd)?; let new_files = snapshot_tracked_files(&cwd, &tracked)?;
print_changes(&old_files, &new_files); print_changes(&old_files, &new_files);
let message = fs::read_to_string(step_dir.join("message")).unwrap_or_default(); let message = fs::read_to_string(step_dir.join("message")).unwrap_or_default();
@@ -100,29 +95,51 @@ fn step(delta: i32) -> Result<(), io::Error> {
Ok(()) Ok(())
} }
fn snapshot_files(root: &Path) -> Result<BTreeMap<PathBuf, String>, io::Error> { fn snapshot_tracked_files(root: &Path, tracked: &BTreeSet<PathBuf>) -> Result<BTreeMap<PathBuf, String>, io::Error> {
let mut files = BTreeMap::new(); let mut files = BTreeMap::new();
collect_files(root, root, &mut files)?; for relative in tracked {
let full = root.join(relative);
if full.is_file() {
let content = fs::read_to_string(&full).unwrap_or_default();
files.insert(relative.clone(), content);
}
}
Ok(files) Ok(files)
} }
fn collect_files( fn get_tracked_files() -> Result<BTreeSet<PathBuf>, io::Error> {
root: &Path, let steps_dir = Path::new(TOUR_DIR).join("steps");
let mut tracked = BTreeSet::new();
if !steps_dir.exists() {
return Ok(tracked);
}
for entry in fs::read_dir(&steps_dir)? {
let entry = entry?;
if entry.path().is_dir() {
collect_step_files(&entry.path(), &entry.path(), &mut tracked)?;
}
}
Ok(tracked)
}
fn collect_step_files(
step_root: &Path,
dir: &Path, dir: &Path,
files: &mut BTreeMap<PathBuf, String>, files: &mut BTreeSet<PathBuf>,
) -> Result<(), io::Error> { ) -> Result<(), io::Error> {
for entry in fs::read_dir(dir)? { for entry in fs::read_dir(dir)? {
let entry = entry?; let entry = entry?;
if entry.file_name() == ".tour" { let path = entry.path();
let relative = path.strip_prefix(step_root).unwrap_or(&path).to_path_buf();
if relative == Path::new("message") {
continue; continue;
} }
let path = entry.path();
if path.is_dir() { if path.is_dir() {
collect_files(root, &path, files)?; collect_step_files(step_root, &path, files)?;
} else { } else {
let relative = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); files.insert(relative);
let content = fs::read_to_string(&path).unwrap_or_default();
files.insert(relative, content);
} }
} }
Ok(()) Ok(())