diff --git a/CHANGELOG.md b/CHANGELOG.md index 14fcfa0..58b57a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Backup refs under `refs/git-time-machine/backups/` before hard resets. - Confirmation dialog now shows the exact Git command before restore. - Project roadmap with near-term recovery work, distribution plans, and explicit non-goals. +- `--list-backups` command for finding backup refs and exact recovery commands. ### Changed - Tightened README, CLI, crate, and landing-page language around what reflog-based recovery can and cannot do. diff --git a/README.md b/README.md index 7a4fc65..2bac7a9 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ git-time-machine --all # Export the current reflog view as JSON git-time-machine --export-json + +# List hard-reset backup refs and recovery commands +git-time-machine --list-backups ``` ### Controls @@ -83,6 +86,7 @@ git-time-machine --export-json - ✅ **Relative Timestamps** - "5m ago", "2h ago", "yesterday" - ✅ **Diff Preview** - Compare the selected entry before restoring - ✅ **Safer Restore Modes** - Choose hard reset, soft reset, or detached checkout +- ✅ **Backup Ref Recovery** - List backup refs and exact recovery commands - ✅ **Search/Filter** - Filter commit messages with multi-word search - ✅ **JSON Export** - Export the reflog timeline for automation - ✅ **Vim Keybindings** - j/k and gg/G navigation @@ -195,6 +199,15 @@ git-time-machine **It's just Git under the hood** - useful, but not magic. Hard reset is destructive, so git-time-machine creates a backup ref under `refs/git-time-machine/backups/` before running it. +To find those backup refs later: + +```bash +git-time-machine --list-backups +``` + +The command prints each backup ref, the commit it points to, and exact `git show` +and `git reset --hard` commands for manual inspection and recovery. + ## 🤔 Why Not Just Use Git Commands? **You absolutely can!** But here's the reality: diff --git a/ROADMAP.md b/ROADMAP.md index d42adf2..3ff98c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,6 +27,7 @@ Recent work established the foundation for safer iteration: - CI runs `cargo fmt --check`, `cargo clippy -- -D warnings`, and tests. - Restore now supports hard reset, soft reset, and detached checkout modes. - Hard reset creates a backup ref under `refs/git-time-machine/backups/`. +- Backup refs can be listed with exact inspect and restore commands. - The confirmation dialog shows the exact Git command before a restore. - Full diff preview now compares `HEAD` to the selected target, matching restore semantics. @@ -35,16 +36,14 @@ Recent work established the foundation for safer iteration: These are the next practical improvements, ordered by risk and user value. -### Backup ref UX +### Backup ref management -Hard reset now creates backup refs, but users still need to know how to inspect -and use them. Add a small workflow to list, inspect, restore from, and eventually -prune refs under `refs/git-time-machine/backups/`. +Hard reset backup refs can now be listed with recovery commands. Future work can +make them easier to restore from or prune inside the TUI. Acceptance criteria: -- A user can find the last backup ref without memorizing the ref path. -- The UI or docs show the exact Git command needed to recover from a backup ref. -- Tests cover backup-ref naming and lookup behavior. +- A user can restore from a selected backup ref without copying the ref path. +- Backup prune behavior is explicit, confirmed, and test-covered. ### Copy selected commit hash diff --git a/docs/index.html b/docs/index.html index 1080690..a69a41f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -103,7 +103,8 @@

✅ With git-time-machine

  • Run git-time-machine
  • Scroll through visual timeline
  • Preview the diff
  • -
  • Press Enter after confirming
  • +
  • Choose hard reset, soft reset, or detached checkout
  • +
  • List backup refs later if you used hard reset
  • Visual and deliberate

    @@ -131,6 +132,11 @@

    Relative Timestamps

    Diff Preview

    Inspect the selected state before restoring

    +
    +
    🛟
    +

    Backup Ref Recovery

    +

    List hard-reset backup refs and exact recovery commands

    +
    ⌨️

    Search and JSON

    @@ -221,7 +227,7 @@

    From Source

    Usage

    - # Launch in any git repository
    git-time-machine

    # Show up to 1000 reflog entries (default: last 50)
    git-time-machine --all

    # Export reflog entries as JSON
    git-time-machine --export-json
    + # Launch in any git repository
    git-time-machine

    # Show up to 1000 reflog entries (default: last 50)
    git-time-machine --all

    # Export reflog entries as JSON
    git-time-machine --export-json

    # List hard-reset backup refs and recovery commands
    git-time-machine --list-backups

    Controls

    diff --git a/index.html b/index.html index 668fa9c..5acb62f 100644 --- a/index.html +++ b/index.html @@ -651,7 +651,7 @@

    Live Diff Preview

    🛡️

    Safety First

    -

    Confirmation dialogs show the exact Git command, hard resets create backup refs first, and safer soft-reset or detached-checkout modes are available.

    +

    Confirmation dialogs show the exact Git command, hard resets create backup refs first, and --list-backups shows recovery commands later.

    🎨
    @@ -721,7 +721,7 @@

    Recover from Bad Rebase

    🔙

    Undo Accidental Reset

    -

    Did git reset --hard and need to go back? Time machine shows recent local reflog states, previews the target, and creates a backup ref before another hard reset.

    +

    Did git reset --hard and need to go back? Time machine shows recent local reflog states, previews the target, creates a backup ref before another hard reset, and can list those backup refs later.

    diff --git a/src/git.rs b/src/git.rs index adfe610..f549823 100644 --- a/src/git.rs +++ b/src/git.rs @@ -16,6 +16,29 @@ pub struct GitManager { repo_path: String, } +#[derive(Debug, Clone, Serialize)] +pub struct BackupRef { + pub name: String, + pub hash: String, + pub subject: String, + pub created_at: Option>, + pub relative_time: String, +} + +impl BackupRef { + pub fn short_hash(&self) -> String { + self.hash.chars().take(7).collect() + } + + pub fn inspect_command(&self) -> String { + format!("git show --stat --oneline {}", self.name) + } + + pub fn restore_command(&self) -> String { + format!("git reset --hard {}", self.name) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RestoreMode { HardReset, @@ -111,6 +134,57 @@ impl GitManager { Ok(entries) } + pub fn list_backup_refs(&self) -> Result> { + let output = Command::new("git") + .current_dir(&self.repo_path) + .args([ + "for-each-ref", + "refs/git-time-machine/backups", + "--format=%(refname)%00%(objectname)%00%(subject)", + ]) + .output() + .context("Failed to list git-time-machine backup refs")?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Failed to list git-time-machine backup refs: {}", error); + } + + let refs_output = String::from_utf8(output.stdout)?; + let mut backup_refs = Vec::new(); + for line in refs_output.lines() { + let parts: Vec<&str> = line.splitn(3, '\x00').collect(); + if parts.len() < 3 { + continue; + } + + let name = parts[0].to_string(); + let hash = parts[1].to_string(); + let subject = parts[2].to_string(); + let created_at = Self::backup_ref_created_at(&name); + let relative_time = created_at + .as_ref() + .map(Self::format_relative_time) + .unwrap_or_else(|| "unknown".to_string()); + + backup_refs.push(BackupRef { + name, + hash, + subject, + created_at, + relative_time, + }); + } + + backup_refs.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.name.cmp(&a.name)) + }); + + Ok(backup_refs) + } + pub fn restore_to_commit( &self, commit_hash: &str, @@ -249,6 +323,13 @@ impl GitManager { Ok(backup_ref) } + fn backup_ref_created_at(ref_name: &str) -> Option> { + let backup_name = ref_name.strip_prefix("refs/git-time-machine/backups/")?; + let (timestamp_millis, _) = backup_name.split_once('-')?; + let timestamp_millis = timestamp_millis.parse::().ok()?; + DateTime::from_timestamp_millis(timestamp_millis) + } + fn format_relative_time(timestamp: &DateTime) -> String { let now = Utc::now(); let duration = now.signed_duration_since(*timestamp); @@ -453,6 +534,40 @@ mod tests { ); } + #[test] + fn list_backup_refs_returns_recovery_commands_for_hard_reset_backups() { + let repo = TestRepo::new("list-backups"); + repo.write_file("first\n"); + let first_hash = repo.commit("first commit"); + repo.write_file("second\n"); + let second_hash = repo.commit("second commit"); + + let outcome = repo + .manager() + .restore_to_commit(&first_hash, RestoreMode::HardReset) + .expect("hard reset should restore"); + + let backups = repo + .manager() + .list_backup_refs() + .expect("backup refs should load"); + + assert_eq!(backups.len(), 1); + assert_eq!(backups[0].name, outcome.backup_ref.unwrap()); + assert_eq!(backups[0].hash, second_hash); + assert_eq!(backups[0].short_hash(), second_hash[..7]); + assert!(backups[0].subject.contains("second commit")); + assert!(backups[0].created_at.is_some()); + assert_eq!( + backups[0].inspect_command(), + format!("git show --stat --oneline {}", backups[0].name) + ); + assert_eq!( + backups[0].restore_command(), + format!("git reset --hard {}", backups[0].name) + ); + } + #[test] fn soft_reset_moves_head_without_changing_worktree() { let repo = TestRepo::new("soft-reset"); diff --git a/src/main.rs b/src/main.rs index b264098..b6bb250 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use ratatui::{ use std::io; mod git; -use git::{GitEntry, GitManager, RestoreMode}; +use git::{BackupRef, GitEntry, GitManager, RestoreMode}; #[derive(Parser)] #[command(name = "git-time-machine")] @@ -25,7 +25,8 @@ use git::{GitEntry, GitManager, RestoreMode}; #[command(after_help = "EXAMPLES:\n \ git-time-machine # Show last 50 reflog entries\n \ git-time-machine --all # Show up to 1000 reflog entries\n \ - git-time-machine --export-json # Export as JSON for automation\n\n\ + git-time-machine --export-json # Export reflog as JSON for automation\n \ + git-time-machine --list-backups # List hard-reset backup refs\n\n\ CONTROLS:\n \ ↑/k, ↓/j Navigate up/down\n \ Home/End Jump to first/last entry\n \ @@ -51,8 +52,12 @@ struct Cli { all: bool, /// Export reflog timeline as JSON - #[arg(long)] + #[arg(long, conflicts_with = "list_backups")] export_json: bool, + + /// List hard-reset backup refs and recovery commands + #[arg(long, conflicts_with = "export_json")] + list_backups: bool, } struct App { @@ -308,6 +313,13 @@ fn main() -> Result<()> { return Ok(()); } + if cli.list_backups { + let git_manager = GitManager::new()?; + let backups = git_manager.list_backup_refs()?; + print_backup_refs(&backups); + return Ok(()); + } + // Setup panic hook to restore terminal let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { @@ -353,6 +365,29 @@ fn main() -> Result<()> { } } +fn print_backup_refs(backups: &[BackupRef]) { + if backups.is_empty() { + println!("No git-time-machine backup refs found."); + println!("Hard reset creates refs under refs/git-time-machine/backups/."); + return; + } + + println!("Backup refs (newest first):"); + for backup in backups { + println!( + "{} {} {}", + backup.relative_time, + backup.short_hash(), + backup.name + ); + if !backup.subject.is_empty() { + println!(" commit: {}", backup.subject); + } + println!(" inspect: {}", backup.inspect_command()); + println!(" restore: {}", backup.restore_command()); + } +} + fn run_app( terminal: &mut Terminal, app: &mut App,