Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 6 additions & 7 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
10 changes: 8 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ <h3>✅ With git-time-machine</h3>
<li>Run <code>git-time-machine</code></li>
<li>Scroll through visual timeline</li>
<li>Preview the diff</li>
<li>Press Enter after confirming</li>
<li>Choose hard reset, soft reset, or detached checkout</li>
<li>List backup refs later if you used hard reset</li>
</ol>
<p class="time">Visual and deliberate</p>
</div>
Expand Down Expand Up @@ -131,6 +132,11 @@ <h3>Relative Timestamps</h3>
<h3>Diff Preview</h3>
<p>Inspect the selected state before restoring</p>
</div>
<div class="feature-card">
<div class="feature-icon">🛟</div>
<h3>Backup Ref Recovery</h3>
<p>List hard-reset backup refs and exact recovery commands</p>
</div>
<div class="feature-card">
<div class="feature-icon">⌨️</div>
<h3>Search and JSON</h3>
Expand Down Expand Up @@ -221,7 +227,7 @@ <h3>From Source</h3>
<div class="usage-section">
<h3>Usage</h3>
<div class="code-block large">
<code># Launch in any git repository<br>git-time-machine<br><br># Show up to 1000 reflog entries (default: last 50)<br>git-time-machine --all<br><br># Export reflog entries as JSON<br>git-time-machine --export-json</code>
<code># Launch in any git repository<br>git-time-machine<br><br># Show up to 1000 reflog entries (default: last 50)<br>git-time-machine --all<br><br># Export reflog entries as JSON<br>git-time-machine --export-json<br><br># List hard-reset backup refs and recovery commands<br>git-time-machine --list-backups</code>
</div>

<h3>Controls</h3>
Expand Down
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@ <h3>Live Diff Preview</h3>
<div class="feature-card">
<div class="feature-icon">🛡️</div>
<h3>Safety First</h3>
<p>Confirmation dialogs show the exact Git command, hard resets create backup refs first, and safer soft-reset or detached-checkout modes are available.</p>
<p>Confirmation dialogs show the exact Git command, hard resets create backup refs first, and --list-backups shows recovery commands later.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎨</div>
Expand Down Expand Up @@ -721,7 +721,7 @@ <h3>Recover from Bad Rebase</h3>
<div class="use-case-icon">🔙</div>
<div class="use-case-content">
<h3>Undo Accidental Reset</h3>
<p>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.</p>
<p>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.</p>
</div>
</div>
<div class="use-case">
Expand Down
115 changes: 115 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DateTime<Utc>>,
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,
Expand Down Expand Up @@ -111,6 +134,57 @@ impl GitManager {
Ok(entries)
}

pub fn list_backup_refs(&self) -> Result<Vec<BackupRef>> {
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,
Expand Down Expand Up @@ -249,6 +323,13 @@ impl GitManager {
Ok(backup_ref)
}

fn backup_ref_created_at(ref_name: &str) -> Option<DateTime<Utc>> {
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::<i64>().ok()?;
DateTime::from_timestamp_millis(timestamp_millis)
}

fn format_relative_time(timestamp: &DateTime<Utc>) -> String {
let now = Utc::now();
let duration = now.signed_duration_since(*timestamp);
Expand Down Expand Up @@ -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");
Expand Down
41 changes: 38 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,16 @@ 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")]
#[command(about = "🕰️ Browse Git reflog and restore reachable local history", long_about = None)]
#[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 \
Expand All @@ -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 {
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
Expand Down
Loading