TUI conversion
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
bin/
|
||||||
|
giteditor
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user