From 9f68cf7b87691f2660243c9a3300958bc8a51eb5 Mon Sep 17 00:00:00 2001 From: Adam Parker <27036556+CaffeinatedTech@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:54:25 +1000 Subject: [PATCH] feat: add try migrate to move existing dirs into tries --- README.md | 1 + spec/command_line.md | 22 ++++ spec/tests/test_40_migrate.sh | 146 +++++++++++++++++++++++++ try.rb | 194 ++++++++++++++++++++++++++++++++++ 4 files changed, 363 insertions(+) create mode 100644 spec/tests/test_40_migrate.sh diff --git a/README.md b/README.md index 4eb0e66..f7602cd 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ try new api # Start with "2025-08-17-new-api" try . [name] # Create a dated worktree dir for current repo try ./path/to/repo [name] # Use another repo as the worktree source try worktree dir [name] # Same as above, explicit CLI form +try migrate [dir] # Move an existing dir into tries try clone https://github.com/user/repo.git # Clone repo into date-prefixed directory try https://github.com/user/repo.git # Shorthand for clone (same as above) try --help # See all options diff --git a/spec/command_line.md b/spec/command_line.md index 34f2de9..f007df5 100644 --- a/spec/command_line.md +++ b/spec/command_line.md @@ -110,6 +110,28 @@ try . # Shorthand (requires name) - Returns shell script to cd into worktree - `try .` without a name is NOT supported (too easy to invoke accidentally) +### migrate + +Move an existing experiment directory into the tries directory. + +``` +try migrate [dir] +try exec migrate [dir] +``` + +**Arguments:** +- `dir` (optional): Directory to migrate, relative to cwd. Defaults to the current working directory. + +**Behavior:** +- Opens a confirmation dialog showing the source, the destination (tries directory), and an editable target name +- Default target name is `YYYY-MM-DD-`; a name that already has a date prefix is kept as-is +- Collision-safe: if the default target already exists, the versioning suffix (`-2`, `-3`, ...) is appended +- Worktrees (directory with a `.git` file) are moved with `git worktree move`; all other directories with `mv` +- Shows a warning when the source is inside a git repository but is not the repository root (moving it cuts it off from git history) +- Refuses to migrate the tries directory itself or any directory already inside it +- Returns a shell script that moves the directory and cds into it +- Pressing Esc cancels (exit code 1) + ### init Output shell function definition for shell integration. diff --git a/spec/tests/test_40_migrate.sh b/spec/tests/test_40_migrate.sh new file mode 100644 index 0000000..66669f0 --- /dev/null +++ b/spec/tests/test_40_migrate.sh @@ -0,0 +1,146 @@ +# Migrate tests +# Spec: command_line.md#migrate + +section "migrate" + +MIG_TRIES=$(mktemp -d) +MIG_SRC=$(mktemp -d) +today=$(date +%Y-%m-%d) + +# Test: ESC cancels without producing a move +mkdir -p "$MIG_SRC/cancel-me" +output=$(cd "$MIG_SRC/cancel-me" && try_run --path="$MIG_TRIES" --and-keys='ESC' exec migrate 2>/dev/null) +if [ -z "$output" ] || ! echo "$output" | grep -q "mv "; then + pass +else + fail "Esc should cancel migrate" "no mv" "$output" "command_line.md#migrate" +fi + +# Test: ENTER accepts default date-prefixed target +mkdir -p "$MIG_SRC/plain-exp" +output=$(cd "$MIG_SRC/plain-exp" && try_run --path="$MIG_TRIES" --and-keys='ENTER' exec migrate 2>/dev/null) +if echo "$output" | grep -q "mv '.*' '$MIG_TRIES/$today-plain-exp'"; then + pass +else + fail "Migrate should mv to date-prefixed target" "$MIG_TRIES/$today-plain-exp" "$output" "command_line.md#migrate" +fi + +# Test: Script includes cd to destination +if echo "$output" | grep -q "cd '$MIG_TRIES/$today-plain-exp'"; then + pass +else + fail "Migrate script should cd to destination" "cd to target" "$output" "command_line.md#migrate" +fi + +# Test: Named directory argument +mkdir -p "$MIG_SRC/other-exp" +output=$(cd "$MIG_SRC" && try_run --path="$MIG_TRIES" --and-keys='ENTER' exec migrate other-exp 2>/dev/null) +if echo "$output" | grep -q "mv '.*/other-exp' '$MIG_TRIES/$today-other-exp'"; then + pass +else + fail "Migrate should move that directory" "$MIG_TRIES/$today-other-exp" "$output" "command_line.md#migrate" +fi + +# Test: Already date-prefixed name is kept (no double prefix) +mkdir -p "$MIG_SRC/2025-06-01-prefixed" +output=$(cd "$MIG_SRC" && try_run --path="$MIG_TRIES" --and-keys='ENTER' exec migrate 2025-06-01-prefixed 2>/dev/null) +if echo "$output" | grep -q "mv '.*/2025-06-01-prefixed' '$MIG_TRIES/2025-06-01-prefixed'"; then + pass +else + fail "Already-prefixed name should be kept" "no double prefix" "$output" "command_line.md#migrate" +fi + +# Test: Collision bumps target to -2 +mkdir -p "$MIG_TRIES/$today-dup-exp" +mkdir -p "$MIG_SRC/dup-exp" +output=$(cd "$MIG_SRC/dup-exp" && try_run --path="$MIG_TRIES" --and-keys='ENTER' exec migrate 2>/dev/null) +if echo "$output" | grep -q "$MIG_TRIES/$today-dup-exp-2"; then + pass +else + fail "Collision should bump target to -2" "$MIG_TRIES/$today-dup-exp-2" "$output" "command_line.md#migrate" +fi + +# Test: Editable target name +mkdir -p "$MIG_SRC/edit-exp" +output=$(cd "$MIG_SRC/edit-exp" && try_run --path="$MIG_TRIES" --and-keys='CTRL-A,CTRL-K,TYPE=custom-target,ENTER' exec migrate 2>/dev/null) +if echo "$output" | grep -q "mv '.*' '$MIG_TRIES/custom-target'"; then + pass +else + fail "Edited target name should be used" "$MIG_TRIES/custom-target" "$output" "command_line.md#migrate" +fi + +# Test: Worktree (.git file) uses git worktree move +mkdir -p "$MIG_SRC/worktree-exp" +echo "gitdir: /tmp/fake-repo/.git/worktrees/worktree-exp" > "$MIG_SRC/worktree-exp/.git" +output=$(cd "$MIG_SRC/worktree-exp" && try_run --path="$MIG_TRIES" --and-keys='ENTER' exec migrate 2>/dev/null) +if echo "$output" | grep -q "git worktree move"; then + pass +else + fail "Worktree should use git worktree move" "git worktree move" "$output" "command_line.md#migrate" +fi + +# Test: Warning shown when inside a git repository (not the root) +GIT_REPO=$(mktemp -d) +mkdir -p "$GIT_REPO/.git" "$GIT_REPO/subdir" +output=$(cd "$GIT_REPO/subdir" && try_run --path="$MIG_TRIES" --and-keys='ENTER' exec migrate 2>/dev/null) +if echo "$output" | grep -q "inside a git repository"; then + pass +else + fail "Inside-repo migrate should warn about git" "warning text" "$output" "command_line.md#migrate" +fi + +# Test: No warning for a repository root (own .git dir) +output=$(cd "$GIT_REPO" && try_run --path="$MIG_TRIES" --and-keys='ENTER' exec migrate 2>/dev/null) +if echo "$output" | grep -q "inside a git repository"; then + fail "Repo root migrate should not warn" "no warning" "$output" "command_line.md#migrate" +else + pass +fi + +# Test: Refuses to migrate the tries directory itself +output=$(try_run --path="$MIG_TRIES" --and-keys='ENTER' exec migrate "$MIG_TRIES" 2>/dev/null) +if echo "$output" | grep -q "Error"; then + pass +else + fail "Migrating tries root should error" "Error" "$output" "command_line.md#migrate" +fi + +# Test: Refuses to migrate a directory already inside tries +mkdir -p "$MIG_TRIES/2025-11-01-alpha" +output=$(try_run --path="$MIG_TRIES" --and-keys='ENTER' exec migrate "$MIG_TRIES/2025-11-01-alpha" 2>/dev/null) +if echo "$output" | grep -q "Error"; then + pass +else + fail "Migrating a dir already in tries should error" "Error" "$output" "command_line.md#migrate" +fi + +# Test: Empty target name is rejected (no move) +mkdir -p "$MIG_SRC/empty-exp" +output=$(cd "$MIG_SRC/empty-exp" && try_run --path="$MIG_TRIES" --and-keys='CTRL-A,CTRL-K,ENTER,ESC' exec migrate 2>/dev/null) +if [ -z "$output" ] || ! echo "$output" | grep -q "mv "; then + pass +else + fail "Empty target name should not produce mv" "no mv" "$output" "command_line.md#migrate" +fi + +# Test: E2E — actually move the directory and keep its contents +E2E_SRC=$(mktemp -d) +mkdir -p "$E2E_SRC/real-exp" +echo "hello" > "$E2E_SRC/real-exp/file.txt" +script=$(cd "$E2E_SRC/real-exp" && eval $TRY_CMD exec --path="$MIG_TRIES" --and-keys='ENTER' migrate 2>/dev/null) +eval "$(echo "$script" | grep -v '^#')" 2>/dev/null +if [ -f "$MIG_TRIES/$today-real-exp/file.txt" ]; then + pass +else + fail "E2E: file should exist at migrated destination" "$MIG_TRIES/$today-real-exp/file.txt" "$(ls -la $MIG_TRIES/ 2>&1)" "command_line.md#migrate" +fi + +# Test: E2E — source directory no longer exists +if [ ! -d "$E2E_SRC/real-exp" ]; then + pass +else + fail "E2E: source should be gone after migrate" "no $E2E_SRC/real-exp" "$(ls -la $E2E_SRC/ 2>&1)" "command_line.md#migrate" +fi + +# Cleanup +rm -rf "$MIG_TRIES" "$MIG_SRC" "$GIT_REPO" "$E2E_SRC" diff --git a/try.rb b/try.rb index c3b3c0a..9910268 100755 --- a/try.rb +++ b/try.rb @@ -845,6 +845,98 @@ def finalize_ascend(entry, ascend_buffer) true end + # Migrate dialog - move a directory into the tries root + def run_migrate_dialog(source, default_target, warn_note) + setup_terminal + target = nil + TryCompat.with_raw_tty do + input = Tui::InputField.new(placeholder: "", text: default_target) + migrate_error = nil + + loop do + render_migrate_dialog(source, input.text, input.cursor, migrate_error, warn_note) + + ch = read_key + next unless ch + before = input.text + if input.handle_key(ch) + migrate_error = nil if input.text != before + next + end + case ch + when "\r" # Enter - confirm + result = finalize_migrate(input.text) + if result == true + target = input.text.strip.gsub(/\s+/, '-') + break + else + migrate_error = result # Error message string + end + when "\x1b", "\x03" # ESC or Ctrl-C - cancel + break + end + end + end + target + ensure + restore_terminal + end + public :run_migrate_dialog + + def render_migrate_dialog(source, target_buffer, target_cursor, migrate_error, warn_note) + screen = Tui::Screen.new(io: STDERR) + + line = screen.header.add_line + line.center.write(emoji("🚚")).write(Tui::Text.accent(" Migrate directory")) + line = screen.header.add_line + line.write.write_dim(fill("─")) + + line = screen.body.add_line + line.write.write(emoji("📁")).write(" #{source}") + screen.body.add_line + + line = screen.body.add_line + line.center.write_dim("Destination: #{@base_path}") + screen.body.add_line + screen.body.add_line + line = screen.body.add_line + prefix = "New name: " + line.center.write_dim(prefix) + line.center.write(screen.input("", value: target_buffer, cursor: target_cursor).to_s) + input_width = [target_buffer.length, target_cursor + 1].max + prefix_width = Tui::Metrics.visible_width(prefix) + max_content = screen.width - 1 + center_start = (max_content - prefix_width - input_width) / 2 + line.mark_has_input(center_start + prefix_width) + + if warn_note + screen.body.add_line + line = screen.body.add_line + line.center.write_bold("⚠ #{warn_note}") + end + + if migrate_error + screen.body.add_line + line = screen.body.add_line + line.center.write_bold(migrate_error) + end + + line = screen.footer.add_line + line.write.write_dim(fill("─")) + line = screen.footer.add_line + line.center.write_dim("Enter: Confirm Esc: Cancel") + + screen.flush + end + + def finalize_migrate(target_buffer) + new_name = target_buffer.strip.gsub(/\s+/, '-') + return "Name cannot be empty" if new_name.empty? + return "Name cannot contain /" if new_name.include?('/') + return "Directory exists: #{new_name}" if Dir.exist?(File.join(@base_path, new_name)) + true + end + def handle_selection(try_dir) # Select existing try directory @selected = { type: :cd, path: try_dir.path } @@ -1032,12 +1124,14 @@ def print_global_help try [query] Interactive directory selector try clone Clone repo into dated directory try worktree Create worktree from current git repo + try migrate [dir] Move an existing directory into tries try --help Show this help Commands: init [path] Output shell function definition clone [name] Clone git repo into date-prefixed directory worktree Create worktree in dated directory + migrate [dir] Move existing directory into date-prefixed tries dir Examples: try Open interactive selector @@ -1492,6 +1586,77 @@ def cmd_cd!(args, tries_path, and_type, and_exit, and_keys, and_confirm) end end + # Returns :worktree (own .git file), :repo (own .git dir), :inside_repo + # (an ancestor has .git), or nil for a plain directory. + def git_context(dir) + git = File.join(dir, '.git') + return :worktree if File.file?(git) + return :repo if File.directory?(git) + cur = File.dirname(dir) + loop do + g = File.join(cur, '.git') + return :inside_repo if File.file?(g) || File.directory?(g) + parent = File.dirname(cur) + break if parent == cur + cur = parent + end + nil + end + + def cmd_migrate!(args, tries_path, and_keys) + dir_arg = args.shift + source = dir_arg ? File.expand_path(dir_arg) : Dir.pwd + + begin + src = File.realpath(source) + rescue Errno::ENOENT, Errno::EACCES + src = nil + end + unless src && Dir.exist?(src) + warn "Error: No such directory: #{source}" + warn "Usage: try migrate [dir]" + exit 1 + end + + TryCompat.mkdir_p(tries_path) + tries_real = File.realpath(tries_path) + if src == tries_real + warn "Error: Refusing to migrate the tries directory itself" + exit 1 + end + if src.start_with?(tries_real + "/") + warn "Error: Already inside the tries directory: #{src}" + exit 1 + end + + if (!STDIN.tty? || !STDERR.tty?) && (and_keys.nil? || and_keys.empty?) + warn "Error: try migrate requires an interactive terminal" + exit 1 + end + + date_prefix = Time.now.strftime("%Y-%m-%d") + base = File.basename(src) + if base.match?(/\A\d{4}-\d{2}-\d{2}-.+\z/) + stem = base[11..-1] + resolved = resolve_unique_name_with_versioning(tries_path, base[0, 10], stem) + default_target = "#{base[0, 10]}-#{resolved}" + else + resolved = resolve_unique_name_with_versioning(tries_path, date_prefix, base) + default_target = "#{date_prefix}-#{resolved}" + end + + ctx = git_context(src) + warn_note = "inside a git repository; moving it will cut it off from git history" if ctx == :inside_repo + + selector = TrySelector.new("", base_path: tries_path, + test_keys: and_keys, test_no_cls: !!(and_keys && !and_keys.empty?)) + target = selector.run_migrate_dialog(src, default_target, warn_note) + return nil unless target + + dest = File.join(tries_path, target) + script_migrate(src, dest, ctx == :worktree) + end + # --- Shell script helpers --- SCRIPT_WARNING = "# if you can read this, you didn't launch try from an alias. run try --help." @@ -1597,6 +1762,16 @@ def script_ascend(source, dest, basename, base_path) cmds + script_cd(dest) end + def script_migrate(src, dest, is_worktree) + cmds = [] + if is_worktree + cmds << "git worktree move #{q(src)} #{q(dest)}" + else + cmds << "mv #{q(src)} #{q(dest)}" + end + cmds + script_cd(dest) + end + def script_rename(base_path, old_name, new_name) new_path = File.join(base_path, new_name) [ @@ -1671,6 +1846,15 @@ def worktree_path(tries_path, repo_dir, custom_name) when 'clone' emit_script(cmd_clone!(ARGV, tries_path)) exit 0 + when 'migrate' + script = cmd_migrate!(ARGV, tries_path, and_keys) + if script + emit_script(script) + exit 0 + else + puts "Cancelled." + exit 1 + end when 'init' cmd_init!(ARGV, tries_path) exit 0 @@ -1683,6 +1867,16 @@ def worktree_path(tries_path, repo_dir, custom_name) when 'clone' ARGV.shift emit_script(cmd_clone!(ARGV, tries_path)) + when 'migrate' + ARGV.shift + script = cmd_migrate!(ARGV, tries_path, and_keys) + if script + emit_script(script) + exit 0 + else + puts "Cancelled." + exit 1 + end when 'worktree' ARGV.shift repo = ARGV.shift