commit 092f1f9a4543d7bb1c4434bc9eb0acb61ab058b7 Author: Andy Dixon Date: Mon Jun 15 07:57:51 2026 +0100 TUI conversion diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1c9a4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin/ +giteditor diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..172916d --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Copyright (c) Andy Dixon. +All rights reserved. + +This software and associated documentation files (the "Software") are proprietary +and confidential. + +Permission is not granted to use, copy, modify, merge, publish, distribute, +sublicense, or sell copies of the Software unless explicit prior written +permission is provided by the copyright holder. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, +OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b18a533 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Nexus + +Nexus is a pure ANSI terminal UI for viewing and editing Git commit metadata, then applying rewritten history in a guided workflow. + +Repository: https://github.com/andydixon/nexus + +## Features + +- Launches in the current Git repository by default, or with `nexus /path/to/repo` +- Left-hand commit browser with hash, timestamp, author, and wrapped commit summary +- Right-hand commit editor for: + - author name, email, and date/time + - committer name, email, and date/time + - full commit message +- Search commits by hash, message, author name, or email +- Track edited commits and reset the selected commit +- Apply all staged metadata changes in one action +- Optionally force push rewritten branches and tags to `origin` + +## Keyboard + +- `Tab`: move from the commit list into the form, then through each editable field +- `Shift+Tab`: move backward through form fields +- `Up`/`Down`, `PgUp`/`PgDn`, `Home`/`End`: navigate commits +- `/`: search commits +- `p`: switch repository path +- `r`: reload history +- `x`: reset the selected commit +- `a`: apply changes +- `f`: toggle force push +- `t`: toggle tag push +- `?`: show help +- `q` or `Ctrl+C`: quit + +## Apply Workflow + +When you apply changes, Nexus: + +1. Checks that the selected repository is clean. +2. Creates a backup tag (`nexus-backup-`). +3. Rewrites history with `git filter-branch` across all refs. +4. Removes temporary rewrite references and runs repository cleanup. +5. Optionally force pushes branches and tags using `--force-with-lease`. + +Force push requires an explicit `FORCE` confirmation in the terminal. + +## Run + +```bash +go run . # open the current directory +go run . ~/repo # open a specific repository +``` + +If the launch directory is not a Git worktree, Nexus opens a path-entry screen instead of exiting. + +## Build + +```bash +./build.sh +./bin/nexus +``` + +## Safety + +History rewriting changes commit hashes and can disrupt shared branches. Coordinate with collaborators before rewriting shared history or force pushing. + +## Licence + +This project is proprietary software. It is **not** released under the MIT licence. + diff --git a/app.go b/app.go new file mode 100644 index 0000000..1d441ec --- /dev/null +++ b/app.go @@ -0,0 +1,558 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type App struct { + mu sync.RWMutex + repoPath string +} + +func NewApp() *App { + return &App{} +} + +type RepositoryState struct { + Path string `json:"path"` + Clean bool `json:"clean"` + DetachedHead bool `json:"detachedHead"` + CurrentBranch string `json:"currentBranch"` + OriginURL string `json:"originUrl"` +} + +type CommitRecord struct { + Hash string `json:"hash"` + ShortHash string `json:"shortHash"` + AuthorName string `json:"authorName"` + AuthorEmail string `json:"authorEmail"` + AuthorDate string `json:"authorDate"` + CommitterName string `json:"committerName"` + CommitterEmail string `json:"committerEmail"` + CommitterDate string `json:"committerDate"` + Refs []string `json:"refs"` + Tree string `json:"tree"` + Parents []string `json:"parents"` + Subject string `json:"subject"` + Message string `json:"message"` +} + +type ApplyRequest struct { + Commits []CommitRecord `json:"commits"` + ForcePush bool `json:"forcePush"` + PushTags bool `json:"pushTags"` +} + +type ApplyResult struct { + RewrittenCommits int `json:"rewrittenCommits"` + ForcePushed bool `json:"forcePushed"` + BackupReference string `json:"backupReference"` + Warnings []string `json:"warnings"` +} + +func (a *App) SetRepository(path string) (RepositoryState, error) { + normalized, err := normalizePath(path) + if err != nil { + return RepositoryState{}, err + } + if err := validateRepository(normalized); err != nil { + return RepositoryState{}, err + } + + a.mu.Lock() + a.repoPath = normalized + a.mu.Unlock() + + return a.inspectRepository(normalized) +} + +func (a *App) GetRepositoryState() (RepositoryState, error) { + repo, ok := a.currentRepository() + if !ok { + return RepositoryState{}, nil + } + return a.inspectRepository(repo) +} + +func (a *App) LoadHistory() ([]CommitRecord, error) { + repo, ok := a.currentRepository() + if !ok { + return nil, errors.New("no repository selected") + } + + output, err := runGit(repo, + "log", + "--all", + "--reverse", + "--date=iso-strict", + "--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%D%x1f%T%x1f%P%x1f%B%x1e", + ) + if err != nil { + return nil, err + } + + records := strings.Split(output, "\x1e") + commits := make([]CommitRecord, 0, len(records)) + + for _, raw := range records { + if strings.TrimSpace(raw) == "" { + continue + } + + parts := strings.SplitN(raw, "\x1f", 12) + if len(parts) != 12 { + return nil, fmt.Errorf("could not parse commit record: %q", raw) + } + + message := normalizeMessage(parts[11]) + commits = append(commits, CommitRecord{ + Hash: parts[0], + ShortHash: parts[1], + AuthorName: parts[2], + AuthorEmail: parts[3], + AuthorDate: parts[4], + CommitterName: parts[5], + CommitterEmail: parts[6], + CommitterDate: parts[7], + Refs: splitAndTrim(parts[8], ","), + Tree: parts[9], + Parents: splitAndTrim(parts[10], " "), + Subject: firstLine(message), + Message: message, + }) + } + + return commits, nil +} + +func (a *App) ApplyChanges(req ApplyRequest) (ApplyResult, error) { + repo, ok := a.currentRepository() + if !ok { + return ApplyResult{}, errors.New("no repository selected") + } + if len(req.Commits) == 0 { + return ApplyResult{}, errors.New("nothing to apply: no commit payload supplied") + } + + dirty, err := hasUncommittedChanges(repo) + if err != nil { + return ApplyResult{}, err + } + if dirty { + return ApplyResult{}, errors.New("repository has uncommitted changes; commit or stash before rewriting history") + } + + currentHistory, err := a.LoadHistory() + if err != nil { + return ApplyResult{}, err + } + + currentByHash := make(map[string]CommitRecord, len(currentHistory)) + for _, commit := range currentHistory { + currentByHash[commit.Hash] = commit + } + + updatedByHash := make(map[string]CommitRecord) + messageOverride := make(map[string]bool) + + for _, incoming := range req.Commits { + current, exists := currentByHash[incoming.Hash] + if !exists { + continue + } + + normalized, err := normalizeIncomingCommit(incoming, current) + if err != nil { + return ApplyResult{}, fmt.Errorf("commit %s is invalid: %w", incoming.ShortHash, err) + } + + if editableFieldsChanged(current, normalized) { + updatedByHash[incoming.Hash] = normalized + messageOverride[incoming.Hash] = normalizeMessage(current.Message) != normalizeMessage(normalized.Message) + } + } + + result := ApplyResult{ + RewrittenCommits: len(updatedByHash), + Warnings: []string{}, + } + + if len(updatedByHash) > 0 { + headBefore, _ := runGit(repo, "rev-parse", "--verify", "HEAD") + backupTag := fmt.Sprintf("nexus-backup-%d", time.Now().UTC().Unix()) + if _, err := runGit(repo, "tag", backupTag); err != nil { + return ApplyResult{}, fmt.Errorf("failed to create backup tag: %w", err) + } + result.BackupReference = "refs/tags/" + backupTag + + if err := rewriteHistory(repo, updatedByHash, messageOverride); err != nil { + return ApplyResult{}, err + } + + if err := cleanupFilterBranchRefs(repo); err != nil { + result.Warnings = append(result.Warnings, err.Error()) + } + + headAfter, _ := runGit(repo, "rev-parse", "--verify", "HEAD") + if strings.TrimSpace(headBefore) != "" && + strings.TrimSpace(headAfter) != "" && + strings.TrimSpace(headBefore) == strings.TrimSpace(headAfter) { + result.Warnings = append(result.Warnings, "HEAD did not change; edited commits may be outside the current branch") + } + } + + if req.ForcePush { + if err := forcePushToOrigin(repo, req.PushTags); err != nil { + return result, err + } + result.ForcePushed = true + } + + return result, nil +} + +func (a *App) inspectRepository(repo string) (RepositoryState, error) { + dirty, err := hasUncommittedChanges(repo) + if err != nil { + return RepositoryState{}, err + } + + branchName, branchErr := runGit(repo, "symbolic-ref", "--quiet", "--short", "HEAD") + detachedHead := false + currentBranch := strings.TrimSpace(branchName) + if branchErr != nil { + detachedHead = true + currentBranch = "" + } + + origin, err := runGit(repo, "remote", "get-url", "origin") + if err != nil { + origin = "" + } + + return RepositoryState{ + Path: repo, + Clean: !dirty, + DetachedHead: detachedHead, + CurrentBranch: currentBranch, + OriginURL: strings.TrimSpace(origin), + }, nil +} + +func (a *App) currentRepository() (string, bool) { + a.mu.RLock() + defer a.mu.RUnlock() + if a.repoPath == "" { + return "", false + } + return a.repoPath, true +} + +func normalizePath(path string) (string, error) { + candidate := strings.TrimSpace(path) + if candidate == "" { + return "", errors.New("repository path is required") + } + + absolute, err := filepath.Abs(filepath.Clean(candidate)) + if err != nil { + return "", err + } + return absolute, nil +} + +func validateRepository(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("selected path is not a directory") + } + + output, err := runGit(path, "rev-parse", "--is-inside-work-tree") + if err != nil { + return fmt.Errorf("selected path is not a git worktree: %w", err) + } + if strings.TrimSpace(output) != "true" { + return errors.New("selected path is not inside a git worktree") + } + return nil +} + +func hasUncommittedChanges(repo string) (bool, error) { + status, err := runGit(repo, "status", "--porcelain") + if err != nil { + return false, err + } + return strings.TrimSpace(status) != "", nil +} + +func rewriteHistory(repo string, updatedByHash map[string]CommitRecord, messageOverride map[string]bool) error { + tempDir, err := os.MkdirTemp("", "giteditor-rewrite-*") + if err != nil { + return err + } + defer os.RemoveAll(tempDir) + + messageDir := filepath.Join(tempDir, "messages") + if err := os.MkdirAll(messageDir, 0o700); err != nil { + return err + } + + keys := make([]string, 0, len(updatedByHash)) + for hash := range updatedByHash { + keys = append(keys, hash) + } + sort.Strings(keys) + + messageFileByHash := make(map[string]string) + for _, hash := range keys { + if !messageOverride[hash] { + continue + } + commit := updatedByHash[hash] + messagePath := filepath.Join(messageDir, hash+".txt") + if err := os.WriteFile(messagePath, []byte(commit.Message), 0o600); err != nil { + return err + } + messageFileByHash[hash] = messagePath + } + + var envFilter strings.Builder + envFilter.WriteString("case \"$GIT_COMMIT\" in\n") + for _, hash := range keys { + commit := updatedByHash[hash] + fmt.Fprintf(&envFilter, "%s)\n", hash) + fmt.Fprintf(&envFilter, "export GIT_AUTHOR_NAME=%s\n", shellQuote(commit.AuthorName)) + fmt.Fprintf(&envFilter, "export GIT_AUTHOR_EMAIL=%s\n", shellQuote(commit.AuthorEmail)) + fmt.Fprintf(&envFilter, "export GIT_AUTHOR_DATE=%s\n", shellQuote(commit.AuthorDate)) + fmt.Fprintf(&envFilter, "export GIT_COMMITTER_NAME=%s\n", shellQuote(commit.CommitterName)) + fmt.Fprintf(&envFilter, "export GIT_COMMITTER_EMAIL=%s\n", shellQuote(commit.CommitterEmail)) + fmt.Fprintf(&envFilter, "export GIT_COMMITTER_DATE=%s\n", shellQuote(commit.CommitterDate)) + envFilter.WriteString(";;\n") + } + envFilter.WriteString("esac\n") + + var msgFilter strings.Builder + msgFilter.WriteString("case \"$GIT_COMMIT\" in\n") + for _, hash := range keys { + messagePath, hasOverride := messageFileByHash[hash] + if !hasOverride { + continue + } + fmt.Fprintf(&msgFilter, "%s)\n", hash) + fmt.Fprintf(&msgFilter, "cat %s\n", shellQuote(messagePath)) + msgFilter.WriteString(";;\n") + } + msgFilter.WriteString("*)\ncat\n;;\nesac\n") + + envFilterPath := filepath.Join(tempDir, "env-filter.sh") + msgFilterPath := filepath.Join(tempDir, "msg-filter.sh") + + if err := os.WriteFile(envFilterPath, []byte(envFilter.String()), 0o700); err != nil { + return err + } + if err := os.WriteFile(msgFilterPath, []byte(msgFilter.String()), 0o700); err != nil { + return err + } + + envFilterCmd := ". " + shellQuote(envFilterPath) + msgFilterCmd := ". " + shellQuote(msgFilterPath) + + output, err := runGit( + repo, + "filter-branch", + "-f", + "--env-filter", envFilterCmd, + "--msg-filter", msgFilterCmd, + "--tag-name-filter", "cat", + "--", + "--all", + ) + if err != nil { + return fmt.Errorf("history rewrite failed: %w", err) + } + if rewrittenRefCount(output) == 0 { + return errors.New("git did not rewrite any refs; no effective history changes were applied") + } + + return nil +} + +func rewrittenRefCount(filterBranchOutput string) int { + return strings.Count(filterBranchOutput, " was rewritten") +} + +func cleanupFilterBranchRefs(repo string) error { + refsRaw, err := runGit(repo, "for-each-ref", "--format=%(refname)", "refs/original/") + if err != nil { + return err + } + + for _, ref := range splitAndTrim(refsRaw, "\n") { + if _, err := runGit(repo, "update-ref", "-d", ref); err != nil { + return err + } + } + + if _, err := runGit(repo, "reflog", "expire", "--expire=now", "--all"); err != nil { + return err + } + if _, err := runGit(repo, "gc", "--prune=now"); err != nil { + return err + } + + return nil +} + +func forcePushToOrigin(repo string, pushTags bool) error { + if _, err := runGit(repo, "remote", "get-url", "origin"); err != nil { + return errors.New("origin remote is not configured; cannot force push") + } + + if _, err := runGit(repo, "push", "--force-with-lease", "origin", "--all"); err != nil { + return err + } + if pushTags { + if _, err := runGit(repo, "push", "--force-with-lease", "origin", "--tags"); err != nil { + return err + } + } + + return nil +} + +func normalizeIncomingCommit(incoming CommitRecord, current CommitRecord) (CommitRecord, error) { + incoming.AuthorName = strings.TrimSpace(incoming.AuthorName) + incoming.AuthorEmail = strings.TrimSpace(incoming.AuthorEmail) + incoming.CommitterName = strings.TrimSpace(incoming.CommitterName) + incoming.CommitterEmail = strings.TrimSpace(incoming.CommitterEmail) + incoming.Message = normalizeMessage(incoming.Message) + + if incoming.AuthorName == "" || incoming.AuthorEmail == "" { + return CommitRecord{}, errors.New("author name and email are required") + } + if incoming.CommitterName == "" || incoming.CommitterEmail == "" { + return CommitRecord{}, errors.New("committer name and email are required") + } + + authorDate, err := normalizeGitDate(incoming.AuthorDate) + if err != nil { + return CommitRecord{}, fmt.Errorf("invalid author date %q", incoming.AuthorDate) + } + committerDate, err := normalizeGitDate(incoming.CommitterDate) + if err != nil { + return CommitRecord{}, fmt.Errorf("invalid committer date %q", incoming.CommitterDate) + } + + incoming.AuthorDate = authorDate + incoming.CommitterDate = committerDate + incoming.Hash = current.Hash + incoming.ShortHash = current.ShortHash + incoming.Refs = current.Refs + incoming.Tree = current.Tree + incoming.Parents = current.Parents + incoming.Subject = firstLine(incoming.Message) + + return incoming, nil +} + +func normalizeGitDate(input string) (string, error) { + date := strings.TrimSpace(input) + if date == "" { + return "", errors.New("date cannot be empty") + } + + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02T15:04", + "2006-01-02 15:04:05", + } + + for _, layout := range layouts { + var ( + t time.Time + err error + ) + + if layout == time.RFC3339 || layout == time.RFC3339Nano { + t, err = time.Parse(layout, date) + } else { + t, err = time.ParseInLocation(layout, date, time.Local) + } + if err == nil { + return t.Format("2006-01-02T15:04:05-07:00"), nil + } + } + + return "", errors.New("date must be RFC3339 or YYYY-MM-DDTHH:MM[:SS]") +} + +func editableFieldsChanged(current CommitRecord, incoming CommitRecord) bool { + return current.AuthorName != incoming.AuthorName || + current.AuthorEmail != incoming.AuthorEmail || + current.AuthorDate != incoming.AuthorDate || + current.CommitterName != incoming.CommitterName || + current.CommitterEmail != incoming.CommitterEmail || + current.CommitterDate != incoming.CommitterDate || + normalizeMessage(current.Message) != normalizeMessage(incoming.Message) +} + +func firstLine(message string) string { + parts := strings.SplitN(message, "\n", 2) + return strings.TrimSpace(parts[0]) +} + +func normalizeMessage(message string) string { + message = strings.ReplaceAll(message, "\r\n", "\n") + return strings.TrimRight(message, "\n") +} + +func splitAndTrim(value string, sep string) []string { + raw := strings.Split(value, sep) + result := make([]string, 0, len(raw)) + for _, candidate := range raw { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + result = append(result, candidate) + } + return result +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} + +func runGit(repo string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + cmd.Env = os.Environ() + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = err.Error() + } + return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), detail) + } + + return stdout.String(), nil +} diff --git a/app_apply_test.go b/app_apply_test.go new file mode 100644 index 0000000..1c0087d --- /dev/null +++ b/app_apply_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestApplyChangesRewritesMetadata(t *testing.T) { + repo := t.TempDir() + + run := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, string(out)) + } + return string(out) + } + + run("init") + run("config", "user.name", "Original User") + run("config", "user.email", "original@example.com") + + if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("one\n"), 0o644); err != nil { + t.Fatal(err) + } + run("add", "a.txt") + run("commit", "-m", "first") + + if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("two\n"), 0o644); err != nil { + t.Fatal(err) + } + run("add", "a.txt") + run("commit", "-m", "second") + + app := NewApp() + if _, err := app.SetRepository(repo); err != nil { + t.Fatal(err) + } + + history, err := app.LoadHistory() + if err != nil { + t.Fatal(err) + } + if len(history) < 2 { + t.Fatalf("expected at least 2 commits, got %d", len(history)) + } + + first := history[0] + first.AuthorName = "Edited Author" + first.Message = "edited first message" + + req := ApplyRequest{ + Commits: history, + ForcePush: false, + PushTags: false, + } + req.Commits[0] = first + + result, err := app.ApplyChanges(req) + if err != nil { + t.Fatal(err) + } + if result.RewrittenCommits == 0 { + t.Fatalf("expected rewritten commits > 0, got %d", result.RewrittenCommits) + } + + out := run("log", "--reverse", "--pretty=format:%an%x1f%B%x1e") + if !strings.Contains(out, "Edited Author") { + t.Fatalf("expected rewritten author in log, got: %s", out) + } + if !strings.Contains(out, "edited first message") { + t.Fatalf("expected rewritten message in log, got: %s", out) + } +} + +func TestRewriteHistoryFailsWhenNoRefsAreRewritten(t *testing.T) { + repo := t.TempDir() + + run := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, string(out)) + } + return string(out) + } + + run("init") + run("config", "user.name", "Original User") + run("config", "user.email", "original@example.com") + + if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("one\n"), 0o644); err != nil { + t.Fatal(err) + } + run("add", "a.txt") + run("commit", "-m", "first") + + app := NewApp() + if _, err := app.SetRepository(repo); err != nil { + t.Fatal(err) + } + history, err := app.LoadHistory() + if err != nil { + t.Fatal(err) + } + if len(history) != 1 { + t.Fatalf("expected one commit, got %d", len(history)) + } + + unchanged := history[0] + err = rewriteHistory(repo, map[string]CommitRecord{ + unchanged.Hash: unchanged, + }, map[string]bool{}) + if err == nil { + t.Fatal("expected rewriteHistory to fail when git reports unchanged refs") + } + if !strings.Contains(err.Error(), "did not rewrite any refs") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..84e885e --- /dev/null +++ b/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +mkdir -p "$ROOT_DIR/bin" +go build -o "$ROOT_DIR/bin/nexus" "$ROOT_DIR" + +echo "Built $ROOT_DIR/bin/nexus" + diff --git a/edit.sh b/edit.sh new file mode 100755 index 0000000..4e142d2 --- /dev/null +++ b/edit.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# Exit immediately if a command fails. +set -euo pipefail + +# Print a small usage message. +usage() { + cat <<'EOF' +Usage: + ./rewrite-history.sh "New Name" "[email protected]" + +What it does: + - Rewrites reachable Git history in the current repository. + - Removes lines starting with "Co-Authored" from commit messages. + - Sets author and committer name/email to the values you provide. + - Moves commit times to either: + 00:00-07:30 + 19:00-23:59 + - Tries to leave at least one hour between commits. + - Never makes a rewritten commit earlier than the previous rewritten commit. + +Notes: + - This rewrites history destructively. + - Run it on a fresh clone or a throwaway copy first. + - You will need to force-push afterwards. +EOF +} + +# Require exactly two arguments. +if [[ $# -ne 2 ]]; then + usage + exit 1 +fi + +# Store the new author name. +NEW_NAME="$1" + +# Store the new author email. +NEW_EMAIL="$2" + +# Make sure we are inside a Git working tree. +git rev-parse --is-inside-work-tree >/dev/null 2>&1 + +# Make sure git-filter-repo is installed. +if ! command -v git-filter-repo >/dev/null 2>&1; then + echo "Error: git-filter-repo is not installed." >&2 + echo "Install it first, then re-run this script." >&2 + exit 1 +fi + +# Make sure Python 3 is available, because the callbacks use Python. +if ! command -v python3 >/dev/null 2>&1; then + echo "Error: python3 is not installed." >&2 + exit 1 +fi + +# Create a safety backup ref pointing at the current HEAD. +BACKUP_REF="refs/backup/pre-rewrite-$(date +%Y%m%d-%H%M%S)" +git update-ref "${BACKUP_REF}" HEAD + +# Tell the user where the backup ref was stored. +echo "Created backup ref at ${BACKUP_REF}" + +# Create a temporary file for the commit callback. +COMMIT_CALLBACK_FILE="$(mktemp)" + +# Create a temporary file for the message callback. +MESSAGE_CALLBACK_FILE="$(mktemp)" + +# Ensure the temporary files are removed when the script exits. +cleanup() { + rm -f "${COMMIT_CALLBACK_FILE}" "${MESSAGE_CALLBACK_FILE}" +} +trap cleanup EXIT + +# Write the commit callback. +cat >"${COMMIT_CALLBACK_FILE}" <<'PY' +# Persist the previously assigned rewritten timestamp across callback calls. +if "prev_assigned_ts" not in globals(): + prev_assigned_ts = None + +# Read the requested replacement identity from the environment. +new_name = os.environ["REWRITE_NEW_NAME"].encode("utf-8") +new_email = os.environ["REWRITE_NEW_EMAIL"].encode("utf-8") + +# Read the preferred minimum gap, in seconds, from the environment. +min_gap_seconds = int(os.environ.get("REWRITE_MIN_GAP_SECONDS", "3600")) + +# Replace the author identity. +commit.author_name = new_name +commit.author_email = new_email + +# Replace the committer identity as well, so both sides match. +commit.committer_name = new_name +commit.committer_email = new_email + +# Use the original author date as the calendar anchor if present. +raw_date = commit.author_date or commit.committer_date + +# Parse the fast-export style date string into a timezone-aware datetime. +original_dt = string_to_date(raw_date) + +# Work with the original timezone attached to the commit. +tz = original_dt.tzinfo + +# Keep the original calendar date as the first preference. +candidate_date = original_dt.date() + +# Build a lower bound based on the previous rewritten commit. +earliest_allowed_dt = None +if prev_assigned_ts is not None: + earliest_allowed_dt = datetime.fromtimestamp(prev_assigned_ts + min_gap_seconds, tz) + +# Build the two allowed windows for a given date. +def windows_for(day): + return [ + ( + datetime(day.year, day.month, day.day, 0, 0, 0, tzinfo=tz), + datetime(day.year, day.month, day.day, 7, 30, 0, tzinfo=tz), + ), + ( + datetime(day.year, day.month, day.day, 19, 0, 0, tzinfo=tz), + datetime(day.year, day.month, day.day, 23, 59, 0, tzinfo=tz), + ), + ] + +# Choose the first valid timestamp on or after the lower bound. +chosen_dt = None + +# Search forward until we find a valid slot. +while chosen_dt is None: + for window_start, window_end in windows_for(candidate_date): + # Start at the opening of the window. + slot = window_start + + # If we already have a monotonic lower bound, honour it. + if earliest_allowed_dt is not None and slot < earliest_allowed_dt: + slot = earliest_allowed_dt + + # If the original time is already inside an allowed window on this date, + # prefer to keep it, provided it does not violate monotonic ordering. + if candidate_date == original_dt.date() and window_start <= original_dt <= window_end: + if earliest_allowed_dt is None: + slot = original_dt + elif original_dt >= earliest_allowed_dt: + slot = original_dt + + # Accept the slot if it still lands inside the current window. + if slot <= window_end: + chosen_dt = slot + break + + # If there was no room on that date, move to the next calendar day. + if chosen_dt is None: + candidate_date = candidate_date + timedelta(days=1) + +# Convert the chosen datetime back into fast-export's expected byte format. +chosen_raw = date_to_string(chosen_dt) + +# Apply the same rewritten datetime to both author and committer dates. +commit.author_date = chosen_raw +commit.committer_date = chosen_raw + +# Remember this timestamp so later commits are never earlier. +prev_assigned_ts = int(chosen_dt.timestamp()) +PY + +# Write the message callback. +cat >"${MESSAGE_CALLBACK_FILE}" <<'PY' +# Remove any line that starts with "Co-Authored", case-insensitively. +message = re.sub( + br'(?im)^[ \t]*Co-Authored[^\r\n]*(?:\r?\n|$)', + b'', + message, +) + +# Trim excessive blank lines from the end of the message. +message = re.sub(br'(?:\r?\n){3,}$', b'\n\n', message) + +# Return the rewritten message bytes. +return message +PY + +# Export the new identity so the Python callbacks can read it. +export REWRITE_NEW_NAME="${NEW_NAME}" +export REWRITE_NEW_EMAIL="${NEW_EMAIL}" + +# Ask for a one-hour preferred gap between rewritten commits. +export REWRITE_MIN_GAP_SECONDS="3600" + +# Run the history rewrite in the current repository. +git filter-repo \ + --force \ + --message-callback "$(cat "${MESSAGE_CALLBACK_FILE}")" \ + --commit-callback "$(cat "${COMMIT_CALLBACK_FILE}")" + +# Tell the user the rewrite finished. +echo +echo "Rewrite complete." +echo "Backup ref: ${BACKUP_REF}" +echo +echo "Check the rewritten history with:" +echo " git log --pretty=fuller --date=iso" +echo +echo "If it looks right, force-push with:" +echo " git push --force --all" +echo " git push --force --tags" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dbfec21 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module giteditor + +go 1.23.0 + +require ( + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbletea v1.2.4 + github.com/charmbracelet/lipgloss v1.0.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..903df1b --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= +github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= +github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= diff --git a/main.go b/main.go new file mode 100644 index 0000000..21e6969 --- /dev/null +++ b/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + repoPath, err := initialRepositoryPath(os.Args[1:], os.Getwd) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + program := tea.NewProgram(newTUIModel(NewApp(), repoPath), tea.WithAltScreen()) + if _, err := program.Run(); err != nil { + fmt.Fprintf(os.Stderr, "nexus failed: %v\n", err) + os.Exit(1) + } +} diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..37de2fb --- /dev/null +++ b/tui.go @@ -0,0 +1,1329 @@ +package main + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + formAuthorName = iota + formAuthorEmail + formAuthorDate + formCommitterName + formCommitterEmail + formCommitterDate + formMessage + formFieldCount +) + +type uiFocus int + +const ( + focusList uiFocus = iota + focusForm +) + +type uiOverlay int + +const ( + overlayNone uiOverlay = iota + overlaySearch + overlayPath + overlayConfirmApply + overlayConfirmForce + overlayHelp +) + +type statusKind string + +const ( + statusInfo statusKind = "info" + statusSuccess statusKind = "success" + statusError statusKind = "error" +) + +var ( + colorInk = lipgloss.Color("#DCE7F7") + colorMuted = lipgloss.Color("#7E8DA3") + colorDim = lipgloss.Color("#506075") + colorPanel = lipgloss.Color("#111B27") + colorPanelLift = lipgloss.Color("#172536") + colorSelected = lipgloss.Color("#20364B") + colorCyan = lipgloss.Color("#55D6FF") + colorGreen = lipgloss.Color("#78F0B4") + colorMagenta = lipgloss.Color("#D68DFF") + colorAmber = lipgloss.Color("#FFD166") + colorRed = lipgloss.Color("#FF6B7A") + colorBorder = lipgloss.Color("#2B435A") + + appBaseStyle = lipgloss.NewStyle(). + Foreground(colorInk). + Background(lipgloss.Color("#08111B")) + panelStyle = lipgloss.NewStyle(). + Background(colorPanel). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder) + activePanelStyle = panelStyle.Copy(). + BorderForeground(colorCyan) + headerStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorCyan) + mutedStyle = lipgloss.NewStyle(). + Foreground(colorMuted) + dimStyle = lipgloss.NewStyle(). + Foreground(colorDim) + selectedItemStyle = lipgloss.NewStyle(). + Background(colorSelected). + Foreground(colorInk). + Bold(true). + Padding(0, 1) + itemStyle = lipgloss.NewStyle(). + Foreground(colorInk). + Padding(0, 1) + fieldLabelStyle = lipgloss.NewStyle(). + Foreground(colorMuted). + Bold(true) + focusedFieldStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(colorCyan). + Background(colorPanelLift). + Padding(0, 1) + blurredFieldStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(colorBorder). + Background(colorPanel). + Padding(0, 1) +) + +type repoLoadedMsg struct { + repo RepositoryState + commits []CommitRecord + path string + err error +} + +type applyFinishedMsg struct { + result ApplyResult + repo RepositoryState + commits []CommitRecord + err error +} + +type tuiModel struct { + app *App + initialPath string + + repository RepositoryState + repoLoaded bool + commits []CommitRecord + + originalByHash map[string]CommitRecord + draftByHash map[string]CommitRecord + + selectedHash string + selectedIndex int + listOffset int + + width int + height int + + focus uiFocus + overlay uiOverlay + + formFocus int + inputs []textinput.Model + message textarea.Model + + searchInput textinput.Model + pathInput textinput.Model + confirmInput textinput.Model + + forcePush bool + pushTags bool + busy bool + + status string + statusKind statusKind +} + +func initialRepositoryPath(args []string, getwd func() (string, error)) (string, error) { + if len(args) > 1 { + return "", errors.New("usage: nexus [repo-path]") + } + if len(args) == 1 { + return normalizePath(args[0]) + } + wd, err := getwd() + if err != nil { + return "", err + } + return normalizePath(wd) +} + +func newTUIModel(app *App, initialPath string) tuiModel { + inputs := make([]textinput.Model, formFieldCount-1) + for i := range inputs { + inputs[i] = newTextInput() + } + + message := textarea.New() + message.Prompt = "" + message.Placeholder = "Commit message" + message.ShowLineNumbers = false + message.CharLimit = 0 + message.Blur() + + searchInput := newTextInput() + searchInput.Placeholder = "Search hash, author, email, or message" + + pathInput := newTextInput() + pathInput.Placeholder = "/path/to/repository" + pathInput.SetValue(initialPath) + + confirmInput := newTextInput() + confirmInput.Placeholder = "FORCE" + + return tuiModel{ + app: app, + initialPath: initialPath, + originalByHash: map[string]CommitRecord{}, + draftByHash: map[string]CommitRecord{}, + inputs: inputs, + message: message, + searchInput: searchInput, + pathInput: pathInput, + confirmInput: confirmInput, + pushTags: true, + status: "Loading repository...", + statusKind: statusInfo, + } +} + +func newTextInput() textinput.Model { + input := textinput.New() + input.Prompt = "" + input.PlaceholderStyle = lipgloss.NewStyle().Foreground(colorDim) + input.TextStyle = lipgloss.NewStyle().Foreground(colorInk) + input.Cursor.Style = lipgloss.NewStyle().Foreground(colorCyan) + return input +} + +func (m tuiModel) Init() tea.Cmd { + return loadRepositoryCmd(m.app, m.initialPath) +} + +func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.updateComponentSizes() + return m, nil + case repoLoadedMsg: + m.busy = false + m.handleRepoLoaded(msg) + return m, nil + case applyFinishedMsg: + m.busy = false + m.handleApplyFinished(msg) + return m, nil + case tea.KeyMsg: + return m.handleKey(msg) + default: + return m, nil + } +} + +func (m *tuiModel) handleRepoLoaded(msg repoLoadedMsg) { + if msg.err != nil { + m.repoLoaded = false + m.repository = RepositoryState{} + m.commits = nil + m.originalByHash = map[string]CommitRecord{} + m.draftByHash = map[string]CommitRecord{} + m.selectedHash = "" + m.selectedIndex = 0 + m.listOffset = 0 + m.overlay = overlayPath + m.focus = focusList + m.pathInput.SetValue(msg.path) + m.focusPathInput() + m.setStatus(fmt.Sprintf("Repository unavailable: %v", msg.err), statusError) + return + } + + m.repoLoaded = true + m.repository = msg.repo + m.commits = newestFirst(msg.commits) + m.originalByHash = mapByHash(m.commits) + m.draftByHash = cloneMapByHash(m.commits) + m.pathInput.SetValue(msg.repo.Path) + m.reconcileSelection() + m.loadSelectedCommitIntoForm() + m.overlay = overlayNone + m.setStatus(fmt.Sprintf("Loaded %d commits from %s", len(m.commits), filepath.Base(msg.repo.Path)), statusSuccess) +} + +func (m *tuiModel) handleApplyFinished(msg applyFinishedMsg) { + if msg.err != nil { + m.setStatus(msg.err.Error(), statusError) + return + } + + if msg.repo.Path != "" { + m.repository = msg.repo + m.repoLoaded = true + } + if msg.commits != nil { + m.commits = newestFirst(msg.commits) + m.originalByHash = mapByHash(m.commits) + m.draftByHash = cloneMapByHash(m.commits) + m.reconcileSelection() + m.loadSelectedCommitIntoForm() + } + + m.setStatus(applyResultSummary(msg.result), statusSuccess) +} + +func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + if key == "ctrl+c" { + return m, tea.Quit + } + + if m.busy { + return m, nil + } + + switch m.overlay { + case overlayHelp: + if key == "esc" || key == "?" || key == "q" { + m.overlay = overlayNone + } + return m, nil + case overlaySearch: + return m.updateSearch(msg) + case overlayPath: + return m.updatePathEntry(msg) + case overlayConfirmApply, overlayConfirmForce: + return m.updateConfirmation(msg) + } + + if m.focus == focusForm { + return m.updateForm(msg) + } + + switch key { + case "q": + return m, tea.Quit + case "?": + m.overlay = overlayHelp + case "/": + m.overlay = overlaySearch + m.searchInput.Focus() + m.searchInput.CursorEnd() + case "p", "o": + m.overlay = overlayPath + m.focusPathInput() + case "r": + if m.repository.Path != "" { + m.busy = true + m.setStatus("Reloading history...", statusInfo) + return m, loadRepositoryCmd(m.app, m.repository.Path) + } + m.overlay = overlayPath + m.focusPathInput() + case "f": + m.forcePush = !m.forcePush + m.setStatus(fmt.Sprintf("Force push %s", onOff(m.forcePush)), statusInfo) + case "t": + m.pushTags = !m.pushTags + m.setStatus(fmt.Sprintf("Push tags %s", onOff(m.pushTags)), statusInfo) + case "a": + return m.beginApply() + case "x": + m.resetSelectedCommit() + case "tab": + if m.selectedHash != "" { + m.focus = focusForm + m.focusFormField(0) + } + case "up", "k": + m.moveSelection(-1) + case "down", "j": + m.moveSelection(1) + case "pgup": + m.moveSelection(-5) + case "pgdown": + m.moveSelection(5) + case "home": + m.moveSelectionTo(0) + case "end": + filtered, _, _ := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value()) + m.moveSelectionTo(len(filtered) - 1) + } + + return m, nil +} + +func (m tuiModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "enter": + m.overlay = overlayNone + m.searchInput.Blur() + m.reconcileSelection() + return m, nil + } + + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + m.reconcileSelection() + return m, cmd +} + +func (m tuiModel) updatePathEntry(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + if m.repoLoaded { + m.overlay = overlayNone + m.pathInput.Blur() + } + return m, nil + case "enter": + path := strings.TrimSpace(m.pathInput.Value()) + if path == "" { + m.setStatus("Repository path is required.", statusError) + return m, nil + } + normalized, err := normalizePath(path) + if err != nil { + m.setStatus(err.Error(), statusError) + return m, nil + } + m.busy = true + m.pathInput.Blur() + m.setStatus("Loading repository...", statusInfo) + return m, loadRepositoryCmd(m.app, normalized) + } + + var cmd tea.Cmd + m.pathInput, cmd = m.pathInput.Update(msg) + return m, cmd +} + +func (m tuiModel) updateConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + if key == "esc" || key == "n" { + m.overlay = overlayNone + m.confirmInput.Blur() + m.setStatus("Apply cancelled.", statusInfo) + return m, nil + } + + if m.overlay == overlayConfirmApply { + if key == "y" { + return m.applyNow() + } + return m, nil + } + + if key == "enter" { + if m.confirmInput.Value() != "FORCE" { + m.setStatus("Type FORCE to confirm rewriting and force pushing.", statusError) + return m, nil + } + return m.applyNow() + } + + var cmd tea.Cmd + m.confirmInput, cmd = m.confirmInput.Update(msg) + return m, cmd +} + +func (m tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "esc": + m.syncFormToDraft() + m.focus = focusList + m.blurForm() + return m, nil + case "tab", "shift+tab": + m.syncFormToDraft() + next, returnToList := nextFormFocus(m.formFocus, key == "shift+tab") + if returnToList { + m.focus = focusList + m.blurForm() + return m, nil + } + m.focusFormField(next) + return m, nil + } + + var cmd tea.Cmd + if m.formFocus == formMessage { + m.message, cmd = m.message.Update(msg) + } else if m.formFocus >= 0 && m.formFocus < len(m.inputs) { + m.inputs[m.formFocus], cmd = m.inputs[m.formFocus].Update(msg) + } + m.syncFormToDraft() + return m, cmd +} + +func (m tuiModel) beginApply() (tea.Model, tea.Cmd) { + if !m.repoLoaded { + m.setStatus("Load a repository before applying changes.", statusError) + return m, nil + } + + m.syncFormToDraft() + count := dirtyCommitCount(m.commits, m.originalByHash, m.draftByHash) + if count == 0 && !m.forcePush { + m.setStatus("No history edits to apply.", statusInfo) + return m, nil + } + + if m.forcePush { + m.overlay = overlayConfirmForce + m.confirmInput.SetValue("") + m.confirmInput.Focus() + m.setStatus("Force push confirmation required.", statusInfo) + return m, nil + } + + m.overlay = overlayConfirmApply + m.setStatus(fmt.Sprintf("Confirm rewriting %d edited commit%s.", count, plural(count)), statusInfo) + return m, nil +} + +func (m tuiModel) applyNow() (tea.Model, tea.Cmd) { + m.syncFormToDraft() + m.overlay = overlayNone + m.confirmInput.Blur() + m.busy = true + m.setStatus("Applying commit metadata changes...", statusInfo) + return m, applyChangesCmd(m.app, m.applyRequest()) +} + +func (m tuiModel) View() string { + m.updateComponentSizes() + width := m.width + height := m.height + if width <= 0 { + width = 120 + } + if height <= 0 { + height = 36 + } + + top := m.renderTopBar(width) + bottom := m.renderBottomBar(width) + bodyHeight := height - lipgloss.Height(top) - lipgloss.Height(bottom) - 2 + if bodyHeight < 12 { + bodyHeight = 12 + } + + leftWidth := clamp(width*38/100, 30, 54) + if width-leftWidth-1 < 34 { + leftWidth = maxInt(24, width-35) + } + rightWidth := maxInt(30, width-leftWidth-1) + + left := m.renderCommitPane(leftWidth, bodyHeight) + right := m.renderDetailPane(rightWidth, bodyHeight) + body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) + + return appBaseStyle.Width(width).Render(strings.Join([]string{top, body, bottom}, "\n")) +} + +func (m tuiModel) renderTopBar(width int) string { + title := lipgloss.NewStyle(). + Bold(true). + Foreground(colorCyan). + Render("NEXUS") + + repoText := "No repository loaded" + if m.repoLoaded { + clean := "clean" + if !m.repository.Clean { + clean = "dirty" + } + branch := "detached HEAD" + if !m.repository.DetachedHead && m.repository.CurrentBranch != "" { + branch = m.repository.CurrentBranch + } + repoText = fmt.Sprintf("%s %s %s", filepath.Base(m.repository.Path), branch, clean) + } + + flags := fmt.Sprintf("force:%s tags:%s edits:%d", onOff(m.forcePush), onOff(m.pushTags), dirtyCommitCount(m.commits, m.originalByHash, m.draftByHash)) + if m.busy { + flags = "working..." + } + + line := lipgloss.JoinHorizontal( + lipgloss.Center, + title, + " ", + mutedStyle.Render(truncateText(repoText, maxInt(8, width-lipgloss.Width(title)-lipgloss.Width(flags)-8))), + ) + line = line + strings.Repeat(" ", maxInt(1, width-lipgloss.Width(line)-lipgloss.Width(flags)-2)) + dimStyle.Render(flags) + + if m.overlay == overlaySearch { + search := focusedFieldStyle.Width(width - 4).Render(fieldLabelStyle.Render("Search") + "\n" + m.searchInput.View()) + return lipgloss.NewStyle().Width(width).Render(line + "\n" + search) + } + + return lipgloss.NewStyle(). + Width(width). + Background(lipgloss.Color("#0C1824")). + Padding(0, 1). + Render(line) +} + +func (m tuiModel) renderBottomBar(width int) string { + statusColor := colorCyan + switch m.statusKind { + case statusSuccess: + statusColor = colorGreen + case statusError: + statusColor = colorRed + } + + status := lipgloss.NewStyle().Foreground(statusColor).Render(truncateText(m.status, width-4)) + help := "Tab form/list / search p path r reload x reset a apply f force t tags ? help q quit" + if m.focus == focusForm { + help = "Tab next field Shift+Tab previous Esc list Enter newline in message Ctrl+C quit" + } + if m.overlay == overlayPath { + help = "Enter load repository Esc close" + } + if m.overlay == overlayConfirmApply { + help = "Press y to rewrite history, n/Esc to cancel" + } + if m.overlay == overlayConfirmForce { + help = "Type FORCE then Enter to rewrite and force push, Esc to cancel" + } + + return lipgloss.NewStyle(). + Width(width). + Background(lipgloss.Color("#0C1824")). + Padding(0, 1). + Render(status + "\n" + dimStyle.Render(truncateText(help, width-4))) +} + +func (m tuiModel) renderCommitPane(width int, height int) string { + style := panelStyle + if m.focus == focusList && m.overlay == overlayNone { + style = activePanelStyle + } + innerWidth := maxInt(10, width-2) + innerHeight := maxInt(6, height-2) + + filtered, selectedIndex, _ := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value()) + header := headerStyle.Render(fmt.Sprintf("Commits %d/%d", len(filtered), len(m.commits))) + if m.searchInput.Value() != "" { + header += " " + dimStyle.Render("/"+truncateText(m.searchInput.Value(), innerWidth-12)) + } + + lines := []string{truncateText(header, innerWidth)} + if len(filtered) == 0 { + lines = append(lines, "", mutedStyle.Render("No commits match this filter.")) + return style.Width(width).Height(height).Render(fitLines(lines, innerHeight, innerWidth)) + } + + visibleSlots := maxInt(1, (innerHeight-2)/5) + offset := clamp(m.listOffset, 0, maxInt(0, len(filtered)-visibleSlots)) + if selectedIndex < offset { + offset = selectedIndex + } + if selectedIndex >= offset+visibleSlots { + offset = selectedIndex - visibleSlots + 1 + } + + for i := offset; i < len(filtered) && len(lines) < innerHeight-1; i++ { + item := m.renderCommitItem(filtered[i], i == selectedIndex, innerWidth) + lines = append(lines, strings.Split(item, "\n")...) + if len(lines) < innerHeight { + lines = append(lines, "") + } + } + + return style.Width(width).Height(height).Render(fitLines(lines, innerHeight, innerWidth)) +} + +func (m tuiModel) renderCommitItem(commit CommitRecord, selected bool, width int) string { + draft := m.draftByHash[commit.Hash] + if draft.Hash == "" { + draft = commit + } + + marker := " " + if commitDirty(commit.Hash, m.originalByHash, m.draftByHash) { + marker = "*" + } + + top := fmt.Sprintf("%s %s %s", marker, draft.ShortHash, formatCommitTime(draft.AuthorDate)) + author := draft.AuthorName + if author == "" { + author = "(no author)" + } + preview := commitPreviewLines(draft.Message, maxInt(8, width-2), 2) + for len(preview) < 2 { + preview = append(preview, "") + } + + block := strings.Join([]string{ + truncateText(top, width-2), + truncateText(author, width-2), + truncateText(preview[0], width-2), + truncateText(preview[1], width-2), + }, "\n") + + if selected { + return selectedItemStyle.Width(width).Render(block) + } + return itemStyle.Width(width).Render(block) +} + +func (m tuiModel) renderDetailPane(width int, height int) string { + style := panelStyle + if m.focus == focusForm && m.overlay == overlayNone { + style = activePanelStyle + } + innerWidth := maxInt(20, width-2) + innerHeight := maxInt(6, height-2) + + var content string + switch m.overlay { + case overlayPath: + content = m.renderPathPane(innerWidth, innerHeight) + case overlayConfirmApply, overlayConfirmForce: + content = m.renderConfirmPane(innerWidth, innerHeight) + case overlayHelp: + content = m.renderHelpPane(innerWidth, innerHeight) + default: + content = m.renderFormPane(innerWidth, innerHeight) + } + + return style.Width(width).Height(height).Render(content) +} + +func (m tuiModel) renderPathPane(width int, height int) string { + lines := []string{ + headerStyle.Render("Open Repository"), + "", + "Enter a Git worktree path. Nexus starts with the current directory, but you can switch repositories here at any time.", + "", + focusedFieldStyle.Width(width).Render(fieldLabelStyle.Render("Path") + "\n" + m.pathInput.View()), + "", + dimStyle.Render("Press Enter to load. Press Esc to return to the current repository."), + } + return fitLines(wrapBlockLines(lines, width), height, width) +} + +func (m tuiModel) renderConfirmPane(width int, height int) string { + count := dirtyCommitCount(m.commits, m.originalByHash, m.draftByHash) + lines := []string{ + headerStyle.Render("Confirm Rewrite"), + "", + fmt.Sprintf("This will rewrite Git history for %d edited commit%s. A backup tag will be created first.", count, plural(count)), + } + + if m.forcePush { + lines = append(lines, + "", + lipgloss.NewStyle().Foreground(colorRed).Bold(true).Render("Force push is enabled."), + "Type FORCE to confirm rewriting history and pushing with --force-with-lease.", + "", + focusedFieldStyle.Width(minInt(width, 24)).Render(fieldLabelStyle.Render("Confirmation")+"\n"+m.confirmInput.View()), + ) + } else { + lines = append(lines, "", "Press y to continue, or n/Esc to cancel.") + } + + return fitLines(wrapBlockLines(lines, width), height, width) +} + +func (m tuiModel) renderHelpPane(width int, height int) string { + lines := []string{ + headerStyle.Render("Keyboard"), + "", + "List: Up/Down, PgUp/PgDn, Home/End select commits.", + "Tab moves from the list into the commit form.", + "Form: Tab and Shift+Tab move through every editable value. Esc returns to the list.", + "/ searches commits. p opens a repository path prompt. r reloads history.", + "x resets the selected commit. a applies edits. f toggles force push. t toggles tag push.", + "", + headerStyle.Render("Safety"), + "", + "Apply creates a backup tag before rewriting history. Force push uses --force-with-lease.", + "", + dimStyle.Render("Press ? or Esc to close help."), + } + return fitLines(wrapBlockLines(lines, width), height, width) +} + +func (m tuiModel) renderFormPane(width int, height int) string { + if !m.repoLoaded { + return fitLines([]string{ + headerStyle.Render("No Repository"), + "", + "Enter a repository path to begin.", + "", + dimStyle.Render("Press p to open the path prompt."), + }, height, width) + } + if m.selectedHash == "" { + return fitLines([]string{ + headerStyle.Render("No Commits"), + "", + "This repository has no commits to edit.", + }, height, width) + } + + draft := m.draftByHash[m.selectedHash] + title := fmt.Sprintf("%s %s", draft.ShortHash, firstLine(draft.Message)) + lines := []string{headerStyle.Render(truncateText(title, width)), ""} + + cellGap := 2 + cellWidth := maxInt(18, (width-cellGap)/2) + rows := [][2]int{ + {formAuthorName, formAuthorEmail}, + {formAuthorDate, formCommitterName}, + {formCommitterEmail, formCommitterDate}, + } + labels := []string{ + "Author name", + "Author email", + "Author date", + "Committer name", + "Committer email", + "Committer date", + } + + for _, row := range rows { + left := m.renderTextField(row[0], labels[row[0]], cellWidth) + right := m.renderTextField(row[1], labels[row[1]], cellWidth) + lines = append(lines, lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", cellGap), right)) + } + + messageHeight := clamp(height-lipgloss.Height(strings.Join(lines, "\n"))-8, 5, 10) + lines = append(lines, m.renderMessageField(width, messageHeight)) + + meta := []string{ + fmt.Sprintf("Hash: %s", draft.Hash), + fmt.Sprintf("Parents: %s", strings.Join(draft.Parents, ", ")), + fmt.Sprintf("Tree: %s", draft.Tree), + fmt.Sprintf("Refs: %s", strings.Join(draft.Refs, ", ")), + } + if len(draft.Parents) == 0 { + meta[1] = "Parents: (root commit)" + } + if len(draft.Refs) == 0 { + meta[3] = "Refs: (none)" + } + lines = append(lines, dimStyle.Render(truncateText(strings.Join(meta, " "), width))) + + return fitLines(lines, height, width) +} + +func (m tuiModel) renderTextField(index int, label string, width int) string { + style := blurredFieldStyle + if m.focus == focusForm && m.overlay == overlayNone && m.formFocus == index { + style = focusedFieldStyle + } + return style.Width(width).Render(fieldLabelStyle.Render(label) + "\n" + m.inputs[index].View()) +} + +func (m tuiModel) renderMessageField(width int, height int) string { + style := blurredFieldStyle + if m.focus == focusForm && m.overlay == overlayNone && m.formFocus == formMessage { + style = focusedFieldStyle + } + return style.Width(width).Height(height).Render(fieldLabelStyle.Render("Commit message") + "\n" + m.message.View()) +} + +func (m *tuiModel) updateComponentSizes() { + width := m.width + height := m.height + if width <= 0 { + width = 120 + } + if height <= 0 { + height = 36 + } + leftWidth := clamp(width*38/100, 30, 54) + if width-leftWidth-1 < 34 { + leftWidth = maxInt(24, width-35) + } + rightWidth := maxInt(30, width-leftWidth-1) + innerRight := maxInt(20, rightWidth-4) + cellWidth := maxInt(18, (innerRight-2)/2) + + for i := range m.inputs { + m.inputs[i].Width = maxInt(8, cellWidth-4) + } + m.message.SetWidth(maxInt(12, innerRight-4)) + m.message.SetHeight(clamp(height/4, 5, 10)) + m.searchInput.Width = maxInt(16, width-10) + m.pathInput.Width = maxInt(20, innerRight-8) + m.confirmInput.Width = 12 +} + +func (m *tuiModel) focusPathInput() { + m.blurForm() + m.searchInput.Blur() + m.confirmInput.Blur() + m.pathInput.Focus() + m.pathInput.CursorEnd() +} + +func (m *tuiModel) focusFormField(index int) { + m.blurForm() + m.formFocus = clamp(index, 0, formFieldCount-1) + if m.formFocus == formMessage { + m.message.Focus() + return + } + m.inputs[m.formFocus].Focus() + m.inputs[m.formFocus].CursorEnd() +} + +func (m *tuiModel) blurForm() { + for i := range m.inputs { + m.inputs[i].Blur() + } + m.message.Blur() +} + +func (m *tuiModel) reconcileSelection() { + previousHash := m.selectedHash + filtered, index, hash := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value()) + if len(filtered) == 0 { + m.selectedHash = "" + m.selectedIndex = 0 + m.listOffset = 0 + m.blurForm() + m.focus = focusList + return + } + m.selectedHash = hash + m.selectedIndex = index + m.ensureSelectionVisible(len(filtered)) + if previousHash != m.selectedHash { + m.loadSelectedCommitIntoForm() + } +} + +func (m *tuiModel) ensureSelectionVisible(total int) { + visibleSlots := 5 + if m.height > 0 { + visibleSlots = maxInt(1, (m.height-8)/5) + } + if m.selectedIndex < m.listOffset { + m.listOffset = m.selectedIndex + } + if m.selectedIndex >= m.listOffset+visibleSlots { + m.listOffset = m.selectedIndex - visibleSlots + 1 + } + m.listOffset = clamp(m.listOffset, 0, maxInt(0, total-visibleSlots)) +} + +func (m *tuiModel) moveSelection(delta int) { + filtered, selectedIndex, _ := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value()) + if len(filtered) == 0 { + return + } + m.syncFormToDraft() + next := clamp(selectedIndex+delta, 0, len(filtered)-1) + m.selectedHash = filtered[next].Hash + m.selectedIndex = next + m.ensureSelectionVisible(len(filtered)) + m.loadSelectedCommitIntoForm() +} + +func (m *tuiModel) moveSelectionTo(index int) { + filtered, _, _ := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value()) + if len(filtered) == 0 { + return + } + m.syncFormToDraft() + next := clamp(index, 0, len(filtered)-1) + m.selectedHash = filtered[next].Hash + m.selectedIndex = next + m.ensureSelectionVisible(len(filtered)) + m.loadSelectedCommitIntoForm() +} + +func (m *tuiModel) loadSelectedCommitIntoForm() { + if m.selectedHash == "" { + return + } + draft, ok := m.draftByHash[m.selectedHash] + if !ok { + return + } + values := []string{ + draft.AuthorName, + draft.AuthorEmail, + draft.AuthorDate, + draft.CommitterName, + draft.CommitterEmail, + draft.CommitterDate, + } + for i, value := range values { + m.inputs[i].SetValue(value) + } + m.message.SetValue(draft.Message) +} + +func (m *tuiModel) syncFormToDraft() { + if m.selectedHash == "" { + return + } + draft, ok := m.draftByHash[m.selectedHash] + if !ok { + return + } + draft.AuthorName = m.inputs[formAuthorName].Value() + draft.AuthorEmail = m.inputs[formAuthorEmail].Value() + draft.AuthorDate = m.inputs[formAuthorDate].Value() + draft.CommitterName = m.inputs[formCommitterName].Value() + draft.CommitterEmail = m.inputs[formCommitterEmail].Value() + draft.CommitterDate = m.inputs[formCommitterDate].Value() + draft.Message = m.message.Value() + draft.Subject = firstLine(draft.Message) + m.draftByHash[m.selectedHash] = draft +} + +func (m *tuiModel) resetSelectedCommit() { + if m.selectedHash == "" { + return + } + original, ok := m.originalByHash[m.selectedHash] + if !ok { + return + } + m.draftByHash[m.selectedHash] = cloneCommitRecord(original) + m.loadSelectedCommitIntoForm() + m.setStatus(fmt.Sprintf("Reset %s", original.ShortHash), statusInfo) +} + +func (m tuiModel) applyRequest() ApplyRequest { + commits := make([]CommitRecord, 0, len(m.commits)) + for _, commit := range m.commits { + draft, ok := m.draftByHash[commit.Hash] + if !ok { + draft = commit + } + commits = append(commits, cloneCommitRecord(draft)) + } + return ApplyRequest{ + Commits: commits, + ForcePush: m.forcePush, + PushTags: m.pushTags, + } +} + +func (m *tuiModel) setStatus(message string, kind statusKind) { + m.status = message + m.statusKind = kind +} + +func loadRepositoryCmd(app *App, path string) tea.Cmd { + return func() tea.Msg { + repo, err := app.SetRepository(path) + if err != nil { + return repoLoadedMsg{path: path, err: err} + } + commits, err := app.LoadHistory() + if err != nil { + return repoLoadedMsg{path: path, err: err} + } + return repoLoadedMsg{repo: repo, commits: commits, path: path} + } +} + +func applyChangesCmd(app *App, req ApplyRequest) tea.Cmd { + return func() tea.Msg { + result, err := app.ApplyChanges(req) + if err != nil { + return applyFinishedMsg{err: err} + } + repo, repoErr := app.GetRepositoryState() + if repoErr != nil { + return applyFinishedMsg{result: result, err: repoErr} + } + commits, historyErr := app.LoadHistory() + if historyErr != nil { + return applyFinishedMsg{result: result, repo: repo, err: historyErr} + } + return applyFinishedMsg{result: result, repo: repo, commits: commits} + } +} + +func newestFirst(commits []CommitRecord) []CommitRecord { + out := make([]CommitRecord, 0, len(commits)) + for i := len(commits) - 1; i >= 0; i-- { + out = append(out, cloneCommitRecord(commits[i])) + } + return out +} + +func mapByHash(commits []CommitRecord) map[string]CommitRecord { + result := make(map[string]CommitRecord, len(commits)) + for _, commit := range commits { + result[commit.Hash] = cloneCommitRecord(commit) + } + return result +} + +func cloneMapByHash(commits []CommitRecord) map[string]CommitRecord { + return mapByHash(commits) +} + +func cloneCommitRecord(commit CommitRecord) CommitRecord { + clone := commit + clone.Refs = append([]string(nil), commit.Refs...) + clone.Parents = append([]string(nil), commit.Parents...) + return clone +} + +func selectionAfterFilter(commits []CommitRecord, draftByHash map[string]CommitRecord, selectedHash string, query string) ([]CommitRecord, int, string) { + filtered := filterCommits(commits, draftByHash, query) + if len(filtered) == 0 { + return filtered, 0, "" + } + for index, commit := range filtered { + if commit.Hash == selectedHash { + return filtered, index, selectedHash + } + } + return filtered, 0, filtered[0].Hash +} + +func filterCommits(commits []CommitRecord, draftByHash map[string]CommitRecord, query string) []CommitRecord { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return append([]CommitRecord(nil), commits...) + } + filtered := make([]CommitRecord, 0, len(commits)) + for _, commit := range commits { + draft := commit + if item, ok := draftByHash[commit.Hash]; ok { + draft = item + } + haystack := strings.ToLower(strings.Join([]string{ + commit.Hash, + commit.ShortHash, + draft.AuthorName, + draft.AuthorEmail, + draft.Subject, + draft.Message, + }, "\n")) + if strings.Contains(haystack, query) { + filtered = append(filtered, commit) + } + } + return filtered +} + +func dirtyCommitCount(commits []CommitRecord, originalByHash map[string]CommitRecord, draftByHash map[string]CommitRecord) int { + count := 0 + for _, commit := range commits { + if commitDirty(commit.Hash, originalByHash, draftByHash) { + count++ + } + } + return count +} + +func commitDirty(hash string, originalByHash map[string]CommitRecord, draftByHash map[string]CommitRecord) bool { + original, hasOriginal := originalByHash[hash] + draft, hasDraft := draftByHash[hash] + if !hasOriginal || !hasDraft { + return false + } + return editableFieldsChanged(original, draft) +} + +func nextFormFocus(current int, backwards bool) (int, bool) { + if backwards { + if current <= 0 { + return 0, true + } + return current - 1, false + } + if current >= formFieldCount-1 { + return current, true + } + return current + 1, false +} + +func commitPreviewLines(message string, width int, maxLines int) []string { + summary := strings.Join(strings.Fields(normalizeMessage(message)), " ") + if summary == "" { + summary = "(no message)" + } + return wrapText(summary, maxInt(4, width), maxInt(1, maxLines)) +} + +func wrapText(value string, width int, maxLines int) []string { + words := strings.Fields(value) + if len(words) == 0 { + return []string{""} + } + lines := make([]string, 0, maxLines) + current := "" + for _, word := range words { + if current == "" { + current = word + continue + } + if lipgloss.Width(current)+1+lipgloss.Width(word) <= width { + current += " " + word + continue + } + lines = append(lines, truncateText(current, width)) + current = word + if len(lines) == maxLines { + break + } + } + if len(lines) < maxLines && current != "" { + lines = append(lines, truncateText(current, width)) + } + if len(lines) == maxLines && len(words) > 0 { + last := strings.Join(lines, " ") + if lipgloss.Width(last) < lipgloss.Width(value) { + lines[len(lines)-1] = truncateText(lines[len(lines)-1], width) + } + } + return lines +} + +func wrapBlockLines(lines []string, width int) []string { + var out []string + for _, line := range lines { + if strings.TrimSpace(line) == "" || strings.Contains(line, "\x1b[") { + out = append(out, line) + continue + } + wrapped := wrapText(line, width, 99) + out = append(out, wrapped...) + } + return out +} + +func fitLines(lines []string, height int, width int) string { + out := make([]string, 0, height) + for _, line := range lines { + if len(out) >= height { + break + } + out = append(out, truncateText(line, width)) + } + for len(out) < height { + out = append(out, "") + } + return strings.Join(out, "\n") +} + +func truncateText(value string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(value) <= width { + return value + } + if width <= 3 { + return strings.Repeat(".", width) + } + var builder strings.Builder + for _, r := range value { + if lipgloss.Width(builder.String()+string(r)+"...") > width { + break + } + builder.WriteRune(r) + } + return builder.String() + "..." +} + +func formatCommitTime(value string) string { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + parsed, err = time.Parse(time.RFC3339Nano, value) + } + if err != nil { + return value + } + return parsed.Local().Format("2006-01-02 15:04") +} + +func applyResultSummary(result ApplyResult) string { + parts := []string{} + if result.RewrittenCommits == 0 { + parts = append(parts, "No commits required rewriting.") + } else { + parts = append(parts, fmt.Sprintf("Rewrote %d commit%s.", result.RewrittenCommits, plural(result.RewrittenCommits))) + } + if result.BackupReference != "" { + parts = append(parts, "Backup "+result.BackupReference+".") + } + if result.ForcePushed { + parts = append(parts, "Force push completed.") + } + if len(result.Warnings) > 0 { + parts = append(parts, "Warning: "+strings.Join(result.Warnings, " ")) + } + return strings.Join(parts, " ") +} + +func onOff(value bool) string { + if value { + return "on" + } + return "off" +} + +func plural(count int) string { + if count == 1 { + return "" + } + return "s" +} + +func clamp(value int, low int, high int) int { + if high < low { + return low + } + if value < low { + return low + } + if value > high { + return high + } + return value +} + +func minInt(a int, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a int, b int) int { + if a > b { + return a + } + return b +} diff --git a/tui_test.go b/tui_test.go new file mode 100644 index 0000000..535274d --- /dev/null +++ b/tui_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +func TestCommitPreviewLinesWrapsToAtMostTwoLines(t *testing.T) { + lines := commitPreviewLines("Add the initial terminal interface with a detailed commit editor and safer apply workflow", 24, 2) + if len(lines) == 0 || len(lines) > 2 { + t.Fatalf("expected 1-2 preview lines, got %d: %#v", len(lines), lines) + } + for _, line := range lines { + if lipgloss.Width(line) > 24 { + t.Fatalf("preview line exceeds width: %q", line) + } + } +} + +func TestDirtyCommitCount(t *testing.T) { + commits := []CommitRecord{ + {Hash: "one", ShortHash: "one", AuthorName: "A", AuthorEmail: "a@example.com", AuthorDate: "2024-01-01T10:00:00Z", CommitterName: "A", CommitterEmail: "a@example.com", CommitterDate: "2024-01-01T10:00:00Z", Message: "first"}, + {Hash: "two", ShortHash: "two", AuthorName: "B", AuthorEmail: "b@example.com", AuthorDate: "2024-01-02T10:00:00Z", CommitterName: "B", CommitterEmail: "b@example.com", CommitterDate: "2024-01-02T10:00:00Z", Message: "second"}, + } + original := mapByHash(commits) + drafts := cloneMapByHash(commits) + edited := drafts["two"] + edited.Message = "edited second" + drafts["two"] = edited + + if got := dirtyCommitCount(commits, original, drafts); got != 1 { + t.Fatalf("expected one dirty commit, got %d", got) + } +} + +func TestSelectionAfterFilterPreservesSelectedCommit(t *testing.T) { + commits := []CommitRecord{ + {Hash: "one", ShortHash: "one", AuthorName: "A", Message: "alpha"}, + {Hash: "two", ShortHash: "two", AuthorName: "B", Message: "beta"}, + {Hash: "three", ShortHash: "three", AuthorName: "C", Message: "gamma"}, + } + drafts := cloneMapByHash(commits) + + filtered, index, hash := selectionAfterFilter(commits, drafts, "two", "") + if len(filtered) != 3 || index != 1 || hash != "two" { + t.Fatalf("expected selected commit to be preserved, got len=%d index=%d hash=%q", len(filtered), index, hash) + } + + filtered, index, hash = selectionAfterFilter(commits, drafts, "two", "gamma") + if len(filtered) != 1 || index != 0 || hash != "three" { + t.Fatalf("expected selection to move to first filtered commit, got len=%d index=%d hash=%q", len(filtered), index, hash) + } +} + +func TestNextFormFocusTraversal(t *testing.T) { + next, done := nextFormFocus(formAuthorName, false) + if done || next != formAuthorEmail { + t.Fatalf("expected tab to advance to author email, got next=%d done=%v", next, done) + } + + next, done = nextFormFocus(formMessage, false) + if !done || next != formMessage { + t.Fatalf("expected tab from final field to return to list, got next=%d done=%v", next, done) + } + + next, done = nextFormFocus(formAuthorName, true) + if !done || next != formAuthorName { + t.Fatalf("expected shift-tab from first field to return to list, got next=%d done=%v", next, done) + } +} + +func TestInitialRepositoryPath(t *testing.T) { + wd := t.TempDir() + got, err := initialRepositoryPath(nil, func() (string, error) { return wd, nil }) + if err != nil { + t.Fatal(err) + } + if got != filepath.Clean(wd) { + t.Fatalf("expected cwd path %q, got %q", filepath.Clean(wd), got) + } + + explicit := filepath.Join(wd, "repo") + got, err = initialRepositoryPath([]string{explicit}, func() (string, error) { return "", errors.New("should not be called") }) + if err != nil { + t.Fatal(err) + } + if got != filepath.Clean(explicit) { + t.Fatalf("expected explicit path %q, got %q", filepath.Clean(explicit), got) + } + + if _, err := initialRepositoryPath([]string{"one", "two"}, func() (string, error) { return wd, nil }); err == nil { + t.Fatal("expected usage error for too many arguments") + } +} + +func TestRepositoryLoadErrorOpensPathEntryState(t *testing.T) { + path := t.TempDir() + model := newTUIModel(NewApp(), path) + model.handleRepoLoaded(repoLoadedMsg{path: path, err: errors.New("not a git worktree")}) + + if model.repoLoaded { + t.Fatal("expected repository to remain unloaded") + } + if model.overlay != overlayPath { + t.Fatalf("expected path overlay, got %v", model.overlay) + } + if model.pathInput.Value() != path { + t.Fatalf("expected path input to keep attempted path %q, got %q", path, model.pathInput.Value()) + } +}