diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index eb199f6..6f55d7e 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -36,9 +36,9 @@ jobs: BRANCH: ${{ github.head_ref || github.ref_name }} run: | for repo in meta_plugin_protocol meta_git_lib meta_cli meta_core loop_lib; do - if git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null; then + if git clone --depth 1 -b "$BRANCH" "https://github.com/gitkb/${repo}.git" 2>/dev/null; then echo "Cloned ${repo}@${BRANCH}" - elif git clone --depth 1 "https://github.com/harmony-labs/${repo}.git"; then + elif git clone --depth 1 "https://github.com/gitkb/${repo}.git"; then echo "Branch ${BRANCH} not found for ${repo}; falling back to default branch" else echo "::error::Failed to clone ${repo}" @@ -48,7 +48,7 @@ jobs: - name: Create workspace root run: | - printf '[workspace]\nmembers = ["meta_git_cli"]\nresolver = "2"\n\n[workspace.package]\nversion = "0.0.0"\nedition = "2021"\nlicense = "MIT"\nrepository = "https://github.com/harmony-labs/meta"\n' > Cargo.toml + printf '[workspace]\nmembers = ["meta_git_cli"]\nresolver = "2"\n\n[workspace.package]\nversion = "0.0.0"\nedition = "2021"\nlicense = "MIT"\nrepository = "https://github.com/gitkb/meta"\n' > Cargo.toml - uses: dtolnay/rust-toolchain@stable with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f09f328..30803c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,9 +31,9 @@ jobs: BRANCH: ${{ github.head_ref || github.ref_name }} run: | for repo in meta_plugin_protocol meta_git_lib meta_cli meta_core loop_lib; do - if git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null; then + if git clone --depth 1 -b "$BRANCH" "https://github.com/gitkb/${repo}.git" 2>/dev/null; then echo "Cloned ${repo}@${BRANCH}" - elif git clone --depth 1 "https://github.com/harmony-labs/${repo}.git"; then + elif git clone --depth 1 "https://github.com/gitkb/${repo}.git"; then echo "Branch ${BRANCH} not found for ${repo}; falling back to default branch" else echo "::error::Failed to clone ${repo}" @@ -44,7 +44,7 @@ jobs: - name: Create workspace root shell: bash run: | - printf '[workspace]\nmembers = ["meta_git_cli"]\nresolver = "2"\n\n[workspace.package]\nversion = "0.0.0"\nedition = "2021"\nlicense = "MIT"\nrepository = "https://github.com/harmony-labs/meta"\n' > Cargo.toml + printf '[workspace]\nmembers = ["meta_git_cli"]\nresolver = "2"\n\n[workspace.package]\nversion = "0.0.0"\nedition = "2021"\nlicense = "MIT"\nrepository = "https://github.com/gitkb/meta"\n' > Cargo.toml - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -72,9 +72,9 @@ jobs: BRANCH: ${{ github.head_ref || github.ref_name }} run: | for repo in meta_plugin_protocol meta_git_lib meta_cli meta_core loop_lib; do - if git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null; then + if git clone --depth 1 -b "$BRANCH" "https://github.com/gitkb/${repo}.git" 2>/dev/null; then echo "Cloned ${repo}@${BRANCH}" - elif git clone --depth 1 "https://github.com/harmony-labs/${repo}.git"; then + elif git clone --depth 1 "https://github.com/gitkb/${repo}.git"; then echo "Branch ${BRANCH} not found for ${repo}; falling back to default branch" else echo "::error::Failed to clone ${repo}" @@ -84,7 +84,7 @@ jobs: - name: Create workspace root run: | - printf '[workspace]\nmembers = ["meta_git_cli"]\nresolver = "2"\n\n[workspace.package]\nversion = "0.0.0"\nedition = "2021"\nlicense = "MIT"\nrepository = "https://github.com/harmony-labs/meta"\n' > Cargo.toml + printf '[workspace]\nmembers = ["meta_git_cli"]\nresolver = "2"\n\n[workspace.package]\nversion = "0.0.0"\nedition = "2021"\nlicense = "MIT"\nrepository = "https://github.com/gitkb/meta"\n' > Cargo.toml - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/notify-parent.yml b/.github/workflows/notify-parent.yml index 7f027b7..faf1c02 100644 --- a/.github/workflows/notify-parent.yml +++ b/.github/workflows/notify-parent.yml @@ -12,11 +12,11 @@ jobs: steps: - name: Notify parent repo # PARENT_REPO_PAT requires `repo` scope (or fine-grained `contents:write`) - # on harmony-labs/meta. Configure in Settings → Secrets → Actions. + # on gitkb/meta. Configure in Settings → Secrets → Actions. uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4 with: token: ${{ secrets.PARENT_REPO_PAT }} - repository: harmony-labs/meta + repository: gitkb/meta event-type: child-repo-updated client-payload: >- { diff --git a/src/commands/worktree/cli_types.rs b/src/commands/worktree/cli_types.rs index c90b276..6d01645 100644 --- a/src/commands/worktree/cli_types.rs +++ b/src/commands/worktree/cli_types.rs @@ -52,6 +52,10 @@ pub struct CreateArgs { #[arg(long, conflicts_with = "repos")] pub all: bool, + /// Preview planned worktree operations without creating anything + #[arg(long)] + pub dry_run: bool, + /// Alias for positional (hidden, kept for backward compatibility) #[arg(long, value_name = "REF", hide = true, conflicts_with_all = ["commit_ish", "from_pr"])] pub from_ref: Option, diff --git a/src/commands/worktree/create.rs b/src/commands/worktree/create.rs index cbf069f..e4fd651 100644 --- a/src/commands/worktree/create.rs +++ b/src/commands/worktree/create.rs @@ -29,6 +29,7 @@ pub(crate) fn handle_create( let branch_flag = args.branch.as_deref(); let repo_specs = &args.repos; let use_all = args.all; + let dry_run = args.dry_run; let ephemeral = args.ephemeral; let ttl_seconds = args.ttl; // Parse custom metadata, collecting any invalid entries for strict mode @@ -58,9 +59,6 @@ pub(crate) fn handle_create( anyhow::bail!("Cannot specify both a commit-ish and --from-pr"); } - // Resolve --from-pr: get PR head branch and identify matching repo - let from_pr_info = from_pr_spec.map(resolve_from_pr).transpose()?; - let no_deps = args.no_deps; let recursive = args.recursive; @@ -145,8 +143,24 @@ pub(crate) fn handle_create( let repos_to_create = ensure_intermediate_parents(&meta_dir, repos_to_create, name, branch_flag)?; - // Apply --from-pr: override branch for the matching repo and fetch + if dry_run { + let plan = CreateDryRunPlan { + name, + wt_dir: &wt_dir, + repos_to_create: &repos_to_create, + from_ref, + from_pr_spec, + ephemeral, + ttl_seconds, + custom_meta: &custom_meta, + }; + print_create_dry_run(&plan); + return Ok(()); + } + + // Resolve/apply --from-pr: get PR head branch, override branch for the matching repo, and fetch let mut repos_to_create = repos_to_create; + let from_pr_info = from_pr_spec.map(resolve_from_pr).transpose()?; if let Some((ref pr_repo_spec, _pr_num, ref pr_branch)) = from_pr_info { let mut matched = false; for (alias, source, branch) in repos_to_create.iter_mut() { @@ -328,6 +342,90 @@ pub(crate) fn handle_create( Ok(()) } +struct CreateDryRunPlan<'a> { + name: &'a str, + wt_dir: &'a std::path::Path, + repos_to_create: &'a [(String, std::path::PathBuf, String)], + from_ref: Option<&'a str>, + from_pr_spec: Option<&'a str>, + ephemeral: bool, + ttl_seconds: Option, + custom_meta: &'a HashMap, +} + +fn print_create_dry_run(plan: &CreateDryRunPlan<'_>) { + println!( + "[DRY RUN] Would create worktree set '{}' at {}", + plan.name, + plan.wt_dir.display() + ); + println!(); + + if let Some(from_ref) = plan.from_ref { + println!("Start ref: {from_ref}"); + } + if let Some(from_pr_spec) = plan.from_pr_spec { + println!("PR source: would resolve {from_pr_spec} and fetch the matching head branch"); + } + if plan.ephemeral { + println!("Ephemeral: true"); + } + if let Some(ttl) = plan.ttl_seconds { + println!("TTL: {}", format_duration(ttl as i64)); + } + if !plan.custom_meta.is_empty() { + println!( + "Metadata: {}", + plan.custom_meta + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + ); + } + + println!("Planned repo operations:"); + if plan.repos_to_create.is_empty() { + println!(" (none)"); + } + for (alias, source, branch) in plan.repos_to_create { + let dest = if alias == "." { + plan.wt_dir.to_path_buf() + } else { + plan.wt_dir.join(alias) + }; + let mut cmd = format!( + "git -C {} worktree add -b {} {}", + shell_quote(&source.display().to_string()), + shell_quote(branch), + shell_quote(&dest.display().to_string()) + ); + if let Some(from_ref) = plan.from_ref { + cmd.push(' '); + cmd.push_str(&shell_quote(from_ref)); + } + println!(" {alias}:"); + println!(" source: {}", source.display()); + println!(" dest: {}", dest.display()); + println!(" branch: {branch}"); + println!(" bash: {cmd}"); + } + + println!(); + println!("No directories, branches, store entries, hooks, or .gitignore changes were written."); +} + +fn shell_quote(value: &str) -> String { + if value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | ':')) + { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + /// Resolve repos with automatic dependency resolution. /// /// When --repo is specified without --no-deps: diff --git a/src/commands/worktree/exec.rs b/src/commands/worktree/exec.rs index 4e25f66..35b8918 100644 --- a/src/commands/worktree/exec.rs +++ b/src/commands/worktree/exec.rs @@ -121,6 +121,7 @@ fn handle_ephemeral_exec(args: ExecArgs, verbose: bool, json: bool) -> Result<() branch: args.branch, repos: args.repos, all: args.all, + dry_run: false, from_ref: args.from_ref, from_pr: args.from_pr, ephemeral: true, diff --git a/src/commands/worktree/mod.rs b/src/commands/worktree/mod.rs index 00742ee..49be9e9 100644 --- a/src/commands/worktree/mod.rs +++ b/src/commands/worktree/mod.rs @@ -37,6 +37,7 @@ pub fn execute_worktree_command( json: bool, strict: bool, recursive: bool, + dry_run: bool, ) -> CommandResult { // Extract the subcommand name from the matched command prefix (if present). // When the plugin registers bare "worktree", the subcommand is in args[0] instead. @@ -63,12 +64,19 @@ pub fn execute_worktree_command( if recursive && is_create && !clap_args.iter().any(|a| a == "--recursive" || a == "-r") { clap_args.push("--recursive".to_string()); } + if dry_run && is_create && !clap_args.iter().any(|a| a == "--dry-run") { + clap_args.push("--dry-run".to_string()); + } // No subcommand at all — show help if clap_args.is_empty() { print_worktree_help(); return CommandResult::Message(String::new()); } + if clap_args.len() == 1 && matches!(clap_args[0].as_str(), "--help" | "-h") { + print_worktree_help(); + return CommandResult::Message(String::new()); + } match WorktreeParser::try_parse_from(&clap_args) { Ok(parsed) => match handle_worktree_command(parsed.command, verbose, json, strict) { @@ -82,7 +90,7 @@ pub fn execute_worktree_command( ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { // These are success cases - print and exit 0 println!("{e}"); - CommandResult::ShowHelp(None) + CommandResult::Message(String::new()) } _ => { // Actual parse errors @@ -190,6 +198,7 @@ fn write_worktree_help(w: &mut dyn std::io::Write) { w, " --from-pr Start from a PR's head branch" ); + let _ = writeln!(w, " --dry-run Preview planned operations"); let _ = writeln!(w, " --ephemeral Mark for automatic cleanup"); let _ = writeln!( w, diff --git a/src/lib.rs b/src/lib.rs index 4766c78..f1fb8df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,8 @@ pub fn execute_command( ); debug!("[meta_git_cli] Projects from meta_cli: {projects:?}"); + let help_requested = has_help_token(command, args); + // Worktree commands (meta worktree * and meta git worktree *) // Checked before --help intercept so worktree subcommands can handle their own --help via clap if command.starts_with("worktree") || command.starts_with("git worktree") { @@ -52,11 +54,18 @@ pub fn execute_command( options.json_output, options.strict, options.recursive, + options.dry_run, ); } - // Intercept --help/-h before dispatching to git subcommand handlers - if args.iter().any(|a| a == "--help" || a == "-h") { + if command.starts_with("git snapshot") && help_requested { + return snapshot::execute_snapshot_command_help(command); + } + + if help_requested { + if let Some(help) = build_passthrough_git_help(command, args, projects, cwd) { + return CommandResult::Message(help); + } return CommandResult::ShowHelp(None); } @@ -90,6 +99,88 @@ fn is_remote_command(command: &str) -> bool { remote_commands.iter().any(|rc| cmd_lower.contains(rc)) } +fn has_help_token(command: &str, args: &[String]) -> bool { + command + .split_whitespace() + .chain(args.iter().map(String::as_str)) + .any(|arg| arg == "--help" || arg == "-h") +} + +fn build_passthrough_git_help( + command: &str, + args: &[String], + projects: &[String], + cwd: &Path, +) -> Option { + let command_words: Vec<&str> = command + .split_whitespace() + .filter(|word| *word != "--help" && *word != "-h") + .collect(); + + if command_words.first().copied() != Some("git") { + return None; + } + + let adapted_commands = [ + "clone", "status", "update", "commit", "snapshot", "worktree", + ]; + let git_args: Vec = command_words + .iter() + .skip(1) + .map(|s| s.to_string()) + .chain( + args.iter() + .filter(|arg| arg.as_str() != "--help" && arg.as_str() != "-h") + .cloned(), + ) + .collect(); + let subcommand = git_args.first()?; + + if adapted_commands.contains(&subcommand.as_str()) { + return None; + } + + let display_command = format!("git {}", git_args.join(" ")); + let scope = match get_project_directories_with_fallback(projects, cwd) { + Ok(dirs) => { + if dirs.is_empty() { + "Current scope: no repos were discovered.".to_string() + } else { + format!( + "Current scope: {} repo(s): {}", + dirs.len(), + dirs.iter() + .map(|d| d.as_str()) + .collect::>() + .join(", ") + ) + } + } + Err(_) => "Current scope: unable to resolve repos from the current directory.".to_string(), + }; + + Some(format!( + "meta git {subcommand} - Pass-through git command\n\n\ +Usage: meta git {subcommand} [GIT_ARGS...] [META_OPTIONS]\n\n\ +Runs `{display_command}` in each repo in the current meta workspace.\n\n\ +{scope}\n\n\ +Meta options:\n\ + --dry-run Show the command that would run in each repo\n\ + --recursive Include repos from nested meta workspaces\n\ + --depth Limit recursive traversal depth\n\ + --include Only run in specified repos/directories\n\ + --exclude Skip specified repos/directories\n\ + --tag Filter by project tag(s), comma-separated\n\ + --parallel Run repo commands concurrently\n\ + --sequential Run repo commands one at a time\n\n\ +Examples:\n\ + meta git {subcommand}\n\ + meta git {subcommand} --dry-run\n\ + meta git {subcommand} --recursive --dry-run\n\n\ +Git help: run `git {subcommand} --help` directly for upstream git options." + )) +} + /// Execute a raw git command across all repos (fallback for unrecognized subcommands) fn execute_raw_git_command( command: &str, diff --git a/src/snapshot.rs b/src/snapshot.rs index 03f3884..eb040dd 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -45,6 +45,83 @@ Use --force to skip confirmation on restore, --dry-run to preview."#, Ok(CommandResult::Message(String::new())) } +pub(crate) fn execute_snapshot_command_help(command: &str) -> CommandResult { + let subcommand = command + .split_whitespace() + .filter(|word| *word != "--help" && *word != "-h") + .nth(2); + + let Some(subcommand) = subcommand else { + return execute_snapshot_help().unwrap_or_else(|e| { + CommandResult::Error(format!("Failed to show snapshot help: {e}")) + }); + }; + + let help = match subcommand { + "create" => { + r#"meta git snapshot create - Save workspace git state + +Usage: meta git snapshot create + +Records each repo's current SHA, branch, and dirty status. + +Examples: + meta git snapshot create before-refactor + meta git snapshot create before-upgrade"# + } + "list" => { + r#"meta git snapshot list - List saved workspace snapshots + +Usage: meta git snapshot list + +Shows snapshot names, creation times, repo counts, and dirty repo counts. + +Examples: + meta git snapshot list"# + } + "show" => { + r#"meta git snapshot show - Display one snapshot + +Usage: meta git snapshot show + +Shows per-repo branch, SHA, and dirty state recorded in a snapshot. + +Examples: + meta git snapshot show before-refactor"# + } + "restore" => { + r#"meta git snapshot restore - Restore workspace git state + +Usage: meta git snapshot restore [--force] [--dry-run] + +Restores repos to the recorded branches/SHAs. Dirty repos are stashed before restore. + +Options: + --force Skip confirmation + --dry-run Preview restore actions without changing repos + +Examples: + meta git snapshot restore before-refactor --dry-run + meta git snapshot restore before-refactor --force"# + } + "delete" => { + r#"meta git snapshot delete - Delete a saved snapshot + +Usage: meta git snapshot delete + +Examples: + meta git snapshot delete before-refactor"# + } + _ => { + return CommandResult::ShowHelp(Some(format!( + "unknown snapshot command '{subcommand}'" + ))); + } + }; + + CommandResult::Message(help.to_string()) +} + /// Create a snapshot of the current workspace state pub(crate) fn execute_snapshot_create( args: &[String],