From b2c786b863f438e9603c9a380fb41d398bfba215 Mon Sep 17 00:00:00 2001 From: Maxim Geraskin Date: Wed, 1 Apr 2026 19:16:28 +0200 Subject: [PATCH] uspecs 0.1.0-a0, 2026-04-01T16:21:32Z --- AGENTS.md | 15 +- uspecs/u/actn-uarchive.md | 31 +- uspecs/u/actn-uchange.md | 30 +- uspecs/u/actn-uimpl.md | 15 +- .../u/{actn-upr.md => actn-upr_deprecated.md} | 11 +- uspecs/u/actn-usync.md | 36 +- uspecs/u/conf.md | 30 +- uspecs/u/prompts.md | 66 + uspecs/u/scripts/_lib/{pr.sh => git.sh} | 198 +-- uspecs/u/scripts/_lib/utils.sh | 330 ++++- uspecs/u/scripts/conf.sh | 331 ++--- uspecs/u/scripts/softeng.sh | 1309 +++++++++++++++++ uspecs/u/scripts/uspecs.sh | 571 ------- uspecs/u/uspecs.yml | 8 +- 14 files changed, 1985 insertions(+), 996 deletions(-) rename uspecs/u/{actn-upr.md => actn-upr_deprecated.md} (79%) create mode 100644 uspecs/u/prompts.md rename uspecs/u/scripts/_lib/{pr.sh => git.sh} (70%) create mode 100644 uspecs/u/scripts/softeng.sh delete mode 100644 uspecs/u/scripts/uspecs.sh diff --git a/AGENTS.md b/AGENTS.md index ff3b2c2..78c45db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,16 +4,13 @@ ## Execution instructions -When a request mentions one of the words below, you must read the corresponding file and follow the rules described there: +When a request starts with one of the following keywords, you must execute `bash uspecs/u/scripts/softeng.sh action {keyword}` and follow the instructions provided in the output: upr, umergepr. -- uchange: `uspecs/u/actn-uchange.md` -- uarchive: `uspecs/u/actn-uarchive.md` -- uimpl: `uspecs/u/actn-uimpl.md` -- usync: `uspecs/u/actn-usync.md` -- udecs: `uspecs/u/actn-udecs.md` -- uhow: `uspecs/u/actn-uhow.md` -- upr: `uspecs/u/actn-upr.md` +When a request starts with one of the keywords below, you must read the corresponding file and follow the rules described there: -Use files from `./uspecs/u` as an initial reference when user mentions uspecs +- uchange: create a change request following rules from `uspecs/u/actn-uchange.md` +- uarchive, uimpl, usync, udecs, uhow: perform action described in `uspecs/u/actn-{keyword}.md` + +Use files from `./uspecs/u` as an initial reference when user mentions uspecs. diff --git a/uspecs/u/actn-uarchive.md b/uspecs/u/actn-uarchive.md index e0bb75b..df5073b 100644 --- a/uspecs/u/actn-uarchive.md +++ b/uspecs/u/actn-uarchive.md @@ -13,21 +13,26 @@ Rules: Parameters: - Input - - Active Change Folder path + - Active Change Folder path (not required when `--all` is used) + - `--all` option (optional): archive all change folders modified vs `pr_remote/default_branch`; no Active Change Folder path needed - Output - - Folder moved to `{changes_folder}/archive` + - Folder(s) moved to `{changes_folder}/archive` - If on PR branch and Engineer confirms: git commit and push with message, branch and refs removed, deleted branch hash and restore instructions reported Flow: -- Identify Active Change Folder to archive, if unclear, ask Engineer to specify folder name -- Run `bash uspecs/u/scripts/uspecs.sh status ispr` - - If output is `yes`: present Engineer with the following options: - 1. Archive + git cleanup (commit, push, delete local branch and remote tracking ref) - 2. Archive only (no git operations) - 3. Cancel - - On option 1: `bash uspecs/u/scripts/uspecs.sh change archive -d`; script output includes deleted branch hash and restore instructions - relay to Engineer - - On option 2: `bash uspecs/u/scripts/uspecs.sh change archive ` - - On option 3: abort, no action taken - - Otherwise: `bash uspecs/u/scripts/uspecs.sh change archive ` -- Analyze output, show to Engineer and stop +- If `--all` option is provided: + - Run `bash uspecs/u/scripts/softeng.sh change archiveall` + - Analyze output, show to Engineer and stop +- Otherwise: + - Identify Active Change Folder to archive, if unclear, ask Engineer to specify folder name + - Run `bash uspecs/u/scripts/softeng.sh status ispr` + - If output is `yes`: present Engineer with the following options: + 1. Archive + git cleanup (commit, push, delete local branch and remote tracking ref) + 2. Archive only (no git operations) + 3. Cancel + - On option 1: `bash uspecs/u/scripts/softeng.sh change archive -d`; script output includes deleted branch hash and restore instructions - relay to Engineer + - On option 2: `bash uspecs/u/scripts/softeng.sh change archive ` + - On option 3: abort, no action taken + - Otherwise: `bash uspecs/u/scripts/softeng.sh change archive ` + - Analyze output, show to Engineer and stop diff --git a/uspecs/u/actn-uchange.md b/uspecs/u/actn-uchange.md index 68f2401..a270fa6 100644 --- a/uspecs/u/actn-uchange.md +++ b/uspecs/u/actn-uchange.md @@ -1,33 +1,49 @@ -# Action: Create change +# Action: Create change request ## Overview -Create a new change request folder with a structured Change File. Optionally fetch issue content from an issue URL (GitLab, GitHub, Jira) and create a git branch. +Create a new change request folder with a structured Change File. + +Optionally: + +- Fetch issue content from an issue URL (GitLab, GitHub, Jira) +- Create a git branch ## Instructions Rules: - Always read `uspecs/u/concepts.md` and `uspecs/u/conf.md` before proceeding and follow the definitions and rules defined there +- Never perform any implementation, code changes, or actions outside of this flow - the only output is the Change Folder, Change File, optional Issue File, git branch +- If change description is not provided, ask the user for it before proceeding. Do not treat the response as a new command - use it as the change description and continue this flow +- Never pass any optional parameter (`--no-branch`, `--branch`, `--issue-url`) to the script or alter default behavior unless it is explicitly requested Parameters: - Input - Change description - - --branch option (optional): create git branch for the change + - --no-branch option (optional): skip git branch creation + - --branch option (optional): force git branch creation even when not on the default branch - Issue reference (optional): URL to a GitLab/GitHub/Jira/etc. issue that prompted the change - Referenced further as `{issue_url}` - Output - Active Change Folder with Change File - Issue File (if issue reference provided) - - Git branch (if --branch option provided and git repository exists) + - Git branch (created by default when on the default branch; skipped when on a non-default branch unless --branch; skipped when --no-branch; git repository must exist) Flow: -- Determine `change_name` from the change description: kebab-case, 15-30 chars, descriptive +- Pre-flight checklist (verify before proceeding): + - [ ] I have read `uspecs/u/concepts.md` and `uspecs/u/conf.md` + - [ ] User has NOT explicitly requested `--no-branch` -> I will NOT pass this flag + - [ ] User has NOT explicitly requested `--branch` -> I will NOT pass this flag + - [ ] User has NOT provided an issue URL -> I will NOT pass `--issue-url` + - [ ] If any of the above are explicitly requested, I will pass ONLY those flags +- Determine `change_name` from the change description: kebab-case, max 40 chars (ideal 15-30), descriptive - Run script to create Change Folder: - - Base command: `bash uspecs/u/scripts/uspecs.sh change new {change_name}` + - Base command: `bash uspecs/u/scripts/softeng.sh change new {change_name}` - If issue reference provided add `--issue-url "{issue_url}"` parameters (quoted to handle shell-special characters such as `&`) + - If --no-branch option provided add `--no-branch` parameter - If --branch option provided add `--branch` parameter - Fail fast if script exits with error - Parse Change Folder path from script output, path is relative to project root @@ -38,4 +54,4 @@ Flow: - Convert it to rich markdown format suitable for Issue File - Save fetched content to Issue File (issue.md) inside the Change Folder - Add reference to Issue File in Why section: `See [issue.md](issue.md) for details.` -- Show user what was created and stop, do not proceed to implementation or other steps +- Show user what was created diff --git a/uspecs/u/actn-uimpl.md b/uspecs/u/actn-uimpl.md index 50d848e..59aca52 100644 --- a/uspecs/u/actn-uimpl.md +++ b/uspecs/u/actn-uimpl.md @@ -10,7 +10,7 @@ Rules: - Strictly follow the definitions from `uspecs/u/concepts.md` and `uspecs/u/conf.md` - Use file name patterns from there, not from the codebase -- Critical: Only one scenario must be executed per command run. After executing the action, the AI Agent must stop further processing and wait for the next command. +- Critical: Only one Gherkin scenario branch (from the `Scenarios` section below) must be selected and entered per command run. Within that branch, all steps and to-do items are processed fully. After completing the branch, the AI Agent must stop further processing and wait for the next command. - All relative links must be correct paths from the file being edited to the target file. Verify that relative paths resolve to the intended file before writing them. Parameters: @@ -38,10 +38,14 @@ Use definitions from sections below and structures from `{templates_folder}/tmpl ### `Functional design` section does not exist and it is needed -The section is needed if: +The section is needed if Domain Files exist and define External actors, and the change request impacts or implies new Functional Design Specifications (files inside `{specs_folder}`): -- Domain Files exist and define External actors -- Change Request description impacts Functional Design Specifications (only files inside `{specs_folder}`) +- updates to existing Domain Files, Scenarios Files, or Requirements Files +- introduction of new domains, features, or requirements not yet specified + +The section is NOT needed if the change request affects only internal system behavior with no impact on what external actors observe or interact with. + +Determine the appropriate domain, context, and feature by searching existing specification files, then decide whether to update an existing file or create a new one. Impact: @@ -72,10 +76,9 @@ The section is needed if Change Request description implies: ### `Technical design` section does not exist and it is needed -Follow this decision hierarchy (in order): +Determine the appropriate domain, context, and technical design files by searching existing specification files, then follow this decision hierarchy (in order): - First priority: Update existing Technical Design Specifications - - Search for existing architecture and technical design files that cover the affected components - Update when the change request affects design elements already documented in: - Domain Architecture - Domain Subsystem Architecture diff --git a/uspecs/u/actn-upr.md b/uspecs/u/actn-upr_deprecated.md similarity index 79% rename from uspecs/u/actn-upr.md rename to uspecs/u/actn-upr_deprecated.md index 640256f..082731b 100644 --- a/uspecs/u/actn-upr.md +++ b/uspecs/u/actn-upr_deprecated.md @@ -9,7 +9,7 @@ Create a pull request from the current change branch by squash-merging it into a Rules: - Strictly follow the definitions from `uspecs/u/concepts.md` and `uspecs/u/conf.md` -- Always call `uspecs.sh` for git/PR operations — never call `_lib/pr.sh` directly +- Always call `softeng.sh` for git/PR operations - never call `_lib/pr.sh` directly - Read `change.md` frontmatter to determine `issue_url` and `issue_id` Parameters: @@ -24,8 +24,9 @@ Parameters: Flow: -- Merge default branch into change_branch: - - Run `bash uspecs/u/scripts/uspecs.sh pr mergedef` +- Determine Active Change Folder path (relative to project root) +- Preflight and merge default branch into change_branch: + - Run `bash uspecs/u/scripts/softeng.sh pr preflight --change-folder "{active_change_folder}"` - If script exits with error: report the error and stop - Parse `change_branch`, `default_branch`, and `change_branch_head` from script output - Read Active Change Folder (change.md) to determine `issue_url` (may be absent) and derive `issue_id` from the URL (last path segment) @@ -34,10 +35,10 @@ Flow: 2. Cancel - On option 2: stop - Get specs diff to derive PR title and body: - - Run `bash uspecs/u/scripts/uspecs.sh diff specs` + - Run `bash uspecs/u/scripts/softeng.sh diff specs` - From the diff output identify `draft_title` and `draft_body`; construct `pr_title` and `pr_body` per `{templates_folder}/tmpl-pr.md` - Create PR: - - Pass `pr_body` via stdin to `bash uspecs/u/scripts/uspecs.sh pr create --title "{pr_title}"` + - Pass `pr_body` via stdin to `bash uspecs/u/scripts/softeng.sh pr create --title "{pr_title}"` - Note: `pr_title` is passed on the command line; ensure it contains no shell-special characters (`<`, `>`, `$`, backticks) - If script exits with error: report the error and stop - Parse `pr_url` from script output diff --git a/uspecs/u/actn-usync.md b/uspecs/u/actn-usync.md index 1b3a745..bf231f3 100644 --- a/uspecs/u/actn-usync.md +++ b/uspecs/u/actn-usync.md @@ -1,33 +1,45 @@ -# Action: Sync Implementation Plan with actual modifications +# Action: Sync Active Change Folder with actual modifications ## Overview -Sync the Implementation Plan with actual file modifications since the baseline commit. +Sync all specs and Active Change Folder files with actual source modifications since the baseline commit. + +Sources (everything outside `uspecs/`) are the source of truth. Sync direction: + +sources -> technical specs -> functional specs -> Active Change Folder files ## Instructions Rules: - Always read `uspecs/u/concepts.md` and `uspecs/u/conf.md` before proceeding -- Track only files related to the current Change Request -- Exclude files in the Change Request folder from analysis -- Add items and sub-items for changes not yet reflected in the Implementation Plan -- Update or remove wrong or outdated items and sub-items +- Exclude files inside `uspecs/` from source analysis +- Focus on high-level changes; avoid unnecessary detail + - E.g. if a scenario was added, do not list each individual step - Do not add more than 5 new sub-items per to-do item in a single sync - Never remove correct items and sub-items to reduce their count -- Avoid adding unnecessary details, focus on high-level changes - - E.g. if scenario was added do not mention each step added Parameters: - Input - Change File frontmatter (baseline commit hash) - - Git diff since baseline + - Git diff of sources since baseline - Output - - Updated Implementation Plan + - Updated technical spec files + - Updated functional spec files + - Updated Active Change Folder files (all of them) Flow: - Read baseline commit hash from Change File frontmatter -- Get diff since baseline using command: `git diff ` -- Update Implementation Plan with what was actually done, including changes in files and file contents +- Get diff of sources since baseline: `git diff -- . ':(exclude)uspecs/'` +- Step 1 - Sync technical specs + - Identify Technical Design Specification files related to changed sources + - Update them to reflect what was actually built (architecture, design decisions, component interactions) +- Step 2 - Sync functional specs + - Identify Functional Design Specification files related to changed sources + - Update them to reflect what was actually built (scenarios, requirements, domain model) +- Step 3 - Sync Active Change Folder files + - Using sources, updated technical specs, and updated functional specs as input + - Update all files present in the Active Change Folder (change.md, impl.md, decs.md, how.md, issue.md and others - whichever exist) + - Implementation Plan: add, update, or remove items and sub-items to match what was actually done diff --git a/uspecs/u/conf.md b/uspecs/u/conf.md index 60a96c8..cdf7846 100644 --- a/uspecs/u/conf.md +++ b/uspecs/u/conf.md @@ -20,15 +20,19 @@ All paths are relative to the project root: - Examples: `remove-uspecs-prefix`, `fetch-issue-to-change`, `alpha-code-bp3-endpoints` - Can be either Active (in `{changes_folder}`) or Archived (in `{changes_archive}`) - Active Change Folder files describe Active Change Request and its implementation - - Branch naming for Change Folder (when --branch option used): - - Format: `{change-name}` (without timestamp prefix) - - Example: For Change Folder `2602141423-branch-option-uchange`, branch name is `branch-option-uchange` + - Branch naming for Change Folder: + - Format: `{issue-id}-{change-name}` when issue URL provided, otherwise `{change-name}` (without timestamp prefix) + - Issue ID extraction: + - GitHub: numeric ID from URL (e.g., `42` from `https://github.com/owner/repo/issues/42`) + - GitLab: numeric ID from URL (e.g., `7` from `https://gitlab.com/group/project/-/issues/7`) + - Jira: project key and issue number (e.g., `PROJ-123` from `https://jira.example.com/browse/PROJ-123`) + - Examples: + - With issue URL: For Change Folder `2602141423-branch-option-uchange` and GitHub issue #42, branch name is `42-branch-option-uchange` + - Without issue URL: For Change Folder `2602141423-branch-option-uchange`, branch name is `branch-option-uchange` - Branch is created from current HEAD after Change Folder and Change File are created - If branch creation fails, error is reported but change creation continues - - PR branch naming (created by upr): - - Format: `{change-name}--pr` - - Example: For change branch `branch-option-uchange`, PR branch is `branch-option-uchange--pr` - - Created from pr_remote/default_branch; contains a single squashed commit +- Working Change Folder: a Change Folder whose files have been modified since merge-base with pr_remote/default_branch + - Can be active or archived - Change Folder System Artifacts - Change File: `change.md` - Issue File: `issue.md` @@ -44,7 +48,7 @@ All paths are relative to the project root: - zero or many `Scenarios File`, `Requirements File`, `Technical Design File` - zero or one `Architecture File` - Functional Design Specifications - - Domain File: `{specs_folder}/{domain}/{domain}--domain.md` + - Domain File: `{specs_folder}/{domain}/domain.md` - Feature Files - Scenarios File: `{context-folder}/{feature}.feature` - Requirements File: `{context-folder}/{feature}--reqs.md` @@ -53,16 +57,16 @@ All paths are relative to the project root: - Files like `go.mod`, `go.work`, `package.json`, `requirements.txt`, `pubspec.yaml` etc. that define project dependencies and configuration - Technical Design Specifications - Domain Technology - - Per domain: `{specs_folder}/{domain}/{domain}--tech.md` + - Per domain: `{specs_folder}/{domain}/tech.md` - Defines tech stack, architecture patterns etc., UI/UX guidelines etc. - Domain Architecture - - `{specs_folder}/{domain}/{domain}--arch.md` + - `{specs_folder}/{domain}/arch.md` - Domain Subsystem Architecture - or `{specs_folder}/{domain}/{subsystem}--arch.md` + or `{specs_folder}/{domain}/arch-{subsystem}.md` - Context Architecture - - `{context-folder}/{context}--arch.md` + - `{context-folder}/arch.md` - Context Subsystem Architecture - or `{context-folder}/{subsystem}--arch.md` + or `{context-folder}/arch-{subsystem}.md` - Feature Technical Design - Per feature: `{context-folder}/{feature}--td.md` diff --git a/uspecs/u/prompts.md b/uspecs/u/prompts.md new file mode 100644 index 0000000..bc680a3 --- /dev/null +++ b/uspecs/u/prompts.md @@ -0,0 +1,66 @@ +# Prompts + +## upr_already_exists: PR already exists for current branch + +PR already exists for the current branch: ${pr_url} + +It has been opened in the browser. + +## upr_success: Next steps after PR creation + +PR has been created: ${pr_url} + +To restore branch to its pre-squash state, if needed: + +```text +git reset --hard ${pre_push_head} +git push --force +``` + +Next steps: + +- Fix any issues raised during review +- Run `umergepr` once the PR is approved and ready to merge + +## upr_success_no_squash: Next steps after PR creation + +PR has been created: ${pr_url} + +Next steps: + +- Fix any issues raised during review +- Run `umergepr` once the PR is approved and ready to merge + +## umergepr_success: PR merged successfully + +PR #${pr_number} has been merged successfully: ${pr_url} + +Local branch `${branch_name}` and its remote tracking ref have been deleted. + +To restore the local branch, if needed: + +```text +git branch ${branch_name} ${branch_head} +``` + +## umergepr_no_pr: No open PR for current branch + +No open PR found for the current branch. + +## umergepr_not_open: PR is not in OPEN state + +PR #${pr_number} is in ${pr_state} state (not OPEN). It has been opened in your browser. + +Local branch `${branch_name}`, upstream, and remote tracking ref have been deleted (errors ignored). + +To restore the local branch: + +```text +git branch ${branch_name} ${branch_head} +``` + +## umergepr_merge_failed: Merge attempt failed + +Merge of PR #${pr_number} failed. The PR has been opened in your browser. + +Handle the PR manually (resolve conflicts, adjust settings, etc.) and run `umergepr` again. diff --git a/uspecs/u/scripts/_lib/pr.sh b/uspecs/u/scripts/_lib/git.sh similarity index 70% rename from uspecs/u/scripts/_lib/pr.sh rename to uspecs/u/scripts/_lib/git.sh index e2fc042..9799946 100644 --- a/uspecs/u/scripts/_lib/pr.sh +++ b/uspecs/u/scripts/_lib/git.sh @@ -1,58 +1,19 @@ #!/usr/bin/env bash + +# Well, we do not neeed it, since it is sourced, just for consistency with other scripts set -Eeuo pipefail -# pr.sh -- Git branch and pull request automation +# git.sh -- Git branch and pull request automation # -# Provides reusable commands for the PR workflow: branch creation from a +# Provides reusable functions for the PR workflow: branch creation from a # remote default branch, and PR submission via GitHub CLI. +# Intended to be sourced, not executed directly. # # Concepts: # pr_remote The remote that owns the target branch for PRs. # "upstream" when a fork setup is detected, otherwise "origin". # change_branch The current working branch (named {change-name}). # pr_branch The squashed PR branch (named {change-name}--pr). -# -# Usage: -# pr.sh info -# Output PR configuration in key=value format: -# pr_remote= -# default_branch= -# -# pr.sh prbranch -# Fetch pr_remote and create a local branch from its default branch. -# -# pr.sh mergedef -# Validate preconditions, fetch pr_remote/default_branch, and merge it into the current branch. -# On success outputs: -# change_branch= -# default_branch= -# change_branch_head= (HEAD before the merge) -# -# pr.sh diff specs -# Output git diff of the specs folder between HEAD and pr_remote/default_branch. -# -# pr.sh changepr --title --body <body> -# Create a PR from the current change_branch: -# - Fail fast if pr_branch ({change_branch}--pr) already exists. -# - Create pr_branch from pr_remote/default_branch. -# - Squash-merge change_branch into pr_branch and commit with title. -# - Push pr_branch to origin and create a PR via GitHub CLI. -# - Delete change_branch (locally, tracking ref, and remote; skip if absent). -# - Output pr_url on success. -# - On failure after pr_branch creation: roll back pr_branch, preserve change_branch. -# -# pr.sh pr --title <title> --body <body> --next-branch <branch> [--delete-branch] -# Stage all changes, commit, push to origin, and open a PR against -# pr_remote's default branch. Switch to --next-branch afterwards. -# If --delete-branch is set, delete the current branch after switching. -# If no changes exist, switch to --next-branch and exit cleanly. -# -# pr.sh ffdefault -# Fetch pr_remote/default_branch and fast-forward the local default branch to it. -# Switches to the default branch if not already on it, and leaves there after completion. -# Fail fast if any of the following conditions are true: -# working directory is not clean -# branches have diverged (fast-forward not possible) @@ -70,6 +31,7 @@ get_project_dir() { cd "$script_dir/../../../.." && pwd } +# // TODO why here? read_conf_param() { local param_name="$1" local conf_file @@ -80,7 +42,7 @@ read_conf_param() { fi local line raw - line=$(_grep -E "^- ${param_name}:" "$conf_file" | head -1 || true) + line=$(grep -E "^- ${param_name}:" "$conf_file" | head -1 || true) raw="${line#*: }" if [ -z "$raw" ]; then @@ -92,13 +54,8 @@ read_conf_param() { echo "$value" } -error() { - echo "Error: $1" >&2 - exit 1 -} - determine_pr_remote() { - if git remote | _grep -q '^upstream$'; then + if git remote | grep -q '^upstream$'; then echo "upstream" else echo "origin" @@ -123,6 +80,33 @@ gh_create_pr() { fi } +# git_validate_working_tree +# Reflects scenario: "Project inside Git working tree" +# Validates that the current directory is inside a git working tree. +git_validate_working_tree() { + if ! is_git_repo "$PWD"; then + error "No git repository found at $PWD" + fi +} + +# git_validate_clean_repo <current_branch> <default_branch> +# Reflects scenario: "Git working tree is clean" +# Validates: inside git working tree, no uncommitted changes, not on default branch. +git_validate_clean_repo() { + local current_branch="$1" + local default_branch="$2" + + git_validate_working_tree + + if [[ -n $(git status --porcelain) ]]; then + error "Working directory has uncommitted changes. Commit or stash changes first" + fi + + if [[ "$current_branch" == "$default_branch" ]]; then + error "Current branch is the default branch '$default_branch'" + fi +} + check_prerequisites() { # Check if git repository exists if ! is_git_repo "$PWD"; then @@ -135,7 +119,7 @@ check_prerequisites() { fi # Check if origin remote exists - if ! git remote | _grep -q '^origin$'; then + if ! git remote | grep -q '^origin$'; then error "'origin' remote does not exist" fi @@ -145,7 +129,7 @@ check_prerequisites() { fi } -default_branch_name() { +git_default_branch_name() { local branch branch=$(git ls-remote --symref origin HEAD | awk '/^ref:/ {sub(/refs\/heads\//, "", $2); print $2}') || { error "Cannot determine the default branch from remote" @@ -157,24 +141,33 @@ default_branch_name() { } # --------------------------------------------------------------------------- -# Commands +# Public functions # --------------------------------------------------------------------------- -cmd_info() { +# git_pr_info <map_nameref> [project_dir] +# Populates an associative array with PR remote info. +# Keys populated: pr_remote, default_branch +# project_dir: directory to run git commands from (defaults to $PWD) +# Returns non-zero if info cannot be determined. +git_pr_info() { + local -n _git_pr_info_map="$1" + local project_dir="${2:-$PWD}" local pr_remote default_branch - pr_remote=$(determine_pr_remote) - default_branch=$(default_branch_name) - echo "pr_remote=$pr_remote" - echo "default_branch=$default_branch" + pr_remote=$(cd "$project_dir" && determine_pr_remote) || return 1 + default_branch=$(cd "$project_dir" && git_default_branch_name) || return 1 + _git_pr_info_map["pr_remote"]="$pr_remote" + _git_pr_info_map["default_branch"]="$default_branch" } -cmd_prbranch() { +# git_prbranch <name> +# Fetch pr_remote and create a local branch from its default branch. +git_prbranch() { local name="${1:-}" - [[ -z "$name" ]] && error "Usage: pr.sh prbranch <name>" + [[ -z "$name" ]] && error "Usage: git_prbranch <name>" local pr_remote default_branch pr_remote=$(determine_pr_remote) - default_branch=$(default_branch_name) + default_branch=$(git_default_branch_name) echo "Fetching $pr_remote/$default_branch..." git fetch "$pr_remote" "$default_branch" 2>&1 @@ -183,12 +176,18 @@ cmd_prbranch() { git checkout -b "$name" "$pr_remote/$default_branch" } -cmd_ffdefault() { +# git_ffdefault +# Fetch pr_remote/default_branch and fast-forward the local default branch to it. +# Switches to the default branch if not already on it, and leaves there after completion. +# Fail fast if any of the following conditions are true: +# working directory is not clean +# branches have diverged (fast-forward not possible) +git_ffdefault() { check_prerequisites local pr_remote default_branch pr_remote=$(determine_pr_remote) - default_branch=$(default_branch_name) + default_branch=$(git_default_branch_name) local current_branch current_branch=$(git symbolic-ref --short HEAD) @@ -207,7 +206,13 @@ cmd_ffdefault() { fi } -cmd_pr() { +# git_pr --title <title> --body <body> --next-branch <branch> [--delete-branch] +# Literal \n sequences in --body are decoded to actual newlines. +# Stage all changes, commit, push to origin, and open a PR against +# pr_remote's default branch. Switch to --next-branch afterwards. +# If --delete-branch is set, delete the current branch after switching. +# If no changes exist, switch to --next-branch and exit cleanly. +git_pr() { local title="" body="" next_branch="" delete_branch=false while [[ $# -gt 0 ]]; do case "$1" in @@ -222,8 +227,11 @@ cmd_pr() { [[ -z "$body" ]] && error "--body is required" [[ -z "$next_branch" ]] && error "--next-branch is required" + # Decode literal \n sequences to actual newlines + body="${body//\\n/$'\n'}" + local default_branch branch_name - default_branch=$(default_branch_name) + default_branch=$(git_default_branch_name) branch_name=$(git symbolic-ref --short HEAD) if [[ "$delete_branch" == "true" && "$branch_name" == "$next_branch" ]]; then @@ -271,12 +279,18 @@ cmd_pr() { echo "PR_BASE=$default_branch" >&2 } -cmd_mergedef() { +# git_mergedef +# Validate preconditions, fetch pr_remote/default_branch, and merge it into the current branch. +# On success outputs: +# change_branch=<name> +# default_branch=<name> +# change_branch_head=<sha> (HEAD before the merge) +git_mergedef() { check_prerequisites local pr_remote default_branch current_branch pr_remote=$(determine_pr_remote) - default_branch=$(default_branch_name) + default_branch=$(git_default_branch_name) current_branch=$(git symbolic-ref --short HEAD) if [[ "$current_branch" == "$default_branch" ]]; then @@ -301,9 +315,12 @@ cmd_mergedef() { echo "change_branch_head=$change_branch_head" } -cmd_diff() { +# git_diff <target> +# Output git diff of the target folder between HEAD and pr_remote/default_branch. +# Available targets: specs +git_diff() { local target="${1:-}" - [[ -z "$target" ]] && error "Usage: pr.sh diff <target>" + [[ -z "$target" ]] && error "Usage: git_diff <target>" local diff_path case "$target" in @@ -317,7 +334,7 @@ cmd_diff() { local pr_remote default_branch pr_remote=$(determine_pr_remote) - default_branch=$(default_branch_name) + default_branch=$(git_default_branch_name) local project_dir project_dir=$(get_project_dir) @@ -326,7 +343,17 @@ cmd_diff() { (cd "$project_dir" && git diff "$pr_remote/$default_branch" HEAD -- "$diff_path") } -cmd_changepr() { +# git_changepr --title <title> --body <body> +# Literal \n sequences in --body are decoded to actual newlines. +# Create a PR from the current change_branch: +# - Fail fast if pr_branch ({change_branch}--pr) already exists. +# - Create pr_branch from pr_remote/default_branch. +# - Squash-merge change_branch into pr_branch and commit with title. +# - Push pr_branch to origin and create a PR via GitHub CLI. +# - Delete change_branch (locally, tracking ref, and remote; skip if absent). +# - Output pr_url on success. +# - On failure after pr_branch creation: roll back pr_branch, preserve change_branch. +git_changepr() { local title="" body="" while [[ $# -gt 0 ]]; do case "$1" in @@ -344,9 +371,12 @@ cmd_changepr() { fi [[ -z "$body" ]] && error "--body is required (or pipe body via stdin)" + # Decode literal \n sequences to actual newlines + body="${body//\\n/$'\n'}" + local pr_remote default_branch change_branch pr_branch pr_remote=$(determine_pr_remote) - default_branch=$(default_branch_name) + default_branch=$(git_default_branch_name) change_branch=$(git symbolic-ref --short HEAD) pr_branch="${change_branch}--pr" @@ -356,7 +386,7 @@ cmd_changepr() { fi # Create pr_branch from pr_remote/default_branch - cmd_prbranch "$pr_branch" + git_prbranch "$pr_branch" # Rollback pr_branch on failure; preserve change_branch local success=false @@ -405,23 +435,3 @@ cmd_changepr() { echo "$pr_url" } - -# --------------------------------------------------------------------------- -# Dispatch -# --------------------------------------------------------------------------- - -if [[ $# -lt 1 ]]; then - error "Usage: pr.sh <info|prbranch|mergedef|diff|changepr|pr|ffdefault> [args...]" -fi - -command="$1"; shift -case "$command" in - info) cmd_info "$@" ;; - prbranch) cmd_prbranch "$@" ;; - mergedef) cmd_mergedef "$@" ;; - diff) cmd_diff "$@" ;; - changepr) cmd_changepr "$@" ;; - pr) cmd_pr "$@" ;; - ffdefault) cmd_ffdefault "$@" ;; - *) error "Unknown command: $command. Available: info, prbranch, mergedef, diff, changepr, pr, ffdefault" ;; -esac diff --git a/uspecs/u/scripts/_lib/utils.sh b/uspecs/u/scripts/_lib/utils.sh index 4807f03..d30a493 100644 --- a/uspecs/u/scripts/_lib/utils.sh +++ b/uspecs/u/scripts/_lib/utils.sh @@ -1,34 +1,99 @@ #!/usr/bin/env bash + +# Well, we do not neeed it, since it is sourced, just for consistency with other scripts set -Eeuo pipefail -# checkcmds command1 [command2 ...] -# Verifies that each listed command is available on PATH. -# Prints an error message and exits with status 1 if any command is missing. -checkcmds() { +# Source guard - utils.sh must only be sourced once per shell. +if [[ -n "${_UTILS_SH_LOADED:-}" ]]; then + return 0 +fi +_UTILS_SH_LOADED=1 + +# atexit API - safe accumulating EXIT handlers +_ATEXIT_CMDS=() +_ATEXIT_STACK=() +_ATEXIT_CHAINED="" + +_atexit_run() { + local rc=$? + trap - EXIT # prevent re-entrancy if a chained handler calls exit local cmd - for cmd in "$@"; do - if ! command -v "$cmd" > /dev/null 2>&1; then - echo "Error: required command not found: $cmd" >&2 - exit 1 - fi + for cmd in "${_ATEXIT_CMDS[@]+"${_ATEXIT_CMDS[@]}"}"; do + eval "$cmd" || true + done + local i + for (( i=${#_ATEXIT_STACK[@]}-1; i>=0; i-- )); do + eval "${_ATEXIT_STACK[$i]}" || true done + # Run chained pre-existing trap last so our cleanup completes even if it calls exit + if [[ -n "$_ATEXIT_CHAINED" ]]; then + eval "$_ATEXIT_CHAINED" || true + fi + exit "$rc" +} + +# Capture any pre-existing EXIT trap and chain it last. +# The source guard above guarantees this runs at most once per shell, preventing +# the self-chaining recursion that would occur on double-sourcing. +{ _prev_trap=$(trap -p EXIT | sed "s/^trap -- '\\(.*\\)' EXIT$/\\1/") + _ATEXIT_CHAINED="${_prev_trap:-}" + unset _prev_trap; } +trap _atexit_run EXIT + +# atexit_add <cmd> +# Appends cmd to the FIFO queue of EXIT handlers. +# cmd must be a single quoted string, e.g. atexit_add 'rm -f /tmp/foo' +atexit_add() { + [[ $# -eq 1 ]] || { echo "atexit_add: expected 1 argument, got $#" >&2; return 1; } + _ATEXIT_CMDS+=("$1") } -# get_pr_info <pr_sh_path> <map_nameref> [project_dir] -# Calls pr.sh info and parses the key=value output into the given associative array. -# Keys populated: pr_remote, default_branch -# project_dir: directory to run pr.sh from (defaults to $PWD) -# Returns non-zero if pr.sh info fails. -get_pr_info() { - local pr_sh="$1" - local -n _pr_info_map="$2" - local project_dir="${3:-$PWD}" - local output - output=$(cd "$project_dir" && bash "$pr_sh" info) || return 1 - while IFS='=' read -r key value; do - [[ -z "$key" ]] && continue - _pr_info_map["$key"]="$value" - done <<< "$output" +# atexit_push <cmd> +# Pushes cmd onto the LIFO stack; dispatcher runs stack entries after _ATEXIT_CMDS. +# cmd must be a single quoted string, e.g. atexit_push 'rm -f /tmp/foo' +atexit_push() { + [[ $# -eq 1 ]] || { echo "atexit_push: expected 1 argument, got $#" >&2; return 1; } + _ATEXIT_STACK+=("$1") +} + +# atexit_pop +# Removes the last-pushed entry from the stack. +atexit_pop() { + if [[ ${#_ATEXIT_STACK[@]} -gt 0 ]]; then + unset '_ATEXIT_STACK[-1]' + fi +} + +# git_path +# Ensures Git's usr/bin is in PATH on Windows (Git Bash / MSYS2 / Cygwin). +# Call this at the start of main() in every top-level script. +git_path() { + if [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]]; then + PATH="/usr/bin:${PATH}" + fi +} + +# quiet <command> [args...] +# Runs the command with both stdout and stderr suppressed. +# On failure, dumps captured stdout to stdout and stderr to stderr, +# then returns the original exit code. +quiet() { + local _q_out _q_err _q_rc=0 + _q_err=$(mktemp) + _q_out=$("$@" 2>"$_q_err") || _q_rc=$? + if [[ $_q_rc -ne 0 ]]; then + [[ -n "$_q_out" ]] && printf '%s\n' "$_q_out" + [[ -s "$_q_err" ]] && cat "$_q_err" >&2 + fi + rm -f "$_q_err" + return $_q_rc +} + +# error <message> +# Prints an error message to stderr and exits with status 1. +error() { + echo "Error: $1" >&2 + exit 1 } # is_tty @@ -39,59 +104,131 @@ is_tty() { # is_git_repo <dir> # Returns 0 if <dir> is inside a git repository, 1 otherwise. +# //TODO replace with git.sh#git_validate_working_tree + is_git_repo() { local dir="$1" (cd "$dir" && git rev-parse --git-dir > /dev/null 2>&1) } -# _GREP_BIN caches the resolved grep binary path for _grep. -_GREP_BIN="" - -# _grep [grep-args...] -# Portable grep wrapper. On Windows (msys/cygwin) resolves grep from the git -# installation and fails fast if not found. On other platforms uses system grep. -_grep() { - if [[ -z "$_GREP_BIN" ]]; then - case "$OSTYPE" in - msys*|cygwin*) - # Use where.exe to get real Windows paths, then pick the grep - # that lives inside the Git for Windows installation. - local git_path git_root candidate - git_path=$(where.exe git 2>/dev/null | head -1 | tr -d $'\r' | tr $'\\\\' / || true) - if [[ -z "$git_path" ]]; then - echo "Error: git not found; cannot locate git's bundled grep" >&2 - exit 1 - fi - git_root=$(dirname "$(dirname "$git_path")") - # Try direct path first (works even if grep is not on PATH). - # Also try one level up to handle mingw64/bin/git.exe layout where - # two dirnames give .../mingw64 instead of the git installation root. - if [[ -x "$git_root/usr/bin/grep.exe" ]]; then - _GREP_BIN="$git_root/usr/bin/grep.exe" - elif [[ -x "$(dirname "$git_root")/usr/bin/grep.exe" ]]; then - git_root=$(dirname "$git_root") - _GREP_BIN="$git_root/usr/bin/grep.exe" - else - # Fall back to where.exe grep, pick the one under git root - while IFS= read -r candidate; do - candidate=$(echo "$candidate" | tr -d $'\r' | tr $'\\\\' /) - if [[ "$candidate" == "$git_root/"* ]]; then - _GREP_BIN="$candidate" - break - fi - done < <(where.exe grep 2>/dev/null || true) - fi - if [[ -z "$_GREP_BIN" ]]; then - echo "Error: grep not found under git root: $git_root" >&2 - exit 1 - fi - ;; - *) - _GREP_BIN="grep" - ;; - esac +# section_templ <file> <section_id> [vars_map] +# Outputs the heading and body of a markdown section whose heading matches +# "## section_id: ...". The output includes the heading line itself. +# section_id may contain alphanumerics, hyphens and underscores. +# The section ends before the next heading (any level) or EOF. +# Subsections are NOT included. +# Substitutes ${VAR} placeholders using the provided associative array. +# Only variables from vars_map are substituted; shell/environment variables +# are NOT expanded (safe against command injection via backticks or $()). +# Fails if the file is missing, the section is not found, or an unsubstituted +# ${VAR} placeholder remains. +section_templ() { + local file="$1" + local section_id="$2" + + [[ -f "$file" ]] || error "file not found: $file" + + local raw + raw=$(sed -n "/^#\\{1,\\} ${section_id}:/,/^#/{/^#\\{1,\\} ${section_id}:/p;/^#\\{1,\\} ${section_id}:/!{/^#/!p}}" "$file") + + [[ -n "$raw" ]] || error "section not found: $section_id in $file" + + # Substitute ${KEY} patterns using awk (safe: no shell expansion of values) + local body="$raw" + if [[ -n "${3:-}" ]]; then + local -n _st_vars="$3" + local _st_key + for _st_key in "${!_st_vars[@]}"; do + export "_ST_VAL=${_st_vars[$_st_key]}" + body=$(printf '%s\n' "$body" | awk -v "pat=\${${_st_key}}" \ + '{ val=ENVIRON["_ST_VAL"] + idx=index($0,pat) + while(idx>0){ $0=substr($0,1,idx-1) val substr($0,idx+length(pat)); idx=index($0,pat) } + print }') + done + unset _ST_VAL fi - "$_GREP_BIN" "$@" + + # Check for remaining unsubstituted ${...} placeholders + if [[ "$body" =~ \$\{[a-zA-Z_][a-zA-Z0-9_]*\} ]]; then + error "unbound variable in section $section_id of $file: ${BASH_REMATCH[0]}" + fi + + printf '%s\n' "$body" +} + +# md_read_frontmatter_field <file> <field_name> +# Extracts the value of a named field from YAML frontmatter (between --- delimiters). +# Returns the trimmed value. Fails if the file is missing or the field is not found. +md_read_frontmatter_field() { + local file="$1" + local field_name="$2" + + [[ -f "$file" ]] || error "file not found: $file" + + local value + value=$(awk -v field="$field_name" ' + /^---$/ { block++; next } + block == 1 { + # Match "field_name: value" + if ($0 ~ "^" field ":") { + sub("^" field ":[[:space:]]*", "") + print + exit + } + } + block >= 2 { exit } + ' "$file") + + [[ -n "$value" ]] || error "frontmatter field not found: $field_name in $file" + printf '%s\n' "$value" +} + +# md_read_title <file> +# Extracts the text of the first top-level heading (# ...) from a markdown file. +# Skips YAML frontmatter if present. Fails if the file is missing or has no heading. +md_read_title() { + local file="$1" + + [[ -f "$file" ]] || error "file not found: $file" + + local title + title=$(awk ' + /^---$/ && !past_fm { in_fm = !in_fm; next } + in_fm { next } + !in_fm { past_fm = 1 } + /^# / { sub(/^# /, ""); print; exit } + ' "$file") + + [[ -n "$title" ]] || error "no title heading found in $file" + printf '%s\n' "$title" +} + +# --------------------------------------------------------------------------- +# Temp file/dir management with automatic cleanup +# --------------------------------------------------------------------------- + +case "$OSTYPE" in + msys*|cygwin*) _TMP_BASE=$(cygpath -w "$TEMP") ;; + *) _TMP_BASE="/tmp" ;; +esac + +# temp_create_dir <varname> +# Creates a temporary directory, stores its path in the caller's variable +# <varname>, and registers it for cleanup on exit. +temp_create_dir() { + local -n _out=$1 + _out=$(mktemp -d "$_TMP_BASE/uspecs.XXXXXX") + atexit_add "rm -rf '$_out'" +} + +# temp_create_file <varname> +# Creates a temporary file, stores its path in the caller's variable +# <varname>, and registers it for cleanup on exit. +temp_create_file() { + local -n _out=$1 + _out=$(mktemp "$_TMP_BASE/uspecs.XXXXXX") + atexit_add "rm -f '$_out'" } # sed_inplace file sed-args... @@ -106,3 +243,54 @@ sed_inplace() { fi rm -f "${file}.bak" } + + +# --------------------------------------------------------------------------- +# Structured prompt output (LOG / AGENT_INSTRUCTIONS) +# --------------------------------------------------------------------------- + +_PROMPT_LOG_OPEN=0 + +# _prompt_close_tags_on_exit +# EXIT handler: auto-closes open LOG and AGENT_INSTRUCTIONS tags. +# If LOG is still open (script failed before prompt_start_instructions), +# emits error-handling instructions. +# If AGENT_INSTRUCTIONS is open, emits the closing tag. +_prompt_close_tags_on_exit() { + if [[ "${_PROMPT_LOG_OPEN:-0}" -eq 1 ]]; then + echo "</LOG>" + echo "<AGENT_INSTRUCTIONS>" + echo "The script exited with an error." + echo "Describe what happened based on the log above." + echo "Suggest recovery options as a numbered list, include Abort as a last item." + echo "Do not take any further action until user explicitly chooses an option." + echo "</AGENT_INSTRUCTIONS>" + elif [[ "${_PROMPT_INSTR_OPEN:-0}" -eq 1 ]]; then + echo "</AGENT_INSTRUCTIONS>" + fi +} +atexit_add '_prompt_close_tags_on_exit' + +# prompt_start_log +# Emits the opening <LOG> tag. +prompt_start_log() { + _PROMPT_LOG_OPEN=1 + echo "<LOG>" +} + +# prompt_start_instructions [meta_instruction] +# Closes the LOG block and opens an AGENT_INSTRUCTIONS block with a meta-instruction. +# If meta_instruction is provided, emits it; otherwise emits the default. +# The closing tag is emitted automatically on exit. +# shellcheck disable=SC2120 +prompt_start_instructions() { + _PROMPT_LOG_OPEN=0 + _PROMPT_INSTR_OPEN=1 + echo "</LOG>" + echo "<AGENT_INSTRUCTIONS>" + if [[ $# -gt 0 ]]; then + printf '%s\n' "$*" + else + echo "Inform user about the results, see below. Ignore the <LOG> content above." + fi +} diff --git a/uspecs/u/scripts/conf.sh b/uspecs/u/scripts/conf.sh index c99b36d..ca4c42d 100644 --- a/uspecs/u/scripts/conf.sh +++ b/uspecs/u/scripts/conf.sh @@ -7,7 +7,7 @@ set -Eeuo pipefail # Manages uspecs lifecycle: install, update, upgrade, and invocation method configuration # # Usage: -# conf.sh install --nlia [--alpha] [--pr] +# conf.sh install --nlia [--alpha] [--local] [--override] [--pr] [-y] # conf.sh update [--pr] # conf.sh upgrade [--pr] # conf.sh im --add nlia @@ -23,126 +23,26 @@ ALPHA_BRANCH="${USPECS_ALPHA_BRANCH:-main}" GITHUB_API="https://api.github.com" GITHUB_RAW="https://raw.githubusercontent.com" -case "$OSTYPE" in - msys*|cygwin*) _TMP_BASE=$(cygpath -w "$TEMP") ;; - *) _TMP_BASE="/tmp" ;; -esac - -_TEMP_DIRS=() -_TEMP_FILES=() - -# checkcmds command1 [command2 ...] -# Verifies that each listed command is available on PATH. -# Prints an error message and exits with status 1 if any command is missing. -checkcmds() { - local cmd - for cmd in "$@"; do - if ! command -v "$cmd" > /dev/null 2>&1; then - echo "Error: required command not found: $cmd" >&2 - exit 1 - fi - done -} - -# get_pr_info <pr_sh_path> <map_nameref> [project_dir] -# Calls pr.sh info and parses the key=value output into the given associative array. -# Keys populated: pr_remote, default_branch -# project_dir: directory to run pr.sh from (defaults to $PWD) -# Returns non-zero if pr.sh info fails. -get_pr_info() { - local pr_sh="$1" - local -n _pr_info_map="$2" - local project_dir="${3:-$PWD}" - local output - output=$(cd "$project_dir" && bash "$pr_sh" info) || return 1 - while IFS='=' read -r key value; do - [[ -z "$key" ]] && continue - _pr_info_map["$key"]="$value" - done <<< "$output" -} - -# is_tty -# Returns 0 if stdin is connected to a terminal, 1 if piped or redirected. -is_tty() { - [ -t 0 ] -} - -# is_git_repo <dir> -# Returns 0 if <dir> is inside a git repository, 1 otherwise. -is_git_repo() { - local dir="$1" - (cd "$dir" && git rev-parse --git-dir > /dev/null 2>&1) -} +# Minimal error function for the curl-pipe case (overwritten by utils.sh when sourced). +error() { echo "Error: $1" >&2; exit 1; } -# _GREP_BIN caches the resolved grep binary path for _grep. -_GREP_BIN="" - -# _grep [grep-args...] -# Portable grep wrapper. On Windows (msys/cygwin) resolves grep from the git -# installation and fails fast if not found. On other platforms uses system grep. -_grep() { - if [[ -z "$_GREP_BIN" ]]; then - case "$OSTYPE" in - msys*|cygwin*) - # Use where.exe to get real Windows paths, then pick the grep - # that lives inside the Git for Windows installation. - local git_path git_root candidate - git_path=$(where.exe git 2>/dev/null | head -1 | tr -d $'\r' | tr $'\\\\' / || true) - if [[ -z "$git_path" ]]; then - echo "Error: git not found; cannot locate git's bundled grep" >&2 - exit 1 - fi - git_root=$(dirname "$(dirname "$git_path")") - # Try direct path first (works even if grep is not on PATH). - # Also try one level up to handle mingw64/bin/git.exe layout where - # two dirnames give .../mingw64 instead of the git installation root. - if [[ -x "$git_root/usr/bin/grep.exe" ]]; then - _GREP_BIN="$git_root/usr/bin/grep.exe" - elif [[ -x "$(dirname "$git_root")/usr/bin/grep.exe" ]]; then - git_root=$(dirname "$git_root") - _GREP_BIN="$git_root/usr/bin/grep.exe" - else - # Fall back to where.exe grep, pick the one under git root - while IFS= read -r candidate; do - candidate=$(echo "$candidate" | tr -d $'\r' | tr $'\\\\' /) - if [[ "$candidate" == "$git_root/"* ]]; then - _GREP_BIN="$candidate" - break - fi - done < <(where.exe grep 2>/dev/null || true) - fi - if [[ -z "$_GREP_BIN" ]]; then - echo "Error: grep not found under git root: $git_root" >&2 - exit 1 - fi - ;; - *) - _GREP_BIN="grep" - ;; - esac +# Authenticated curl for GitHub API and raw content requests. +# Uses USPECS_GITHUB_TOKEN when set (required for private repositories). +github_curl() { + local auth_args=() + if [[ -n "${USPECS_GITHUB_TOKEN:-}" ]]; then + auth_args=(-H "Authorization: token $USPECS_GITHUB_TOKEN") fi - "$_GREP_BIN" "$@" + curl -fsSL "${auth_args[@]}" "$@" } -# sed_inplace file sed-args... -# Portable in-place sed. Uses -i.bak for BSD compatibility. -# Restores the original file on failure. -sed_inplace() { - local file="$1" - shift - if ! sed -i.bak "$@" "$file"; then - mv "${file}.bak" "$file" 2>/dev/null || true - return 1 - fi - rm -f "${file}.bak" -} - -checkcmds curl - -error() { - echo "Error: $1" >&2 - exit 1 -} +# Source _lib/git.sh only when running from a file (not piped via curl). +# When piped, BASH_SOURCE[0] is empty or not a file path, so we skip sourcing +# and rely on the self-contained cmd_install path (Phase 1 of curl-pipe install). +if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then + # shellcheck source=_lib/git.sh + source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_lib/git.sh" +fi get_timestamp() { date -u +"%Y-%m-%dT%H:%M:%SZ" @@ -205,8 +105,8 @@ load_config() { } get_latest_tag() { - curl -fsSL "$GITHUB_API/repos/$REPO_OWNER/$REPO_NAME/tags" | \ - _grep '"name":' | \ + github_curl "$GITHUB_API/repos/$REPO_OWNER/$REPO_NAME/tags" | \ + grep '"name":' | \ sed 's/.*"name": *"v\?\([^"]*\)".*/\1/' | \ head -n 1 } @@ -217,10 +117,10 @@ get_latest_minor_tag() { IFS='.' read -r major minor _ <<< "$current_version" local result - result=$(curl -fsSL "$GITHUB_API/repos/$REPO_OWNER/$REPO_NAME/tags" | \ - _grep '"name":' | \ + result=$(github_curl "$GITHUB_API/repos/$REPO_OWNER/$REPO_NAME/tags" | \ + grep '"name":' | \ sed 's/.*"name": *"v\?\([^"]*\)".*/\1/' | \ - _grep "^$major\.$minor\." | \ + grep "^$major\.$minor\." | \ head -n 1 || true) echo "${result:-$current_version}" } @@ -231,16 +131,33 @@ get_latest_major_tag() { get_latest_commit_info() { local response - response=$(curl -fsSL "$GITHUB_API/repos/$REPO_OWNER/$REPO_NAME/commits/$ALPHA_BRANCH") + response=$(github_curl "$GITHUB_API/repos/$REPO_OWNER/$REPO_NAME/commits/$ALPHA_BRANCH") local sha - sha=$(echo "$response" | _grep '"sha":' | head -n 1 | sed 's/.*"sha": *"\([^"]*\)".*/\1/') + sha=$(echo "$response" | grep '"sha":' | head -n 1 | sed 's/.*"sha": *"\([^"]*\)".*/\1/') local commit_date - commit_date=$(echo "$response" | _grep '"date":' | head -n 1 | sed 's/.*"date": *"\([^"]*\)".*/\1/') + commit_date=$(echo "$response" | grep '"date":' | head -n 1 | sed 's/.*"date": *"\([^"]*\)".*/\1/') echo "$sha $commit_date" } get_alpha_version() { - curl -fsSL "$GITHUB_RAW/$REPO_OWNER/$REPO_NAME/$ALPHA_BRANCH/version.txt" | tr -d '[:space:]' + github_curl "$GITHUB_RAW/$REPO_OWNER/$REPO_NAME/$ALPHA_BRANCH/version.txt" | tr -d '[:space:]' +} + +get_local_version() { + local script_dir + script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + tr -d '[:space:]' < "$script_dir/../../../version.txt" +} + +get_local_commit_info() { + local script_dir + script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + local repo_root + repo_root=$(native_path "$(cd "$script_dir/../../.." && pwd)") + local sha timestamp + sha=$(git -C "$repo_root" log -1 --format="%H") + timestamp=$(git -C "$repo_root" log -1 --format="%cI") + echo "$sha $timestamp" } is_alpha_version() { @@ -251,8 +168,8 @@ download_archive() { local ref="$1" local temp_dir="$2" - local archive_url="https://github.com/$REPO_OWNER/$REPO_NAME/archive/$ref.tar.gz" - curl -fsSL "$archive_url" | tar -xz -C "$temp_dir" --strip-components=1 + local archive_url="$GITHUB_API/repos/$REPO_OWNER/$REPO_NAME/tarball/$ref" + github_curl "$archive_url" | tar -xz -C "$temp_dir" --strip-components=1 } get_nli_file() { @@ -316,34 +233,6 @@ format_version_string_branch() { sanitize_branch_name "$result" } -cleanup_temp() { - if [[ ${#_TEMP_FILES[@]} -gt 0 ]]; then - for file in "${_TEMP_FILES[@]}"; do - rm -f "$file" - done - fi - if [[ ${#_TEMP_DIRS[@]} -gt 0 ]]; then - for dir in "${_TEMP_DIRS[@]}"; do - rm -rf "$dir" - done - fi -} -trap cleanup_temp EXIT - -create_temp_dir() { - local temp_dir - temp_dir=$(mktemp -d "$_TMP_BASE/uspecs.XXXXXX") - _TEMP_DIRS+=("$temp_dir") - echo "$temp_dir" -} - -create_temp_file() { - local temp_file - temp_file=$(mktemp "$_TMP_BASE/uspecs.XXXXXX") - _TEMP_FILES+=("$temp_file") - echo "$temp_file" -} - show_operation_plan() { local operation="$1" local current_version="${2:-}" @@ -409,7 +298,7 @@ show_operation_plan() { local -A pr_info local pr_remote="" default_branch="" target_repo_url="" pr_branch="" - if get_pr_info "$script_dir/_lib/pr.sh" pr_info "$project_dir" 2>/dev/null; then + if git_pr_info pr_info "$project_dir" 2>/dev/null; then pr_remote="${pr_info[pr_remote]:-}" default_branch="${pr_info[default_branch]:-}" target_repo_url=$(git -C "$project_dir" remote get-url "$pr_remote" 2>/dev/null) @@ -432,9 +321,14 @@ show_operation_plan() { confirm_action() { local action="$1" + local yes_flag="${2:-false}" echo "" + if [[ "$yes_flag" == "true" ]]; then + return 0 + fi + # Try to read from /dev/tty (works even when stdin is piped) if [ -e /dev/tty ]; then read -p "Proceed with $action? (y/n) " -n 1 -r < /dev/tty @@ -459,6 +353,10 @@ replace_uspecs_u() { echo "Removing installation metadata file from archive..." rm -f "$source_dir/uspecs/u/uspecs.yml" echo "Removing old uspecs/u files..." + # Delete only regular files, not directories. Removing directories while they + # may still be in use causes "directory busy" errors on Windows (and with some + # tools on other platforms). Leaving empty directories behind is harmless + # because cp -r will overwrite or reuse them. find "$project_dir/uspecs/u" -type f -delete echo "Installing new uspecs/u..." cp -r "$source_dir/uspecs/u" "$project_dir/uspecs/" @@ -475,7 +373,7 @@ has_markers() { local file="$1" local begin_marker="$2" local end_marker="$3" - _grep -q "$begin_marker" "$file" && _grep -q "$end_marker" "$file" + grep -q "$begin_marker" "$file" && grep -q "$end_marker" "$file" } inject_instructions() { @@ -496,7 +394,7 @@ inject_instructions() { fi local temp_extract - temp_extract=$(create_temp_file) + temp_create_file temp_extract sed -n "/$begin_marker/,/$end_marker/p" "$source_file" > "$temp_extract" if [[ ! -s "$temp_extract" ]]; then @@ -522,7 +420,7 @@ inject_instructions() { fi local temp_output - temp_output=$(create_temp_file) + temp_create_file temp_output sed "/$begin_marker/,\$d" "$target_file" > "$temp_output" cat "$temp_extract" >> "$temp_output" sed "1,/$end_marker/d" "$target_file" >> "$temp_output" @@ -660,8 +558,11 @@ cmd_apply() { local project_dir="" version="" commit="" commit_timestamp="" pr_flag=false local current_version="" + local override=false local invocation_methods=() + local yes_flag=false + while [[ $# -gt 0 ]]; do case "$1" in --project-dir) project_dir=$(native_path "$2"); shift 2 ;; @@ -670,12 +571,15 @@ cmd_apply() { --commit-timestamp) commit_timestamp="$2"; shift 2 ;; --current-version) current_version="$2"; shift 2 ;; --pr) pr_flag=true; shift ;; + --override) override=true; shift ;; --nlia) invocation_methods+=("nlia"); shift ;; --nlic) invocation_methods+=("nlic"); shift ;; + -y) yes_flag=true; shift ;; *) error "Unknown flag: $1" ;; esac done + [[ -z "$project_dir" ]] && error "--project-dir is required" [[ -z "$version" ]] && error "--version is required" [[ "$command_name" != "install" && -z "$current_version" ]] && error "--current-version is required for update/upgrade" @@ -695,7 +599,7 @@ cmd_apply() { local metadata_file="$project_dir/uspecs/u/uspecs.yml" - if [[ "$command_name" == "install" && -f "$metadata_file" ]]; then + if [[ "$command_name" == "install" && "$override" != "true" && -f "$metadata_file" ]]; then error "uspecs is already installed, use update instead" fi @@ -703,14 +607,23 @@ cmd_apply() { local prev_branch="" if [[ "$pr_flag" == "true" ]]; then prev_branch=$(git -C "$project_dir" symbolic-ref --short HEAD) - (cd "$project_dir" && bash "$script_dir/_lib/pr.sh" ffdefault) + (cd "$project_dir" && git_ffdefault) + atexit_push "git -C '$project_dir' checkout '$prev_branch' || true" fi local -A config if [[ "$command_name" == "install" ]]; then - if [[ -f "$metadata_file" ]]; then + if [[ "$override" != "true" && -f "$metadata_file" ]]; then error "uspecs is already installed, use update instead" fi + if [[ "$override" == "true" && -f "$metadata_file" ]]; then + load_config "$project_dir" config + if [[ -n "$commit" && "${config[commit]:-}" == "$commit" ]] || \ + [[ -z "$commit" && "${config[version]:-}" == "$version" ]]; then + echo "Version is already installed. Remove uspecs.yml to force reinstall." + return 0 + fi + fi else load_config "$project_dir" config if [[ "${config[version]:-}" != "$current_version" ]]; then @@ -720,7 +633,6 @@ cmd_apply() { if [[ -n "$commit" && "${config[commit]:-}" == "$commit" ]] || \ [[ -z "$commit" && "${config[version]:-}" == "$version" ]]; then echo "Already up to date" - [[ -n "$prev_branch" ]] && git -C "$project_dir" checkout "$prev_branch" return 0 fi fi @@ -735,15 +647,14 @@ cmd_apply() { # Show operation plan and confirm show_operation_plan "$command_name" "$current_version" "$version" "$commit" "$commit_timestamp" "$plan_invocation_methods_str" "$pr_flag" "$project_dir" "$script_dir" - if ! confirm_action "$command_name"; then - [[ -n "$prev_branch" ]] && git -C "$project_dir" checkout "$prev_branch" + if ! confirm_action "$command_name" "$yes_flag"; then return 0 fi # PR: create feature branch from default branch local branch_name="${command_name}-uspecs-${version_string_branch}" if [[ "$pr_flag" == "true" ]]; then - (cd "$project_dir" && bash "$script_dir/_lib/pr.sh" prbranch "$branch_name") + (cd "$project_dir" && git_prbranch "$branch_name") fi # Save existing metadata for update/upgrade @@ -757,14 +668,8 @@ cmd_apply() { invocation_methods_str=$(IFS=', '; echo "${invocation_methods[*]}") fi - if [[ "$command_name" == "install" ]]; then - rm -f "$source_dir/uspecs/u/uspecs.yml" - echo "Installing uspecs/u..." - mkdir -p "$project_dir/uspecs" - cp -r "$source_dir/uspecs/u" "$project_dir/uspecs/" - else - replace_uspecs_u "$source_dir" "$project_dir" - fi + mkdir -p "$project_dir/uspecs/u" + replace_uspecs_u "$source_dir" "$project_dir" # Write metadata echo "Writing installation metadata..." @@ -787,16 +692,19 @@ cmd_apply() { local pr_title="uspecs ${version_string}" local pr_body="$pr_title" local pr_info_file - pr_info_file=$(create_temp_file) + temp_create_file pr_info_file + + # git_pr --next-branch handles its own branch switch; remove our atexit handler + atexit_pop # Capture PR info from stderr while showing normal output - (cd "$project_dir" && bash "$script_dir/_lib/pr.sh" pr --title "$pr_title" --body "$pr_body" \ + (cd "$project_dir" && git_pr --title "$pr_title" --body "$pr_body" \ --next-branch "$prev_branch" --delete-branch) 2> "$pr_info_file" # Parse PR info from temp file - pr_url=$(_grep '^PR_URL=' "$pr_info_file" | cut -d= -f2-) - pr_branch=$(_grep '^PR_BRANCH=' "$pr_info_file" | cut -d= -f2) - pr_base=$(_grep '^PR_BASE=' "$pr_info_file" | cut -d= -f2) + pr_url=$(grep '^PR_URL=' "$pr_info_file" | cut -d= -f2-) + pr_branch=$(grep '^PR_BRANCH=' "$pr_info_file" | cut -d= -f2) + pr_base=$(grep '^PR_BASE=' "$pr_info_file" | cut -d= -f2) fi echo "" @@ -816,15 +724,21 @@ cmd_apply() { cmd_install() { local alpha=false + local local_flag=false local pr_flag=false + local yes_flag=false + local override=false local invocation_methods=() while [[ $# -gt 0 ]]; do case "$1" in --alpha) alpha=true; shift ;; + --local) local_flag=true; shift ;; --pr) pr_flag=true; shift ;; + --override) override=true; shift ;; --nlia) invocation_methods+=("nlia"); shift ;; --nlic) invocation_methods+=("nlic"); shift ;; + -y) yes_flag=true; shift ;; *) error "Unknown flag: $1" ;; esac done @@ -832,11 +746,16 @@ cmd_install() { if [[ ${#invocation_methods[@]} -eq 0 ]]; then error "At least one invocation method (--nlia or --nlic) is required" fi + if [[ "$alpha" == "true" && "$local_flag" == "true" ]]; then + error "--alpha and --local are mutually exclusive" + fi local project_dir project_dir=$PWD - check_not_installed "$project_dir" + if [[ "$override" != "true" ]]; then + check_not_installed "$project_dir" + fi local ref version commit="" commit_timestamp="" if [[ "$alpha" == "true" ]]; then @@ -845,6 +764,11 @@ cmd_install() { read -r commit commit_timestamp <<< "$(get_latest_commit_info)" ref="$commit" echo "Latest alpha version: $version" + elif [[ "$local_flag" == "true" ]]; then + echo "Using local version..." + version=$(get_local_version) + read -r commit commit_timestamp <<< "$(get_local_commit_info)" + echo "Local version: $version" else echo "Fetching latest stable version..." version=$(get_latest_tag) @@ -852,12 +776,6 @@ cmd_install() { echo "Latest version: $version" fi - local temp_dir - temp_dir=$(create_temp_dir) - - echo "Downloading uspecs..." - download_archive "$ref" "$temp_dir" - local apply_args=("install" "--project-dir" "$project_dir" "--version" "$version") for method in "${invocation_methods[@]}"; do apply_args+=("--$method") @@ -865,12 +783,28 @@ cmd_install() { if [[ -n "$commit" ]]; then apply_args+=("--commit" "$commit" "--commit-timestamp" "$commit_timestamp") fi + if [[ "$override" == "true" ]]; then + apply_args+=("--override") + fi if [[ "$pr_flag" == "true" ]]; then apply_args+=("--pr") fi + if [[ "$yes_flag" == "true" ]]; then + apply_args+=("-y") + fi echo "Running install..." - bash "$temp_dir/uspecs/u/scripts/conf.sh" apply "${apply_args[@]}" + if [[ "$local_flag" == "true" ]]; then + bash "${BASH_SOURCE[0]}" apply "${apply_args[@]}" + else + local temp_dir + temp_dir=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf '$temp_dir'" EXIT + echo "Downloading uspecs..." + download_archive "$ref" "$temp_dir" + bash "$temp_dir/uspecs/u/scripts/conf.sh" apply "${apply_args[@]}" + fi } cmd_update_or_upgrade() { @@ -878,6 +812,7 @@ cmd_update_or_upgrade() { shift local pr_flag=false + local yes_flag=false while [[ $# -gt 0 ]]; do case "$1" in @@ -885,6 +820,10 @@ cmd_update_or_upgrade() { pr_flag=true shift ;; + -y) + yes_flag=true + shift + ;; *) error "Unknown flag: $1" ;; @@ -908,7 +847,7 @@ cmd_update_or_upgrade() { fi local temp_dir - temp_dir=$(create_temp_dir) + temp_create_dir temp_dir echo "Downloading uspecs..." download_archive "$target_ref" "$temp_dir" @@ -921,6 +860,9 @@ cmd_update_or_upgrade() { if [[ "$pr_flag" == "true" ]]; then apply_args+=("--pr") fi + if [[ "$yes_flag" == "true" ]]; then + apply_args+=("-y") + fi echo "Running ${command_name}..." bash "$temp_dir/uspecs/u/scripts/conf.sh" apply "${apply_args[@]}" @@ -974,10 +916,10 @@ cmd_im() { fi if [[ -z "$temp_source" ]]; then - temp_source=$(create_temp_file) + temp_create_file temp_source echo "Downloading source file for triggering instructions..." local source_url="$GITHUB_RAW/$REPO_OWNER/$REPO_NAME/$ref/AGENTS.md" - if ! curl -fsSL "$source_url" -o "$temp_source"; then + if ! github_curl "$source_url" -o "$temp_source"; then error "Failed to download source file from $source_url" fi fi @@ -1050,6 +992,13 @@ cmd_im() { } main() { + # git_path and error are available from utils.sh when sourced (file-based execution). + # When piped, they are not available; the install command doesn't need git_path, + # and error messages use inline echo/exit. + if type -t git_path &>/dev/null; then + git_path + fi + if [[ $# -lt 1 ]]; then error "Usage: conf.sh <command> [args...]" fi diff --git a/uspecs/u/scripts/softeng.sh b/uspecs/u/scripts/softeng.sh new file mode 100644 index 0000000..d817858 --- /dev/null +++ b/uspecs/u/scripts/softeng.sh @@ -0,0 +1,1309 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# softeng automation +# +# Usage: +# softeng action upr [--no-archive] +# softeng action umergepr +# softeng change new <change-name> [--issue-url <url>] [--no-branch] [--branch] +# softeng change archive <change-folder-name> [-d] +# softeng change archiveall +# softeng pr preflight +# softeng pr create --title <title> [--body <body>] +# softeng diff specs +# +# change new: +# Creates Change Folder and change.md with frontmatter: +# - Folder: <changes_folder from conf.md>/ymdHM-<change-name> +# - registered_at: YYYY-MM-DDTHH:MM:SSZ +# - change_id: ymdHM-<change-name> +# - baseline: <commit-hash> (if git repository) +# - issue_url: <url> (if --issue-url provided) +# Creates git branch by default (skip with --no-branch; --branch forces creation explicitly) +# Prints: <relative-path-to-change-folder> (e.g. uspecs/changes/2602201746-my-change) +# +# change archive [-d]: +# Archives change folder to <changes-folder>/archive/yymm/ymdHM-<change-name> +# Adds archived_at metadata and updates folder date prefix +# -d: commit and push staged changes, checkout default branch, delete branch and refs +# Requires git repository, clean working tree, PR branch (ending with --pr) +# +# change archiveall: +# Archives all change folders with modifications vs pr_remote/default_branch +# No change-folder-name needed; mutually exclusive with -d +# +# pr preflight --change-folder <path>: +# Checks for uncompleted todo items in Change Folder, then validates preconditions, fetches +# pr_remote/default_branch, and merges it into the current branch. +# On success outputs: change_branch=<name>, default_branch=<name>, change_branch_head=<sha> +# +# pr create --title <title> --body <body>: +# Creates a PR from the current change branch (delegates to _lib/git.sh git_changepr). +# Body can be passed via --body or piped via stdin. +# Literal \n sequences in --body are decoded to actual newlines. +# +# diff specs: +# Outputs git diff of the specs folder between HEAD and pr_remote/default_branch. + +get_timestamp() { + date -u +"%Y-%m-%dT%H:%M:%SZ" +} + +get_baseline() { + local project_dir="$1" + if is_git_repo "$project_dir"; then + (cd "$project_dir" && git rev-parse HEAD 2>/dev/null) || echo "" + else + echo "" + fi +} + +get_folder_name() { + local path="$1" + basename "$path" +} + +count_uncompleted_items() { + local folder="$1" + local count + count=$(grep -r "^[[:space:]]*-[[:space:]]*\[ \]" "$folder"/*.md 2>/dev/null | wc -l) + echo "${count:-0}" | tr -d ' ' +} + +extract_change_name() { + local folder_name="$1" + # shellcheck disable=SC2001 + echo "$folder_name" | sed 's/^[0-9]\{10\}-//' +} + +move_folder() { + local source="$1" + local destination="$2" + local project_dir="${3:-}" + local check_dir="${project_dir:-$PWD}" + if is_git_repo "$check_dir"; then + if [[ -n "$project_dir" ]]; then + local rel_src="${source#"$project_dir/"}" + local rel_dst="${destination#"$project_dir/"}" + (cd "$project_dir" && git mv "$rel_src" "$rel_dst" 2>/dev/null) || mv "$source" "$destination" + else + git mv "$source" "$destination" 2>/dev/null || mv "$source" "$destination" + fi + else + mv "$source" "$destination" + fi +} + +get_script_dir() { + cd "$(dirname "${BASH_SOURCE[0]}")" && pwd +} + +# shellcheck source=_lib/utils.sh +source "$(get_script_dir)/_lib/utils.sh" +# shellcheck source=_lib/git.sh +source "$(get_script_dir)/_lib/git.sh" + +get_project_dir() { + local script_dir + script_dir=$(get_script_dir) + # scripts/ -> u/ -> uspecs/ -> project root + cd "$script_dir/../../.." && pwd +} + +cmd_status_ispr() { + local project_dir + project_dir=$(get_project_dir) + if ! is_git_repo "$project_dir"; then + return 0 + fi + local branch + branch=$(cd "$project_dir" && git branch --show-current 2>&1) + if [[ "$branch" == *"--pr" ]]; then + echo "yes" + fi +} + +read_conf_param() { + local param_name="$1" + local conf_file + conf_file="$(get_project_dir)/uspecs/u/conf.md" + + if [ ! -f "$conf_file" ]; then + error "conf.md not found: $conf_file" + fi + + local line raw + line=$(grep -E "^- ${param_name}:" "$conf_file" | head -1 || true) + raw="${line#*: }" + + if [ -z "$raw" ]; then + error "Parameter '${param_name}' not found in conf.md" + fi + + # trim leading/trailing whitespace and surrounding backticks + local value + value=$(echo "$raw" | sed 's/^[[:space:]`]*//' | sed 's/[[:space:]`]*$//') + + echo "$value" +} + +extract_issue_id() { + # Extract issue ID from the last segment of an issue URL + # Takes the last /-separated segment, finds the first contiguous + # run of valid characters (alphanumeric, hyphens, underscores) + local url="$1" + local segment="${url##*/}" + if [[ "$segment" =~ ^[^a-zA-Z0-9_-]*([a-zA-Z0-9_-]+) ]]; then + echo "${BASH_REMATCH[1]}" + fi +} + +cmd_change_new() { + local change_name="" + local issue_url="" + local opt_branch="" + local opt_no_branch="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --issue-url) + if [[ $# -lt 2 || -z "$2" ]]; then + error "--issue-url requires a URL argument" + fi + issue_url="$2" + shift 2 + ;; + --branch) + opt_branch="1" + shift + ;; + --no-branch) + opt_no_branch="1" + shift + ;; + *) + if [ -z "$change_name" ]; then + change_name="$1" + shift + else + error "Unknown argument: $1" + fi + ;; + esac + done + + if [ -n "$opt_branch" ] && [ -n "$opt_no_branch" ]; then + error "--branch and --no-branch are mutually exclusive" + fi + + local is_new_branch="1" + if [ -n "$opt_no_branch" ]; then + is_new_branch="" + elif [ -z "$opt_branch" ]; then + # Skip branch creation when not on the default branch (unless --branch forces it) + local project_dir_check + project_dir_check=$(get_project_dir) + if is_git_repo "$project_dir_check"; then + local current_branch_name + current_branch_name=$(cd "$project_dir_check" && git symbolic-ref --short HEAD) + local def_branch + def_branch=$(cd "$project_dir_check" && git_default_branch_name || echo "") + if [ "$current_branch_name" != "$def_branch" ]; then + is_new_branch="" + fi + fi + fi + + if [ -z "$change_name" ]; then + error "change-name is required" + fi + + if [[ ! "$change_name" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + error "change-name must be kebab-case (lowercase letters, numbers, hyphens): $change_name" + fi + + local changes_folder_rel + changes_folder_rel=$(read_conf_param "changes_folder") + + local project_dir + project_dir=$(get_project_dir) + + local changes_folder="$project_dir/$changes_folder_rel" + + if [ ! -d "$changes_folder" ]; then + error "Changes folder not found: $changes_folder" + fi + + local timestamp + timestamp=$(date -u +"%y%m%d%H%M") + + local folder_name="${timestamp}-${change_name}" + local change_folder="$changes_folder/$folder_name" + + if [ -d "$change_folder" ]; then + error "Change folder already exists: $change_folder" + fi + + mkdir -p "$change_folder" + + local registered_at baseline + registered_at=$(get_timestamp) + baseline=$(get_baseline "$project_dir") + + local frontmatter="---"$'\n' + frontmatter+="registered_at: $registered_at"$'\n' + frontmatter+="change_id: $folder_name"$'\n' + + if [ -n "$baseline" ]; then + frontmatter+="baseline: $baseline"$'\n' + fi + + if [ -n "$issue_url" ]; then + frontmatter+="issue_url: $issue_url"$'\n' + fi + + frontmatter+="---" + + printf '%s\n' "$frontmatter" > "$change_folder/change.md" + + if [ -n "$is_new_branch" ]; then + if is_git_repo "$project_dir"; then + local branch_name="$change_name" + if [ -n "$issue_url" ]; then + local issue_id + issue_id=$(extract_issue_id "$issue_url") + if [ -n "$issue_id" ]; then + branch_name="${issue_id}-${change_name}" + fi + fi + if ! (cd "$project_dir" && git checkout -b "$branch_name"); then + echo "Warning: Failed to create branch '$branch_name'" >&2 + fi + else + echo "Warning: Not a git repository, cannot create branch" >&2 + fi + fi + + echo "$changes_folder_rel/$folder_name" +} + +convert_links_to_relative() { + local folder="$1" + + if [ -z "$folder" ]; then + error "folder path is required for convert_links_to_relative" + fi + + if [ ! -d "$folder" ]; then + error "Folder not found: $folder" + fi + + # Find all .md files in the folder + local md_files + md_files=$(find "$folder" -maxdepth 1 -name "*.md" -type f) + + if [ -z "$md_files" ]; then + # No markdown files to process, return success + return 0 + fi + + # Process each markdown file + while IFS= read -r file; do + # Archive moves folder 2 levels deeper (changes/ -> changes/archive/yymm/) + # Only paths starting with ../ need adjustment - add ../../ prefix + # + # Example: ](../foo) -> ](../../../foo) + # + # Skip (do not modify): + # - http://, https:// (absolute URLs) + # - # (anchors) + # - / (absolute paths) + # - ./ (current directory - stays in same folder) + # - filename.ext (same folder files like impl.md, issue.md) + + # Add ../../ prefix to paths starting with ../ + # ](../ -> ](../../../ + if ! sed_inplace "$file" -E 's#\]\(\.\./#](../../../#g'; then + error "Failed to convert links in file: $file" + fi + done <<< "$md_files" + + return 0 +} + +cmd_pr_preflight() { + local change_folder_path="" + while [[ $# -gt 0 ]]; do + case "$1" in + --change-folder) change_folder_path="$2"; shift 2 ;; + *) error "Unknown flag: $1" ;; + esac + done + if [ -z "$change_folder_path" ]; then + error "pr preflight requires --change-folder <path>" + fi + if [ ! -d "$change_folder_path" ]; then + error "Change folder not found: $change_folder_path" + fi + local uncompleted_count + uncompleted_count=$(count_uncompleted_items "$change_folder_path") + if [ "$uncompleted_count" -gt 0 ]; then + echo "Cannot create PR: $uncompleted_count uncompleted todo item(s) found" + echo "" + echo "Uncompleted items:" + grep -rn "^[[:space:]]*-[[:space:]]*\[ \]" "$change_folder_path"/*.md 2>/dev/null | sed 's/^/ /' + echo "" + echo "Complete or cancel todo items before creating a PR" + exit 1 + fi + git_mergedef +} + +cmd_change_archiveall() { + if [ $# -gt 0 ]; then + error "change archiveall takes no arguments" + fi + + local changes_folder_rel + changes_folder_rel=$(read_conf_param "changes_folder") + + local project_dir + project_dir=$(get_project_dir) + + if ! is_git_repo "$project_dir"; then + error "change archiveall requires a git repository" + fi + + local -A pr_info + if ! git_pr_info pr_info "$project_dir"; then + error "change archiveall requires remote info to be available (remote reachable?)" + fi + local default_branch="${pr_info[default_branch]:-}" + local pr_remote="${pr_info[pr_remote]:-}" + + local changes_folder="$project_dir/$changes_folder_rel" + + echo "Fetching ${pr_remote}/${default_branch}..." + (cd "$project_dir" && git fetch "$pr_remote" "$default_branch" 2>&1) + + if [ ! -d "$changes_folder" ]; then + error "Changes folder not found: $changes_folder" + fi + + local archived=0 unchanged=0 failed=0 + local script_path + script_path="$(get_script_dir)/softeng.sh" + + for folder_path in "$changes_folder"/*/; do + [ -d "$folder_path" ] || continue + local fname + fname=$(basename "$folder_path") + [ "$fname" = "archive" ] && continue + + local rel_folder="$changes_folder_rel/$fname" + local diff_output + diff_output=$(cd "$project_dir" && git diff --name-only "${pr_remote}/${default_branch}" HEAD -- "$rel_folder") + if [ -z "$diff_output" ]; then + unchanged=$((unchanged + 1)) + continue + fi + + if bash "$script_path" change archive "$fname"; then + archived=$((archived + 1)) + else + echo "Warning: could not archive $fname" >&2 + failed=$((failed + 1)) + fi + done + + echo "Done: $archived archived, $unchanged unchanged, $failed failed" +} + +# changes_archive <project_dir> <changes_folder> <change_folder> <is_git> <result_var> +# Archives an active change folder: updates YAML metadata, converts links, +# moves to archive/YYMM/YYMMDDHHMM-<change_name>. +# project_dir: absolute path to project root +# changes_folder: relative to project_dir (e.g. uspecs/changes) +# change_folder: relative to project_dir (e.g. uspecs/changes/2601010000-my-change) +# is_git: non-empty if project is a git repo +# Sets result_var (nameref) to the archived folder path, relative to project_dir. +changes_archive() { + local project_dir="$1" + local changes_folder="$2" + local change_folder="$3" + local is_git="$4" + local -n result_ref="$5" + + local abs_change="$project_dir/$change_folder" + local abs_changes="$project_dir/$changes_folder" + + local folder_basename + folder_basename=$(basename "$change_folder") + + local change_name + change_name=$(extract_change_name "$folder_basename") + + local change_file="$abs_change/change.md" + + local timestamp + timestamp=$(get_timestamp) + + # Insert archived_at into YAML front matter (before closing ---) + local temp_file + temp_create_file temp_file + # // TODO archived_at may already exists... + awk -v ts="$timestamp" ' + /^---$/ { + if (count == 0) { + print + count++ + } else { + print "archived_at: " ts + print + } + next + } + /^archived_at:/ { next } + { print } + ' "$change_file" > "$temp_file" + if cat "$temp_file" > "$change_file"; then + : # Success, continue + else + error "failed to update $change_file" + fi + + # Add ../ prefix to relative links for archive folder depth + if ! convert_links_to_relative "$abs_change"; then + error "failed to convert links to relative paths" + fi + + local archive_dir="$abs_changes/archive" + + local date_prefix + date_prefix=$(date -u +"%y%m%d%H%M") + + local yymm="${date_prefix:0:4}" + + local archive_sub="$archive_dir/$yymm" + mkdir -p "$archive_sub" + + local dest="$archive_sub/${date_prefix}-${change_name}" + + if [ -d "$dest" ]; then + error "Archive folder already exists: $dest" + fi + + if [ -n "$is_git" ]; then + (cd "$project_dir" && quiet git add "$change_folder") + fi + + move_folder "$abs_change" "$dest" "$project_dir" + + local rel_dest="${dest#"$project_dir/"}" + + if [ -n "$is_git" ]; then + (cd "$project_dir" && quiet git add "$rel_dest") + fi + + # shellcheck disable=SC2034 + result_ref="$rel_dest" +} + +cmd_change_archive() { + local folder_name="" + local delete_branch="" + + while [[ $# -gt 0 ]]; do + case "$1" in + -d) + delete_branch="1" + shift + ;; + *) + if [ -z "$folder_name" ]; then + folder_name="$1" + shift + else + error "Unknown argument: $1" + fi + ;; + esac + done + + if [ -z "$folder_name" ]; then + error "change-folder-name is required" + fi + + local changes_folder_rel + changes_folder_rel=$(read_conf_param "changes_folder") + + local project_dir + project_dir=$(get_project_dir) + + local is_git="" + if is_git_repo "$project_dir"; then + is_git="1" + fi + + if [ -n "$delete_branch" ] && [ -z "$is_git" ]; then + error "-d requires a git repository" + fi + + local changes_folder="$project_dir/$changes_folder_rel" + + local path_to_change_folder="$changes_folder/$folder_name" + + if [ ! -d "$path_to_change_folder" ]; then + error "Folder not found: $path_to_change_folder" + fi + + local change_file="$path_to_change_folder/change.md" + if [ ! -f "$change_file" ]; then + error "change.md not found in folder: $path_to_change_folder" + fi + + if [[ "$folder_name" == archive/* ]]; then + error "Folder is already in archive: $folder_name" + fi + + local uncompleted_count + uncompleted_count=$(count_uncompleted_items "$path_to_change_folder") + + if [ "$uncompleted_count" -gt 0 ]; then + echo "Cannot archive: $uncompleted_count uncompleted todo item(s) found" + echo "" + echo "Uncompleted items:" + grep -rn "^[[:space:]]*-[[:space:]]*\[ \]" "$path_to_change_folder"/*.md 2>/dev/null | sed 's/^/ /' + echo "" + echo "Complete or cancel todo items before archiving" + exit 1 + fi + + local change_name + change_name=$(extract_change_name "$folder_name") + + if [ -n "$delete_branch" ] && [ -n "$is_git" ]; then + local branch_name + branch_name=$(cd "$project_dir" && git symbolic-ref --short HEAD 2>/dev/null || echo "") + if [ -z "$branch_name" ]; then + error "-d requires a named branch (HEAD is detached)" + fi + + local -A pr_info + if ! git_pr_info pr_info; then + error "-d requires git remote info to be available (remote reachable?)" + fi + local default_branch="${pr_info[default_branch]:-}" + local pr_remote="${pr_info[pr_remote]:-}" + + # a) no uncommitted changes + local git_status + git_status=$(cd "$project_dir" && git status --porcelain) + if [ -n "$git_status" ]; then + error "-d requires a clean working tree (uncommitted changes found)" + fi + + # b) branch must not be the default branch + if [ "$branch_name" = "$default_branch" ]; then + error "-d cannot be used on the default branch '$default_branch'" + fi + + # c) check whether the remote branch actually exists + # exit non-zero means remote unreachable/auth failed -- treat as hard error, not as "branch gone" + local remote_exists + if ! remote_exists=$(cd "$project_dir" && git ls-remote --heads "${pr_remote:-origin}" "$branch_name"); then + error "Cannot reach remote '${pr_remote:-origin}'. Check connectivity and authentication." + fi + + # d) no divergence (skip when remote branch is already gone) + if [ -n "$remote_exists" ]; then + (cd "$project_dir" && git fetch "${pr_remote:-origin}" "$branch_name" 2>&1) + local behind + behind=$(cd "$project_dir" && git rev-list --count "HEAD..${pr_remote:-origin}/$branch_name") + if [ "$behind" -gt 0 ]; then + error "Branch '$branch_name' is behind ${pr_remote:-origin} by $behind commit(s). Pull or rebase first." + fi + fi + + # e) branch must be a PR branch + if [[ "$branch_name" != *--pr ]]; then + error "-d can only be used on a PR branch (must end with '--pr'): '$branch_name'" + fi + + # f) PR branch with remote gone: skip archive, just refresh default and switch + if [ -z "$remote_exists" ]; then + echo "Remote branch '${pr_remote:-origin}/$branch_name' no longer exists; skipping archive." + echo "Switching to $default_branch..." + if ! (cd "$project_dir" && git checkout "$default_branch" 2>&1); then + error "Failed to checkout '$default_branch'. Resolve manually." + fi + + local deleted_branch_hash="" + if (cd "$project_dir" && git show-ref --verify --quiet "refs/heads/$branch_name"); then + deleted_branch_hash=$(cd "$project_dir" && git rev-parse "refs/heads/$branch_name") + (cd "$project_dir" && git branch -D "$branch_name" 2>&1) + fi + (cd "$project_dir" && git branch -dr "${pr_remote:-origin}/$branch_name") 2>/dev/null || true + + if [ -n "$deleted_branch_hash" ]; then + echo "Deleted branch: $branch_name ($deleted_branch_hash)" + echo "To restore: git branch $branch_name $deleted_branch_hash" + fi + + echo "Updating local $default_branch from ${pr_remote:-origin}/$default_branch..." + (cd "$project_dir" && git fetch "${pr_remote:-origin}" "$default_branch" 2>&1) + if ! (cd "$project_dir" && git merge --ff-only "${pr_remote:-origin}/$default_branch" 2>&1); then + error "Cannot fast-forward '$default_branch' to '${pr_remote:-origin}/$default_branch' (branches have diverged). Run 'git rebase' or resolve manually." + fi + + echo "Done: skipped archive (remote branch gone), switched to $default_branch" + return 0 + fi + fi + + local rel_change_folder="$changes_folder_rel/$folder_name" + + local archive_path + changes_archive "$project_dir" "$changes_folder_rel" "$rel_change_folder" "$is_git" archive_path + + if [ -n "$delete_branch" ] && [ -n "$is_git" ]; then + (cd "$project_dir" && git commit -m "archive $rel_change_folder to $archive_path" 2>&1) + if [ -n "$remote_exists" ]; then + if ! (cd "$project_dir" && git push 2>&1); then + local archive_commit + archive_commit=$(cd "$project_dir" && git rev-parse HEAD) + error "Push to '${pr_remote:-origin}/$branch_name' failed. Branch '$branch_name' preserved (archive commit: $archive_commit). Resolve the push issue and re-run the archive command." + fi + else + echo "Remote branch '${pr_remote:-origin}/$branch_name' no longer exists; skipping push." + fi + + if ! (cd "$project_dir" && git checkout "$default_branch" 2>&1); then + error "Failed to checkout '$default_branch'. Resolve manually." + fi + + local deleted_branch_hash="" + if (cd "$project_dir" && git show-ref --verify --quiet "refs/heads/$branch_name"); then + deleted_branch_hash=$(cd "$project_dir" && git rev-parse "refs/heads/$branch_name") + (cd "$project_dir" && git branch -D "$branch_name" 2>&1) + else + echo "Warning: branch '$branch_name' not found, skipping branch deletion" >&2 + fi + (cd "$project_dir" && git branch -dr "${pr_remote:-origin}/$branch_name") 2>/dev/null || true + + if [ -n "$deleted_branch_hash" ]; then + echo "Deleted branch: $branch_name ($deleted_branch_hash)" + echo "To restore: git branch $branch_name $deleted_branch_hash" + fi + + echo "Updating local $default_branch from ${pr_remote:-origin}/$default_branch..." + (cd "$project_dir" && git fetch "${pr_remote:-origin}" "$default_branch" 2>&1) + if ! (cd "$project_dir" && git merge --ff-only "${pr_remote:-origin}/$default_branch" 2>&1); then + error "Cannot fast-forward '$default_branch' to '${pr_remote:-origin}/$default_branch' (branches have diverged). Run 'git rebase' or resolve manually." + fi + fi +} + +# changes_validate_single_wcf <project_dir> <changes_folder_rel> <pr_remote> <default_branch> +# Reflects scenario: "Exactly one Working Change Folder" +# Detects the Working Change Folder (WCF) -- a change folder whose files have been +# modified since merge-base with pr_remote/default_branch. +# Outputs the relative path from changes_folder (e.g. "my-change" for active, +# "archive/yymm/timestamp-name" for archived). Fails if not exactly one WCF is found. +changes_validate_single_wcf() { + local project_dir="$1" + local changes_folder_rel="$2" + local pr_remote="$3" + local default_branch="$4" + + local merge_base + merge_base=$(cd "$project_dir" && git merge-base HEAD "${pr_remote}/${default_branch}") + + local diff_output + diff_output=$(cd "$project_dir" && git diff --name-only "$merge_base" HEAD -- "$changes_folder_rel") + + # Collect unique change folder paths. + # Active folders: first path component (e.g. "my-change") + # Archived folders: archive/yymm/<name> (3 components) + local -A folders=() + while IFS= read -r line; do + [[ -z "$line" ]] && continue + # Strip changes_folder_rel/ prefix + local rel="${line#"${changes_folder_rel}/"}" + local top="${rel%%/*}" + [[ -z "$top" ]] && continue + if [[ "$top" == "archive" ]]; then + # Extract archive/yymm/<name> (3 path components) + local rest="${rel#archive/}" + local yymm="${rest%%/*}" + rest="${rest#"$yymm"/}" + local name="${rest%%/*}" + if [[ -n "$yymm" && -n "$name" ]]; then + folders["archive/$yymm/$name"]=1 + fi + else + folders["$top"]=1 + fi + done <<< "$diff_output" + + local count=${#folders[@]} + if [[ "$count" -eq 0 ]]; then + error "No Working Change Folder found (no changes in $changes_folder_rel since merge-base)" + elif [[ "$count" -gt 1 ]]; then + local names + names=$(printf '%s\n' "${!folders[@]}" | sort) + error "Multiple Working Change Folders found (expected exactly one):\n$names" + fi + + printf '%s\n' "${!folders[@]}" +} + +# changes_validate_todos_completed <wcf_path> <project_dir> +# Reflects scenario: "All todo items are completed" +# Checks that there are no uncompleted todo items in the WCF. +# On failure, outputs error to stderr and exits. +changes_validate_todos_completed() { + local wcf_path="$1" + local project_dir="$2" + + local uncompleted_count + uncompleted_count=$(count_uncompleted_items "$wcf_path") + if [[ "$uncompleted_count" -gt 0 ]]; then + local uncompleted_files + uncompleted_files=$(grep -rl "^[[:space:]]*-[[:space:]]*\[ \]" "$wcf_path"/*.md 2>/dev/null | sed "s|^$project_dir/||") + + { + echo "Error: $uncompleted_count uncompleted todo item(s) found in files:" + echo "" + echo "$uncompleted_files" + echo "" + echo "Complete todo items before creating a PR." + } >&2 + exit 1 + fi +} + +# cmd_action_upr +# Full upr flow: validate, detect WCF, check no existing PR, read change.md, +# compute pr_title/commit_message/see_details_line, +# set upstream, squash, force-push, open PR creation in browser, output prompt. +cmd_action_upr() { + local opt_no_archive="" + while [[ $# -gt 0 ]]; do + case "$1" in + --no-archive) opt_no_archive="1"; shift ;; + *) error "Unknown argument: $1" ;; + esac + done + + local project_dir + project_dir=$(get_project_dir) + cd "$project_dir" + + prompt_start_log + + # Validate preconditions + check_prerequisites + + local current_branch + current_branch=$(git symbolic-ref --short HEAD) + + local pr_remote default_branch + pr_remote=$(determine_pr_remote) + default_branch=$(git_default_branch_name) + + git_validate_clean_repo "$current_branch" "$default_branch" + + echo "Branch: $current_branch -> $pr_remote/$default_branch" + + # Fetch remote default branch + echo "Fetching $pr_remote/$default_branch..." + quiet git fetch "$pr_remote" "$default_branch" + + # Check for changes since branching + echo "Checking for changes since branching..." + local merge_base + merge_base=$(git merge-base HEAD "${pr_remote}/${default_branch}") + local diff_stat + diff_stat=$(git diff --name-only "$merge_base" HEAD) + if [[ -z "$diff_stat" ]]; then + error "No changes detected in the current branch since branching from $default_branch" + fi + + # Detect Working Change Folder + local changes_folder_rel + changes_folder_rel=$(read_conf_param "changes_folder") + local wcf_name + wcf_name=$(changes_validate_single_wcf "$project_dir" "$changes_folder_rel" "$pr_remote" "$default_branch") + + echo "Working Change Folder: $wcf_name" + + local wcf_path="$project_dir/$changes_folder_rel/$wcf_name" + local change_file="$wcf_path/change.md" + + if [[ ! -f "$change_file" ]]; then + error "change.md not found in Working Change Folder: $wcf_path" + fi + + # Check for uncompleted todo items + echo "Checking for uncompleted to-do items..." + changes_validate_todos_completed "$wcf_path" "$project_dir" + + local prompts_file + prompts_file="$(get_script_dir)/../prompts.md" + + # Check if PR already exists for this branch + echo "Checking for existing PR..." + local pr_state pr_number + if pr_state=$(gh pr view --json state -q ".state" 2>/dev/null); then + # PR exists -- check its state + pr_number=$(gh pr view --json number -q ".number") + + if [[ "$pr_state" == "OPEN" ]]; then + # PR exists and is OPEN -- open in browser and show message + local pr_url + pr_url=$(gh pr view --json url -q ".url") + quiet gh pr view --web || true + + prompt_start_instructions + # shellcheck disable=SC2034 # open_vars used via nameref in section_templ + declare -A open_vars=([pr_url]="$pr_url") + section_templ "$prompts_file" "upr_already_exists" open_vars + return 0 + elif [[ "$pr_state" == "MERGED" ]]; then + echo "PR #${pr_number} for this branch was already merged. Proceeding with new PR creation..." + fi + # PR exists but is CLOSED -- proceed silently with new PR creation + fi + + # Read change.md: title and optional issue_url + local full_title + full_title=$(md_read_title "$change_file") + # change_title is text after ":" in the heading, trimmed + local change_title + if [[ "$full_title" == *:* ]]; then + change_title="${full_title#*:}" + change_title="${change_title#"${change_title%%[![:space:]]*}"}" + else + change_title="$full_title" + fi + + local issue_url pr_title commit_message see_details_line + issue_url=$(md_read_frontmatter_field "$change_file" "issue_url" 2>/dev/null) || true + + see_details_line="See change.md for details" + + if [[ -n "$issue_url" ]]; then + local issue_id + issue_id=$(extract_issue_id "$issue_url") + pr_title="[${issue_id}] ${change_title}" + commit_message="Closes #${issue_id}: ${change_title}"$'\n'"${see_details_line}" + else + pr_title="${change_title}" + commit_message="${change_title}"$'\n'"${see_details_line}" + fi + + # Archive WCF if active and --no-archive not set + if [[ -z "$opt_no_archive" && -d "$wcf_path" && "$wcf_name" != archive/* ]]; then + echo "Archiving WCF $wcf_name..." + local archived_path + changes_archive "$project_dir" "$changes_folder_rel" "$changes_folder_rel/$wcf_name" "1" archived_path + + # Update change_file to archived location + change_file="$project_dir/$archived_path/change.md" + + if [[ -n $(git status --porcelain) ]]; then + quiet git add -A + quiet git commit -m "Archive $wcf_name" + fi + fi + + # Count commits since merge-base to decide whether to squash + local commit_count + commit_count=$(git rev-list --count "$merge_base"..HEAD) + + # Set upstream if not already set + if ! git rev-parse --abbrev-ref "@{upstream}" >/dev/null 2>&1; then + quiet git push -u origin "$current_branch" + fi + + echo "PR title: $pr_title" + echo "Commits since merge-base: $commit_count" + + local pre_push_head="" + if [[ "$commit_count" -gt 1 ]]; then + # Record pre-push HEAD for branch restoration + pre_push_head=$(git rev-parse HEAD) + + # Squash branch into single commit + echo "Squashing $commit_count commits into one..." + quiet git reset --soft "$merge_base" + quiet git commit -m "$commit_message" + + # Register branch restoration handler in case force-push fails + atexit_push "git reset --hard ${pre_push_head}" + + # Force-push + echo "Force-pushing squashed commit..." + quiet git push --force + + # Force-push succeeded -- remove restoration handler + atexit_pop + else + # Already a single commit -- skip squash and force-push + echo "Single commit, pushing..." + quiet git push + fi + + # Prepare PR body: strip YAML frontmatter --- delimiters (keep field lines as plain text). + # Fenced code blocks (```yaml / ```) are NOT used because GitHub interprets backtick + # sequences in the PR body incorrectly. + local pr_body_file + temp_create_file pr_body_file + local pr_body_max_lines=40 + local pr_body_max_chars=4000 + awk ' + BEGIN { fm=0 } + /^---$/ && fm==0 { fm=1; next } + /^---$/ && fm==1 { fm=2; next } + { print } + ' "$change_file" > "$pr_body_file" + local pr_body_truncated=false + local pr_body_lines + pr_body_lines=$(wc -l < "$pr_body_file") + if (( pr_body_lines > pr_body_max_lines )); then + head -n "$pr_body_max_lines" "$pr_body_file" > "${pr_body_file}.tmp" + mv "${pr_body_file}.tmp" "$pr_body_file" + pr_body_truncated=true + fi + local pr_body_size + pr_body_size=$(wc -c < "$pr_body_file") + if (( pr_body_size > pr_body_max_chars )); then + local truncated + truncated=$(head -c "$pr_body_max_chars" "$pr_body_file") + printf '%s' "$truncated" > "$pr_body_file" + pr_body_truncated=true + fi + if [[ "$pr_body_truncated" == "true" ]]; then + printf '\n\n---\n(truncated -- see change.md for full details)\n' >> "$pr_body_file" + fi + + # Create PR via gh CLI + echo "Creating PR..." + local pr_url + pr_url=$(gh_create_pr "$pr_remote" "$default_branch" "$current_branch" "$pr_title" < "$pr_body_file") + + # Open the created PR in browser + echo "Opening PR in browser..." + quiet gh pr view --web || true + + prompt_start_instructions + + # Output success prompt + if [[ -n "$pre_push_head" ]]; then + declare -A vars=([pre_push_head]="$pre_push_head" [pr_url]="$pr_url") + section_templ "$prompts_file" "upr_success" vars + else + # shellcheck disable=SC2034 # vars used via nameref + declare -A vars=([pr_url]="$pr_url") + section_templ "$prompts_file" "upr_success_no_squash" vars + fi +} + +# cmd_action_umergepr +# Full umergepr flow: validate, detect WCF, check PR state, handle branches, +# archive WCF if active, attempt merge, handle failure, branch cleanup. +cmd_action_umergepr() { + local project_dir + project_dir=$(get_project_dir) + cd "$project_dir" + + prompt_start_log + + # Validate preconditions + check_prerequisites + + local current_branch + current_branch=$(git symbolic-ref --short HEAD) + + local pr_remote default_branch + pr_remote=$(determine_pr_remote) + default_branch=$(git_default_branch_name) + + git_validate_clean_repo "$current_branch" "$default_branch" + + echo "Branch: $current_branch -> $pr_remote/$default_branch" + + # Check upstream + if ! git rev-parse --abbrev-ref "@{upstream}" >/dev/null 2>&1; then + error "Current branch '$current_branch' has no upstream" + fi + + # Fetch remote default branch + echo "Fetching $pr_remote/$default_branch..." + quiet git fetch "$pr_remote" "$default_branch" + + # Detect Working Change Folder + local changes_folder_rel + changes_folder_rel=$(read_conf_param "changes_folder") + local wcf_name + wcf_name=$(changes_validate_single_wcf "$project_dir" "$changes_folder_rel" "$pr_remote" "$default_branch") + echo "Working Change Folder: $wcf_name" + + local prompts_file + prompts_file="$(get_script_dir)/../prompts.md" + + # Check PR state + echo "Checking PR state..." + local pr_state pr_number + if ! pr_state=$(gh pr view --json state -q ".state" 2>/dev/null); then + # No PR found + prompt_start_instructions + section_templ "$prompts_file" "umergepr_no_pr" + return 0 + fi + + pr_number=$(gh pr view --json number -q ".number") + local pr_url + pr_url=$(gh pr view --json url -q ".url") + + echo "PR #$pr_number state: $pr_state" + + if [[ "$pr_state" != "OPEN" ]]; then + # PR is not in OPEN state + quiet gh pr view --web || true + + local branch_head + branch_head=$(git rev-parse HEAD) + + # Delete local branch, upstream and remote tracking ref (errors ignored) + quiet git checkout "$default_branch" || true + git branch -D "$current_branch" >/dev/null 2>&1 || true + git branch -dr "origin/$current_branch" >/dev/null 2>&1 || true + + # shellcheck disable=SC2034 # vars used via nameref + declare -A vars=( + [pr_number]="$pr_number" + [pr_state]="$pr_state" + [branch_name]="$current_branch" + [branch_head]="$branch_head" + ) + prompt_start_instructions + section_templ "$prompts_file" "umergepr_not_open" vars + return 0 + fi + + # PR is in OPEN state + # Archive WCF if active + local wcf_path="$project_dir/$changes_folder_rel/$wcf_name" + local archived_path="" + if [[ -d "$wcf_path" && ! "$wcf_path" == */archive/* ]]; then + echo "Archiving WCF $wcf_name..." + changes_archive "$project_dir" "$changes_folder_rel" "$changes_folder_rel/$wcf_name" "1" archived_path + + # Commit the archive + if [[ -n $(git status --porcelain) ]]; then + quiet git add -A + quiet git commit -m "Archive $wcf_name" + echo "Pushing archive commit..." + quiet git push || true + fi + fi + + # Sync PR branch with latest base branch (handles "base branch was modified" error) + echo "Updating PR branch with latest base..." + quiet gh pr update-branch || echo "Warning: gh pr update-branch failed (may not be needed)" + + # Record branch HEAD before merge deletes it + local branch_head + branch_head=$(git rev-parse HEAD) + + # Attempt merge with squash and delete branch + echo "Merging PR #$pr_number (squash)..." + if ! quiet gh pr merge --squash --delete-branch; then + # Merge failed + quiet gh pr view --web || true + + # shellcheck disable=SC2034 # vars used via nameref + declare -A fail_vars=([pr_number]="$pr_number") + prompt_start_instructions + section_templ "$prompts_file" "umergepr_merge_failed" fail_vars + return 0 + fi + + # Merge succeeded -- cleanup + echo "Merge succeeded, cleaning up..." + # gh pr merge --delete-branch switches to default branch and deletes local branch, + # but in fork workflows (crossRepoPR) it skips remote branch deletion by design. + # Explicitly delete the branch on origin (the fork) and clean up tracking ref. + if git ls-remote --exit-code --heads origin "$current_branch" >/dev/null 2>&1; then + echo "Deleting branch $current_branch from origin..." + quiet git push origin --delete "$current_branch" || echo "Warning: failed to delete $current_branch from origin" + fi + git branch -dr "origin/$current_branch" >/dev/null 2>&1 || true + + # When upstream remote exists, fast-forward local default branch. + # Retry fetch+ff for up to 5 seconds -- the squashed commit may not appear + # immediately due to eventual consistency. + if [[ "$pr_remote" == "upstream" ]]; then + echo "Syncing local $default_branch with $pr_remote/$default_branch..." + cd "$project_dir" + # Ensure we are on the default branch (gh pr merge should have switched, + # but be explicit to avoid accidentally fast-forwarding a wrong branch). + quiet git checkout "$default_branch" || true + + echo "Fetching $pr_remote/$default_branch..." + quiet git fetch "$pr_remote" "$default_branch" + + if ! git merge-base --is-ancestor HEAD "$pr_remote/$default_branch" 2>/dev/null; then + # Local branch has diverged from upstream -- log and skip sync entirely + echo "Warning: local $default_branch has diverged from $pr_remote/$default_branch, skipping sync" + echo " local HEAD: $(git rev-parse --short HEAD)" + echo " $pr_remote/$default_branch: $(git rev-parse --short "$pr_remote/$default_branch")" + echo " merge-base: $(git merge-base HEAD "$pr_remote/$default_branch" | cut -c1-7)" + echo " local-only commits:" + git log --oneline "$pr_remote/$default_branch..HEAD" 2>&1 | sed 's/^/ /' + else + # Fast-forward is possible -- retry ff+WCF detection for up to 5 seconds + # (squashed commit may not appear immediately due to eventual consistency) + local _wcf_check_path="$project_dir/${archived_path:-$changes_folder_rel/$wcf_name}" + local _wcf_found=false + for _attempt in 1 2 3 4 5; do + echo "Fast-forwarding $default_branch (attempt $_attempt)..." + quiet git fetch "$pr_remote" "$default_branch" + quiet git merge --ff-only "$pr_remote/$default_branch" + quiet git push origin "$default_branch" || echo "Warning: failed to push $default_branch to origin" + if [[ -d "$_wcf_check_path" ]]; then + _wcf_found=true + break + fi + sleep 1 + done + if [[ "$_wcf_found" != "true" ]]; then + echo "Warning: WCF not detected in $default_branch after 5 seconds" + fi + fi + fi + + # shellcheck disable=SC2034 # vars used via nameref + declare -A success_vars=( + [pr_number]="$pr_number" + [pr_url]="$pr_url" + [branch_name]="$current_branch" + [branch_head]="$branch_head" + ) + # shellcheck disable=SC2119 + prompt_start_instructions + section_templ "$prompts_file" "umergepr_success" success_vars +} + +main() { + git_path + + if [ $# -lt 1 ]; then + error "Usage: softeng <command> [args...]" + fi + + local command="$1" + shift + + case "$command" in + action) + if [ $# -lt 1 ]; then + error "Usage: softeng action <keyword>" + fi + local keyword="$1" + shift + case "$keyword" in + upr) + cmd_action_upr "$@" + ;; + umergepr) + cmd_action_umergepr "$@" + ;; + *) + error "Unknown action keyword: $keyword. Available: upr, umergepr" + ;; + esac + ;; + change) + if [ $# -lt 1 ]; then + error "Usage: softeng change <subcommand> [args...]" + fi + local subcommand="$1" + shift + + case "$subcommand" in + new) + cmd_change_new "$@" + ;; + archive) + cmd_change_archive "$@" + ;; + archiveall) + cmd_change_archiveall "$@" + ;; + *) + error "Unknown change subcommand: $subcommand. Available: new, archive, archiveall" + ;; + esac + ;; + pr) + if [ $# -lt 1 ]; then + error "Usage: softeng pr <subcommand> [args...]" + fi + local subcommand="$1" + shift + + case "$subcommand" in + preflight) + cmd_pr_preflight "$@" + ;; + create) + git_changepr "$@" + ;; + *) + error "Unknown pr subcommand: $subcommand. Available: preflight, create" + ;; + esac + ;; + diff) + if [ $# -lt 1 ]; then + error "Usage: softeng diff <target>" + fi + local target="$1" + shift + + case "$target" in + specs) + git_diff specs "$@" + ;; + *) + error "Unknown diff target: $target. Available: specs" + ;; + esac + ;; + status) + if [ $# -lt 1 ]; then + error "Usage: softeng status <subcommand> [args...]" + fi + local subcommand="$1" + shift + + case "$subcommand" in + ispr) + cmd_status_ispr "$@" + ;; + *) + error "Unknown status subcommand: $subcommand. Available: ispr" + ;; + esac + ;; + *) + error "Unknown command: $command" + ;; + esac +} + +main "$@" diff --git a/uspecs/u/scripts/uspecs.sh b/uspecs/u/scripts/uspecs.sh deleted file mode 100644 index 0b6797a..0000000 --- a/uspecs/u/scripts/uspecs.sh +++ /dev/null @@ -1,571 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -# uspecs automation -# -# Usage: -# uspecs change new <change-name> [--issue-url <url>] [--branch] -# uspecs change archive <change-folder-name> [-d] -# uspecs pr mergedef -# uspecs pr create --title <title> [--body <body>] -# uspecs diff specs -# -# change new: -# Creates Change Folder and change.md with frontmatter: -# - Folder: <changes_folder from conf.md>/ymdHM-<change-name> -# - registered_at: YYYY-MM-DDTHH:MM:SSZ -# - change_id: ymdHM-<change-name> -# - baseline: <commit-hash> (if git repository) -# - issue_url: <url> (if --issue-url provided) -# Creates git branch (if --branch provided and git repository exists) -# Prints: <relative-path-to-change-folder> (e.g. uspecs/changes/2602201746-my-change) -# -# change archive [-d]: -# Archives change folder to <changes-folder>/archive/yymm/ymdHM-<change-name> -# Adds archived_at metadata and updates folder date prefix -# -d: commit and push staged changes, checkout default branch, delete branch and refs -# Requires git repository, clean working tree, PR branch (ending with --pr) -# -# pr mergedef: -# Validates preconditions, fetches pr_remote/default_branch, and merges it into the current branch. -# On success outputs: change_branch=<name>, default_branch=<name>, change_branch_head=<sha> -# -# pr create --title <title> --body <body>: -# Creates a PR from the current change branch (delegates to _lib/pr.sh changepr). -# Body can be passed via --body or piped via stdin. -# -# diff specs: -# Outputs git diff of the specs folder between HEAD and pr_remote/default_branch. - -error() { - echo "Error: $1" >&2 - exit 1 -} - -get_timestamp() { - date -u +"%Y-%m-%dT%H:%M:%SZ" -} - -get_baseline() { - local project_dir="$1" - if is_git_repo "$project_dir"; then - (cd "$project_dir" && git rev-parse HEAD 2>/dev/null) || echo "" - else - echo "" - fi -} - -get_folder_name() { - local path="$1" - basename "$path" -} - -count_uncompleted_items() { - local folder="$1" - local count - count=$(_grep -r "^[[:space:]]*-[[:space:]]*\[ \]" "$folder"/*.md 2>/dev/null | wc -l) - echo "${count:-0}" | tr -d ' ' -} - -extract_change_name() { - local folder_name="$1" - echo "$folder_name" | sed 's/^[0-9]\{10\}-//' -} - -move_folder() { - local source="$1" - local destination="$2" - local project_dir="${3:-}" - local check_dir="${project_dir:-$PWD}" - if is_git_repo "$check_dir"; then - if [[ -n "$project_dir" ]]; then - local rel_src="${source#"$project_dir/"}" - local rel_dst="${destination#"$project_dir/"}" - (cd "$project_dir" && git mv "$rel_src" "$rel_dst" 2>/dev/null) || mv "$source" "$destination" - else - git mv "$source" "$destination" 2>/dev/null || mv "$source" "$destination" - fi - else - mv "$source" "$destination" - fi -} - -get_script_dir() { - cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -} - -# shellcheck source=_lib/utils.sh -source "$(get_script_dir)/_lib/utils.sh" - -get_project_dir() { - local script_dir - script_dir=$(get_script_dir) - # scripts/ -> u/ -> uspecs/ -> project root - cd "$script_dir/../../.." && pwd -} - -cmd_status_ispr() { - local project_dir - project_dir=$(get_project_dir) - if ! is_git_repo "$project_dir"; then - return 0 - fi - local branch - branch=$(cd "$project_dir" && git branch --show-current 2>&1) - if [[ "$branch" == *"--pr" ]]; then - echo "yes" - fi -} - -read_conf_param() { - local param_name="$1" - local conf_file - conf_file="$(get_project_dir)/uspecs/u/conf.md" - - if [ ! -f "$conf_file" ]; then - error "conf.md not found: $conf_file" - fi - - local line raw - line=$(_grep -E "^- ${param_name}:" "$conf_file" | head -1 || true) - raw="${line#*: }" - - if [ -z "$raw" ]; then - error "Parameter '${param_name}' not found in conf.md" - fi - - # trim leading/trailing whitespace and surrounding backticks - local value - value=$(echo "$raw" | sed 's/^[[:space:]`]*//' | sed 's/[[:space:]`]*$//') - - echo "$value" -} - -cmd_change_new() { - local change_name="" - local issue_url="" - local create_branch="" - - while [[ $# -gt 0 ]]; do - case "$1" in - --issue-url) - if [[ $# -lt 2 || -z "$2" ]]; then - error "--issue-url requires a URL argument" - fi - issue_url="$2" - shift 2 - ;; - --branch) - create_branch="1" - shift - ;; - *) - if [ -z "$change_name" ]; then - change_name="$1" - shift - else - error "Unknown argument: $1" - fi - ;; - esac - done - - if [ -z "$change_name" ]; then - error "change-name is required" - fi - - if [[ ! "$change_name" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then - error "change-name must be kebab-case (lowercase letters, numbers, hyphens): $change_name" - fi - - local changes_folder_rel - changes_folder_rel=$(read_conf_param "changes_folder") - - local project_dir - project_dir=$(get_project_dir) - - local changes_folder="$project_dir/$changes_folder_rel" - - if [ ! -d "$changes_folder" ]; then - error "Changes folder not found: $changes_folder" - fi - - local timestamp - timestamp=$(date -u +"%y%m%d%H%M") - - local folder_name="${timestamp}-${change_name}" - local change_folder="$changes_folder/$folder_name" - - if [ -d "$change_folder" ]; then - error "Change folder already exists: $change_folder" - fi - - mkdir -p "$change_folder" - - local registered_at baseline - registered_at=$(get_timestamp) - baseline=$(get_baseline "$project_dir") - - local frontmatter="---"$'\n' - frontmatter+="registered_at: $registered_at"$'\n' - frontmatter+="change_id: $folder_name"$'\n' - - if [ -n "$baseline" ]; then - frontmatter+="baseline: $baseline"$'\n' - fi - - if [ -n "$issue_url" ]; then - frontmatter+="issue_url: $issue_url"$'\n' - fi - - frontmatter+="---" - - printf '%s\n' "$frontmatter" > "$change_folder/change.md" - - if [ -n "$create_branch" ]; then - if is_git_repo "$project_dir"; then - if ! (cd "$project_dir" && git checkout -b "$change_name" 2>&1); then - echo "Warning: Failed to create branch '$change_name'" >&2 - fi - else - echo "Warning: Not a git repository, cannot create branch" >&2 - fi - fi - - echo "$changes_folder_rel/$folder_name" -} - -convert_links_to_relative() { - local folder="$1" - - if [ -z "$folder" ]; then - error "folder path is required for convert_links_to_relative" - fi - - if [ ! -d "$folder" ]; then - error "Folder not found: $folder" - fi - - # Find all .md files in the folder - local md_files - md_files=$(find "$folder" -maxdepth 1 -name "*.md" -type f) - - if [ -z "$md_files" ]; then - # No markdown files to process, return success - return 0 - fi - - # Process each markdown file - while IFS= read -r file; do - # Archive moves folder 2 levels deeper (changes/ -> changes/archive/yymm/) - # Only paths starting with ../ need adjustment - add ../../ prefix - # - # Example: ](../foo) -> ](../../../foo) - # - # Skip (do not modify): - # - http://, https:// (absolute URLs) - # - # (anchors) - # - / (absolute paths) - # - ./ (current directory - stays in same folder) - # - filename.ext (same folder files like impl.md, issue.md) - - # Add ../../ prefix to paths starting with ../ - # ](../ -> ](../../../ - if ! sed_inplace "$file" -E 's#\]\(\.\./#](../../../#g'; then - error "Failed to convert links in file: $file" - fi - done <<< "$md_files" - - return 0 -} - -cmd_change_archive() { - local folder_name="" - local delete_branch="" - - while [[ $# -gt 0 ]]; do - case "$1" in - -d) - delete_branch="1" - shift - ;; - *) - if [ -z "$folder_name" ]; then - folder_name="$1" - shift - else - error "Unknown argument: $1" - fi - ;; - esac - done - - if [ -z "$folder_name" ]; then - error "change-folder-name is required" - fi - - local changes_folder_rel - changes_folder_rel=$(read_conf_param "changes_folder") - - local project_dir - project_dir=$(get_project_dir) - - local is_git="" - if is_git_repo "$project_dir"; then - is_git="1" - fi - - if [ -n "$delete_branch" ] && [ -z "$is_git" ]; then - error "-d requires a git repository" - fi - - local changes_folder="$project_dir/$changes_folder_rel" - local path_to_change_folder="$changes_folder/$folder_name" - - if [ ! -d "$path_to_change_folder" ]; then - error "Folder not found: $path_to_change_folder" - fi - - local change_file="$path_to_change_folder/change.md" - if [ ! -f "$change_file" ]; then - error "change.md not found in folder: $path_to_change_folder" - fi - - if [[ "$folder_name" == archive/* ]]; then - error "Folder is already in archive: $folder_name" - fi - - local uncompleted_count - uncompleted_count=$(count_uncompleted_items "$path_to_change_folder") - - if [ "$uncompleted_count" -gt 0 ]; then - echo "Cannot archive: $uncompleted_count uncompleted todo item(s) found" - echo "" - echo "Uncompleted items:" - _grep -rn "^[[:space:]]*-[[:space:]]*\[ \]" "$path_to_change_folder"/*.md 2>/dev/null | sed 's/^/ /' - echo "" - echo "Complete or cancel todo items before archiving" - exit 1 - fi - - local change_name - change_name=$(extract_change_name "$folder_name") - - if [ -n "$delete_branch" ] && [ -n "$is_git" ]; then - local branch_name - branch_name=$(cd "$project_dir" && git symbolic-ref --short HEAD 2>/dev/null || echo "") - if [ -z "$branch_name" ]; then - error "-d requires a named branch (HEAD is detached)" - fi - - local -A pr_info - local pr_sh - pr_sh="$(get_script_dir)/_lib/pr.sh" - if ! get_pr_info "$pr_sh" pr_info; then - error "-d requires pr.sh info to be available (remote reachable?)" - fi - local default_branch="${pr_info[default_branch]:-}" - - # a) no uncommitted changes - local git_status - git_status=$(cd "$project_dir" && git status --porcelain) - if [ -n "$git_status" ]; then - error "-d requires a clean working tree (uncommitted changes found)" - fi - - # b) branch must not be the default branch - if [ "$branch_name" = "$default_branch" ]; then - error "-d cannot be used on the default branch '$default_branch'" - fi - - # c) remote tracking ref must exist - if ! (cd "$project_dir" && git rev-parse --verify "refs/remotes/origin/$branch_name" > /dev/null 2>&1); then - error "No remote tracking ref found for '$branch_name'. Push the branch first." - fi - - # d) no divergence - local behind - behind=$(cd "$project_dir" && git rev-list --count "HEAD..origin/$branch_name") - if [ "$behind" -gt 0 ]; then - error "Branch '$branch_name' is behind origin by $behind commit(s). Pull or rebase first." - fi - - # e) branch must be a PR branch - if [[ "$branch_name" != *--pr ]]; then - error "-d can only be used on a PR branch (must end with '--pr'): '$branch_name'" - fi - fi - - local timestamp - timestamp=$(get_timestamp) - - # Insert archived_at into YAML front matter (before closing ---) - local temp_file - temp_file=$(mktemp) - awk -v ts="$timestamp" ' - /^---$/ { - if (count == 0) { - print - count++ - } else { - print "archived_at: " ts - print - } - next - } - /^archived_at:/ { next } - { print } - ' "$change_file" > "$temp_file" - if cat "$temp_file" > "$change_file"; then - : # Success, continue - else - return 1 - fi - - # Add ../ prefix to relative links for archive folder depth - if ! convert_links_to_relative "$path_to_change_folder"; then - error "Failed to convert links to relative paths" - fi - - local archive_folder="$changes_folder/archive" - - local date_prefix - date_prefix=$(date -u +"%y%m%d%H%M") - - # Extract yymm for subfolder - local yymm_prefix="${date_prefix:0:4}" - - local archive_subfolder="$archive_folder/$yymm_prefix" - mkdir -p "$archive_subfolder" - - local archive_path="$archive_subfolder/${date_prefix}-${change_name}" - - if [ -d "$archive_path" ]; then - error "Archive folder already exists: $archive_path" - fi - - if [ -n "$is_git" ]; then - local rel_change_folder="${path_to_change_folder#"$project_dir/"}" - (cd "$project_dir" && git add "$rel_change_folder") - fi - - move_folder "$path_to_change_folder" "$archive_path" "$project_dir" - - if [ -n "$is_git" ]; then - local rel_archive_path="${archive_path#"$project_dir/"}" - (cd "$project_dir" && git add "$rel_archive_path") - fi - - if [ -n "$delete_branch" ] && [ -n "$is_git" ]; then - (cd "$project_dir" && git commit -m "archive $rel_change_folder to $rel_archive_path" 2>&1) - (cd "$project_dir" && git push 2>&1) - - (cd "$project_dir" && git checkout "$default_branch" 2>&1) - - local deleted_branch_hash="" - if (cd "$project_dir" && git show-ref --verify --quiet "refs/heads/$branch_name"); then - deleted_branch_hash=$(cd "$project_dir" && git rev-parse "refs/heads/$branch_name") - (cd "$project_dir" && git branch -D "$branch_name" 2>&1) - else - echo "Warning: branch '$branch_name' not found, skipping branch deletion" >&2 - fi - (cd "$project_dir" && git branch -dr "origin/$branch_name") 2>/dev/null || true - - if [ -n "$deleted_branch_hash" ]; then - echo "Deleted branch: $branch_name ($deleted_branch_hash)" - echo "To restore: git branch $branch_name $deleted_branch_hash" - fi - fi - - echo "Archived change: $changes_folder_rel/archive/$yymm_prefix/${date_prefix}-${change_name}" -} - -main() { - if [ $# -lt 1 ]; then - error "Usage: uspecs <command> [args...]" - fi - - local command="$1" - shift - - local lib_dir - lib_dir="$(get_script_dir)/_lib" - - case "$command" in - change) - if [ $# -lt 1 ]; then - error "Usage: uspecs change <subcommand> [args...]" - fi - local subcommand="$1" - shift - - case "$subcommand" in - new) - cmd_change_new "$@" - ;; - archive) - cmd_change_archive "$@" - ;; - *) - error "Unknown change subcommand: $subcommand" - ;; - esac - ;; - pr) - if [ $# -lt 1 ]; then - error "Usage: uspecs pr <subcommand> [args...]" - fi - local subcommand="$1" - shift - - case "$subcommand" in - mergedef) - "$lib_dir/pr.sh" mergedef "$@" - ;; - create) - "$lib_dir/pr.sh" changepr "$@" - ;; - *) - error "Unknown pr subcommand: $subcommand. Available: mergedef, create" - ;; - esac - ;; - diff) - if [ $# -lt 1 ]; then - error "Usage: uspecs diff <target>" - fi - local target="$1" - shift - - case "$target" in - specs) - "$lib_dir/pr.sh" diff specs "$@" - ;; - *) - error "Unknown diff target: $target. Available: specs" - ;; - esac - ;; - status) - if [ $# -lt 1 ]; then - error "Usage: uspecs status <subcommand> [args...]" - fi - local subcommand="$1" - shift - - case "$subcommand" in - ispr) - cmd_status_ispr "$@" - ;; - *) - error "Unknown status subcommand: $subcommand. Available: ispr" - ;; - esac - ;; - *) - error "Unknown command: $command" - ;; - esac -} - -main "$@" diff --git a/uspecs/u/uspecs.yml b/uspecs/u/uspecs.yml index e304f30..dd335bc 100644 --- a/uspecs/u/uspecs.yml +++ b/uspecs/u/uspecs.yml @@ -2,7 +2,7 @@ # DO NOT EDIT - managed by uspecs version: 0.1.0-a0 invocation_methods: [nlia] -installed_at: 2026-02-21T18:48:11Z -modified_at: 2026-02-25T17:58:57Z -commit: b810301a0ef29c56fadb02d3452346312d42e570 -commit_timestamp: 2026-02-25T17:56:59Z +installed_at: 2026-04-01T17:16:26Z +modified_at: 2026-04-01T17:16:26Z +commit: 364f565aee0664483703c3be02c767af2aba0094 +commit_timestamp: 2026-04-01T16:21:32Z