diff --git a/.gitignore b/.gitignore index a066e39..53fef74 100644 --- a/.gitignore +++ b/.gitignore @@ -37,9 +37,4 @@ go.work.sum .idea -# generated result of aictx itself -output.txt - -.claude - /build diff --git a/cmd/git-back/main.go b/cmd/git-back/main.go index 63ce0e2..c6109c7 100644 --- a/cmd/git-back/main.go +++ b/cmd/git-back/main.go @@ -1,44 +1,54 @@ package main import ( - "fmt" + "context" "os" - "runtime/debug" + "os/signal" + "syscall" + "github.com/amberpixels/git-undo/cmd/shared" "github.com/amberpixels/git-undo/internal/app" + "github.com/urfave/cli/v3" ) // version is set by the build ldflags // The default value is "dev+dirty" but it should never be used. In success path, it's always overwritten. var version = "dev+dirty" +var versionSource = "hardcoded" -func main() { - var verbose, dryRun bool - for _, arg := range os.Args[1:] { - if arg == "-v" || arg == "--verbose" { - verbose = true - } - if arg == "--dry-run" { - dryRun = true - } - } +const ( + appNameGitBack = "git-back" +) - // When running binary that was installed via `go install`, here we'll get the proper version - if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" { - version = bi.Main.Version +func main() { + // Create a context that can be cancelled with Ctrl+C + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + version, versionSource = app.HandleAppVersion(version, versionSource) + + cmd := &cli.Command{ + Name: appNameGitBack, + Usage: "Navigate back through git checkout/switch operations", + Flags: shared.CommonFlags(), + Action: func(ctx context.Context, c *cli.Command) error { + a := app.NewAppGitBack(version, versionSource) + + if c.Bool("version") { + return a.HandleVersion(c.Bool("verbose")) + } + + return a.Run(ctx, app.RunOptions{ + Verbose: c.Bool("verbose"), + DryRun: c.Bool("dry-run"), + HookCommand: c.String("hook"), + ShowLog: c.Bool("log"), + Args: c.Args().Slice(), + }) + }, } - application := app.NewAppGiBack(version, verbose, dryRun) - if err := application.Run(os.Args[1:]); err != nil { - _, _ = fmt.Fprintln(os.Stderr, redColor+appNameGitBack+" ❌: "+grayColor+err.Error()+resetColor) - os.Exit(1) + if err := cmd.Run(ctx, os.Args); err != nil { + app.HandleError(appNameGitBack, err) } } - -const ( - grayColor = "\033[90m" - redColor = "\033[31m" - resetColor = "\033[0m" - - appNameGitBack = "git-back" -) diff --git a/cmd/git-undo/main.go b/cmd/git-undo/main.go index 5c43eea..68aa9f7 100644 --- a/cmd/git-undo/main.go +++ b/cmd/git-undo/main.go @@ -1,44 +1,58 @@ package main import ( - "fmt" + "context" "os" - "runtime/debug" + "os/signal" + "syscall" + "github.com/amberpixels/git-undo/cmd/shared" "github.com/amberpixels/git-undo/internal/app" + "github.com/urfave/cli/v3" ) // version is set by the build ldflags // The default value is "dev+dirty" but it should never be used. In success path, it's always overwritten. var version = "dev+dirty" +var versionSource = "hardcoded" -func main() { - var verbose, dryRun bool - for _, arg := range os.Args[1:] { - if arg == "-v" || arg == "--verbose" { - verbose = true - } - if arg == "--dry-run" { - dryRun = true - } - } +const ( + appNameGitUndo = "git-undo" +) - // When running binary that was installed via `go install`, here we'll get the proper version - if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" { - version = bi.Main.Version +func main() { + // Create a context that can be cancelled with Ctrl+C + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + version, versionSource = app.HandleAppVersion(version, versionSource) + + cmd := &cli.Command{ + Name: appNameGitUndo, + Usage: "Universal \"Ctrl + Z\" for Git commands", + DisableSliceFlagSeparator: true, + HideHelp: true, + Flags: shared.CommonFlags(), + Action: func(ctx context.Context, c *cli.Command) error { + application := app.NewAppGitUndo(version, versionSource) + if c.Bool("version") { + return application.HandleVersion(c.Bool("verbose")) + } + + // Use the new structured approach with parsed options + opts := app.RunOptions{ + Verbose: c.Bool("verbose"), + DryRun: c.Bool("dry-run"), + HookCommand: c.String("hook"), + ShowLog: c.Bool("log"), + Args: c.Args().Slice(), + } + + return application.Run(ctx, opts) + }, } - application := app.NewAppGitUndo(version, verbose, dryRun) - if err := application.Run(os.Args[1:]); err != nil { - _, _ = fmt.Fprintln(os.Stderr, redColor+appNameGitUndo+" ❌: "+grayColor+err.Error()+resetColor) - os.Exit(1) + if err := cmd.Run(ctx, os.Args); err != nil { + app.HandleError(appNameGitUndo, err) } } - -const ( - grayColor = "\033[90m" - redColor = "\033[31m" - resetColor = "\033[0m" - - appNameGitUndo = "git-undo" -) diff --git a/cmd/shared/flags.go b/cmd/shared/flags.go new file mode 100644 index 0000000..7625b72 --- /dev/null +++ b/cmd/shared/flags.go @@ -0,0 +1,37 @@ +package shared + +import ( + "github.com/urfave/cli/v3" +) + +// CommonFlags returns the standard set of CLI flags used by both git-undo and git-back commands. +func CommonFlags() []cli.Flag { + return []cli.Flag{ + &cli.BoolFlag{ + Name: "help", + Aliases: []string{"h"}, + Usage: "Show help", + }, + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Usage: "Enable verbose output", + }, + &cli.BoolFlag{ + Name: "dry-run", + Usage: "Show what would be executed without running commands", + }, + &cli.BoolFlag{ + Name: "version", + Usage: "Print the version", + }, + &cli.StringFlag{ + Name: "hook", + Usage: "Hook command for shell integration (internal use)", + }, + &cli.BoolFlag{ + Name: "log", + Usage: "Display the git-undo command log", + }, + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index 298363f..4fec22f 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.3 require ( github.com/mattn/go-shellwords v1.0.12 github.com/stretchr/testify v1.10.0 + github.com/urfave/cli/v3 v3.3.8 ) require ( diff --git a/go.sum b/go.sum index 2e6a1b1..91e3c37 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E= +github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/install.sh b/install.sh index 5afb334..f5e9ea3 100755 --- a/install.sh +++ b/install.sh @@ -231,7 +231,6 @@ install_dispatcher_into() { log "Installing dispatcher script to: $DISPATCHER_FILE" # Debug: Check if source file exists - echo "AAAA $DISPATCHER_SRC" if [[ ! -f "$DISPATCHER_SRC" ]]; then log_error "Source dispatcher script not found: $DISPATCHER_SRC" log_error "DISPATCHER_SRC variable: '$DISPATCHER_SRC'" diff --git a/internal/app/app.go b/internal/app/app.go index e7f8dba..bdc11bc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,11 +1,14 @@ package app import ( + "context" "errors" "fmt" "os" "strings" + "runtime/debug" + gitundoembeds "github.com/amberpixels/git-undo" "github.com/amberpixels/git-undo/internal/git-undo/logging" "github.com/amberpixels/git-undo/internal/git-undo/undoer" @@ -23,10 +26,11 @@ type GitHelper interface { // App represents the main app. type App struct { - verbose bool - dryRun bool - buildVersion string + version string + versionSource string + // dir stands for working dir for the App + // It's suggested to be filled and used in tests only. dir string // isInternalCall is a hack, so app works OK even without GIT_UNDO_INTERNAL_HOOK env variable. @@ -38,8 +42,8 @@ type App struct { isBackMode bool } -// IsInternalCall checks if the hook is being called internally (either via test or zsh script). -func (a *App) IsInternalCall() bool { +// getIsInternalCall checks if the hook is being called internally (either via test or zsh script). +func (a *App) getIsInternalCall() bool { if a.isInternalCall { return true } @@ -49,97 +53,52 @@ func (a *App) IsInternalCall() bool { } // NewAppGitUndo creates a new App instance. -func NewAppGitUndo(version string, verbose, dryRun bool) *App { +func NewAppGitUndo(version string, versionSource string) *App { return &App{ - dir: ".", - buildVersion: version, - verbose: verbose, - dryRun: dryRun, - isBackMode: false, + dir: ".", + version: version, + versionSource: versionSource, + isBackMode: false, } } -// NewAppGiBack creates a new App instance for git-back. -func NewAppGiBack(version string, verbose, dryRun bool) *App { - app := NewAppGitUndo(version, verbose, dryRun) +// NewAppGitBack creates a new App instance for git-back. +func NewAppGitBack(version, versionSource string) *App { + app := NewAppGitUndo(version, versionSource) app.isBackMode = true return app } -// ANSI escape code for gray color. -const ( - yellowColor = "\033[33m" - orangeColor = "\033[38;5;208m" - grayColor = "\033[90m" - redColor = "\033[31m" - resetColor = "\033[0m" -) - -// Application names. -const ( - appNameGitUndo = "git-undo" - appNameGitBack = "git-back" -) - -// getAppName returns the appropriate app name based on mode. -func (a *App) getAppName() string { - if a.isBackMode { - return appNameGitBack - } - return appNameGitUndo -} - -// isCheckoutOrSwitchCommand checks if a command is a git checkout or git switch command. -func (a *App) isCheckoutOrSwitchCommand(command string) bool { - // Parse the command to check its type - gitCmd, err := githelpers.ParseGitCommand(command) - if err != nil { - return false - } - - return gitCmd.Name == "checkout" || gitCmd.Name == "switch" -} - -// logDebugf writes debug messages to stderr when verbose mode is enabled. -func (a *App) logDebugf(format string, args ...any) { - if !a.verbose { - return - } - - _, _ = fmt.Fprintf(os.Stderr, yellowColor+a.getAppName()+" ⚙️: "+grayColor+format+resetColor+"\n", args...) +// HandleVersion handles the --version flag by delegating to SelfController. +func (a *App) HandleVersion(verbose bool) error { + selfCtrl := NewSelfController(a.version, a.versionSource, verbose, a.getAppName()) + return selfCtrl.cmdVersion() } -// logErrorf writes error messages to stderr. -func (a *App) logErrorf(format string, args ...any) { - _, _ = fmt.Fprintf(os.Stderr, redColor+a.getAppName()+" ❌️: "+grayColor+format+resetColor+"\n", args...) +// RunOptions contains parsed CLI options. +type RunOptions struct { + Verbose bool + DryRun bool + HookCommand string + ShowLog bool + Args []string } -// logWarnf writes warning (soft error) messages to stderr. -func (a *App) logWarnf(format string, args ...any) { - _, _ = fmt.Fprintf(os.Stderr, orangeColor+a.getAppName()+" ⚠️: "+grayColor+format+resetColor+"\n", args...) -} - -// logInfof writes info messages to stderr. -func (a *App) logInfof(format string, args ...any) { - _, _ = fmt.Fprintf(os.Stderr, yellowColor+a.getAppName()+" ℹ️: "+grayColor+format+resetColor+"\n", args...) -} - -// Run executes the main app logic. -func (a *App) Run(args []string) (err error) { - a.logDebugf("called in verbose mode") +// Run executes the app with parsed options. +func (a *App) Run(ctx context.Context, opts RunOptions) error { + a.logDebugf(opts.Verbose, "called in verbose mode") defer func() { if recovered := recover(); recovered != nil { - a.logDebugf("git-undo panic recovery: %v", recovered) - err = errors.New("unexpected internal failure") + a.logDebugf(opts.Verbose, "git-undo panic recovery: %v", recovered) } }() - selfCtrl := NewSelfController(a.buildVersion, a.verbose, a.getAppName()). + selfCtrl := NewSelfController(a.version, a.versionSource, opts.Verbose, a.getAppName()). AddScript(CommandUpdate, gitundoembeds.GetUpdateScript()). AddScript(CommandUninstall, gitundoembeds.GetUninstallScript()) - if err := selfCtrl.HandleSelfCommand(args); err == nil { + if err := selfCtrl.HandleSelfCommand(opts.Args); err == nil { return nil } else if !errors.Is(err, ErrNotSelfCommand) { return err @@ -150,7 +109,7 @@ func (a *App) Run(args []string) (err error) { gitDir, err := g.GetRepoGitDir() if err != nil { // Silently return for non-git repos when not using self commands - a.logDebugf("not in a git repository, ignoring command%v: %s", args, err) + a.logDebugf(opts.Verbose, "not in a git repository, ignoring command%v: %s", opts.Args, err) return nil } @@ -159,99 +118,160 @@ func (a *App) Run(args []string) (err error) { return errors.New("failed to create git-undo logger") } - // Custom commands are --hook and --log - for _, arg := range args { - switch { - case strings.HasPrefix(arg, "--hook"): - return a.cmdHook(lgr, arg) - case arg == "--log": - return a.cmdLog(lgr) - } + // Handle --hook flag + if opts.HookCommand != "" { + return a.cmdHook(lgr, opts.Verbose, opts.HookCommand) } - // Check if this is a "git undo undo" command - if len(args) > 0 && args[0] == "undo" { - // Get the last undoed entry (from current reference) - lastEntry, err := lgr.GetLastEntry() - if err != nil { - a.logErrorf("something wrong with the log: %v", err) - return nil - } - if lastEntry == nil || !lastEntry.Undoed { - // nothing to undo - return nil - } + // Handle --log flag + if opts.ShowLog { + return a.cmdLog(lgr) + } - // Unmark the entry in the log - if err := lgr.ToggleEntry(lastEntry.GetIdentifier()); err != nil { - return fmt.Errorf("failed to unmark command: %w", err) - } + return a.run(ctx, lgr, g, opts) +} - // Execute the original command - gitCmd, err := githelpers.ParseGitCommand(lastEntry.Command) - if err != nil { - return fmt.Errorf("invalid last undo-ed cmd[%s]: %w", lastEntry.Command, err) - } - if !gitCmd.Supported { - return fmt.Errorf("invalid last undo-ed cmd[%s]: not supported", lastEntry.Command) - } +// run contains the core undo/back functionality. +func (a *App) run(ctx context.Context, lgr *logging.Logger, g GitHelper, opts RunOptions) error { + if a.isBackMode { + return a.runBack(ctx, lgr, g, opts) + } - if err := g.GitRun(gitCmd.Name, gitCmd.Args...); err != nil { - return fmt.Errorf("failed to redo command[%s]: %w", lastEntry.Command, err) - } + // Determine the operation type based on args and app mode + // `git undo undo` -> redo + if len(opts.Args) > 0 && opts.Args[0] == githelpers.CustomCommandUndo { + return a.runRedo(ctx, lgr, g, opts) + } + + // This is git-undo + return a.runUndo(ctx, lgr, g, opts) +} + +// runRedo handles "git undo undo" operations (redo functionality). +func (a *App) runRedo(_ context.Context, lgr *logging.Logger, g GitHelper, opts RunOptions) error { + a.logDebugf(opts.Verbose, "runRedo called") - a.logDebugf("Successfully redid: %s", lastEntry.Command) + // Get the last undoed entry (from current reference) + lastEntry, err := lgr.GetLastUndoedEntry() + if err != nil { + a.logErrorf("something wrong with the log: %v", err) + return nil + } + if lastEntry == nil { + // nothing to redo + a.logInfof("nothing to redo") return nil } - // Get the last git command - var lastEntry *logging.Entry - if a.isBackMode { - // For git-back, look for the last checkout/switch command (including undoed ones for toggle behavior) - // We pass "any" to look across all refs, not just the current one - lastEntry, err = lgr.GetLastEntry(logging.RefAny) + a.logDebugf(opts.Verbose, "runRedo: found undoed entry: %s", lastEntry.Command) + + // Unmark the entry in the log + if err := lgr.ToggleEntry(lastEntry.GetIdentifier()); err != nil { + return fmt.Errorf("failed to unmark command: %w", err) + } + + // Execute the original command + gitCmd, err := githelpers.ParseGitCommand(lastEntry.Command) + if err != nil { + return fmt.Errorf("invalid last undo-ed cmd[%s]: %w", lastEntry.Command, err) + } + if !gitCmd.Supported { + return fmt.Errorf("invalid last undo-ed cmd[%s]: not supported", lastEntry.Command) + } + + if err := g.GitRun(gitCmd.Name, gitCmd.Args...); err != nil { + return fmt.Errorf("failed to redo command[%s]: %w", lastEntry.Command, err) + } + + a.logDebugf(opts.Verbose, "Successfully redid: %s", lastEntry.Command) + return nil +} + +// runBack handles git-back operations (navigation undo). +func (a *App) runBack(ctx context.Context, lgr *logging.Logger, g GitHelper, opts RunOptions) error { + // For git-back, look for the last checkout/switch command (including undoed ones for toggle behavior) + // We pass "any" to look across all refs, not just the current one + lastEntry, err := lgr.GetLastEntry(logging.RefAny) + if err != nil { + return fmt.Errorf("failed to get last command: %w", err) + } + if lastEntry == nil { + a.logInfof("no commands found") + return nil + } + + // Check if the last command was a checkout or switch command + if !a.isCheckoutOrSwitchCommand(lastEntry.Command) { + // If not, try to find the last checkout/switch command (including undoed ones for toggle behavior) + lastEntry, err = lgr.GetLastCheckoutSwitchEntryForToggle(logging.RefAny) if err != nil { - return fmt.Errorf("failed to get last command: %w", err) + return fmt.Errorf("failed to get last checkout/switch command: %w", err) } if lastEntry == nil { - a.logDebugf("no commands found") + a.logInfof("no checkout/switch commands to undo") return nil } - // Check if the last command was a checkout or switch command - if !a.isCheckoutOrSwitchCommand(lastEntry.Command) { - // If not, try to find the last checkout/switch command (including undoed ones for toggle behavior) - lastEntry, err = lgr.GetLastCheckoutSwitchEntryForToggle(logging.RefAny) - if err != nil { - return fmt.Errorf("failed to get last checkout/switch command: %w", err) - } - if lastEntry == nil { - a.logDebugf("no checkout/switch commands to undo") - return nil - } - } - } else { - // For git-undo, get any regular entry - lastEntry, err = lgr.GetLastRegularEntry() + } + + return a.executeUndoOperation(ctx, lgr, g, opts, lastEntry, true) +} + +// runUndo handles git-undo operations (mutation undo). +func (a *App) runUndo(ctx context.Context, lgr *logging.Logger, g GitHelper, opts RunOptions) error { + // First, check if the chronologically last command was a checkout/switch command + absoluteLastEntry, err := lgr.GetLastEntry() + if err != nil { + return fmt.Errorf("failed to get last command: %w", err) + } + + if absoluteLastEntry != nil && a.isCheckoutOrSwitchCommand(absoluteLastEntry.Command) { + a.logInfof("Last operation can't be undone. Use %sgit back%s instead.", yellowColor, resetColor) + return nil + } + + // For git-undo, get the last regular (mutation) entry to undo + lastEntry, err := lgr.GetLastRegularEntry() + if err != nil { + return fmt.Errorf("failed to get last git command: %w", err) + } + + if lastEntry == nil { + // Check if there are any navigation commands if no mutation commands exist + lastNavEntry, err := lgr.GetLastCheckoutSwitchEntry() if err != nil { - return fmt.Errorf("failed to get last git command: %w", err) + return fmt.Errorf("failed to get last checkout/switch command: %w", err) } - if lastEntry == nil { - a.logDebugf("nothing to undo") - return nil - } - - // Check if the last command was checkout or switch - suggest git back instead - if a.isCheckoutOrSwitchCommand(lastEntry.Command) { + if lastNavEntry != nil { a.logInfof("Last operation can't be undone. Use %sgit back%s instead.", yellowColor, resetColor) return nil } + a.logInfof("nothing to undo") + return nil } - a.logDebugf("Last git command[%s]: %s", lastEntry.Ref, yellowColor+lastEntry.Command+resetColor) + // Check if the last regular command was checkout or switch - suggest git back instead + if a.isCheckoutOrSwitchCommand(lastEntry.Command) { + a.logInfof("Last operation can't be undone. Use %sgit back%s instead.", yellowColor, resetColor) + return nil + } + + return a.executeUndoOperation(ctx, lgr, g, opts, lastEntry, false) +} + +// executeUndoOperation performs the actual undo operation for a given entry. +func (a *App) executeUndoOperation( + ctx context.Context, + lgr *logging.Logger, + g GitHelper, + opts RunOptions, + lastEntry *logging.Entry, + isBackMode bool, +) error { + a.logDebugf(opts.Verbose, "Last git command[%s]: %s", lastEntry.Ref, yellowColor+lastEntry.Command+resetColor) // Get the appropriate undoer var u undoer.Undoer - if a.isBackMode { + if isBackMode { u = undoer.NewBack(lastEntry.Command, g) } else { u = undoer.New(lastEntry.Command, g) @@ -263,25 +283,54 @@ func (a *App) Run(args []string) (err error) { return err } - if a.dryRun { - for _, undoCmd := range undoCmds { - a.logDebugf("Would run: %s\n", undoCmd.Command) - if len(undoCmd.Warnings) > 0 { - for _, warning := range undoCmd.Warnings { - a.logWarnf("%s", warning) - } + if opts.DryRun { + return a.showDryRunOutput(opts, undoCmds) + } + + // Execute the undo commands + if err := a.executeUndoCommands(ctx, opts, lastEntry, undoCmds); err != nil { + return err + } + + // Mark the entry as undoed in the log + if err := lgr.ToggleEntry(lastEntry.GetIdentifier()); err != nil { + a.logWarnf("Failed to mark command as undoed: %v", err) + } + + // Summary message + a.logUndoSummary(opts, lastEntry, undoCmds) + return nil +} + +// showDryRunOutput displays what would be executed in dry-run mode. +func (a *App) showDryRunOutput(opts RunOptions, undoCmds []*undoer.UndoCommand) error { + for _, undoCmd := range undoCmds { + a.logDebugf(opts.Verbose, "Would run: %s\n", undoCmd.Command) + if len(undoCmd.Warnings) > 0 { + for _, warning := range undoCmd.Warnings { + a.logWarnf("%s", warning) } } - return nil } + return nil +} - // Execute the undo commands +// executeUndoCommands executes the list of undo commands. +func (a *App) executeUndoCommands( + ctx context.Context, + opts RunOptions, + lastEntry *logging.Entry, + undoCmds []*undoer.UndoCommand, +) error { for i, undoCmd := range undoCmds { + // TODO: at some point we can check ctx here for timeout/cancel/etc + _ = ctx + if err := undoCmd.Exec(); err != nil { return fmt.Errorf("failed to execute undo command %d/%d %s via %s: %w", i+1, len(undoCmds), lastEntry.Command, undoCmd.Command, err) } - a.logDebugf("Successfully executed undo command %d/%d: %s via %s", + a.logDebugf(opts.Verbose, "Successfully executed undo command %d/%d: %s via %s", i+1, len(undoCmds), lastEntry.Command, undoCmd.Command) if len(undoCmd.Warnings) > 0 { for _, warning := range undoCmd.Warnings { @@ -289,41 +338,95 @@ func (a *App) Run(args []string) (err error) { } } } + return nil +} - // Mark the entry as undoed in the log - if err := lgr.ToggleEntry(lastEntry.GetIdentifier()); err != nil { - a.logWarnf("Failed to mark command as undoed: %v", err) - } - - // Summary message for all commands +// logUndoSummary logs a summary message after successful undo operation. +func (a *App) logUndoSummary(opts RunOptions, lastEntry *logging.Entry, undoCmds []*undoer.UndoCommand) { if len(undoCmds) == 1 { - a.logDebugf("Successfully undid: %s via %s", lastEntry.Command, undoCmds[0].Command) + a.logDebugf(opts.Verbose, "Successfully undid: %s via %s", lastEntry.Command, undoCmds[0].Command) } else { - a.logDebugf("Successfully undid: %s via %d commands", lastEntry.Command, len(undoCmds)) + a.logDebugf(opts.Verbose, "Successfully undid: %s via %d commands", lastEntry.Command, len(undoCmds)) } - return nil } -func (a *App) cmdHook(lgr *logging.Logger, hookArg string) error { - a.logDebugf("hook: start") +// ANSI escape code for gray color. +const ( + yellowColor = "\033[33m" + orangeColor = "\033[38;5;208m" + grayColor = "\033[90m" + redColor = "\033[31m" + resetColor = "\033[0m" +) + +// Application names. +const ( + appNameGitUndo = "git-undo" + appNameGitBack = "git-back" +) + +// getAppName returns the appropriate app name based on mode. +func (a *App) getAppName() string { + if a.isBackMode { + return appNameGitBack + } + return appNameGitUndo +} + +// isCheckoutOrSwitchCommand checks if a command is a git checkout or git switch command. +func (a *App) isCheckoutOrSwitchCommand(command string) bool { + // Parse the command to check its type + gitCmd, err := githelpers.ParseGitCommand(command) + if err != nil { + return false + } + + return gitCmd.Name == "checkout" || gitCmd.Name == "switch" +} + +// logDebugf writes debug messages to stderr when verbose mode is enabled. +func (a *App) logDebugf(verbose bool, format string, args ...any) { + if !verbose { + return + } + + _, _ = fmt.Fprintf(os.Stderr, yellowColor+a.getAppName()+" ⚙️: "+grayColor+format+resetColor+"\n", args...) +} + +// logErrorf writes error messages to stderr. +func (a *App) logErrorf(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, redColor+a.getAppName()+" ❌️: "+grayColor+format+resetColor+"\n", args...) +} + +// logWarnf writes warning (soft error) messages to stderr. +func (a *App) logWarnf(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, orangeColor+a.getAppName()+" ⚠️: "+grayColor+format+resetColor+"\n", args...) +} - if !a.IsInternalCall() { +// logInfof writes info messages to stderr. +func (a *App) logInfof(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, yellowColor+a.getAppName()+" ℹ️: "+grayColor+format+resetColor+"\n", args...) +} + +func (a *App) cmdHook(lgr *logging.Logger, verbose bool, hooked string) error { + a.logDebugf(verbose, "hook: start") + + if !a.getIsInternalCall() { return errors.New("hook must be called from inside shell script (bash/zsh hook)") } - hooked := strings.TrimSpace(strings.TrimPrefix(hookArg, "--hook")) - hooked = strings.TrimSpace(strings.TrimPrefix(hooked, "=")) + hooked = strings.TrimSpace(hooked) gitCmd, err := githelpers.ParseGitCommand(hooked) if err != nil || !gitCmd.Supported { // This should not happen in a success path // because the zsh script should only send non-failed (so valid) git command // but just in case let's re-validate again here - a.logDebugf("hook: skipping as invalid git command %q", hooked) + a.logDebugf(verbose, "hook: skipping as invalid git command %q", hooked) return nil //nolint:nilerr // We're fine with this } - if !gitCmd.ShouldBeLogged() { - a.logDebugf("hook: skipping as a read-only command: %q", hooked) + if !logging.ShouldBeLogged(gitCmd) { + a.logDebugf(verbose, "hook: skipping as a read-only command: %q", hooked) return nil } @@ -331,7 +434,7 @@ func (a *App) cmdHook(lgr *logging.Logger, hookArg string) error { return fmt.Errorf("failed to log command: %w", err) } - a.logDebugf("hook: prepended %q", hooked) + a.logDebugf(verbose, "hook: prepended %q", hooked) return nil } @@ -339,3 +442,26 @@ func (a *App) cmdHook(lgr *logging.Logger, hookArg string) error { func (a *App) cmdLog(lgr *logging.Logger) error { return lgr.Dump(os.Stdout) } + +// HandleError prints error messages and exits with status code 1. +func HandleError(appName string, err error) { + _, _ = fmt.Fprintln(os.Stderr, redColor+appName+" ❌: "+grayColor+err.Error()+resetColor) + os.Exit(1) +} + +// HandleAppVersion handles the app binary version. +func HandleAppVersion(ldFlagVersion, versionSource string) (string, string) { + // 1. `build way`: by default version is given via `go build` from ldflags + var version = ldFlagVersion + if version != "" { + versionSource = "ldflags" + } + + // 2. `install way`: When running binary that was installed via `go install`, here we'll get the proper version + if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" { + version = bi.Main.Version + versionSource = "buildinfo" + } + + return version, versionSource +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 5de996c..2096e3c 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1,6 +1,7 @@ package app_test import ( + "context" "io" "os" "path/filepath" @@ -13,9 +14,10 @@ import ( ) const ( - verbose = false - autoGitUndoHook = true - testAppVersion = "v1.2.3-test" + verbose = false + autoGitUndoHook = true + testAppVersion = "v1.2.3-test" + testAppVersionSource = "fixed" ) // GitTestSuite provides a test environment for git operations. @@ -43,7 +45,7 @@ func (s *GitTestSuite) SetupSuite() { s.GitTestSuite.GitUndoHook = autoGitUndoHook s.GitTestSuite.SetupSuite() - s.app = app.NewAppGitUndo(testAppVersion, verbose, false) + s.app = app.NewAppGitUndo(testAppVersion, testAppVersionSource) app.SetupAppDir(s.app, s.GetRepoDir()) app.SetupInternalCall(s.app) s.GitTestSuite.SetApplication(s.app) @@ -51,7 +53,10 @@ func (s *GitTestSuite) SetupSuite() { // gitUndo runs git-undo with the given arguments. func (s *GitTestSuite) gitUndo(args ...string) { - err := s.app.Run(args) + opts := app.RunOptions{ + Args: args, + } + err := s.app.Run(context.Background(), opts) s.Require().NoError(err) } @@ -63,7 +68,10 @@ func (s *GitTestSuite) gitUndoLog() string { setGlobalStdout(w) // Run the log command - err = s.app.Run([]string{"--log"}) + opts := app.RunOptions{ + ShowLog: true, + } + err = s.app.Run(context.Background(), opts) // Close the writer end and restore stdout _ = w.Close() setGlobalStdout(origStdout) @@ -275,7 +283,8 @@ func (s *GitTestSuite) TestCheckoutSwitchDetection() { os.Stderr = w // Run git undo - should warn about checkout - err = s.app.Run([]string{}) + opts := app.RunOptions{} + err = s.app.Run(context.Background(), opts) // Close writer and restore stderr _ = w.Close() @@ -303,7 +312,8 @@ func (s *GitTestSuite) TestCheckoutSwitchDetection() { os.Stderr = w // Run git undo - should warn about switch - err = s.app.Run([]string{}) + opts = app.RunOptions{} + err = s.app.Run(context.Background(), opts) // Close writer and restore stderr _ = w.Close() @@ -371,23 +381,27 @@ func (s *GitTestSuite) TestSelfCommands() { // We'll just test that they don't error out and attempt to call the scripts // Create a temporary app without git repo requirement for this test - testApp := app.NewAppGitUndo(testAppVersion, false, false) // not verbose, not dry run + testApp := app.NewAppGitUndo(testAppVersion, testAppVersionSource) // Test self update command - these will actually try to run the real scripts // but should fail on network/permission issues rather than script issues - err := testApp.Run([]string{"self", "update"}) + opts := app.RunOptions{Args: []string{"self", "update"}} + err := testApp.Run(context.Background(), opts) s.Require().Error(err) // Expected to fail in test environment // Test self-update command (hyphenated form) - err = testApp.Run([]string{"self-update"}) + opts = app.RunOptions{Args: []string{"self-update"}} + err = testApp.Run(context.Background(), opts) s.Require().Error(err) // Expected to fail in test environment // Test self uninstall command - err = testApp.Run([]string{"self", "uninstall"}) + opts = app.RunOptions{Args: []string{"self", "uninstall"}} + err = testApp.Run(context.Background(), opts) s.Require().Error(err) // Expected to fail in test environment // Test self-uninstall command (hyphenated form) - err = testApp.Run([]string{"self-uninstall"}) + opts = app.RunOptions{Args: []string{"self-uninstall"}} + err = testApp.Run(context.Background(), opts) s.Require().Error(err) // Expected to fail in test environment } @@ -400,7 +414,7 @@ func (s *GitTestSuite) TestSelfCommandsParsing() { tmpDir := s.T().TempDir() _ = tmpDir // Create an app pointing to the non-git directory - testApp := app.NewAppGitUndo(testAppVersion, false, false) + testApp := app.NewAppGitUndo(testAppVersion, testAppVersionSource) s.Require().NotNil(testApp) // These should attempt to run (and fail on script execution) rather than fail on git repo validation @@ -412,7 +426,8 @@ func (s *GitTestSuite) TestSelfCommandsParsing() { } for _, args := range testCases { - err := testApp.Run(args) + opts := app.RunOptions{Args: args} + err := testApp.Run(context.Background(), opts) // Should fail on script execution, not on git repo validation s.Require().Error(err, "Command %v should fail on script execution", args) // Should not contain git repo error @@ -438,7 +453,8 @@ func (s *GitTestSuite) TestVersionCommands() { setGlobalStdout(w) // Run the version command - err = s.app.Run(args) + opts := app.RunOptions{Args: args} + err = s.app.Run(context.Background(), opts) // Close writer and restore stdout _ = w.Close() @@ -462,7 +478,8 @@ func (s *GitTestSuite) TestVersionDetection() { s.T().Skip("Skipping version detection test") // TODO: fix me in future // Test with git version available (in actual git repo) - gitApp := app.NewAppGitUndo(testAppVersion, false, false) + gitApp := app.NewAppGitUndo(testAppVersion, testAppVersionSource) + s.Require().NotNil(gitApp) // Capture stdout to check git version @@ -471,7 +488,8 @@ func (s *GitTestSuite) TestVersionDetection() { origStdout := os.Stdout setGlobalStdout(w) - err = gitApp.Run([]string{"version"}) + opts := app.RunOptions{Args: []string{"version"}} + err = gitApp.Run(context.Background(), opts) _ = w.Close() setGlobalStdout(origStdout) @@ -487,14 +505,16 @@ func (s *GitTestSuite) TestVersionDetection() { // Test with build version only (no git repo) tmpDir := s.T().TempDir() - buildApp := app.NewAppGitUndo(testAppVersion, false, false) + buildApp := app.NewAppGitUndo(testAppVersion, testAppVersionSource) + _ = tmpDir r, w, err = os.Pipe() s.Require().NoError(err) setGlobalStdout(w) - err = buildApp.Run([]string{"version"}) + opts = app.RunOptions{Args: []string{"version"}} + err = buildApp.Run(context.Background(), opts) _ = w.Close() setGlobalStdout(origStdout) @@ -508,14 +528,16 @@ func (s *GitTestSuite) TestVersionDetection() { s.Contains(buildOutput, "git-undo v2.0.0-build", "Should show build version when no git") // Test fallback to unknown - unknownApp := app.NewAppGitUndo(testAppVersion, false, false) + unknownApp := app.NewAppGitUndo(testAppVersion, testAppVersionSource) + // Don't set build version r, w, err = os.Pipe() s.Require().NoError(err) setGlobalStdout(w) - err = unknownApp.Run([]string{"version"}) + opts = app.RunOptions{Args: []string{"version"}} + err = unknownApp.Run(context.Background(), opts) _ = w.Close() setGlobalStdout(origStdout) diff --git a/internal/app/self_controller.go b/internal/app/self_controller.go index a7c367d..f13a429 100644 --- a/internal/app/self_controller.go +++ b/internal/app/self_controller.go @@ -30,21 +30,23 @@ var allowedSelfCommands = []string{ // SelfController handles self-management commands that don't require a git repository. type SelfController struct { - buildVersion string - verbose bool - appName string + version string + versionSource string + verbose bool + appName string // scripts is a map of self-management commands to their scripts. scripts map[string]string } // NewSelfController creates a new SelfController instance. -func NewSelfController(buildVersion string, verbose bool, appName string) *SelfController { +func NewSelfController(version, versionSource string, verbose bool, appName string) *SelfController { return &SelfController{ - buildVersion: buildVersion, - verbose: verbose, - appName: appName, - scripts: map[string]string{}, + version: version, + versionSource: versionSource, + verbose: verbose, + appName: appName, + scripts: map[string]string{}, } } @@ -123,14 +125,18 @@ func (sc *SelfController) ExtractSelfCommand(args []string) string { // cmdVersion displays the version information. func (sc *SelfController) cmdVersion() error { - fmt.Fprintf(os.Stdout, "%s\n", sc.buildVersion) + if sc.verbose { + fmt.Fprintf(os.Stdout, "%s (obtained via %s)\n", sc.version, sc.versionSource) + } else { + fmt.Fprintf(os.Stdout, "%s\n", sc.version) + } return nil } // cmdHelp displays the help information. func (sc *SelfController) cmdHelp() error { if sc.appName == appNameGitBack { - fmt.Fprintf(os.Stdout, "%s %s\n", appNameGitBack, sc.buildVersion) + fmt.Fprintf(os.Stdout, "%s %s\n", appNameGitBack, sc.version) fmt.Fprintf(os.Stdout, "Usage: %s\n", appNameGitBack) fmt.Fprintf(os.Stdout, "\n") fmt.Fprintf(os.Stdout, "Git-back undoes the last git checkout or git switch command,\n") @@ -143,7 +149,7 @@ func (sc *SelfController) cmdHelp() error { } // Default git-undo help - fmt.Fprintf(os.Stdout, "%s %s\n", appNameGitUndo, sc.buildVersion) + fmt.Fprintf(os.Stdout, "%s %s\n", appNameGitUndo, sc.version) fmt.Fprintf(os.Stdout, "Usage: %s [command]\n", appNameGitUndo) fmt.Fprintf(os.Stdout, "\n") fmt.Fprintf(os.Stdout, "Commands:\n") diff --git a/internal/git-undo/logging/export_test.go b/internal/git-undo/logging/export_test.go index c679edd..85814ea 100644 --- a/internal/git-undo/logging/export_test.go +++ b/internal/git-undo/logging/export_test.go @@ -1,5 +1,3 @@ package logging -var ParseLogLine = parseLogLine - -var ToggleLine = toggleLine +var ToggleLogLine = toggleLine diff --git a/internal/git-undo/logging/helpers.go b/internal/git-undo/logging/helpers.go index a56c417..2c648bf 100644 --- a/internal/git-undo/logging/helpers.go +++ b/internal/git-undo/logging/helpers.go @@ -18,8 +18,8 @@ func EnsureLogDir(logDir string) error { return nil } -// toggleLine toggles the line number in the log file. -func toggleLine(file *os.File, lineNumber int) error { +// ToggleLine toggles commenting/uncommenting of a line in the file using # prefix. +func ToggleLine(file *os.File, lineNumber int) error { // Reset to start of file _, err := file.Seek(0, io.SeekStart) if err != nil { @@ -40,10 +40,12 @@ func toggleLine(file *os.File, lineNumber int) error { } if currentLine == lineNumber { - // Toggle the line + // Toggle the line between commented/uncommented if strings.HasPrefix(line, "#") { + // Uncomment: remove the # prefix line = strings.TrimPrefix(line, "#") } else { + // Comment: add # prefix line = "#" + line } } @@ -76,3 +78,65 @@ func toggleLine(file *os.File, lineNumber int) error { _, err = buffer.WriteTo(file) return err } + +// toggleLine toggles the line number in the log file. +func toggleLine(file *os.File, lineNumber int) error { + // Reset to start of file + _, err := file.Seek(0, io.SeekStart) + if err != nil { + return err + } + + reader := bufio.NewReader(file) + var buffer bytes.Buffer + currentLine := 0 + + for { + // Read the line including the newline character + line, err := reader.ReadString('\n') + isEOF := err == io.EOF + + if err != nil && !isEOF { + return err + } + + if currentLine == lineNumber { + // Toggle the line between +/- prefix + switch { + case strings.HasPrefix(line, "+"): + line = "-" + strings.TrimPrefix(line, "+") + case strings.HasPrefix(line, "-"): + line = "+" + strings.TrimPrefix(line, "-") + default: + return fmt.Errorf("invalid line syntax. Line must start with +/-, started with `%s`", string(line[0])) + } + } + + // Write the line to buffer + buffer.WriteString(line) + + if isEOF { + break + } + + currentLine++ + } + + if currentLine < lineNumber { + return fmt.Errorf("line %d not found: file has only %d lines", lineNumber, currentLine) + } + + // Write back to file + _, err = file.Seek(0, io.SeekStart) + if err != nil { + return err + } + + err = file.Truncate(0) + if err != nil { + return err + } + + _, err = buffer.WriteTo(file) + return err +} diff --git a/internal/git-undo/logging/logger.go b/internal/git-undo/logging/logger.go index 67173ce..e4f0f06 100644 --- a/internal/git-undo/logging/logger.go +++ b/internal/git-undo/logging/logger.go @@ -19,7 +19,8 @@ type Logger struct { logDir string logFile string - // err is nil when everything IS OK: logger is healthy, initialized OK (files exists, are accessible, etc) + // err is nil when everything IS OK: + // logger is healthy, initialized OK (files exists, are accessible, etc) err error // git is a GitHelper (calling getting current ref, etc) @@ -46,11 +47,13 @@ const ( RegularEntry // UndoedEntry represents an entry that has been marked as undoed. UndoedEntry + // NavigationEntry represents a navigation command entry. + NavigationEntry ) // String returns the string representation of the EntryType. func (et EntryType) String() string { - return [...]string{"", "regular", "undoed"}[et] + return [...]string{"", "regular", "undoed", "navigation"}[et] } type Ref string @@ -82,11 +85,17 @@ type Entry struct { // Undoed is true if the entry is undoed. Undoed bool + + // IsNavigation is true if this is a navigation command (checkout/switch). + IsNavigation bool } -// GetIdentifier returns full command without sign of undoed state (# prefix). +// GetIdentifier uses String() representation as the identifier itself +// But without prefix sign (so undoed command are still found). func (e *Entry) GetIdentifier() string { - return strings.TrimPrefix(e.String(), "#") + return strings.TrimLeft( + e.String(), "+-", + ) } // String returns a human-readable representation of the entry. @@ -97,20 +106,46 @@ func (e *Entry) String() string { } func (e *Entry) MarshalText() ([]byte, error) { - entryString := fmt.Sprintf("%s|%s|%s", e.Timestamp.Format(logEntryDateFormat), e.Ref, e.Command) + // Determine prefix based on navigation type and undo status + prefixLetter := "M" // M for `modified` as the regular entry type + if e.IsNavigation { + prefixLetter = "N" + } + prefixSign := "+" if e.Undoed { - entryString = "#" + entryString + prefixSign = "-" } + prefix := prefixSign + prefixLetter + " " + + entryString := fmt.Sprintf("%s%s|%s|%s", prefix, e.Timestamp.Format(logEntryDateFormat), e.Ref, e.Command) return []byte(entryString), nil } func (e *Entry) UnmarshalText(data []byte) error { entryString := string(data) - if strings.HasPrefix(entryString, "#") { - entryString = strings.TrimPrefix(entryString, "#") + + switch { + case strings.HasPrefix(entryString, "+"): + e.Undoed = false + case strings.HasPrefix(entryString, "-"): e.Undoed = true + default: + return fmt.Errorf("invalid syntax line: entry must start with +/-, not [%s]", string(entryString[0])) + } + + entryString = strings.TrimLeft(entryString, "+-") + switch { + case strings.HasPrefix(entryString, "M"): + e.IsNavigation = false + case strings.HasPrefix(entryString, "N"): + e.IsNavigation = true + default: + return fmt.Errorf("invalid syntax line: entry must have M/N prefix, not [%s]", string(entryString[0])) } + entryString = strings.TrimLeft(entryString, "MN") + entryString = strings.TrimSpace(entryString) + // nMustParts = 3 for date, ref, cmd const nMustParts = 3 @@ -130,10 +165,6 @@ func (e *Entry) UnmarshalText(data []byte) error { return nil } -// lineProcessor is a function that processes each line of the log file. -// If it returns false, file reading stops. -type lineProcessor func(line string) (continueReading bool) - // NewLogger creates a new Logger instance. func NewLogger(repoGitDir string, git GitHelper) *Logger { lgr := &Logger{git: git} @@ -146,17 +177,89 @@ func NewLogger(repoGitDir string, git GitHelper) *Logger { return nil } + // Check if we need to migrate/truncate old format + if err := lgr.migrateOldFormatIfNeeded(); err != nil { + // If migration fails, we continue but the logger might have issues + // TODO: Add verbose logging here (and remove panic) + panic("should not happen " + err.Error()) + } + return lgr } -// LogCommand logs a git command with timestamp. +// migrateOldFormatIfNeeded checks if the log file has old format entries and truncates it if needed. +func (l *Logger) migrateOldFormatIfNeeded() error { + // Check if the log file exists + _, err := os.Stat(l.logFile) + if os.IsNotExist(err) { + // No log file exists, nothing to migrate + return nil + } + if err != nil { + return fmt.Errorf("failed to check log file: %w", err) + } + + // Read the first few lines to check format + file, err := os.Open(l.logFile) + if err != nil { + return fmt.Errorf("failed to open log file for migration check: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineCount := 0 + hasOldFormat := false + + // Check first 10 lines or until we find new format + for scanner.Scan() && lineCount < 10 { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + lineCount++ + + // Check if this line uses new format (+M, -M, +N, -N) + if strings.HasPrefix(line, "+M ") || strings.HasPrefix(line, "-M ") || + strings.HasPrefix(line, "+N ") || strings.HasPrefix(line, "-N ") { + // Found new format, no migration needed + return nil + } + + // Check if this line uses old format (N prefix, # prefix, or no prefix) + if strings.HasPrefix(line, "N ") || strings.HasPrefix(line, "#") || + (!strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "-")) { + hasOldFormat = true + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading log file for migration: %w", err) + } + + // If we found old format, truncate the file + if hasOldFormat && lineCount > 0 { + if err := os.Truncate(l.logFile, 0); err != nil { + return fmt.Errorf("failed to truncate old format log file: %w", err) + } + } + + return nil +} + +// LogCommand logs a git command with timestamp and handles branch-aware logging. func (l *Logger) LogCommand(strGitCommand string) error { if l.err != nil { return fmt.Errorf("logger is not healthy: %w", l.err) } - // Skip logging git undo commands - if strings.HasPrefix(strGitCommand, "git undo") { + // Parse and check if command should be logged + gitCmd, err := githelpers.ParseGitCommand(strGitCommand) + if err != nil { + // If we can't parse it, skip logging to be safe + return nil //nolint:nilerr // it's intended to be like that + } + if !ShouldBeLogged(gitCmd) { return nil } @@ -167,6 +270,20 @@ func (l *Logger) LogCommand(strGitCommand string) error { ref = Ref(refStr) } + // Handle branch-aware logging for mutation commands + if !l.IsNavigationCommand(strGitCommand) { + // Check if we have consecutive undone commands + undoneCount, err := l.CountConsecutiveUndoneCommands(ref) + if err == nil && undoneCount > 0 { + // We're branching - truncate undone mutation commands + if err := l.TruncateToCurrentBranch(ref); err != nil { + // Log the error but don't fail the operation + // TODO: Add verbose logging here + _ = err + } + } + } + return l.logCommandWithDedup(strGitCommand, ref) } @@ -194,11 +311,17 @@ func (l *Logger) logCommandWithDedup(strGitCommand string, ref Ref) error { l.markLoggedByShellHook(cmdIdentifier) } - return l.prependLogEntry((&Entry{ - Timestamp: time.Now(), - Ref: ref, - Command: strGitCommand, - }).String()) + // Create entry with proper navigation flag + isNav := l.IsNavigationCommand(strGitCommand) + entry := &Entry{ + Timestamp: time.Now(), + Ref: ref, + Command: strGitCommand, + Undoed: false, + IsNavigation: isNav, + } + + return l.prependLogEntry(entry.String()) } // createCommandIdentifier creates a short identifier for a command to detect duplicates. @@ -348,8 +471,15 @@ func (l *Logger) ToggleEntry(entryIdentifier string) error { } var foundLineIdx int - err := l.processLogFile(func(line string) bool { - if strings.TrimSpace(strings.TrimLeft(line, "#")) == entryIdentifier { + err := l.ProcessLogFile(func(line string) bool { + // Parse the entry and get its identifier + entry, err := ParseLogLine(line) + if err != nil { + foundLineIdx++ + return true + } + + if entry.GetIdentifier() == entryIdentifier { return false } @@ -371,6 +501,7 @@ func (l *Logger) ToggleEntry(entryIdentifier string) error { // GetLastRegularEntry returns last regular entry (ignoring undoed ones) // for the given ref (or current ref if not specified). +// For git-undo, this skips navigation commands (N prefixed). func (l *Logger) GetLastRegularEntry(refArg ...Ref) (*Entry, error) { if l.err != nil { return nil, fmt.Errorf("logger is not healthy: %w", l.err) @@ -378,23 +509,70 @@ func (l *Logger) GetLastRegularEntry(refArg ...Ref) (*Entry, error) { ref := l.resolveRef(refArg...) var foundEntry *Entry - err := l.processLogFile(func(line string) bool { - // skip undoed - if strings.HasPrefix(line, "#") { + err := l.ProcessLogFile(func(line string) bool { + // Parse the log line into an Entry + entry, err := ParseLogLine(line) + if err != nil { // TODO: Logger.lgr should display warnings in Verbose mode here + return true + } + + // Skip navigation commands - git-undo doesn't process these + if entry.IsNavigation { + return true + } + + // Skip undoed entries + if entry.Undoed { + return true + } + + if !l.matchRef(entry.Ref, ref) { return true } + // Found a matching entry! + foundEntry = entry + return false + }) + if err != nil { + return nil, err + } + + return foundEntry, nil +} + +// GetLastUndoedEntry returns the last undoed entry for the given ref (or current ref if not specified). +// This is used for redo functionality to find the most recent undoed command to re-execute. +// For git-undo, this skips navigation commands (N prefixed). +func (l *Logger) GetLastUndoedEntry(refArg ...Ref) (*Entry, error) { + if l.err != nil { + return nil, fmt.Errorf("logger is not healthy: %w", l.err) + } + ref := l.resolveRef(refArg...) + + var foundEntry *Entry + err := l.ProcessLogFile(func(line string) bool { // Parse the log line into an Entry - entry, err := parseLogLine(line) + entry, err := ParseLogLine(line) if err != nil { // TODO: Logger.lgr should display warnings in Verbose mode here return true } + // Skip navigation commands - git-undo doesn't process these + if entry.IsNavigation { + return true + } + + // Only process undoed entries + if !entry.Undoed { + return true + } + if !l.matchRef(entry.Ref, ref) { return true } - // Found a matching entry! + // Found a matching undoed entry! foundEntry = entry return false }) @@ -407,6 +585,7 @@ func (l *Logger) GetLastRegularEntry(refArg ...Ref) (*Entry, error) { // GetLastEntry returns last entry for the given ref (or current ref if not specified) // regarding of the entry type (undoed or regular). +// This handles both navigation commands (N prefixed) and mutation commands. func (l *Logger) GetLastEntry(refArg ...Ref) (*Entry, error) { if l.err != nil { return nil, fmt.Errorf("logger is not healthy: %w", l.err) @@ -415,9 +594,9 @@ func (l *Logger) GetLastEntry(refArg ...Ref) (*Entry, error) { ref := l.resolveRef(refArg...) var foundEntry *Entry - err := l.processLogFile(func(line string) bool { + err := l.ProcessLogFile(func(line string) bool { // Parse the log line into an Entry - entry, err := parseLogLine(line) + entry, err := ParseLogLine(line) if err != nil { // TODO: warnings maybe? return true } @@ -439,6 +618,7 @@ func (l *Logger) GetLastEntry(refArg ...Ref) (*Entry, error) { // GetLastCheckoutSwitchEntry returns the last checkout or switch command entry // for the given ref (or current ref if not specified). +// This method finds NON-UNDOED navigation commands for git-back. func (l *Logger) GetLastCheckoutSwitchEntry(refArg ...Ref) (*Entry, error) { if l.err != nil { return nil, fmt.Errorf("logger is not healthy: %w", l.err) @@ -447,14 +627,9 @@ func (l *Logger) GetLastCheckoutSwitchEntry(refArg ...Ref) (*Entry, error) { ref := l.resolveRef(refArg...) var foundEntry *Entry - err := l.processLogFile(func(line string) bool { - // skip undoed - if strings.HasPrefix(line, "#") { - return true - } - + err := l.ProcessLogFile(func(line string) bool { // Parse the log line into an Entry - entry, err := parseLogLine(line) + entry, err := ParseLogLine(line) if err != nil { // TODO: warnings maybe? return true } @@ -462,6 +637,16 @@ func (l *Logger) GetLastCheckoutSwitchEntry(refArg ...Ref) (*Entry, error) { return true } + // Skip undoed entries + if entry.Undoed { + return true + } + + // Only process navigation commands + if !entry.IsNavigation { + return true + } + // Check if this is a checkout or switch command if !isCheckoutOrSwitchCommand(entry.Command) { return true @@ -480,6 +665,7 @@ func (l *Logger) GetLastCheckoutSwitchEntry(refArg ...Ref) (*Entry, error) { // GetLastCheckoutSwitchEntryForToggle returns the last checkout or switch command entry // for git-back, including undoed entries. This allows git-back to toggle back and forth. +// This method finds ANY navigation command (including undoed ones) for toggle behavior. func (l *Logger) GetLastCheckoutSwitchEntryForToggle(refArg ...Ref) (*Entry, error) { if l.err != nil { return nil, fmt.Errorf("logger is not healthy: %w", l.err) @@ -488,9 +674,9 @@ func (l *Logger) GetLastCheckoutSwitchEntryForToggle(refArg ...Ref) (*Entry, err ref := l.resolveRef(refArg...) var foundEntry *Entry - err := l.processLogFile(func(line string) bool { + err := l.ProcessLogFile(func(line string) bool { // Parse the log line into an Entry (including undoed entries) - entry, err := parseLogLine(line) + entry, err := ParseLogLine(line) if err != nil { // TODO: warnings maybe? return true } @@ -498,6 +684,11 @@ func (l *Logger) GetLastCheckoutSwitchEntryForToggle(refArg ...Ref) (*Entry, err return true } + // Only process navigation commands + if !entry.IsNavigation { + return true + } + // Check if this is a checkout or switch command if !isCheckoutOrSwitchCommand(entry.Command) { return true @@ -525,6 +716,140 @@ func isCheckoutOrSwitchCommand(command string) bool { return gitCmd.Name == "checkout" || gitCmd.Name == "switch" } +// IsNavigationCommand checks if a command is a navigation command (checkout, switch, etc.). +func (l *Logger) IsNavigationCommand(command string) bool { + return isCheckoutOrSwitchCommand(command) +} + +// CountConsecutiveUndoneCommands counts consecutive undone mutation commands from the top of the log. +// It ignores navigation commands (N prefixed) and only counts mutation commands. +func (l *Logger) CountConsecutiveUndoneCommands(refArg ...Ref) (int, error) { + if l.err != nil { + return 0, fmt.Errorf("logger is not healthy: %w", l.err) + } + + ref := l.resolveRef(refArg...) + count := 0 + + err := l.ProcessLogFile(func(line string) bool { + // Skip empty lines + if strings.TrimSpace(line) == "" { + return true + } + + // Parse the log line into an Entry + entry, err := ParseLogLine(line) + if err != nil { + return true // Skip malformed lines + } + + // Skip navigation commands + if entry.IsNavigation { + return true + } + + // Check if this entry matches our target ref + if !l.matchRef(entry.Ref, ref) { + return true + } + + // If this is an undone mutation command, count it + if entry.Undoed { + count++ + return true + } + + // If we hit a non-undone mutation command, stop counting + return false + }) + + if err != nil { + return 0, err + } + + return count, nil +} + +// TruncateToCurrentBranch removes undone mutation commands from the log while preserving +// all navigation commands. This implements the branch-aware behavior. +func (l *Logger) TruncateToCurrentBranch(refArg ...Ref) error { + if l.err != nil { + return fmt.Errorf("logger is not healthy: %w", l.err) + } + + ref := l.resolveRef(refArg...) + + // Read all lines and filter out undone mutation commands for the target ref + var filteredLines []string + err := l.ProcessLogFile(func(line string) bool { + // Skip empty lines + if strings.TrimSpace(line) == "" { + return true + } + + // Parse the log line into an Entry + entry, err := ParseLogLine(line) + if err != nil { + // Keep malformed lines as-is for safety + filteredLines = append(filteredLines, line) + return true + } + + // Always preserve navigation commands + if entry.IsNavigation { + filteredLines = append(filteredLines, line) + return true + } + + // If this entry doesn't match our target ref, keep it + if !l.matchRef(entry.Ref, ref) { + filteredLines = append(filteredLines, line) + return true + } + + // For entries matching our ref: keep only non-undone mutation commands + if !entry.Undoed { + filteredLines = append(filteredLines, line) + } + // Skip undone mutation commands (they get truncated) + + return true + }) + + if err != nil { + return err + } + + // Write the filtered lines back to the log file + return l.rewriteLogFile(filteredLines) +} + +// rewriteLogFile completely rewrites the log file with the provided lines. +func (l *Logger) rewriteLogFile(lines []string) error { + tmpFile := l.logFile + ".tmp" + + // Create a temp file + out, err := os.Create(tmpFile) + if err != nil { + return fmt.Errorf("cannot create temporary log file: %w", err) + } + defer out.Close() + + // Write all lines to the temp file + for _, line := range lines { + if _, err := out.WriteString(line + "\n"); err != nil { + return fmt.Errorf("failed to write log line: %w", err) + } + } + + // Replace the original file + if err := os.Rename(tmpFile, l.logFile); err != nil { + return fmt.Errorf("failed to rename temporary log file: %w", err) + } + + return nil +} + // Dump reads the log file content and writes it directly to the provided writer. func (l *Logger) Dump(w io.Writer) error { if l.err != nil { @@ -618,10 +943,10 @@ func (l *Logger) matchRef(lineRef, targetRef Ref) bool { return lineRef == targetRef } -// processLogFile reads the log file line by line and calls the processor function for each line. +// ProcessLogFile reads the log file line by line and calls the processor function for each line. // This is more efficient than reading the entire file at once, especially when only // the first few lines are needed. -func (l *Logger) processLogFile(processor lineProcessor) error { +func (l *Logger) ProcessLogFile(processor func(line string) bool) error { if l.err != nil { return fmt.Errorf("logger is not healthy: %w", l.err) } @@ -682,9 +1007,8 @@ func (l *Logger) getFile() (*os.File, error) { return os.OpenFile(l.logFile, os.O_RDONLY, 0600) } -// parseLogLine parses a log line into an Entry. -// Format: {"d":"2025-05-16 11:02:55","ref":"main","cmd":"git commit -m 'test'"}. -func parseLogLine(line string) (*Entry, error) { +// ParseLogLine parses a log line into an Entry. +func ParseLogLine(line string) (*Entry, error) { var entry Entry if err := entry.UnmarshalText([]byte(line)); err != nil { return nil, fmt.Errorf("invalid log line format: %s", line) @@ -692,3 +1016,14 @@ func parseLogLine(line string) (*Entry, error) { return &entry, nil } + +// ShouldBeLogged returns true if the command should be logged. +func ShouldBeLogged(gitCmd *githelpers.GitCommand) bool { + // Internal commands (git undo and git back) should never be logged + if gitCmd.Name == githelpers.CustomCommandBack || gitCmd.Name == githelpers.CustomCommandUndo { + return false + } + + // Mutating and navigating commands are logged + return gitCmd.BehaviorType == githelpers.Mutating || gitCmd.BehaviorType == githelpers.Navigating +} diff --git a/internal/git-undo/logging/logger_test.go b/internal/git-undo/logging/logger_test.go index e33a3e6..58d8f93 100644 --- a/internal/git-undo/logging/logger_test.go +++ b/internal/git-undo/logging/logger_test.go @@ -86,6 +86,7 @@ func TestLogger_E2E(t *testing.T) { SwitchRef(mgc, "feature/test") entry, err := lgr.GetLastRegularEntry() require.NoError(t, err) + require.NotNil(t, entry) assert.Equal(t, commands[4].cmd, entry.Command) assert.Equal(t, "feature/test", entry.Ref.String()) @@ -115,7 +116,7 @@ func TestLogger_E2E(t *testing.T) { // 7. Test entry parsing t.Log("Testing entry parsing...") - parsedEntry, err := logging.ParseLogLine(mainEntry.GetIdentifier()) + parsedEntry, err := logging.ParseLogLine(mainEntry.String()) require.NoError(t, err) assert.Equal(t, mainEntry.Command, parsedEntry.Command) assert.Equal(t, mainEntry.Ref, parsedEntry.Ref) @@ -550,3 +551,322 @@ func TestGitBackFindAnyCheckout(t *testing.T) { t.Log("✅ git-back can successfully find checkout commands for toggle behavior") } + +// TestBranchTruncation tests the branch-aware log truncation functionality. +func TestBranchTruncation(t *testing.T) { + t.Log("Testing branch truncation logic when logging after undos") + + mgc := NewMockGitHelper() + SwitchRef(mgc, "main") + + tmpDir := t.TempDir() + lgr := logging.NewLogger(tmpDir, mgc) + require.NotNil(t, lgr) + + // Set up the scenario: A → B → C → undo → undo → F + // Log commands A, B, C + err := lgr.LogCommand("git add fileA.txt") + require.NoError(t, err) + err = lgr.LogCommand("git commit -m 'B'") + require.NoError(t, err) + err = lgr.LogCommand("git add fileC.txt") + require.NoError(t, err) + + // Get and undo C + entryC, err := lgr.GetLastRegularEntry() + require.NoError(t, err) + require.NotNil(t, entryC) + assert.Equal(t, "git add fileC.txt", entryC.Command) + err = lgr.ToggleEntry(entryC.GetIdentifier()) + require.NoError(t, err) + + // Get and undo B + entryB, err := lgr.GetLastRegularEntry() + require.NoError(t, err) + require.NotNil(t, entryB) + assert.Equal(t, "git commit -m 'B'", entryB.Command) + err = lgr.ToggleEntry(entryB.GetIdentifier()) + require.NoError(t, err) + + // Check that we have 2 consecutive undone commands + count, err := lgr.CountConsecutiveUndoneCommands() + require.NoError(t, err) + assert.Equal(t, 2, count) + + // Now log command F - this should trigger branch truncation + err = lgr.LogCommand("git add fileF.txt") + require.NoError(t, err) + + // After truncation, the log should contain only F and A + var buffer bytes.Buffer + require.NoError(t, lgr.Dump(&buffer)) + content := buffer.String() + t.Logf("Log content after truncation:\n%s", content) + + lines := strings.Split(strings.TrimSpace(content), "\n") + if len(lines) == 1 && lines[0] == "" { + lines = []string{} + } + + assert.Len(t, lines, 2, "After branching, should have 2 entries (F and A)") + + // Verify the entries are correct + entryF, err := lgr.GetLastRegularEntry() + require.NoError(t, err) + require.NotNil(t, entryF) + assert.Equal(t, "git add fileF.txt", entryF.Command) + + t.Log("✅ Branch truncation working correctly") +} + +// TestNavigationPrefixing tests that navigation commands are prefixed with N. +func TestNavigationPrefixing(t *testing.T) { + t.Log("Testing navigation command prefixing with N") + + mgc := NewMockGitHelper() + SwitchRef(mgc, "main") + + tmpDir := t.TempDir() + lgr := logging.NewLogger(tmpDir, mgc) + require.NotNil(t, lgr) + + // Log a navigation command + err := lgr.LogCommand("git checkout feature") + require.NoError(t, err) + + // Log a mutation command + err = lgr.LogCommand("git add file.txt") + require.NoError(t, err) + + // Check the raw log content + var buffer bytes.Buffer + require.NoError(t, lgr.Dump(&buffer)) + content := buffer.String() + t.Logf("Log content:\n%s", content) + + lines := strings.Split(strings.TrimSpace(content), "\n") + require.Len(t, lines, 2) + + // First line should be mutation command (+M prefix) + assert.Contains(t, lines[0], "+M ") + assert.Contains(t, lines[0], "git add file.txt") + + // Second line should be navigation command (+N prefix) + assert.Contains(t, lines[1], "+N ") + assert.Contains(t, lines[1], "git checkout feature") + + t.Log("✅ Navigation command prefixing working correctly") +} + +// TestOldFormatMigration tests that old format files are truncated during migration. +func TestOldFormatMigration(t *testing.T) { + t.Log("Testing old format migration (truncation)") + + mgc := NewMockGitHelper() + SwitchRef(mgc, "main") + + tmpDir := t.TempDir() + + // Manually create a log with old format (no +/- M/N prefixes) + logPath := tmpDir + "/.git/git-undo/commands" + err := os.MkdirAll(tmpDir+"/.git/git-undo", 0755) + require.NoError(t, err) + + oldFormatContent := `2025-06-25 10:00:00|main|git add old-file.txt +N 2025-06-25 09:59:00|main|git checkout old-branch +2025-06-25 09:58:00|main|git commit -m 'old commit'` + + err = os.WriteFile(logPath, []byte(oldFormatContent), 0600) + require.NoError(t, err) + + // Create logger - this should trigger migration (truncation) + lgr := logging.NewLogger(tmpDir+"/.git", mgc) + require.NotNil(t, lgr) + + // Check that the file was truncated (should be empty) + var buffer bytes.Buffer + require.NoError(t, lgr.Dump(&buffer)) + content := buffer.String() + assert.Empty(t, strings.TrimSpace(content), "Old format file should be truncated") + + // Now log new commands which should use new format + err = lgr.LogCommand("git checkout new-branch") + require.NoError(t, err) + err = lgr.LogCommand("git add new-file.txt") + require.NoError(t, err) + + // Check log content has new format + buffer.Reset() + require.NoError(t, lgr.Dump(&buffer)) + content = buffer.String() + t.Logf("Log content after migration:\n%s", content) + + lines := strings.Split(strings.TrimSpace(content), "\n") + require.Len(t, lines, 2) + assert.Contains(t, lines[0], "+M ", "Should use new format +M") + assert.Contains(t, lines[1], "+N ", "Should use new format +N") + + t.Log("✅ Old format migration working correctly") +} + +// TestNavigationCommandSeparation tests that git-undo and git-back handle commands separately. +func TestNavigationCommandSeparation(t *testing.T) { + t.Log("Testing navigation command separation for git-undo vs git-back") + + mgc := NewMockGitHelper() + SwitchRef(mgc, "main") + + tmpDir := t.TempDir() + lgr := logging.NewLogger(tmpDir, mgc) + require.NotNil(t, lgr) + + // Log a mix of navigation and mutation commands + err := lgr.LogCommand("git checkout feature") + require.NoError(t, err) + err = lgr.LogCommand("git add file1.txt") + require.NoError(t, err) + err = lgr.LogCommand("git switch main") + require.NoError(t, err) + err = lgr.LogCommand("git commit -m 'test'") + require.NoError(t, err) + + // git-undo should get the last mutation command (commit) + undoEntry, err := lgr.GetLastRegularEntry() + require.NoError(t, err) + require.NotNil(t, undoEntry) + assert.Equal(t, "git commit -m 'test'", undoEntry.Command) + + // git-back should get the last navigation command (switch) + backEntry, err := lgr.GetLastCheckoutSwitchEntry() + require.NoError(t, err) + require.NotNil(t, backEntry) + assert.Equal(t, "git switch main", backEntry.Command) + + t.Log("✅ Navigation command separation working correctly") +} + +// TestTruncateToCurrentBranchPreservesNavigation tests that truncation preserves navigation commands. +func TestTruncateToCurrentBranchPreservesNavigation(t *testing.T) { + t.Log("Testing that branch truncation preserves all navigation commands") + + mgc := NewMockGitHelper() + SwitchRef(mgc, "main") + + tmpDir := t.TempDir() + lgr := logging.NewLogger(tmpDir, mgc) + require.NotNil(t, lgr) + + // Create a complex scenario with mixed navigation and mutation commands + err := lgr.LogCommand("git checkout feature") // N prefixed + require.NoError(t, err) + err = lgr.LogCommand("git add fileA.txt") // mutation + require.NoError(t, err) + err = lgr.LogCommand("git switch main") // N prefixed + require.NoError(t, err) + err = lgr.LogCommand("git commit -m 'B'") // mutation + require.NoError(t, err) + err = lgr.LogCommand("git add fileC.txt") // mutation + require.NoError(t, err) + + // Undo the last two mutation commands + entryC, err := lgr.GetLastRegularEntry() + require.NoError(t, err) + err = lgr.ToggleEntry(entryC.GetIdentifier()) + require.NoError(t, err) + + entryB, err := lgr.GetLastRegularEntry() + require.NoError(t, err) + err = lgr.ToggleEntry(entryB.GetIdentifier()) + require.NoError(t, err) + + // Manually call truncation + err = lgr.TruncateToCurrentBranch() + require.NoError(t, err) + + // Check that navigation commands are preserved + var buffer bytes.Buffer + require.NoError(t, lgr.Dump(&buffer)) + content := buffer.String() + t.Logf("Log content after truncation:\n%s", content) + + // Should have both navigation commands plus the remaining mutation command + navEntry1, err := lgr.GetLastCheckoutSwitchEntry() + require.NoError(t, err) + require.NotNil(t, navEntry1) + assert.Equal(t, "git switch main", navEntry1.Command) + + // Verify navigation history is intact for git-back + lines := strings.Split(strings.TrimSpace(content), "\n") + navigationLines := 0 + for _, line := range lines { + if strings.Contains(line, "+N ") || strings.Contains(line, "-N ") { + navigationLines++ + } + } + assert.Equal(t, 2, navigationLines, "Should preserve both navigation commands") + + t.Log("✅ Branch truncation preserves navigation commands correctly") +} + +// TestGetLastUndoedEntry tests the GetLastUndoedEntry method for redo functionality. +func TestGetLastUndoedEntry(t *testing.T) { + t.Log("Testing GetLastUndoedEntry method for redo functionality") + + mgc := NewMockGitHelper() + SwitchRef(mgc, "main") + + tmpDir := t.TempDir() + lgr := logging.NewLogger(tmpDir, mgc) + require.NotNil(t, lgr) + + // Log commands A, B, C + err := lgr.LogCommand("git add fileA.txt") + require.NoError(t, err) + err = lgr.LogCommand("git add fileB.txt") + require.NoError(t, err) + err = lgr.LogCommand("git add fileC.txt") + require.NoError(t, err) + + // Initially, no undoed entries + undoedEntry, err := lgr.GetLastUndoedEntry() + require.NoError(t, err) + assert.Nil(t, undoedEntry) + + // Get and undo C + entryC, err := lgr.GetLastRegularEntry() + require.NoError(t, err) + err = lgr.ToggleEntry(entryC.GetIdentifier()) + require.NoError(t, err) + + // Now should find C as last undoed entry + undoedEntry, err = lgr.GetLastUndoedEntry() + require.NoError(t, err) + require.NotNil(t, undoedEntry) + assert.Equal(t, "git add fileC.txt", undoedEntry.Command) + assert.True(t, undoedEntry.Undoed) + + // Get and undo B + entryB, err := lgr.GetLastRegularEntry() + require.NoError(t, err) + err = lgr.ToggleEntry(entryB.GetIdentifier()) + require.NoError(t, err) + + // Now should still find C as last undoed entry (C is at top of log) + undoedEntry, err = lgr.GetLastUndoedEntry() + require.NoError(t, err) + require.NotNil(t, undoedEntry) + assert.Equal(t, "git add fileC.txt", undoedEntry.Command) + assert.True(t, undoedEntry.Undoed) + + // Test with navigation commands - should skip them + err = lgr.LogCommand("git checkout feature") + require.NoError(t, err) + + // Should still find C as last undoed entry (ignoring navigation commands) + undoedEntry, err = lgr.GetLastUndoedEntry() + require.NoError(t, err) + require.NotNil(t, undoedEntry) + assert.Equal(t, "git add fileC.txt", undoedEntry.Command) + + t.Log("✅ GetLastUndoedEntry working correctly for redo functionality") +} diff --git a/internal/githelpers/git_reference.go b/internal/githelpers/git_reference.go index 1b5e5cd..a284e07 100644 --- a/internal/githelpers/git_reference.go +++ b/internal/githelpers/git_reference.go @@ -58,7 +58,9 @@ var conditionalBehavior = map[string]struct{}{ "tag": {}, "remote": {}, "config": {}, - "undo": {}, + + CustomCommandUndo: {}, + CustomCommandBack: {}, } // porcelainCommands is the list of "user-facing" verbs (main porcelain commands). @@ -89,9 +91,15 @@ var plumbingCommands = []string{ "name-rev", } +const ( + CustomCommandUndo = "undo" + CustomCommandBack = "back" +) + // customCommands is the list of custom commands (third-party plugins). var customCommands = []string{ - "undo", + CustomCommandUndo, + CustomCommandBack, } // buildLookup builds a map from verb → its CommandType. diff --git a/internal/githelpers/gitcommand.go b/internal/githelpers/gitcommand.go index 6eed09a..14ea417 100644 --- a/internal/githelpers/gitcommand.go +++ b/internal/githelpers/gitcommand.go @@ -86,11 +86,6 @@ func (c *GitCommand) IsNavigating() bool { return c.BehaviorType == Navigating } -// ShouldBeLogged returns true if the command should be logged. -func (c *GitCommand) ShouldBeLogged() bool { - return c.BehaviorType == Mutating || c.BehaviorType == Navigating -} - // ParseGitCommand parses a git command string into a GitCommand struct. func ParseGitCommand(raw string) (*GitCommand, error) { parts, err := shellwords.NewParser().Parse(raw) @@ -105,7 +100,7 @@ func ParseGitCommand(raw string) (*GitCommand, error) { args := parts[2:] // Special handling for git undo --hook - if name == "undo" { + if name == CustomCommandUndo { if slices.Contains(args, "--hook") { return &GitCommand{ Name: name, @@ -372,8 +367,10 @@ func determineConditionalBehavior(name string, args []string) BehaviorType { return determineRemoteBehavior(args) case "config": return determineConfigBehavior(args) - case "undo": + case CustomCommandUndo: // "undo" return determineUndoBehavior(args) + case CustomCommandBack: // "back + return determineBackBehavior(args) case "restore": // restore is always mutating when it has file arguments for _, arg := range args { @@ -532,13 +529,13 @@ func determineConfigBehavior(args []string) BehaviorType { // determineUndoBehavior determines if an undo command is mutating, navigating, or read-only. func determineUndoBehavior(args []string) BehaviorType { - // Check for read-only flags - for _, arg := range args { - if arg == "--log" { - return ReadOnly - } + // git undo --log (same as git back --log) are simple read-only commands (show commands log) + if slices.Contains(args, "--log") { + return ReadOnly } - - // All other undo operations are mutating return Mutating } + +// determineBackBehavior determines if a back command is mutating, navigating, or read-only. +// behaves exactly the same as `git undo`. +var determineBackBehavior = determineUndoBehavior diff --git a/internal/githelpers/gitcommand_test.go b/internal/githelpers/gitcommand_test.go index 2757b33..9443b2e 100644 --- a/internal/githelpers/gitcommand_test.go +++ b/internal/githelpers/gitcommand_test.go @@ -4,6 +4,7 @@ import ( "reflect" "testing" + "github.com/amberpixels/git-undo/internal/git-undo/logging" "github.com/amberpixels/git-undo/internal/githelpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -575,7 +576,7 @@ func TestParseGitCommand_Undo(t *testing.T) { name: "undo with other args is valid and not read-only", command: "git undo --some-arg value", want: &githelpers.GitCommand{ - Name: "undo", + Name: githelpers.CustomCommandUndo, Args: []string{"--some-arg", "value"}, Supported: true, Type: githelpers.Custom, @@ -822,17 +823,27 @@ func TestBehaviorTypeClassification(t *testing.T) { assert.True(t, gitCmd.IsMutating(), "Command should be mutating") assert.False(t, gitCmd.IsReadOnly(), "Command should not be read-only") assert.False(t, gitCmd.IsNavigating(), "Command should not be navigating") - assert.True(t, gitCmd.ShouldBeLogged(), "Command should be logged") + // Internal commands (undo/back) should not be logged even if they are mutating + if gitCmd.Name == githelpers.CustomCommandUndo || gitCmd.Name == githelpers.CustomCommandBack { + assert.False(t, logging.ShouldBeLogged(gitCmd), "Internal command should not be logged") + } else { + assert.True(t, logging.ShouldBeLogged(gitCmd), "Command should be logged") + } case githelpers.Navigating: assert.False(t, gitCmd.IsMutating(), "Command should not be mutating") assert.False(t, gitCmd.IsReadOnly(), "Command should not be read-only") assert.True(t, gitCmd.IsNavigating(), "Command should be navigating") - assert.True(t, gitCmd.ShouldBeLogged(), "Command should be logged") + // Internal commands (undo/back) should not be logged even if they are navigating + if gitCmd.Name == githelpers.CustomCommandUndo || gitCmd.Name == githelpers.CustomCommandBack { + assert.False(t, logging.ShouldBeLogged(gitCmd), "Internal command should not be logged") + } else { + assert.True(t, logging.ShouldBeLogged(gitCmd), "Command should be logged") + } case githelpers.ReadOnly: assert.False(t, gitCmd.IsMutating(), "Command should not be mutating") assert.True(t, gitCmd.IsReadOnly(), "Command should be read-only") assert.False(t, gitCmd.IsNavigating(), "Command should not be navigating") - assert.False(t, gitCmd.ShouldBeLogged(), "Command should not be logged") + assert.False(t, logging.ShouldBeLogged(gitCmd), "Command should not be logged") case githelpers.UnknownBehavior: fallthrough default: diff --git a/internal/testutil/suite.go b/internal/testutil/suite.go index af741c5..38ab6ae 100644 --- a/internal/testutil/suite.go +++ b/internal/testutil/suite.go @@ -1,17 +1,19 @@ package testutil import ( + "context" "fmt" "os" "os/exec" "path/filepath" "strings" + "github.com/amberpixels/git-undo/internal/app" "github.com/stretchr/testify/suite" ) type GitUndoApp interface { - Run(args []string) error + Run(ctx context.Context, opts app.RunOptions) error } // GitTestSuite provides a test environment for git operations. @@ -58,7 +60,8 @@ func (s *GitTestSuite) Git(args ...string) { // Create the hook command string hookCmd := "git " + strings.Join(args, " ") // Call git-undo hook via the application - if err := s.app.Run([]string{"--hook=" + hookCmd}); err != nil { + opts := app.RunOptions{HookCommand: hookCmd} + if err := s.app.Run(context.Background(), opts); err != nil { s.FailNow("Failed to run git-undo hook", err) } } diff --git a/scripts/integration/0_basic_workflow.bats b/scripts/integration/0_basic_workflow.bats new file mode 100644 index 0000000..329dd1a --- /dev/null +++ b/scripts/integration/0_basic_workflow.bats @@ -0,0 +1,435 @@ +#!/usr/bin/env bats + +load 'test_helpers' + +setup() { + setup_git_undo_test +} + +teardown() { + teardown_git_undo_test +} + +@test "0__A: complete git-undo integration workflow" { + # ============================================================================ + # PHASE 0A-1: Verify Installation + # ============================================================================ + title "Phase 0A-1: Verifying git-undo installation..." + + run which git-undo + assert_success + assert_output --regexp "git-undo$" + + # Test version command + run git-undo --version + assert_success + assert_output --partial "v0." + + # ============================================================================ + # HOOK DIAGNOSTICS: Debug hook installation and activation + # ============================================================================ + title "HOOK DIAGNOSTICS: Checking hook installation..." + + # Check if hook files exist + echo "> Checking if hook files exist in ~/.config/git-undo/..." + run ls -la ~/.config/git-undo/ + assert_success + echo "> Hook directory contents: ${output}" + + # Verify hook files are present + assert [ -f ~/.config/git-undo/git-undo-hook.bash ] + echo "> ✓ Hook file exists: ~/.config/git-undo/git-undo-hook.bash" + + # Verify that the test hook is actually installed (should contain git function) + echo "> Checking if test hook is installed (contains git function)..." + run grep -q "git()" ~/.config/git-undo/git-undo-hook.bash + assert_success + echo "> ✓ Test hook confirmed: contains git function" + + # Check if .bashrc has the source line + echo "> Checking if .bashrc contains git-undo source line..." + run grep -n git-undo ~/.bashrc + assert_success + echo "> .bashrc git-undo lines: ${output}" + + # Check current git command type (before sourcing hooks) + echo "> Checking git command type before hook loading..." + run type git + echo "> Git type before: ${output}" + + # Check git command type (hooks are sourced in setup) + echo "> Checking git command type after hook loading..." + run type git + echo "> Git type after: ${output}" + + # Test if git-undo function/alias is available + echo "> Testing if git undo command is available..." + run git undo --help + if [[ $status -eq 0 ]]; then + echo "> ✓ git undo command responds" + else + echo "> ✗ git undo command failed with status: $status" + echo "> Output: ${output}" + fi + + # ============================================================================ + # PHASE 0A-2: Basic git add and undo workflow + # ============================================================================ + title "Phase 0A-2: Testing basic git add and undo..." + + # Create test files + echo "content of file1" > file1.txt + echo "content of file2" > file2.txt + echo "content of file3" > file3.txt + + # Verify files are untracked initially + run_verbose git status --porcelain + assert_success + assert_output --partial "?? file1.txt" + assert_output --partial "?? file2.txt" + assert_output --partial "?? file3.txt" + + # Add first file + git add file1.txt + run_verbose git status --porcelain + assert_success + assert_output --partial "A file1.txt" + assert_output --partial "?? file2.txt" + assert_output --partial "?? file3.txt" + + # Add second file + git add file2.txt + run_verbose git status --porcelain + assert_success + assert_output --partial "A file1.txt" + assert_output --partial "A file2.txt" + assert_output --partial "?? file3.txt" + + # First undo - should unstage file2.txt + debug "Checking git-undo log before first undo..." + run_verbose git undo --log + assert_success + refute_output "" # Log should not be empty if hooks are tracking + + run git undo + assert_success + + run_verbose git status --porcelain + assert_success + assert_output --partial "A file1.txt" + assert_output --partial "?? file2.txt" + assert_output --partial "?? file3.txt" + + # Second undo - should unstage file1.txt + debug "Checking git-undo log before second undo..." + run_verbose git undo --log + assert_success + refute_output "" # Log should not be empty if hooks are tracking + + run git undo + assert_success + + run_verbose git status --porcelain + assert_success + assert_output --partial "?? file1.txt" + assert_output --partial "?? file2.txt" + assert_output --partial "?? file3.txt" + refute_output --partial "A file1.txt" + refute_output --partial "A file2.txt" + + # ============================================================================ + # PHASE 0A-3: Commit and undo workflow + # ============================================================================ + title "Phase 0A-3: Testing commit and undo..." + + # Stage and commit first file + git add file1.txt + git commit -m "Add file1.txt" + + # Verify clean working directory (except for untracked files from previous phase) + run_verbose git status --porcelain + assert_success + assert_output --partial "?? file2.txt" + assert_output --partial "?? file3.txt" + refute_output --partial "file1.txt" # file1.txt should be committed, not in status + + # Verify file1 exists and is committed + assert [ -f "file1.txt" ] + + # Stage and commit second file + git add file2.txt + git commit -m "Add file2.txt" + + # Verify clean working directory again (only file3.txt should remain untracked) + run_verbose git status --porcelain + assert_success + assert_output --partial "?? file3.txt" + refute_output --partial "file1.txt" # file1.txt should be committed + refute_output --partial "file2.txt" # file2.txt should be committed + + # First commit undo - should undo last commit, leaving file2 staged + debug "Checking git-undo log before commit undo..." + run_verbose git undo --log + assert_success + refute_output "" # Log should not be empty if hooks are tracking + + run git undo + assert_success + + run_verbose git status --porcelain + assert_success + assert_output --partial "A file2.txt" + + # Verify files still exist in working directory + assert [ -f "file1.txt" ] + assert [ -f "file2.txt" ] + + # Second undo - should unstage file2.txt + debug "Checking git-undo log before second commit undo..." + run_verbose git undo --log + assert_success + refute_output "" # Log should not be empty if hooks are tracking + + run git undo + assert_success + + run_verbose git status --porcelain + assert_success + assert_output --partial "?? file2.txt" + assert_output --partial "?? file3.txt" + refute_output --partial "A file2.txt" + + # ============================================================================ + # PHASE 0A-4: Complex sequential workflow + # ============================================================================ + title "Phase 0A-4: Testing complex sequential operations..." + + # Commit file3 + git add file3.txt + git commit -m "Add file3.txt" + + # Modify file1 and stage the modification + echo "modified content" >> file1.txt + git add file1.txt + + # Verify modified file1 is staged + run_verbose git status --porcelain + assert_success + assert_output --partial "M file1.txt" + + # Create and stage a new file + echo "content of file4" > file4.txt + git add file4.txt + + # Verify both staged + run_verbose git status --porcelain + assert_success + assert_output --partial "M file1.txt" + assert_output --partial "A file4.txt" + + # Undo staging of file4 + debug "Checking git-undo log before file4 undo..." + run_verbose git undo --log + assert_success + refute_output "" # Log should not be empty if hooks are tracking + + run git undo + assert_success + + run_verbose git status --porcelain + assert_success + assert_output --partial "M file1.txt" # file1 still staged + assert_output --partial "?? file4.txt" # file4 unstaged + refute_output --partial "A file4.txt" + + # Undo staging of modified file1 + debug "Checking git-undo log before modified file1 undo..." + run_verbose git undo --log + assert_success + refute_output "" # Log should not be empty if hooks are tracking + + run git undo + assert_success + + run_verbose git status --porcelain + assert_success + assert_output --partial " M file1.txt" # Modified but unstaged + assert_output --partial "?? file4.txt" + refute_output --partial "M file1.txt" # Should not be staged anymore + + # Undo commit of file3 + run git undo + assert_success + + run_verbose git status --porcelain + assert_success + assert_output --partial "A file3.txt" # file3 back to staged + assert_output --partial " M file1.txt" # file1 still modified + assert_output --partial "?? file4.txt" + + # ============================================================================ + # PHASE 0A-5: Verification of final state + # ============================================================================ + title "Phase 0A-5: Final state verification..." + + # Verify all files exist + assert [ -f "file1.txt" ] + assert [ -f "file2.txt" ] + assert [ -f "file3.txt" ] + assert [ -f "file4.txt" ] + + # Verify git log shows only the first commit + run git log --oneline + assert_success + assert_output --partial "Add file1.txt" + refute_output --partial "Add file2.txt" + refute_output --partial "Add file3.txt" + + print "Integration test completed successfully!" +} + +@test "0__B: git-back integration test - checkout and switch undo" { + # ============================================================================ + # PHASE 0B-1: Verify git-back Installation + # ============================================================================ + title "Phase 0B-1: Verifying git-back installation..." + + run which git-back + assert_success + assert_output --regexp "git-back$" + + # Test version command + run git-back --version + assert_success + assert_output --partial "v0." + + # Test help command + run git-back --help + assert_success + assert_output --partial "Navigate back through git checkout/switch operations" + + # ============================================================================ + # PHASE 0B-2: Basic branch switching workflow + # ============================================================================ + title "Phase 0B-2: Testing basic branch switching..." + + # Create and commit some files to establish a proper git history + echo "main content" > main.txt + git add main.txt + git commit -m "Add main content" + + # Create a feature branch + git checkout -b feature-branch + echo "feature content" > feature.txt + git add feature.txt + git commit -m "Add feature content" + + # Create another branch + git checkout -b another-branch + echo "another content" > another.txt + git add another.txt + git commit -m "Add another content" + + # Verify we're on another-branch + run git branch --show-current + assert_success + assert_output "another-branch" + + # ============================================================================ + # PHASE 0B-3: Test git-back with checkout commands + # ============================================================================ + title "Phase 0B-3: Testing git-back with checkout..." + + # Switch to feature branch (this should be tracked) + git checkout feature-branch + + # Verify we're on feature-branch + run git branch --show-current + assert_success + assert_output "feature-branch" + + # Use git-back to go back to previous branch (should be another-branch) + run_verbose git back + assert_success + + # Verify we're back on another-branch + run git branch --show-current + assert_success + assert_output "another-branch" + + # ============================================================================ + # PHASE 0B-4: Test multiple branch switches + # ============================================================================ + title "Phase 0B-4: Testing multiple branch switches..." + + # Switch to main branch + git checkout main + + # Verify we're on main + run git branch --show-current + assert_success + assert_output "main" + + # Use git-back to go back to previous branch (should be another-branch) + run_verbose git back + assert_success + + # Verify we're back on another-branch + run git branch --show-current + assert_success + assert_output "another-branch" + + # Switch to feature-branch again + git checkout feature-branch + + debug "Checking git-undo log before modified file1 undo..." + run_verbose git undo --log + assert_success + refute_output "" # Log should not be empty if hooks are tracking + + # Use git-back to go back to another-branch + run_verbose git back + assert_success + + # Verify we're on another-branch + run git branch --show-current + assert_success + assert_output "another-branch" + + # ============================================================================ + # PHASE 0B-5: Test git-back with uncommitted changes (should show warnings) + # ============================================================================ + title "Phase 0B-5: Testing git-back with uncommitted changes..." + + # Make some uncommitted changes + echo "modified content" >> another.txt + echo "new file content" > unstaged.txt + + # Stage one file + git add unstaged.txt + + # Now try git-back in verbose mode to see warnings + run_verbose git undo --log + + # Now try git-back in verbose mode to see warnings + run_verbose git back -v + # Note: This might fail due to conflicts, but we want to verify warnings are shown + # The important thing is that warnings are displayed to the user + + # Verify that the failure message is shown + assert_output --partial "failed to execute" + + # For testing purposes, let's stash the changes and try again + git stash + + # Now git-back should work + run_verbose git back + assert_success + + # Verify we're back on feature-branch + run git branch --show-current + assert_success + assert_output "feature-branch" + + print "git-back integration test completed successfully!" +} diff --git a/scripts/integration/1_phase1_commands.bats b/scripts/integration/1_phase1_commands.bats new file mode 100644 index 0000000..5ec713b --- /dev/null +++ b/scripts/integration/1_phase1_commands.bats @@ -0,0 +1,197 @@ +#!/usr/bin/env bats + +load 'test_helpers' + +setup() { + setup_git_undo_test +} + +teardown() { + teardown_git_undo_test +} + +@test "1__A: Phase 1A Commands: git rm, mv, tag, restore undo functionality" { + title "Phase 1A-1: Testing git rm, mv, tag, restore undo functionality" + + run_verbose git status --porcelain + assert_success + + # Setup: Create some initial commits so we're not trying to undo the initial commit + echo "initial content" > initial.txt + git add initial.txt + git commit -m "Initial commit" + + echo "second content" > second.txt + git add second.txt + git commit -m "Second commit" + + # ============================================================================ + # PHASE 1A-2: Test git tag undo + # ============================================================================ + title "Phase 1A-2: Testing git tag undo..." + + # Create a tag + git tag v1.0.0 + + # Verify tag exists + run git tag -l v1.0.0 + assert_success + assert_output "v1.0.0" + + # Undo the tag creation + run_verbose git-undo + assert_success + + # Verify tag is deleted + run git tag -l v1.0.0 + assert_success + assert_output "" + + # Test annotated tag + git tag -a v2.0.0 -m "Release version 2.0.0" + + # Verify tag exists + run git tag -l v2.0.0 + assert_success + assert_output "v2.0.0" + + # Undo the annotated tag creation + run_verbose git-undo + assert_success + + # Verify tag is deleted + run git tag -l v2.0.0 + assert_success + assert_output "" + + # ============================================================================ + # PHASE 1A-3: Test git mv undo + # ============================================================================ + title "Phase 1A-3: Testing git mv undo..." + + # Create a file to move + echo "content for moving" > moveme.txt + git add moveme.txt + git commit -m "Add file to move" + + # Move the file + git mv moveme.txt moved.txt + + # Verify file was moved + [ ! -f moveme.txt ] + [ -f moved.txt ] + + # Undo the move + run_verbose git-undo + assert_success + + # Verify file is back to original name + [ -f moveme.txt ] + [ ! -f moved.txt ] + + # Test moving multiple files to directory + mkdir subdir + echo "file1 content" > file1.txt + echo "file2 content" > file2.txt + git add file1.txt file2.txt + git commit -m "Add files for directory move" + + # Move files to subdirectory + git mv file1.txt file2.txt subdir/ + + # Verify files were moved + [ ! -f file1.txt ] + [ ! -f file2.txt ] + [ -f subdir/file1.txt ] + [ -f subdir/file2.txt ] + + # Undo the move + run_verbose git-undo + assert_success + + # Verify files are back + [ -f file1.txt ] + [ -f file2.txt ] + [ ! -f subdir/file1.txt ] + [ ! -f subdir/file2.txt ] + + # ============================================================================ + # PHASE 1A-4: Test git rm undo + # ============================================================================ + title "Phase 1A-4: Testing git rm undo..." + + # Create a file to remove + echo "content for removal" > removeme.txt + git add removeme.txt + git commit -m "Add file to remove" + + # Test cached removal (--cached flag) + git rm --cached removeme.txt + + # Verify file is unstaged but still exists + run git ls-files removeme.txt + assert_success + assert_output "" + [ -f removeme.txt ] + + # Undo the cached removal + run_verbose git-undo + assert_success + + # Verify file is back in index + run git ls-files removeme.txt + assert_success + assert_output "removeme.txt" + + # Test full removal + git rm removeme.txt + + # Verify file is removed from both index and working directory + run git ls-files removeme.txt + assert_success + assert_output "" + [ ! -f removeme.txt ] + + # Undo the removal + run_verbose git-undo + assert_success + + # Verify file is restored + run git ls-files removeme.txt + assert_success + assert_output "removeme.txt" + [ -f removeme.txt ] + + # ============================================================================ + # PHASE 1A-5: Test git restore undo (staged only) + # ============================================================================ + title "Phase 1A-5: Testing git restore --staged undo..." + + # Create and stage a file + echo "staged content" > staged.txt + git add staged.txt + + # Verify file is staged + run git diff --cached --name-only + assert_success + assert_line "staged.txt" + + # Restore (unstage) the file + git restore --staged staged.txt + + # Verify file is no longer staged + run git diff --cached --name-only + assert_success + assert_output "" + + # Undo the restore (re-stage the file) + run_verbose git-undo + assert_success + + # Verify file is staged again + run git diff --cached --name-only + assert_success + assert_line "staged.txt" + + print "Phase 1A Commands integration test completed successfully!" +} diff --git a/scripts/integration/2_phase2_commands.bats b/scripts/integration/2_phase2_commands.bats new file mode 100644 index 0000000..56d1f17 --- /dev/null +++ b/scripts/integration/2_phase2_commands.bats @@ -0,0 +1,198 @@ +#!/usr/bin/env bats + +load 'test_helpers' + +setup() { + setup_git_undo_test +} + +teardown() { + teardown_git_undo_test +} + +@test "2__A: Phase 2A Commands: git reset, revert, cherry-pick undo functionality" { + title "Phase 2A-1: Testing git reset, revert, cherry-pick undo functionality" + + # Setup: Create initial commit structure for testing + echo "initial content" > initial.txt + git add initial.txt + git commit -m "Initial commit" + + echo "second content" > second.txt + git add second.txt + git commit -m "Second commit" + + echo "third content" > third.txt + git add third.txt + git commit -m "Third commit" + + # ============================================================================ + # PHASE 2A-2: Test git reset undo + # ============================================================================ + title "Phase 2A-2: Testing git reset undo..." + + # Get current commit hash for verification + run_verbose git rev-parse HEAD + assert_success + third_commit="$output" + + # Perform a soft reset to previous commit + run_verbose git reset --soft HEAD~1 + + # Verify we're at the second commit with staged changes + run_verbose git rev-parse HEAD + assert_success + second_commit="$output" + + # Verify third.txt is staged + run_verbose git diff --cached --name-only + assert_success + assert_line "third.txt" + + # Undo the reset (should restore HEAD to third_commit) + run_verbose git-undo + assert_success + + # Verify we're back at the third commit + run git rev-parse HEAD + assert_success + assert_output "$third_commit" + + # Test mixed reset undo + run_verbose git reset HEAD~1 + + # Verify second commit with unstaged changes + run git rev-parse HEAD + assert_success + assert_output "$second_commit" + + # Debug: Check what's in the log before undo + run_verbose git-undo --log + + run_verbose git status --porcelain + assert_success + assert_output --partial "?? third.txt" + + # Undo the mixed reset + run_verbose git-undo + assert_success + + # Verify restoration + run git rev-parse HEAD + assert_success + assert_output "$third_commit" + + # ============================================================================ + # PHASE 2A-3: Test git revert undo + # ============================================================================ + title "Phase 2A-3: Testing git revert undo..." + + # Create a commit to revert + echo "revert-me content" > revert-me.txt + git add revert-me.txt + git commit -m "Commit to be reverted" + + # Get the commit hash + run git rev-parse HEAD + assert_success + revert_target="$output" + + # Revert the commit + git revert --no-edit HEAD + + # Verify revert commit was created + run git log -1 --format="%s" + assert_success + assert_output --partial "Revert" + + # Verify file was removed by revert + [ ! -f revert-me.txt ] + + # Undo the revert + run_verbose git-undo + assert_success + + # Debug: Check git status after undo + run_verbose git status --porcelain + run_verbose ls -la revert-me.txt || echo "File not found" + + # Verify we're back to the original commit + run git rev-parse HEAD + assert_success + assert_output "$revert_target" + + # Verify file is back + [ -f revert-me.txt ] + + # ============================================================================ + # PHASE 2A-4: Test git cherry-pick undo + # ============================================================================ + title "Phase 2A-4: Testing git cherry-pick undo..." + + # Create a feature branch with a commit to cherry-pick + git checkout -b feature-cherry + echo "cherry content" > cherry.txt + git add cherry.txt + git commit -m "Cherry-pick target commit" + + # Get the commit hash + run git rev-parse HEAD + assert_success + cherry_commit="$output" + + # Go back to main branch + git checkout main + + # Record main branch state + run git rev-parse HEAD + assert_success + main_before_cherry="$output" + + # Cherry-pick the commit + git cherry-pick "$cherry_commit" + + # Verify cherry-pick was successful + [ -f cherry.txt ] + run git log -1 --format="%s" + assert_success + assert_output "Cherry-pick target commit" + + # Undo the cherry-pick + run_verbose git-undo + assert_success + + # Verify we're back to the original main state + run git rev-parse HEAD + assert_success + assert_output "$main_before_cherry" + + # Verify cherry-picked file is gone + [ ! -f cherry.txt ] + + # ============================================================================ + # PHASE 2A-5: Test git clean undo (expected to fail) + # ============================================================================ + title "Phase 2A-5: Testing git clean undo (should show unsupported error)..." + + # Create untracked files + echo "untracked1" > untracked1.txt + echo "untracked2" > untracked2.txt + + # Verify files exist + [ -f untracked1.txt ] + [ -f untracked2.txt ] + + # Clean the files + git clean -f + + # Verify files are gone + [ ! -f untracked1.txt ] + [ ! -f untracked2.txt ] + + # Try to undo clean (should fail with clear error message) + run_verbose git-undo + assert_failure + assert_output --partial "permanently removes untracked files that cannot be recovered" + + print "Phase 2A Commands integration test completed successfully!" +} diff --git a/scripts/integration/3_checkout_detection.bats b/scripts/integration/3_checkout_detection.bats new file mode 100644 index 0000000..a53a501 --- /dev/null +++ b/scripts/integration/3_checkout_detection.bats @@ -0,0 +1,138 @@ +#!/usr/bin/env bats + +load 'test_helpers' + +setup() { + setup_git_undo_test +} + +teardown() { + teardown_git_undo_test +} + +@test "3__A: git undo checkout/switch detection - warns and suggests git back" { + title "Phase 3A: Checkout/Switch Detection Test: Testing that git undo warns for checkout/switch commands" + + # Setup: Create initial commit structure for testing + echo "initial content" > initial.txt + git add initial.txt + git commit -m "Initial commit" + + echo "main content" > main.txt + git add main.txt + git commit -m "Main content commit" + + # ============================================================================ + # PHASE 3A-1: Test git checkout detection + # ============================================================================ + title "Phase 3A-1: Testing git checkout detection..." + + # Create a test branch + git branch test-branch + + # Perform checkout operation (should be tracked) + git checkout test-branch + + # Verify we're on the test branch + run git branch --show-current + assert_success + assert_output "test-branch" + + # Try git undo - should warn about checkout command + run_verbose git undo 2>&1 + assert_success + assert_output --partial "can't be undone" + assert_output --partial "git back" + + # ============================================================================ + # PHASE 3A-2: Test git switch -c (branch creation) - should show warning + # ============================================================================ + title "Phase 3A-2: Testing git switch -c warning..." + + # Switch back to main first + git checkout main + + # Create a new branch using git switch -c + git switch -c feature-switch + + # Verify we're on the new branch + run git branch --show-current + assert_success + assert_output "feature-switch" + + # Try git undo - should warn that switch can't be undone and suggest git back + run_verbose git undo 2>&1 + assert_success + assert_output --partial "can't be undone" + assert_output --partial "git back" + + # Verify we're still on the feature-switch branch (no actual undo happened) + run git branch --show-current + assert_success + assert_output "feature-switch" + + # ============================================================================ + # PHASE 3A-3: Test regular git switch - should show warning + # ============================================================================ + title "Phase 3A-3: Testing regular git switch warning..." + + # Add content to feature branch + echo "feature content" > feature.txt + git add feature.txt + git commit -m "Feature content" + + # Switch back to main + git switch main + + # Verify we're on main + run git branch --show-current + assert_success + assert_output "main" + + # Try git undo - should warn about switch command and suggest git back + run_verbose git undo 2>&1 + assert_success + assert_output --partial "can't be undone" + assert_output --partial "git back" + + # Verify we're still on main (no actual undo happened) + run git branch --show-current + assert_success + assert_output "main" + + # ============================================================================ + # PHASE 3A-4: Test that git back works as expected for switch/checkout operations + # ============================================================================ + title "Phase 3A-4: Testing that git back works for switch/checkout operations..." + + # Use git back to go back to the previous branch (should be feature-switch) + run_verbose git back + assert_success + + # Verify we're back on feature-switch + run git branch --show-current + assert_success + assert_output "feature-switch" + + # ============================================================================ + # PHASE 3A-5: Test mixed commands - ensure warning only appears for switch/checkout + # ============================================================================ + title "Phase 3A-5: Testing that warning only appears for switch/checkout commands..." + + # Create and stage a file + echo "test file" > test-file.txt + git add test-file.txt + + # Try git undo - should work normally (no warning about git back) + run_verbose git undo + assert_success + refute_output --partial "can't be undone" + refute_output --partial "git back" + + # Verify file was unstaged + run_verbose git status --porcelain + assert_success + assert_output --partial "?? test-file.txt" + + print "Checkout/switch detection integration test completed successfully!" +} diff --git a/scripts/integration/4_additional_commands.bats b/scripts/integration/4_additional_commands.bats new file mode 100644 index 0000000..69ad603 --- /dev/null +++ b/scripts/integration/4_additional_commands.bats @@ -0,0 +1,182 @@ +#!/usr/bin/env bats + +load 'test_helpers' + +setup() { + setup_git_undo_test +} + +teardown() { + teardown_git_undo_test +} + +@test "4__A: Additional Commands: git stash, merge, reset --hard, restore, branch undo functionality" { + title "Phase 4A: Testing additional git command undo functionality" + + # Setup: Create initial commit structure for testing + echo "initial content" > initial.txt + git add initial.txt + git commit -m "Initial commit" + + echo "main content" > main.txt + git add main.txt + git commit -m "Main content commit" + + # ============================================================================ + # PHASE 4A-1: Test git stash undo + # ============================================================================ + title "Phase 4A-1: Testing git stash undo..." + + # Create some changes to stash + echo "changes to stash" >> main.txt + echo "new unstaged file" > unstaged.txt + + # Stage one change + git add unstaged.txt + + # Verify we have both staged and unstaged changes + run_verbose git status --porcelain + assert_success + assert_output --partial "A unstaged.txt" + assert_output --partial " M main.txt" + + # Stash the changes + run_verbose git stash push -m "Test stash message" + + # Verify working directory is clean + run_verbose git status --porcelain + assert_success + assert_output "" + + # Verify files are back to original state + [ ! -f unstaged.txt ] + run cat main.txt + assert_success + assert_output "main content" + + # Undo the stash (should restore the changes) + run_verbose git-undo + assert_success + + # Verify changes are restored + run_verbose git status --porcelain + assert_success + assert_output --partial "A unstaged.txt" + assert_output --partial " M main.txt" + + # Clean up for next test + git reset HEAD unstaged.txt + git checkout -- main.txt + rm -f unstaged.txt + + # ============================================================================ + # PHASE 4A-2: Test git reset --hard undo + # ============================================================================ + title "Phase 4A-2: Testing git reset --hard undo..." + + # Create a commit to reset from + echo "content to be reset" > reset-test.txt + git add reset-test.txt + git commit -m "Commit to be reset with --hard" + + # Get current commit hash + run git rev-parse HEAD + assert_success + current_commit="$output" + + # Create some uncommitted changes + echo "uncommitted changes" >> main.txt + echo "untracked file" > untracked.txt + + # Perform hard reset (should lose uncommitted changes) + git reset --hard HEAD~1 + + # Verify we're at previous commit and changes are gone + run git rev-parse HEAD + assert_success + refute_output "$current_commit" + [ ! -f reset-test.txt ] + [ -f untracked.txt ] + + # Undo the hard reset + run_verbose git-undo + assert_success + + # Verify we're back at the original commit + run git rev-parse HEAD + assert_success + assert_output "$current_commit" + [ -f reset-test.txt ] + + # ============================================================================ + # PHASE 4A-3: Test git merge undo (fast-forward) + # ============================================================================ + title "Phase 4A-3: Testing git merge undo..." + + # Create a feature branch with commits + git checkout -b feature-merge + echo "feature change 1" > feature1.txt + git add feature1.txt + git commit -m "Feature commit 1" + + echo "feature change 2" > feature2.txt + git add feature2.txt + git commit -m "Feature commit 2" + + # Record feature branch head + run git rev-parse HEAD + assert_success + feature_head="$output" + + # Switch back to main and record state + git checkout main + run git rev-parse HEAD + assert_success + main_before_merge="$output" + + # Perform fast-forward merge + git merge feature-merge + + # Verify merge was successful (should be fast-forward) + run git rev-parse HEAD + assert_success + assert_output "$feature_head" + [ -f feature1.txt ] + [ -f feature2.txt ] + + # Undo the merge + run_verbose git-undo + assert_success + + # Verify we're back to pre-merge state + run git rev-parse HEAD + assert_success + assert_output "$main_before_merge" + [ ! -f feature1.txt ] + [ ! -f feature2.txt ] + + # ============================================================================ + # PHASE 4A-4: Test git branch -D undo (should fail with clear error message) + # ============================================================================ + title "Phase 4A-4: Testing git branch -D undo (should show unsupported error)..." + + # Verify feature branch still exists + run git branch --list feature-merge + assert_success + assert_output --partial "feature-merge" + + # Delete the feature branch (use -D since it's not merged) + git branch -D feature-merge + + # Verify branch is deleted + run git branch --list feature-merge + assert_success + assert_output "" + + # Try to undo the branch deletion (should fail with clear error message) + run_verbose git-undo + assert_failure + assert_output --partial "git undo not supported for branch deletion" + + print "Phase 4A: Additional commands integration test completed successfully!" +} diff --git a/scripts/integration/5_error_cases.bats b/scripts/integration/5_error_cases.bats new file mode 100644 index 0000000..907b4b8 --- /dev/null +++ b/scripts/integration/5_error_cases.bats @@ -0,0 +1,127 @@ +#!/usr/bin/env bats + +load 'test_helpers' + +setup() { + setup_git_undo_test +} + +teardown() { + teardown_git_undo_test +} + +@test "5__A: Error Conditions and Edge Cases" { + title "Phase 5A: Testing error conditions and edge cases" + + # ============================================================================ + # PHASE 5A-1: Test git undo with no previous commands + # ============================================================================ + title "Phase 5A-1: Testing git undo with empty log..." + + # Clear any existing log by creating a fresh repository state + # The setup() already creates a clean state with just init commit + + # Try git undo when there are no tracked commands + # First undo should fail because it's trying to undo the initial commit + run_verbose git undo + assert_failure + assert_output --partial "this appears to be the initial commit and cannot be undone this way" + + # Second undo should still fail with the same error since the initial commit is still there + run_verbose git undo + assert_failure + assert_output --partial "this appears to be the initial commit and cannot be undone this way" + + # ============================================================================ + # PHASE 5A-2: Test git undo --log with empty log + # ============================================================================ + title "Phase 5A-2: Testing git undo --log with empty log..." + + # Check that log shows appropriate message when empty + run_verbose git undo --log + assert_success + # Should either show empty output or a message about no commands + + # ============================================================================ + # PHASE 5A-3: Test unsupported commands + # ============================================================================ + title "Phase 5A-3: Testing unsupported commands..." + + # Setup some commits first + echo "test content" > test.txt + git add test.txt + git commit -m "Test commit" + + # Test git rebase (should show warning/error about being unsupported) + git checkout -b rebase-test + echo "branch content" > branch.txt + git add branch.txt + git commit -m "Branch commit" + + git checkout main + # Attempt rebase + git rebase rebase-test 2>/dev/null || true + + # Try to undo rebase - should fail or warn appropriately + run_verbose git undo + # This might succeed or fail depending on implementation + # The important thing is it handles it gracefully + + # ============================================================================ + # PHASE 5A-4: Test git undo after hook failures + # ============================================================================ + title "Phase 5A-4: Testing behavior after hook failures..." + + # Perform a normal operation that should be tracked + echo "tracked content" > tracked.txt + git add tracked.txt + + # Verify it can be undone normally + run_verbose git undo + assert_success + + # Verify file is unstaged + run_verbose git status --porcelain + assert_success + assert_output --partial "?? tracked.txt" + + # ============================================================================ + # PHASE 5A-5: Test concurrent operations and rapid commands + # ============================================================================ + title "Phase 5A-5: Testing rapid sequential commands..." + + # Perform multiple rapid operations + echo "rapid1" > rapid1.txt + git add rapid1.txt + echo "rapid2" > rapid2.txt + git add rapid2.txt + echo "rapid3" > rapid3.txt + git add rapid3.txt + + # Verify all operations are tracked in correct order (LIFO) + run_verbose git undo + assert_success + run_verbose git status --porcelain + assert_success + assert_output --partial "A rapid1.txt" + assert_output --partial "A rapid2.txt" + assert_output --partial "?? rapid3.txt" + + run_verbose git undo + assert_success + run_verbose git status --porcelain + assert_success + assert_output --partial "A rapid1.txt" + assert_output --partial "?? rapid2.txt" + assert_output --partial "?? rapid3.txt" + + run_verbose git undo + assert_success + run_verbose git status --porcelain + assert_success + assert_output --partial "?? rapid1.txt" + assert_output --partial "?? rapid2.txt" + assert_output --partial "?? rapid3.txt" + + print "Phase 5A:Error conditions and edge cases test completed successfully!" +} diff --git a/scripts/integration/6_cursor_history.bats b/scripts/integration/6_cursor_history.bats new file mode 100644 index 0000000..57a3912 --- /dev/null +++ b/scripts/integration/6_cursor_history.bats @@ -0,0 +1,108 @@ +#!/usr/bin/env bats + +load 'test_helpers' + +setup() { + setup_git_undo_test +} + +teardown() { + teardown_git_undo_test +} + +@test "6__A: Phase 6a - cursor-history: branching behavior after undo + new command" { + title "Phase 6a: Cursor-History Branching Test" + + # ============================================================================ + # Flow: A → B → C → undo → undo → F → test undo/undo behavior + # ============================================================================ + + # Create sequence A, B, C + echo "A content" > fileA.txt + git add fileA.txt # A + + echo "B content" > fileB.txt + git add fileB.txt # B + + echo "C content" > fileC.txt + git add fileC.txt # C + + # Now we have: A, B, C staged + run_verbose git status --porcelain + assert_success + assert_output --partial "A fileA.txt" + assert_output --partial "A fileB.txt" + assert_output --partial "A fileC.txt" + + # Undo twice: should leave only A staged + run_verbose git undo # Undo C + run_verbose git undo # Undo B + + run_verbose git status --porcelain + assert_success + assert_output --partial "A fileA.txt" + assert_output --partial "?? fileB.txt" + assert_output --partial "?? fileC.txt" + + # Do new action F (branching occurs) + echo "F content" > fileF.txt + git add fileF.txt # F + + # Now we have: A and F staged + run_verbose git status --porcelain + assert_success + assert_output --partial "A fileA.txt" + assert_output --partial "A fileF.txt" + + # Test 1: After branching, we can still undo commands (branch-aware behavior) + run_verbose git undo # Undo F, leaving only A staged + + run_verbose git status --porcelain + assert_success + assert_output --partial "A fileA.txt" + refute_output --partial "A fileF.txt" + + # With branch-aware logging, we can now undo A after the branch + run_verbose git undo # Should now succeed and undo A + assert_success + + run_verbose git status --porcelain + assert_success + refute_output --partial "A fileA.txt" + assert_output --partial "?? fileA.txt" + + # Test 2: After branch truncation, undo/redo behavior resets + # Since we branched from A to F, previous undoed commands (B,C) are no longer accessible + # So git undo should not have anything to redo + run git undo + assert_failure # Should fail because no undoed commands are available after truncation + + # Test 3: We can still do normal undo operations on the current branch (A, F) + git add fileA.txt # Re-add A + git add fileF.txt # Re-add F + + # Verify both A and F are staged after re-adding + run_verbose git status --porcelain + assert_success + assert_output --partial "A fileA.txt" + assert_output --partial "A fileF.txt" + + run git undo # Should undo F + assert_success + + run git status --porcelain + assert_success + assert_output --partial "A fileA.txt" + refute_output --partial "A fileF.txt" + + # Test 4: Use explicit git undo undo to redo F + run_verbose git undo undo --verbose # This should redo F (git undo undo behavior) + assert_success + + run git status --porcelain + assert_success + assert_output --partial "A fileA.txt" + assert_output --partial "A fileF.txt" + + print "Phase 6a completed - tested branching behavior with branch-aware logging" +} diff --git a/scripts/integration/Dockerfile b/scripts/integration/Dockerfile index b14778f..c149cb6 100644 --- a/scripts/integration/Dockerfile +++ b/scripts/integration/Dockerfile @@ -41,7 +41,8 @@ RUN mkdir -p test_helper && \ git clone https://github.com/bats-core/bats-assert test_helper/bats-assert # Copy integration test files -COPY --chown=testuser:testuser scripts/integration/integration-test.bats /home/testuser/ +COPY --chown=testuser:testuser scripts/integration/*.bats /home/testuser/ +COPY --chown=testuser:testuser scripts/integration/test_helpers.bash /home/testuser/ COPY --chown=testuser:testuser scripts/integration/setup-and-test-prod.sh /home/testuser/setup-and-test.sh # Make the setup script executable @@ -51,4 +52,4 @@ RUN chmod +x /home/testuser/setup-and-test.sh WORKDIR /home/testuser # Run setup and integration test -CMD ["./setup-and-test.sh"] \ No newline at end of file +CMD ["./setup-and-test.sh"] diff --git a/scripts/integration/Dockerfile.dev b/scripts/integration/Dockerfile.dev index 1c8d22f..f355232 100644 --- a/scripts/integration/Dockerfile.dev +++ b/scripts/integration/Dockerfile.dev @@ -44,7 +44,8 @@ RUN mkdir -p test_helper && \ COPY --chown=testuser:testuser . /home/testuser/git-undo-source/ # Copy integration test files -COPY --chown=testuser:testuser scripts/integration/integration-test.bats /home/testuser/ +COPY --chown=testuser:testuser scripts/integration/*.bats /home/testuser/ +COPY --chown=testuser:testuser scripts/integration/test_helpers.bash /home/testuser/ COPY --chown=testuser:testuser scripts/integration/setup-and-test-dev.sh /home/testuser/setup-and-test.sh # Make the setup script executable @@ -54,4 +55,4 @@ RUN chmod +x /home/testuser/setup-and-test.sh WORKDIR /home/testuser # Run setup and integration test -CMD ["./setup-and-test.sh"] \ No newline at end of file +CMD ["./setup-and-test.sh"] diff --git a/scripts/integration/integration-test.bats b/scripts/integration/integration-test.bats deleted file mode 100644 index 65d4a66..0000000 --- a/scripts/integration/integration-test.bats +++ /dev/null @@ -1,1308 +0,0 @@ -#!/usr/bin/env bats - -# Load bats helpers -load 'test_helper/bats-support/load' -load 'test_helper/bats-assert/load' - -# Helper function for verbose command execution -# Usage: run_verbose [args...] -# Shows command output with boxing for multi-line content -run_verbose() { - run "$@" - local cmd_str="$1" - shift - while [[ $# -gt 0 ]]; do - cmd_str="$cmd_str $1" - shift - done - - if [[ $status -eq 0 ]]; then - if [[ -n "$output" ]]; then - # Check if output has multiple lines or is long - local line_count=$(echo "$output" | wc -l) - local char_count=${#output} - if [[ $line_count -gt 1 ]] || [[ $char_count -gt 80 ]]; then - echo "" - echo -e "\033[95m┌─ $cmd_str ─\033[0m" - echo -e "\033[95m$output\033[0m" - echo -e "\033[95m└────────────\033[0m" - else - echo -e "\033[32m>\033[0m $cmd_str: $output" - fi - else - echo -e "\033[32m>\033[0m $cmd_str: (no output)" - fi - else - echo "" - echo -e "\033[95m┌─ $cmd_str (FAILED: status $status) ─\033[0m" - echo -e "\033[95m$output\033[0m" - echo -e "\033[95m└────────────\033[0m" - fi -} - -# Helper function for commands that should only show output on failure -# Usage: run_quiet [args...] -# Only shows output if command fails -run_quiet() { - run "$@" - if [[ $status -ne 0 ]]; then - local cmd_str="$1" - shift - while [[ $# -gt 0 ]]; do - cmd_str="$cmd_str $1" - shift - done - echo "> $cmd_str FAILED: $output (status: $status)" - fi -} - -# Helper function for colored output -# Usage: print - prints in cyan -# Usage: debug - prints in gray -# Usage: title - prints in yellow -print() { - echo -e "\033[96m> $*\033[0m" # Cyan -} - -debug() { - echo -e "\033[90m> DEBUG: $*\033[0m" # Gray -} - -title() { - echo -e "\033[93m================================================================================" - echo -e "\033[93m $*\033[0m" # Yellow - echo -e "\033[93m================================================================================\033[0m" -} - -setup() { - # Create isolated test repository for the test - export TEST_REPO="$(mktemp -d)" - cd "$TEST_REPO" - - git init - git config user.email "git-undo-test@amberpixels.io" - git config user.name "Git-Undo Integration Test User" - - # Configure git hooks for this repository - git config core.hooksPath ~/.config/git-undo/hooks - - # Source hooks in the test shell environment - # shellcheck disable=SC1090 - source ~/.config/git-undo/git-undo-hook.bash - - # Create initial empty commit so we always have HEAD (like in unit tests) - git commit --allow-empty -m "init" -} - -teardown() { - # Clean up test repository - rm -rf "$TEST_REPO" -} - -@test "0A: complete git-undo integration workflow" { - # ============================================================================ - # PHASE 0A-1: Verify Installation - # ============================================================================ - title "Phase 0A-1: Verifying git-undo installation..." - - run which git-undo - assert_success - assert_output --regexp "git-undo$" - - # Test version command - run git-undo --version - assert_success - assert_output --partial "v0." - - # ============================================================================ - # HOOK DIAGNOSTICS: Debug hook installation and activation - # ============================================================================ - title "HOOK DIAGNOSTICS: Checking hook installation..." - - # Check if hook files exist - echo "> Checking if hook files exist in ~/.config/git-undo/..." - run ls -la ~/.config/git-undo/ - assert_success - echo "> Hook directory contents: ${output}" - - # Verify hook files are present - assert [ -f ~/.config/git-undo/git-undo-hook.bash ] - echo "> ✓ Hook file exists: ~/.config/git-undo/git-undo-hook.bash" - - # Verify that the test hook is actually installed (should contain git function) - echo "> Checking if test hook is installed (contains git function)..." - run grep -q "git()" ~/.config/git-undo/git-undo-hook.bash - assert_success - echo "> ✓ Test hook confirmed: contains git function" - - # Check if .bashrc has the source line - echo "> Checking if .bashrc contains git-undo source line..." - run grep -n git-undo ~/.bashrc - assert_success - echo "> .bashrc git-undo lines: ${output}" - - # Check current git command type (before sourcing hooks) - echo "> Checking git command type before hook loading..." - run type git - echo "> Git type before: ${output}" - - # Check git command type (hooks are sourced in setup) - echo "> Checking git command type after hook loading..." - run type git - echo "> Git type after: ${output}" - - # Test if git-undo function/alias is available - echo "> Testing if git undo command is available..." - run git undo --help - if [[ $status -eq 0 ]]; then - echo "> ✓ git undo command responds" - else - echo "> ✗ git undo command failed with status: $status" - echo "> Output: ${output}" - fi - - # ============================================================================ - # PHASE 0A-2: Basic git add and undo workflow - # ============================================================================ - title "Phase 0A-2: Testing basic git add and undo..." - - # Create test files - echo "content of file1" > file1.txt - echo "content of file2" > file2.txt - echo "content of file3" > file3.txt - - # Verify files are untracked initially - run_verbose git status --porcelain - assert_success - assert_output --partial "?? file1.txt" - assert_output --partial "?? file2.txt" - assert_output --partial "?? file3.txt" - - # Add first file - git add file1.txt - run_verbose git status --porcelain - assert_success - assert_output --partial "A file1.txt" - assert_output --partial "?? file2.txt" - assert_output --partial "?? file3.txt" - - # Add second file - git add file2.txt - run_verbose git status --porcelain - assert_success - assert_output --partial "A file1.txt" - assert_output --partial "A file2.txt" - assert_output --partial "?? file3.txt" - - # First undo - should unstage file2.txt - debug "Checking git-undo log before first undo..." - run_verbose git undo --log - assert_success - refute_output "" # Log should not be empty if hooks are tracking - - run git undo - assert_success - - run_verbose git status --porcelain - assert_success - assert_output --partial "A file1.txt" - assert_output --partial "?? file2.txt" - assert_output --partial "?? file3.txt" - - # Second undo - should unstage file1.txt - debug "Checking git-undo log before second undo..." - run_verbose git undo --log - assert_success - refute_output "" # Log should not be empty if hooks are tracking - - run git undo - assert_success - - run_verbose git status --porcelain - assert_success - assert_output --partial "?? file1.txt" - assert_output --partial "?? file2.txt" - assert_output --partial "?? file3.txt" - refute_output --partial "A file1.txt" - refute_output --partial "A file2.txt" - - # ============================================================================ - # PHASE 0A-3: Commit and undo workflow - # ============================================================================ - title "Phase 0A-3: Testing commit and undo..." - - # Stage and commit first file - git add file1.txt - git commit -m "Add file1.txt" - - # Verify clean working directory (except for untracked files from previous phase) - run_verbose git status --porcelain - assert_success - assert_output --partial "?? file2.txt" - assert_output --partial "?? file3.txt" - refute_output --partial "file1.txt" # file1.txt should be committed, not in status - - # Verify file1 exists and is committed - assert [ -f "file1.txt" ] - - # Stage and commit second file - git add file2.txt - git commit -m "Add file2.txt" - - # Verify clean working directory again (only file3.txt should remain untracked) - run_verbose git status --porcelain - assert_success - assert_output --partial "?? file3.txt" - refute_output --partial "file1.txt" # file1.txt should be committed - refute_output --partial "file2.txt" # file2.txt should be committed - - # First commit undo - should undo last commit, leaving file2 staged - debug "Checking git-undo log before commit undo..." - run_verbose git undo --log - assert_success - refute_output "" # Log should not be empty if hooks are tracking - - run git undo - assert_success - - run_verbose git status --porcelain - assert_success - assert_output --partial "A file2.txt" - - # Verify files still exist in working directory - assert [ -f "file1.txt" ] - assert [ -f "file2.txt" ] - - # Second undo - should unstage file2.txt - debug "Checking git-undo log before second commit undo..." - run_verbose git undo --log - assert_success - refute_output "" # Log should not be empty if hooks are tracking - - run git undo - assert_success - - run_verbose git status --porcelain - assert_success - assert_output --partial "?? file2.txt" - assert_output --partial "?? file3.txt" - refute_output --partial "A file2.txt" - - # ============================================================================ - # PHASE 0A-4: Complex sequential workflow - # ============================================================================ - title "Phase 0A-4: Testing complex sequential operations..." - - # Commit file3 - git add file3.txt - git commit -m "Add file3.txt" - - # Modify file1 and stage the modification - echo "modified content" >> file1.txt - git add file1.txt - - # Verify modified file1 is staged - run_verbose git status --porcelain - assert_success - assert_output --partial "M file1.txt" - - # Create and stage a new file - echo "content of file4" > file4.txt - git add file4.txt - - # Verify both staged - run_verbose git status --porcelain - assert_success - assert_output --partial "M file1.txt" - assert_output --partial "A file4.txt" - - # Undo staging of file4 - debug "Checking git-undo log before file4 undo..." - run_verbose git undo --log - assert_success - refute_output "" # Log should not be empty if hooks are tracking - - run git undo - assert_success - - run_verbose git status --porcelain - assert_success - assert_output --partial "M file1.txt" # file1 still staged - assert_output --partial "?? file4.txt" # file4 unstaged - refute_output --partial "A file4.txt" - - # Undo staging of modified file1 - debug "Checking git-undo log before modified file1 undo..." - run_verbose git undo --log - assert_success - refute_output "" # Log should not be empty if hooks are tracking - - run git undo - assert_success - - run_verbose git status --porcelain - assert_success - assert_output --partial " M file1.txt" # Modified but unstaged - assert_output --partial "?? file4.txt" - refute_output --partial "M file1.txt" # Should not be staged anymore - - # Undo commit of file3 - run git undo - assert_success - - run_verbose git status --porcelain - assert_success - assert_output --partial "A file3.txt" # file3 back to staged - assert_output --partial " M file1.txt" # file1 still modified - assert_output --partial "?? file4.txt" - - # ============================================================================ - # PHASE 0A-5: Verification of final state - # ============================================================================ - title "Phase 0A-5: Final state verification..." - - # Verify all files exist - assert [ -f "file1.txt" ] - assert [ -f "file2.txt" ] - assert [ -f "file3.txt" ] - assert [ -f "file4.txt" ] - - # Verify git log shows only the first commit - run git log --oneline - assert_success - assert_output --partial "Add file1.txt" - refute_output --partial "Add file2.txt" - refute_output --partial "Add file3.txt" - - print "Integration test completed successfully!" -} - -@test "0B: git-back integration test - checkout and switch undo" { - # ============================================================================ - # PHASE 0B-1: Verify git-back Installation - # ============================================================================ - title "Phase 0B-1: Verifying git-back installation..." - - run which git-back - assert_success - assert_output --regexp "git-back$" - - # Test version command - run git-back --version - assert_success - assert_output --partial "v0." - - # Test help command - run git-back --help - assert_success - assert_output --partial "Git-back undoes the last git checkout or git switch command" - - # ============================================================================ - # PHASE 0B-2: Basic branch switching workflow - # ============================================================================ - title "Phase 0B-2: Testing basic branch switching..." - - # Create and commit some files to establish a proper git history - echo "main content" > main.txt - git add main.txt - git commit -m "Add main content" - - # Create a feature branch - git checkout -b feature-branch - echo "feature content" > feature.txt - git add feature.txt - git commit -m "Add feature content" - - # Create another branch - git checkout -b another-branch - echo "another content" > another.txt - git add another.txt - git commit -m "Add another content" - - # Verify we're on another-branch - run git branch --show-current - assert_success - assert_output "another-branch" - - # ============================================================================ - # PHASE 0B-3: Test git-back with checkout commands - # ============================================================================ - title "Phase 0B-3: Testing git-back with checkout..." - - # Switch to feature branch (this should be tracked) - git checkout feature-branch - - # Verify we're on feature-branch - run git branch --show-current - assert_success - assert_output "feature-branch" - - # Use git-back to go back to previous branch (should be another-branch) - run_verbose git back - assert_success - - # Verify we're back on another-branch - run git branch --show-current - assert_success - assert_output "another-branch" - - # ============================================================================ - # PHASE 0B-4: Test multiple branch switches - # ============================================================================ - title "Phase 0B-4: Testing multiple branch switches..." - - # Switch to main branch - git checkout main - - # Verify we're on main - run git branch --show-current - assert_success - assert_output "main" - - # Use git-back to go back to previous branch (should be another-branch) - run_verbose git back - assert_success - - # Verify we're back on another-branch - run git branch --show-current - assert_success - assert_output "another-branch" - - # Switch to feature-branch again - git checkout feature-branch - - debug "Checking git-undo log before modified file1 undo..." - run_verbose git undo --log - assert_success - refute_output "" # Log should not be empty if hooks are tracking - - # Use git-back to go back to another-branch - run_verbose git back - assert_success - - # Verify we're on another-branch - run git branch --show-current - assert_success - assert_output "another-branch" - - # ============================================================================ - # PHASE 0B-5: Test git-back with uncommitted changes (should show warnings) - # ============================================================================ - title "Phase 0B-5: Testing git-back with uncommitted changes..." - - # Make some uncommitted changes - echo "modified content" >> another.txt - echo "new file content" > unstaged.txt - - # Stage one file - git add unstaged.txt - - # Now try git-back in verbose mode to see warnings - run_verbose git undo --log - - # Now try git-back in verbose mode to see warnings - run_verbose git back -v - # Note: This might fail due to conflicts, but we want to verify warnings are shown - # The important thing is that warnings are displayed to the user - - # For testing purposes, let's stash the changes and try again - git stash - - # Now git-back should work - run_verbose git back - assert_success - - # Verify we're back on feature-branch - run git branch --show-current - assert_success - assert_output "feature-branch" - - print "git-back integration test completed successfully!" -} - -@test "1A: Phase 1A Commands: git rm, mv, tag, restore undo functionality" { - title "Phase 1A-1: Testing git rm, mv, tag, restore undo functionality" - - run_verbose git status --porcelain - assert_success - - # Setup: Create some initial commits so we're not trying to undo the initial commit - echo "initial content" > initial.txt - git add initial.txt - git commit -m "Initial commit" - - echo "second content" > second.txt - git add second.txt - git commit -m "Second commit" - - # ============================================================================ - # PHASE 1A-2: Test git tag undo - # ============================================================================ - title "Phase 1A-2: Testing git tag undo..." - - # Create a tag - git tag v1.0.0 - - # Verify tag exists - run git tag -l v1.0.0 - assert_success - assert_output "v1.0.0" - - # Undo the tag creation - run_verbose git-undo - assert_success - - # Verify tag is deleted - run git tag -l v1.0.0 - assert_success - assert_output "" - - # Test annotated tag - git tag -a v2.0.0 -m "Release version 2.0.0" - - # Verify tag exists - run git tag -l v2.0.0 - assert_success - assert_output "v2.0.0" - - # Undo the annotated tag creation - run_verbose git-undo - assert_success - - # Verify tag is deleted - run git tag -l v2.0.0 - assert_success - assert_output "" - - # ============================================================================ - # PHASE 1A-3: Test git mv undo - # ============================================================================ - title "Phase 1A-3: Testing git mv undo..." - - # Create a file to move - echo "content for moving" > moveme.txt - git add moveme.txt - git commit -m "Add file to move" - - # Move the file - git mv moveme.txt moved.txt - - # Verify file was moved - [ ! -f moveme.txt ] - [ -f moved.txt ] - - # Undo the move - run_verbose git-undo - assert_success - - # Verify file is back to original name - [ -f moveme.txt ] - [ ! -f moved.txt ] - - # Test moving multiple files to directory - mkdir subdir - echo "file1 content" > file1.txt - echo "file2 content" > file2.txt - git add file1.txt file2.txt - git commit -m "Add files for directory move" - - # Move files to subdirectory - git mv file1.txt file2.txt subdir/ - - # Verify files were moved - [ ! -f file1.txt ] - [ ! -f file2.txt ] - [ -f subdir/file1.txt ] - [ -f subdir/file2.txt ] - - # Undo the move - run_verbose git-undo - assert_success - - # Verify files are back - [ -f file1.txt ] - [ -f file2.txt ] - [ ! -f subdir/file1.txt ] - [ ! -f subdir/file2.txt ] - - # ============================================================================ - # PHASE 1A-4: Test git rm undo - # ============================================================================ - title "Phase 1A-4: Testing git rm undo..." - - # Create a file to remove - echo "content for removal" > removeme.txt - git add removeme.txt - git commit -m "Add file to remove" - - # Test cached removal (--cached flag) - git rm --cached removeme.txt - - # Verify file is unstaged but still exists - run git ls-files removeme.txt - assert_success - assert_output "" - [ -f removeme.txt ] - - # Undo the cached removal - run_verbose git-undo - assert_success - - # Verify file is back in index - run git ls-files removeme.txt - assert_success - assert_output "removeme.txt" - - # Test full removal - git rm removeme.txt - - # Verify file is removed from both index and working directory - run git ls-files removeme.txt - assert_success - assert_output "" - [ ! -f removeme.txt ] - - # Undo the removal - run_verbose git-undo - assert_success - - # Verify file is restored - run git ls-files removeme.txt - assert_success - assert_output "removeme.txt" - [ -f removeme.txt ] - - # ============================================================================ - # PHASE 1A-5: Test git restore undo (staged only) - # ============================================================================ - title "Phase 1A-5: Testing git restore --staged undo..." - - # Create and stage a file - echo "staged content" > staged.txt - git add staged.txt - - # Verify file is staged - run git diff --cached --name-only - assert_success - assert_line "staged.txt" - - # Restore (unstage) the file - git restore --staged staged.txt - - # Verify file is no longer staged - run git diff --cached --name-only - assert_success - assert_output "" - - # Undo the restore (re-stage the file) - run_verbose git-undo - assert_success - - # Verify file is staged again - run git diff --cached --name-only - assert_success - assert_line "staged.txt" - - print "Phase 1A Commands integration test completed successfully!" -} - -@test "2A: Phase 2A Commands: git reset, revert, cherry-pick undo functionality" { - title "Phase 2A-1: Testing git reset, revert, cherry-pick undo functionality" - - # Setup: Create initial commit structure for testing - echo "initial content" > initial.txt - git add initial.txt - git commit -m "Initial commit" - - echo "second content" > second.txt - git add second.txt - git commit -m "Second commit" - - echo "third content" > third.txt - git add third.txt - git commit -m "Third commit" - - # ============================================================================ - # PHASE 2A-2: Test git reset undo - # ============================================================================ - title "Phase 2A-2: Testing git reset undo..." - - # Get current commit hash for verification - run_verbose git rev-parse HEAD - assert_success - third_commit="$output" - - # Perform a soft reset to previous commit - run_verbose git reset --soft HEAD~1 - - # Verify we're at the second commit with staged changes - run_verbose git rev-parse HEAD - assert_success - second_commit="$output" - - # Verify third.txt is staged - run_verbose git diff --cached --name-only - assert_success - assert_line "third.txt" - - # Undo the reset (should restore HEAD to third_commit) - run_verbose git-undo - assert_success - - # Verify we're back at the third commit - run git rev-parse HEAD - assert_success - assert_output "$third_commit" - - # Test mixed reset undo - run_verbose git reset HEAD~1 - - # Verify second commit with unstaged changes - run git rev-parse HEAD - assert_success - assert_output "$second_commit" - - # Debug: Check what's in the log before undo - run_verbose git-undo --log - - run_verbose git status --porcelain - assert_success - assert_output --partial "?? third.txt" - - # Undo the mixed reset - run_verbose git-undo - assert_success - - # Verify restoration - run git rev-parse HEAD - assert_success - assert_output "$third_commit" - - # ============================================================================ - # PHASE 2A-3: Test git revert undo - # ============================================================================ - title "Phase 2A-3: Testing git revert undo..." - - # Create a commit to revert - echo "revert-me content" > revert-me.txt - git add revert-me.txt - git commit -m "Commit to be reverted" - - # Get the commit hash - run git rev-parse HEAD - assert_success - revert_target="$output" - - # Revert the commit - git revert --no-edit HEAD - - # Verify revert commit was created - run git log -1 --format="%s" - assert_success - assert_output --partial "Revert" - - # Verify file was removed by revert - [ ! -f revert-me.txt ] - - # Undo the revert - run_verbose git-undo - assert_success - - # Debug: Check git status after undo - run_verbose git status --porcelain - run_verbose ls -la revert-me.txt || echo "File not found" - - # Verify we're back to the original commit - run git rev-parse HEAD - assert_success - assert_output "$revert_target" - - # Verify file is back - [ -f revert-me.txt ] - - # ============================================================================ - # PHASE 2A-4: Test git cherry-pick undo - # ============================================================================ - title "Phase 2A-4: Testing git cherry-pick undo..." - - # Create a feature branch with a commit to cherry-pick - git checkout -b feature-cherry - echo "cherry content" > cherry.txt - git add cherry.txt - git commit -m "Cherry-pick target commit" - - # Get the commit hash - run git rev-parse HEAD - assert_success - cherry_commit="$output" - - # Go back to main branch - git checkout main - - # Record main branch state - run git rev-parse HEAD - assert_success - main_before_cherry="$output" - - # Cherry-pick the commit - git cherry-pick "$cherry_commit" - - # Verify cherry-pick was successful - [ -f cherry.txt ] - run git log -1 --format="%s" - assert_success - assert_output "Cherry-pick target commit" - - # Undo the cherry-pick - run_verbose git-undo - assert_success - - # Verify we're back to the original main state - run git rev-parse HEAD - assert_success - assert_output "$main_before_cherry" - - # Verify cherry-picked file is gone - [ ! -f cherry.txt ] - - # ============================================================================ - # PHASE 2A-5: Test git clean undo (expected to fail) - # ============================================================================ - title "Phase 2A-5: Testing git clean undo (should show unsupported error)..." - - # Create untracked files - echo "untracked1" > untracked1.txt - echo "untracked2" > untracked2.txt - - # Verify files exist - [ -f untracked1.txt ] - [ -f untracked2.txt ] - - # Clean the files - git clean -f - - # Verify files are gone - [ ! -f untracked1.txt ] - [ ! -f untracked2.txt ] - - # Try to undo clean (should fail with clear error message) - run_verbose git-undo - assert_failure - assert_output --partial "permanently removes untracked files that cannot be recovered" - - print "Phase 2A Commands integration test completed successfully!" -} - -@test "3A: git undo checkout/switch detection - warns and suggests git back" { - title "Phase 3A: Checkout/Switch Detection Test: Testing that git undo warns for checkout/switch commands" - - # Setup: Create initial commit structure for testing - echo "initial content" > initial.txt - git add initial.txt - git commit -m "Initial commit" - - echo "main content" > main.txt - git add main.txt - git commit -m "Main content commit" - - # ============================================================================ - # PHASE 3A-1: Test git checkout detection - # ============================================================================ - title "Phase 3A-1: Testing git checkout detection..." - - # Create a test branch - git branch test-branch - - # Perform checkout operation (should be tracked) - git checkout test-branch - - # Verify we're on the test branch - run git branch --show-current - assert_success - assert_output "test-branch" - - # Try git undo - should warn about checkout command - run_verbose git undo 2>&1 - assert_success - assert_output --partial "can't be undone" - assert_output --partial "git back" - - # ============================================================================ - # PHASE 3A-2: Test git switch -c (branch creation) - should show warning - # ============================================================================ - title "Phase 3A-2: Testing git switch -c warning..." - - # Switch back to main first - git checkout main - - # Create a new branch using git switch -c - git switch -c feature-switch - - # Verify we're on the new branch - run git branch --show-current - assert_success - assert_output "feature-switch" - - # Try git undo - should warn that switch can't be undone and suggest git back - run_verbose git undo 2>&1 - assert_success - assert_output --partial "can't be undone" - assert_output --partial "git back" - - # Verify we're still on the feature-switch branch (no actual undo happened) - run git branch --show-current - assert_success - assert_output "feature-switch" - - # ============================================================================ - # PHASE 3A-3: Test regular git switch - should show warning - # ============================================================================ - title "Phase 3A-3: Testing regular git switch warning..." - - # Add content to feature branch - echo "feature content" > feature.txt - git add feature.txt - git commit -m "Feature content" - - # Switch back to main - git switch main - - # Verify we're on main - run git branch --show-current - assert_success - assert_output "main" - - # Try git undo - should warn about switch command and suggest git back - run_verbose git undo 2>&1 - assert_success - assert_output --partial "can't be undone" - assert_output --partial "git back" - - # Verify we're still on main (no actual undo happened) - run git branch --show-current - assert_success - assert_output "main" - - # ============================================================================ - # PHASE 3A-4: Test that git back works as expected for switch/checkout operations - # ============================================================================ - title "Phase 3A-4: Testing that git back works for switch/checkout operations..." - - # Use git back to go back to the previous branch (should be feature-switch) - run_verbose git back - assert_success - - # Verify we're back on feature-switch - run git branch --show-current - assert_success - assert_output "feature-switch" - - # ============================================================================ - # PHASE 3A-5: Test mixed commands - ensure warning only appears for switch/checkout - # ============================================================================ - title "Phase 3A-5: Testing that warning only appears for switch/checkout commands..." - - # Create and stage a file - echo "test file" > test-file.txt - git add test-file.txt - - # Try git undo - should work normally (no warning about git back) - run_verbose git undo - assert_success - refute_output --partial "can't be undone" - refute_output --partial "git back" - - # Verify file was unstaged - run_verbose git status --porcelain - assert_success - assert_output --partial "?? test-file.txt" - - print "Checkout/switch detection integration test completed successfully!" -} - -@test "4A: Additional Commands: git stash, merge, reset --hard, restore, branch undo functionality" { - title "Phase 4A: Testing additional git command undo functionality" - - # Setup: Create initial commit structure for testing - echo "initial content" > initial.txt - git add initial.txt - git commit -m "Initial commit" - - echo "main content" > main.txt - git add main.txt - git commit -m "Main content commit" - - # ============================================================================ - # PHASE 4A-1: Test git stash undo - # ============================================================================ - title "Phase 4A-1: Testing git stash undo..." - - # Create some changes to stash - echo "changes to stash" >> main.txt - echo "new unstaged file" > unstaged.txt - - # Stage one change - git add unstaged.txt - - # Verify we have both staged and unstaged changes - run_verbose git status --porcelain - assert_success - assert_output --partial "A unstaged.txt" - assert_output --partial " M main.txt" - - # Stash the changes - run_verbose git stash push -m "Test stash message" - - # Verify working directory is clean - run_verbose git status --porcelain - assert_success - assert_output "" - - # Verify files are back to original state - [ ! -f unstaged.txt ] - run cat main.txt - assert_success - assert_output "main content" - - # Undo the stash (should restore the changes) - run_verbose git-undo - assert_success - - # Verify changes are restored - run_verbose git status --porcelain - assert_success - assert_output --partial "A unstaged.txt" - assert_output --partial " M main.txt" - - # Clean up for next test - git reset HEAD unstaged.txt - git checkout -- main.txt - rm -f unstaged.txt - - # ============================================================================ - # PHASE 4A-2: Test git reset --hard undo - # ============================================================================ - title "Phase 4A-2: Testing git reset --hard undo..." - - # Create a commit to reset from - echo "content to be reset" > reset-test.txt - git add reset-test.txt - git commit -m "Commit to be reset with --hard" - - # Get current commit hash - run git rev-parse HEAD - assert_success - current_commit="$output" - - # Create some uncommitted changes - echo "uncommitted changes" >> main.txt - echo "untracked file" > untracked.txt - - # Perform hard reset (should lose uncommitted changes) - git reset --hard HEAD~1 - - # Verify we're at previous commit and changes are gone - run git rev-parse HEAD - assert_success - refute_output "$current_commit" - [ ! -f reset-test.txt ] - [ -f untracked.txt ] - - # Undo the hard reset - run_verbose git-undo - assert_success - - # Verify we're back at the original commit - run git rev-parse HEAD - assert_success - assert_output "$current_commit" - [ -f reset-test.txt ] - - # ============================================================================ - # PHASE 4A-3: Test git merge undo (fast-forward) - # ============================================================================ - title "Phase 4A-3: Testing git merge undo..." - - # Create a feature branch with commits - git checkout -b feature-merge - echo "feature change 1" > feature1.txt - git add feature1.txt - git commit -m "Feature commit 1" - - echo "feature change 2" > feature2.txt - git add feature2.txt - git commit -m "Feature commit 2" - - # Record feature branch head - run git rev-parse HEAD - assert_success - feature_head="$output" - - # Switch back to main and record state - git checkout main - run git rev-parse HEAD - assert_success - main_before_merge="$output" - - # Perform fast-forward merge - git merge feature-merge - - # Verify merge was successful (should be fast-forward) - run git rev-parse HEAD - assert_success - assert_output "$feature_head" - [ -f feature1.txt ] - [ -f feature2.txt ] - - # Undo the merge - run_verbose git-undo - assert_success - - # Verify we're back to pre-merge state - run git rev-parse HEAD - assert_success - assert_output "$main_before_merge" - [ ! -f feature1.txt ] - [ ! -f feature2.txt ] - - # ============================================================================ - # PHASE 4A-4: Test git branch -D undo (should fail with clear error message) - # ============================================================================ - title "Phase 4A-4: Testing git branch -D undo (should show unsupported error)..." - - # Verify feature branch still exists - run git branch --list feature-merge - assert_success - assert_output --partial "feature-merge" - - # Delete the feature branch (use -D since it's not merged) - git branch -D feature-merge - - # Verify branch is deleted - run git branch --list feature-merge - assert_success - assert_output "" - - # Try to undo the branch deletion (should fail with clear error message) - run_verbose git-undo - assert_failure - assert_output --partial "git undo not supported for branch deletion" - - print "Phase 4A: Additional commands integration test completed successfully!" -} - -@test "5A: Error Conditions and Edge Cases" { - title "Phase 5A: Testing error conditions and edge cases" - - # ============================================================================ - # PHASE 5A-1: Test git undo with no previous commands - # ============================================================================ - title "Phase 5A-1: Testing git undo with empty log..." - - # Clear any existing log by creating a fresh repository state - # The setup() already creates a clean state with just init commit - - # Try git undo when there are no tracked commands - # First undo should fail because it's trying to undo the initial commit - run_verbose git undo - assert_failure - assert_output --partial "this appears to be the initial commit and cannot be undone this way" - - # Second undo should still fail with the same error since the initial commit is still there - run_verbose git undo - assert_failure - assert_output --partial "this appears to be the initial commit and cannot be undone this way" - - # ============================================================================ - # PHASE 5A-2: Test git undo --log with empty log - # ============================================================================ - title "Phase 5A-2: Testing git undo --log with empty log..." - - # Check that log shows appropriate message when empty - run_verbose git undo --log - assert_success - # Should either show empty output or a message about no commands - - # ============================================================================ - # PHASE 5A-3: Test unsupported commands - # ============================================================================ - title "Phase 5A-3: Testing unsupported commands..." - - # Setup some commits first - echo "test content" > test.txt - git add test.txt - git commit -m "Test commit" - - # Test git rebase (should show warning/error about being unsupported) - git checkout -b rebase-test - echo "branch content" > branch.txt - git add branch.txt - git commit -m "Branch commit" - - git checkout main - # Attempt rebase - git rebase rebase-test 2>/dev/null || true - - # Try to undo rebase - should fail or warn appropriately - run_verbose git undo - # This might succeed or fail depending on implementation - # The important thing is it handles it gracefully - - # ============================================================================ - # PHASE 5A-4: Test git undo after hook failures - # ============================================================================ - title "Phase 5A-4: Testing behavior after hook failures..." - - # Perform a normal operation that should be tracked - echo "tracked content" > tracked.txt - git add tracked.txt - - # Verify it can be undone normally - run_verbose git undo - assert_success - - # Verify file is unstaged - run_verbose git status --porcelain - assert_success - assert_output --partial "?? tracked.txt" - - # ============================================================================ - # PHASE 5A-5: Test concurrent operations and rapid commands - # ============================================================================ - title "Phase 5A-5: Testing rapid sequential commands..." - - # Perform multiple rapid operations - echo "rapid1" > rapid1.txt - git add rapid1.txt - echo "rapid2" > rapid2.txt - git add rapid2.txt - echo "rapid3" > rapid3.txt - git add rapid3.txt - - # Verify all operations are tracked in correct order (LIFO) - run_verbose git undo - assert_success - run_verbose git status --porcelain - assert_success - assert_output --partial "A rapid1.txt" - assert_output --partial "A rapid2.txt" - assert_output --partial "?? rapid3.txt" - - run_verbose git undo - assert_success - run_verbose git status --porcelain - assert_success - assert_output --partial "A rapid1.txt" - assert_output --partial "?? rapid2.txt" - assert_output --partial "?? rapid3.txt" - - run_verbose git undo - assert_success - run_verbose git status --porcelain - assert_success - assert_output --partial "?? rapid1.txt" - assert_output --partial "?? rapid2.txt" - assert_output --partial "?? rapid3.txt" - - print "Phase 5A:Error conditions and edge cases test completed successfully!" -} \ No newline at end of file diff --git a/scripts/integration/setup-and-test-dev.sh b/scripts/integration/setup-and-test-dev.sh index eb7f4d9..1e807dc 100644 --- a/scripts/integration/setup-and-test-dev.sh +++ b/scripts/integration/setup-and-test-dev.sh @@ -23,4 +23,4 @@ cd /home/testuser echo " Running integration tests..." echo "================================================================================" echo "" -bats integration-test.bats #--filter "5A:" # <- uncomment to run a specific test \ No newline at end of file +bats ./*.bats --show-output-of-passing-tests #--filter "6__" diff --git a/scripts/integration/setup-and-test-prod.sh b/scripts/integration/setup-and-test-prod.sh index 4179a06..30b6c0b 100644 --- a/scripts/integration/setup-and-test-prod.sh +++ b/scripts/integration/setup-and-test-prod.sh @@ -17,4 +17,4 @@ source ~/.bashrc # Note: Git-undo hooks will be sourced within each BATS test's setup() function echo "Running integration tests..." -bats integration-test.bats # --filter "3A:" # <- uncomment to run a specific test \ No newline at end of file +bats ./*.bats --show-output-of-passing-tests #--filter "0A:" diff --git a/scripts/integration/test_helpers.bash b/scripts/integration/test_helpers.bash new file mode 100644 index 0000000..79b0df5 --- /dev/null +++ b/scripts/integration/test_helpers.bash @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +# Load bats assertion libraries +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' + +# Helper function for verbose command execution +# Usage: run_verbose [args...] +# Shows command output with boxing for multi-line content +run_verbose() { + run "$@" + local cmd_str="$1" + shift + while [[ $# -gt 0 ]]; do + cmd_str="$cmd_str $1" + shift + done + + # shellcheck disable=SC2154 + if [[ $status -eq 0 ]]; then + if [[ -n "$output" ]]; then + # Check if output has multiple lines or is long + local line_count + line_count=$(echo "$output" | wc -l) + local char_count=${#output} + if [[ $line_count -gt 1 ]] || [[ $char_count -gt 80 ]]; then + echo "" + echo -e "\033[95m┌─ $cmd_str ─\033[0m" + echo -e "\033[95m$output\033[0m" + echo -e "\033[95m└────────────\033[0m" + else + echo -e "\033[32m>\033[0m $cmd_str: $output" + fi + else + echo -e "\033[32m>\033[0m $cmd_str: (no output)" + fi + else + echo "" + echo -e "\033[95m┌─ $cmd_str (FAILED: status $status) ─\033[0m" + echo -e "\033[95m$output\033[0m" + echo -e "\033[95m└────────────\033[0m" + fi +} + +# Helper function for commands that should only show output on failure +# Usage: run_quiet [args...] +# Only shows output if command fails +run_quiet() { + run "$@" + if [[ $status -ne 0 ]]; then + local cmd_str="$1" + shift + while [[ $# -gt 0 ]]; do + cmd_str="$cmd_str $1" + shift + done + echo "> $cmd_str FAILED: $output (status: $status)" + fi +} + +# Helper function for colored output +# Usage: print - prints in cyan +# Usage: debug - prints in gray +# Usage: title - prints in yellow +print() { + echo -e "\033[96m> $*\033[0m" # Cyan +} + +debug() { + echo -e "\033[90m> DEBUG: $*\033[0m" # Gray +} + +title() { + echo -e "\033[93m================================================================================" + echo -e "\033[93m $*\033[0m" # Yellow + echo -e "\033[93m================================================================================\033[0m" +} + +# Common setup function for all tests +setup_git_undo_test() { + # Create isolated test repository for the test + TEST_REPO="$(mktemp -d)" + export TEST_REPO + cd "$TEST_REPO" || exit + + git init + git config user.email "git-undo-test@amberpixels.io" + git config user.name "Git-Undo Integration Test User" + + # Configure git hooks for this repository + git config core.hooksPath ~/.config/git-undo/hooks + + # Source hooks in the test shell environment + # shellcheck disable=SC1090 + source ~/.config/git-undo/git-undo-hook.bash + + # Create initial empty commit so we always have HEAD (like in unit tests) + git commit --allow-empty -m "init" +} + +# Common teardown function for all tests +teardown_git_undo_test() { + # Clean up test repository + rm -rf "$TEST_REPO" +} diff --git a/scripts/src/common.sh b/scripts/src/common.sh index e8ba013..508d48a 100644 --- a/scripts/src/common.sh +++ b/scripts/src/common.sh @@ -175,7 +175,6 @@ install_dispatcher_into() { log "Installing dispatcher script to: $DISPATCHER_FILE" # Debug: Check if source file exists - echo "AAAA $DISPATCHER_SRC" if [[ ! -f "$DISPATCHER_SRC" ]]; then log_error "Source dispatcher script not found: $DISPATCHER_SRC" log_error "DISPATCHER_SRC variable: '$DISPATCHER_SRC'" diff --git a/scripts/src/update.src.sh b/scripts/src/update.src.sh index ce42032..b006e2f 100755 --- a/scripts/src/update.src.sh +++ b/scripts/src/update.src.sh @@ -15,10 +15,10 @@ main() { log "Could not determine current version. Is git-undo installed?" exit 1 fi - + if [[ -z "$current_version" || "$current_version" == "unknown" ]]; then echo -e " ${YELLOW}UNKNOWN${NC}" - log "No version information found. Reinstall git-undo." + log_warning "No version information found. Reinstall git-undo manually." exit 1 else echo -e " ${BLUE}$current_version${NC}" @@ -38,7 +38,7 @@ main() { echo -en "${GRAY}git-undo:${NC} 3. Comparing releases..." local comparison comparison=$(version_compare "$current_version" "$latest_version") - + case "$comparison" in "same") echo -e " ${GREEN}UP TO DATE${NC}" @@ -60,7 +60,7 @@ main() { echo -e "Update available: ${BLUE}$current_version${NC} → ${GREEN}$latest_version${NC}" echo -en "Do you want to update? [Y/n]: " read -r response - + case "$response" in [nN]|[nN][oO]) log "Update cancelled." @@ -74,7 +74,7 @@ main() { echo -en "${GRAY}git-undo:${NC} 4. Downloading latest installer..." local temp_installer temp_installer=$(mktemp) - + if command -v curl >/dev/null 2>&1; then if curl -sL "$INSTALL_URL" -o "$temp_installer"; then echo -e " ${GREEN}OK${NC}" diff --git a/test-bats/test.bats b/test-bats/test.bats deleted file mode 100644 index 4ac8e9c..0000000 --- a/test-bats/test.bats +++ /dev/null @@ -1,8 +0,0 @@ -#\!/usr/bin/env bats - -@test "simple test" { - echo "this is a test line" - echo "this is another line" - run echo "command output" - echo "$output" -} diff --git a/uninstall.sh b/uninstall.sh index 53602fb..1fa43a8 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -210,7 +210,6 @@ install_dispatcher_into() { log "Installing dispatcher script to: $DISPATCHER_FILE" # Debug: Check if source file exists - echo "AAAA $DISPATCHER_SRC" if [[ ! -f "$DISPATCHER_SRC" ]]; then log_error "Source dispatcher script not found: $DISPATCHER_SRC" log_error "DISPATCHER_SRC variable: '$DISPATCHER_SRC'" diff --git a/update.sh b/update.sh index 4633e89..9276932 100755 --- a/update.sh +++ b/update.sh @@ -210,7 +210,6 @@ install_dispatcher_into() { log "Installing dispatcher script to: $DISPATCHER_FILE" # Debug: Check if source file exists - echo "AAAA $DISPATCHER_SRC" if [[ ! -f "$DISPATCHER_SRC" ]]; then log_error "Source dispatcher script not found: $DISPATCHER_SRC" log_error "DISPATCHER_SRC variable: '$DISPATCHER_SRC'" @@ -331,10 +330,10 @@ main() { log "Could not determine current version. Is git-undo installed?" exit 1 fi - + if [[ -z "$current_version" || "$current_version" == "unknown" ]]; then echo -e " ${YELLOW}UNKNOWN${NC}" - log "No version information found. Reinstall git-undo." + log_warning "No version information found. Reinstall git-undo manually." exit 1 else echo -e " ${BLUE}$current_version${NC}" @@ -354,7 +353,7 @@ main() { echo -en "${GRAY}git-undo:${NC} 3. Comparing releases..." local comparison comparison=$(version_compare "$current_version" "$latest_version") - + case "$comparison" in "same") echo -e " ${GREEN}UP TO DATE${NC}" @@ -376,7 +375,7 @@ main() { echo -e "Update available: ${BLUE}$current_version${NC} → ${GREEN}$latest_version${NC}" echo -en "Do you want to update? [Y/n]: " read -r response - + case "$response" in [nN]|[nN][oO]) log "Update cancelled." @@ -390,7 +389,7 @@ main() { echo -en "${GRAY}git-undo:${NC} 4. Downloading latest installer..." local temp_installer temp_installer=$(mktemp) - + if command -v curl >/dev/null 2>&1; then if curl -sL "$INSTALL_URL" -o "$temp_installer"; then echo -e " ${GREEN}OK${NC}"