TUI conversion

This commit is contained in:
Andy Dixon
2026-06-15 07:57:51 +01:00
commit 092f1f9a45
12 changed files with 2524 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
bin/
giteditor
+17
View File
@@ -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.
+70
View File
@@ -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-<timestamp>`).
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.
+558
View File
@@ -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
}
+126
View File
@@ -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)
}
}
Executable
+10
View File
@@ -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"
Executable
+206
View File
@@ -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"
+28
View File
@@ -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
)
+43
View File
@@ -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=
+22
View File
@@ -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)
}
}
+1329
View File
File diff suppressed because it is too large Load Diff
+113
View File
@@ -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())
}
}