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::utils::{copy_path, is_descendant_of_current_dir, is_file_in_dir};
use crate::TOUR_DIR;
@@ -7,6 +8,16 @@ use std::path::{Path, PathBuf};
pub fn commit(files: Vec<PathBuf>, message: String) -> Result<(), CommitError> {
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 {
if !is_descendant_of_current_dir(file)? {
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)?;
clear_staged()?;
crate::info::update_last_modified()?;
println!("Step {}: {}", step_num, message);

View File

@@ -5,6 +5,7 @@ use std::path::PathBuf;
pub enum CommitError {
NotADescendantOfCurrentDir(PathBuf),
InsideTourDir(PathBuf),
NothingToCommit,
Io(io::Error),
}
@@ -17,6 +18,9 @@ impl std::fmt::Display for CommitError {
Self::InsideTourDir(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),
}
}

View File

@@ -2,19 +2,20 @@ use clap::{Parser, Subcommand};
use std::error::Error;
use std::path::PathBuf;
mod add;
mod commit;
mod end;
mod error;
mod info;
mod init;
mod step;
mod utils;
mod info;
const TOUR_DIR: &str = "./.tour";
const SESSION_PATH: &str = "./.tour/session";
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
#[command(author, version, about, long_about = None, disable_help_subcommand = true)]
struct Args {
#[command(subcommand)]
command: Option<Commands>,
@@ -25,6 +26,11 @@ enum Commands {
// Create a new tour
Init,
// Stage files for the next commit
Add {
files: Vec<PathBuf>,
},
// Add steps to the tour
Commit {
files: Vec<PathBuf>,
@@ -61,12 +67,40 @@ enum Commands {
Start,
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>> {
let args = Args::parse();
match args.command {
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::End { message }) => crate::end::end(message)?,
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::Start) => crate::step::step_n(0)?,
Some(Commands::Info) => crate::info::info()?,
_ => println!("command not found"),
Some(Commands::Help) | None => help(),
}
Ok(())
}

View File

@@ -1,7 +1,7 @@
use crate::utils::get_tour_step;
use crate::SESSION_PATH;
use crate::TOUR_DIR;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
@@ -62,19 +62,14 @@ fn step(delta: i32) -> Result<(), io::Error> {
let new_step = new_step as u32;
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/
for entry in fs::read_dir(&cwd)? {
let entry = entry?;
if entry.file_name() == ".tour" {
continue;
}
let path = entry.path();
if path.is_dir() {
fs::remove_dir_all(&path)?;
} else {
fs::remove_file(&path)?;
// Remove only tracked files from CWD
for relative in &tracked {
let full = cwd.join(relative);
if full.is_file() {
fs::remove_file(&full)?;
}
}
@@ -91,7 +86,7 @@ fn step(delta: i32) -> Result<(), io::Error> {
// Persist the 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);
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(())
}
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();
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)
}
fn collect_files(
root: &Path,
fn get_tracked_files() -> Result<BTreeSet<PathBuf>, io::Error> {
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,
files: &mut BTreeMap<PathBuf, String>,
files: &mut BTreeSet<PathBuf>,
) -> Result<(), io::Error> {
for entry in fs::read_dir(dir)? {
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;
}
let path = entry.path();
if path.is_dir() {
collect_files(root, &path, files)?;
collect_step_files(step_root, &path, files)?;
} else {
let relative = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
let content = fs::read_to_string(&path).unwrap_or_default();
files.insert(relative, content);
files.insert(relative);
}
}
Ok(())