diff --git a/README.md b/README.md index 12a965c..e705d7f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Ever find yourself with 50 directories named `test`, `test2`, `new-test`, `actua **try** is here for your beautifully chaotic mind. -# What it does +# What it does ![Fuzzy Search Demo](assets/try-fuzzy-search-demo.gif) @@ -131,6 +131,7 @@ try ./path/to/repo [name] # Use another repo as the worktre try worktree dir [name] # Same as above, explicit CLI form 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 repo 2025-08-17-my-app # Publish a try directory to a new private GitHub repo try --help # See all options ``` @@ -165,12 +166,34 @@ Supported git URI formats: The `.git` suffix is automatically removed from URLs when generating directory names. +### GitHub Repository Publishing + +Publish a plain try directory to a fresh GitHub repository with the GitHub CLI: + +```bash +# Publish a try as a private repo named my-app +try repo 2025-08-17-my-app + +# Choose a custom repo name +try repo 2025-08-17-my-app better-name + +# Create a public repo instead of the private default +try repo 2025-08-17-my-app --public +``` + +The command temporarily runs `git init`, commits the current files, runs `gh repo create --source=. --remote=origin --push`, then removes the local `.git` directory after a successful push. If `gh` or `git` fails, `.git` remains so you can inspect and retry. + +In the TUI, select a try and press `Ctrl-U` to edit the repo name and choose private or public visibility before publishing. + +For safety, `try repo` only publishes plain directories. It refuses directories that already contain `.git`. + ### Keyboard Shortcuts - `↑/↓` or `Ctrl-P/N/J/K` - Navigate - `Enter` - Select or create - `Backspace` - Delete character - `Ctrl-D` - Delete directory (with confirmation) +- `Ctrl-U` - Create GitHub repo from selected try - `ESC` - Cancel - Just type to filter @@ -199,9 +222,9 @@ nix run github:tobi/try init ~/my-tries ```nix { inputs.try.url = "github:tobi/try"; - + imports = [ inputs.try.homeManagerModules.default ]; - + programs.try = { enable = true; path = "~/experiments"; # optional, defaults to ~/src/tries diff --git a/spec/command_line.md b/spec/command_line.md index 52066c2..50103a2 100644 --- a/spec/command_line.md +++ b/spec/command_line.md @@ -98,6 +98,44 @@ try . # Shorthand (requires name) - Returns shell script to cd into worktree - `try .` without a name is NOT supported (too easy to invoke accidentally) +### repo + +Publish a plain try directory to a fresh GitHub repository using the GitHub CLI. + +``` +try repo [path-or-name] [repo-name] [--private|--public] +try exec repo [path-or-name] [repo-name] [--private|--public] +``` + +**Arguments:** +- `path-or-name` (optional): Source directory to publish. Existing paths are used directly; otherwise the value is resolved as a child of the tries directory. Defaults to the current directory. +- `repo-name` (optional): GitHub repository name. Defaults to the source basename with a leading `YYYY-MM-DD-` prefix removed. + +**Flags:** +- `--private`: Create a private repository (default) +- `--public`: Create a public repository + +**Behavior:** +- Refuses sources that already contain `.git` +- Changes into the source directory +- Runs `git init`, `git add .`, and `git commit -m 'Initial commit'` +- Runs `gh repo create --private --source=. --remote=origin --push` by default +- Uses `--public` instead of `--private` when requested +- Removes local `.git` only after the GitHub repo creation and push succeed +- Returns shell script to cd back into the source directory + +**Examples:** +``` +try repo 2025-11-30-my-app +# Creates a private GitHub repo named my-app + +try repo 2025-11-30-my-app better-name +# Creates a private GitHub repo named better-name + +try repo 2025-11-30-my-app --public +# Creates a public GitHub repo named my-app +``` + ### init Output shell function definition for shell integration. diff --git a/spec/tests/test_38_repo.sh b/spec/tests/test_38_repo.sh new file mode 100644 index 0000000..5c8717a --- /dev/null +++ b/spec/tests/test_38_repo.sh @@ -0,0 +1,101 @@ +# GitHub repo publishing tests +# Verify try repo emits a script that publishes a plain try folder via gh. + +section "repo" + +REPO_DIR=$(mktemp -d) +mkdir -p "$REPO_DIR/2026-05-16-my-app" +echo "hello" > "$REPO_DIR/2026-05-16-my-app/README.md" + +# Test: CLI repo command initializes git and creates a private GitHub repo by default +output=$(try_run --path="$REPO_DIR" repo 2026-05-16-my-app 2>&1) +if echo "$output" | grep -q "git init" && echo "$output" | grep -q "gh repo create 'my-app' --private --source=. --remote=origin --push"; then + pass +else + fail "repo should init git and create private GitHub repo" "git init and gh repo create 'my-app' --private" "$output" "command_line.md#repo" +fi + +# Test: repo command stages and commits current files +if echo "$output" | grep -q "git add ." && echo "$output" | grep -q "git commit -m 'Initial commit'"; then + pass +else + fail "repo should add and commit files" "git add . and initial commit" "$output" "command_line.md#repo" +fi + +# Test: cleanup runs after gh repo create so failures leave .git for debugging +gh_line=$(echo "$output" | grep -n "gh repo create" | cut -d: -f1 | head -1) +cleanup_line=$(echo "$output" | grep -n "rm -rf '.git'" | cut -d: -f1 | head -1) +if [ -n "$gh_line" ] && [ -n "$cleanup_line" ] && [ "$cleanup_line" -gt "$gh_line" ]; then + pass +else + fail "repo should clean up .git after gh succeeds" "rm -rf '.git' after gh repo create" "$output" "command_line.md#repo" +fi + +# Test: --public uses public visibility +output=$(try_run --path="$REPO_DIR" repo 2026-05-16-my-app --public 2>&1) +if echo "$output" | grep -q "gh repo create 'my-app' --public --source=. --remote=origin --push"; then + pass +else + fail "repo --public should create public GitHub repo" "--public" "$output" "command_line.md#repo" +fi + +# Test: custom repo name overrides stripped directory name +output=$(try_run --path="$REPO_DIR" repo 2026-05-16-my-app better-name 2>&1) +if echo "$output" | grep -q "gh repo create 'better-name' --private --source=. --remote=origin --push"; then + pass +else + fail "repo should accept custom repo name" "better-name" "$output" "command_line.md#repo" +fi + +# Test: existing relative paths win over same-named TRY_PATH children +LOCAL_ROOT=$(mktemp -d) +mkdir -p "$LOCAL_ROOT/shared-name" +mkdir -p "$REPO_DIR/shared-name" +LOCAL_SOURCE=$(cd "$LOCAL_ROOT/shared-name" && pwd -P) +output=$(cd "$LOCAL_ROOT" && try_run --path="$REPO_DIR" repo ./shared-name 2>&1) +if echo "$output" | grep -q "cd '$LOCAL_SOURCE'"; then + pass +else + fail "repo should prefer existing relative source paths" "cd '$LOCAL_SOURCE'" "$output" "command_line.md#repo" +fi + +# Test: repo refuses existing git repositories to avoid deleting history +mkdir -p "$REPO_DIR/2026-05-16-existing-git/.git" +output=$(try_run --path="$REPO_DIR" repo 2026-05-16-existing-git 2>&1) +if echo "$output" | grep -q "already contains .git"; then + pass +else + fail "repo should refuse existing git repository" "already contains .git" "$output" "command_line.md#repo" +fi + +# Test: exec repo command emits the same publish script +output=$(try_run --path="$REPO_DIR" exec repo 2026-05-16-my-app 2>&1) +if echo "$output" | grep -q "gh repo create 'my-app' --private --source=. --remote=origin --push"; then + pass +else + fail "exec repo should emit publish script" "gh repo create" "$output" "command_line.md#repo" +fi + +# Test: TUI action can publish selected try with default private visibility +output=$(try_run --path="$REPO_DIR" --and-keys='DOWN,CTRL-U,ENTER' exec 2>/dev/null) +if echo "$output" | grep -q "gh repo create 'my-app' --private --source=. --remote=origin --push"; then + pass +else + fail "Ctrl-U should publish selected try as private repo" "gh repo create 'my-app' --private" "$output" "tui_spec.md#keyboard" +fi + +# Test: TUI action can toggle public visibility +output=$(try_run --path="$REPO_DIR" --and-keys='DOWN,CTRL-U,DOWN,ENTER' exec 2>/dev/null) +if echo "$output" | grep -q "gh repo create 'my-app' --public --source=. --remote=origin --push"; then + pass +else + fail "Repo dialog should allow public visibility" "--public" "$output" "tui_spec.md#keyboard" +fi + +# Test: TUI repo dialog can be cancelled +output=$(try_run --path="$REPO_DIR" --and-keys='CTRL-U,ESC' exec 2>/dev/null) +if echo "$output" | grep -q "gh repo create"; then + fail "Repo dialog Esc should cancel" "no gh repo create" "$output" "tui_spec.md#keyboard" +else + pass +fi diff --git a/try.rb b/try.rb index d966f80..1a737e8 100755 --- a/try.rb +++ b/try.rb @@ -257,6 +257,11 @@ def main_loop run_ascend_dialog(tries[@cursor_pos]) break if @selected end + when "\x15" # Ctrl-U - publish selected entry to GitHub + if @cursor_pos < tries.length + run_repo_dialog(tries[@cursor_pos]) + break if @selected + end when "\x03", "\e" # Ctrl-C or ESC if @delete_mode # Exit delete mode, clear marks @@ -353,7 +358,7 @@ def render(tries) end else screen.footer.add_line do |line| - line.center.write_dim("↑/↓: Navigate Enter: Select ^R: Rename ^G: Graduate ^D: Delete Esc: Cancel") + line.center.write_dim("↑/↓: Navigate Enter: Select ^R: Rename ^G: Graduate ^U: Repo ^D: Delete Esc: Cancel") end end @@ -777,6 +782,133 @@ def finalize_ascend(entry, ascend_buffer) true end + # Repo dialog - publish a try to a fresh GitHub repository + def run_repo_dialog(entry) + @delete_mode = false + @marked_for_deletion.clear + + current_name = entry[:basename] + repo_buffer = repo_name_from_basename(current_name) + repo_cursor = repo_buffer.length + visibility = "private" + repo_error = nil + + loop do + render_repo_dialog(current_name, repo_buffer, repo_cursor, visibility, repo_error) + + ch = read_key + case ch + when "\r" # Enter - confirm + result = finalize_repo(entry, repo_buffer, visibility) + if result == true + break + else + repo_error = result + end + when "\e", "\x03" # ESC or Ctrl-C - cancel + break + when "\e[A", "\e[B", "\x10", "\x0E" # arrows or Ctrl-P/N toggle visibility + visibility = visibility == "private" ? "public" : "private" + repo_error = nil + when "\x7F", "\b" # Backspace + if repo_cursor > 0 + repo_buffer = repo_buffer[0...(repo_cursor - 1)] + repo_buffer[repo_cursor..].to_s + repo_cursor -= 1 + end + repo_error = nil + when "\x01" # Ctrl-A - start of line + repo_cursor = 0 + when "\x05" # Ctrl-E - end of line + repo_cursor = repo_buffer.length + when "\x02" # Ctrl-B - back one char + repo_cursor = [repo_cursor - 1, 0].max + when "\x06" # Ctrl-F - forward one char + repo_cursor = [repo_cursor + 1, repo_buffer.length].min + when "\x0B" # Ctrl-K - kill to end + repo_buffer = repo_buffer[0...repo_cursor] + repo_error = nil + when "\x17" # Ctrl-W - delete word backward + if repo_cursor > 0 + new_pos = word_boundary_backward(repo_buffer, repo_cursor) + repo_buffer = repo_buffer[0...new_pos] + repo_buffer[repo_cursor..].to_s + repo_cursor = new_pos + end + repo_error = nil + when String + if ch.length == 1 && ch =~ /[a-zA-Z0-9\-_\.]/ + repo_buffer = repo_buffer[0...repo_cursor] + ch + repo_buffer[repo_cursor..].to_s + repo_cursor += 1 + repo_error = nil + end + end + end + + @needs_redraw = true + end + + def render_repo_dialog(current_name, repo_buffer, repo_cursor, visibility, repo_error) + screen = Tui::Screen.new(io: STDERR) + + screen.header.add_line do |line| + line.center << emoji("🐙") << Tui::Text.accent(" Create GitHub repo") + end + screen.header.add_line { |line| line.write.write_dim(fill("─")) } + + screen.body.add_line do |line| + line.write << emoji("📁") << " #{current_name}" + end + screen.body.add_line + + screen.body.add_line do |line| + prefix = "Repo name: " + line.center.write_dim(prefix) + line.center << screen.input("", value: repo_buffer, cursor: repo_cursor).to_s + input_width = [repo_buffer.length, repo_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) + end + + screen.body.add_line + screen.body.add_line do |line| + private_label = visibility == "private" ? "[private]" : " private " + public_label = visibility == "public" ? "[public]" : " public " + line.center.write_dim("Visibility: ") + line.center << Tui::Text.highlight(private_label) if visibility == "private" + line.center.write_dim(private_label) if visibility != "private" + line.center << " " + line.center << Tui::Text.highlight(public_label) if visibility == "public" + line.center.write_dim(public_label) if visibility != "public" + end + + if repo_error + screen.body.add_line + screen.body.add_line { |line| line.center.write_bold(repo_error) } + end + + screen.footer.add_line { |line| line.write.write_dim(fill("─")) } + screen.footer.add_line { |line| line.center.write_dim("Enter: Confirm ↑/↓: Toggle visibility Esc: Cancel") } + + screen.flush + end + + def finalize_repo(entry, repo_buffer, visibility) + repo_name = repo_buffer.strip + + return "Repo name cannot be empty" if repo_name.empty? + return "Repo name cannot contain /" if repo_name.include?('/') + return "Visibility must be private or public" unless %w[private public].include?(visibility) + return "Directory already contains .git: #{entry[:path]}" if File.exist?(File.join(entry[:path], '.git')) + + @selected = { type: :repo, path: entry[:path], repo_name: repo_name, visibility: visibility } + true + end + + def repo_name_from_basename(basename) + basename.sub(/^\d{4}-\d{2}-\d{2}-/, '') + end + def handle_selection(try_dir) # Select existing try directory @selected = { type: :cd, path: try_dir[:path] } @@ -972,18 +1104,21 @@ def print_global_help Usage: try [query] Interactive directory selector try clone Clone repo into dated directory + try repo [path] Create GitHub repo from a try directory try worktree Create worktree from current git repo try --help Show this help Commands: init [path] Output shell function definition clone [name] Clone git repo into date-prefixed directory + repo [path] [name] Publish plain try directory to GitHub worktree Create worktree in dated directory Examples: try Open interactive selector try project Selector with initial filter try clone https://github.com/user/repo + try repo 2026-05-16-my-app try worktree feature-branch Manual mode (without alias): @@ -998,6 +1133,7 @@ def print_global_help Enter Select / Create new Ctrl-R Rename Ctrl-G Graduate (promote try to project) + Ctrl-U Create GitHub repo Ctrl-D Mark for deletion Ctrl-T Create new try Esc Cancel @@ -1123,6 +1259,7 @@ def parse_test_keys(spec) when 'CTRL-P', 'CTRLP' then keys << "\x10" when 'CTRL-R', 'CTRLR' then keys << "\x12" when 'CTRL-T', 'CTRLT' then keys << "\x14" + when 'CTRL-U', 'CTRLU' then keys << "\x15" when 'CTRL-W', 'CTRLW' then keys << "\x17" when /^TYPE=/i tok.sub(/^TYPE=/i, '').each_char { |ch| keys << ch } @@ -1169,6 +1306,68 @@ def cmd_clone!(args, tries_path) script_clone(File.join(tries_path, dir_name), git_uri) end + def repo_name_from_path(path) + File.basename(path).sub(/^\d{4}-\d{2}-\d{2}-/, '') + end + + def extract_repo_visibility!(args) + visibility = "private" + if args.delete('--public') + visibility = "public" + end + if args.delete('--private') + visibility = "private" + end + visibility + end + + def resolve_repo_source(source_arg, tries_path) + return Dir.pwd if source_arg.nil? || source_arg.strip.empty? + + begin + expanded = File.expand_path(source_arg) + return expanded if Dir.exist?(expanded) + rescue Errno::ENOENT + # The test runner can remove the current working directory after prior + # eval-based tests. In that case, unresolved relative paths still fall + # through to the TRY_PATH child below. + end + + File.join(tries_path, source_arg) + end + + def cmd_repo!(args, tries_path) + visibility = extract_repo_visibility!(args) + source_arg = args.shift + repo_name = args.shift + source = resolve_repo_source(source_arg, tries_path) + + unless Dir.exist?(source) + warn "Error: repo source directory does not exist: #{source}" + exit 1 + end + + if File.exist?(File.join(source, '.git')) + warn "Error: repo source already contains .git: #{source}" + exit 1 + end + + repo_name = repo_name_from_path(source) if repo_name.nil? || repo_name.strip.empty? + repo_name = repo_name.strip + + if repo_name.empty? + warn "Error: repo name cannot be empty" + exit 1 + end + + if repo_name.include?('/') + warn "Error: repo name cannot contain /" + exit 1 + end + + script_repo(source, repo_name, visibility) + end + def cmd_init!(args, tries_path) script_path = File.expand_path($0) @@ -1380,6 +1579,8 @@ def cmd_cd!(args, tries_path, and_type, and_exit, and_keys, and_confirm) script_rename(result[:base_path], result[:old], result[:new]) when :ascend script_ascend(result[:source], result[:dest], result[:basename], result[:base_path]) + when :repo + script_repo(result[:path], result[:repo_name], result[:visibility]) else script_cd(result[:path]) end @@ -1431,6 +1632,20 @@ def script_worktree(path, repo = nil) ["mkdir -p #{q(path)}", "echo #{q("Using git worktree to create this trial from #{src}.")}", worktree_cmd] + script_cd(path) end + def script_repo(path, repo_name, visibility) + visibility_flag = visibility == "public" ? "--public" : "--private" + [ + "cd #{q(path)}", + "git init", + "git add .", + "git commit -m #{q('Initial commit')}", + "gh repo create #{q(repo_name)} #{visibility_flag} --source=. --remote=origin --push", + "rm -rf #{q('.git')}", + "echo #{q(path)}", + "cd #{q(path)}" + ] + end + def script_delete(paths, base_path) cmds = ["cd #{q(base_path)}"] paths.each { |item| cmds << "test -d #{q(item[:basename])} && rm -rf #{q(item[:basename])}" } @@ -1529,6 +1744,9 @@ def worktree_path(tries_path, repo_dir, custom_name) when 'clone' emit_script(cmd_clone!(ARGV, tries_path)) exit 0 + when 'repo' + emit_script(cmd_repo!(ARGV, tries_path)) + exit 0 when 'init' cmd_init!(ARGV, tries_path) exit 0 @@ -1541,6 +1759,9 @@ def worktree_path(tries_path, repo_dir, custom_name) when 'clone' ARGV.shift emit_script(cmd_clone!(ARGV, tries_path)) + when 'repo' + ARGV.shift + emit_script(cmd_repo!(ARGV, tries_path)) when 'worktree' ARGV.shift repo = ARGV.shift