Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## Features

- First-class zsh history support, including extended history lines and zsh-metafied command decoding
- First-class zsh history support, including extended history, multiline commands, and zsh-metafied command decoding
- Dry-run by default
- Reasoned removals for every dropped entry
- Duplicate removal while keeping the latest occurrence
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## 特性

- first-class 支持 zsh history,包括 extended history 格式和 zsh metafication 解码
- first-class 支持 zsh history,包括 extended history、多行命令和 zsh metafication 解码
- 默认 dry-run,只预览不写入
- 每条删除记录都有原因说明
- 去重时保留最后一次出现的命令
Expand Down
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ func writeAnalyzeText(w io.Writer, summary analyzeSummary) error {
return content.Err()
}
for _, top := range summary.TopCommands {
content.Writef(" %s: %d\n", top.Command, top.Count)
content.Writef(" %s: %d\n", render.CommandText(top.Command), top.Count)
}
return content.Err()
}
Expand Down
75 changes: 75 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,31 @@ func TestAnalyzeTextIncludesCountsAndTopCommand(t *testing.T) {
}
}

func TestAnalyzeTreatsMultilineHistoryAsLogicalCommands(t *testing.T) {
historyPath := writeHistoryFixture(t, multilineDuplicateHistory())
var out bytes.Buffer
runners := NewRunners(&out, nil)

if err := runners.Analyze(context.Background(), cli.AnalyzeOptions{File: historyPath}); err != nil {
t.Fatalf("Analyze returned error: %v", err)
}
text := out.String()
for _, want := range []string{
"Scanned: 3",
"zsh_extended: 3",
"plain: 0",
"malformed: 0",
"Duplicate commands: 1",
`"curl https://example.test`,
`\n \"role\": \"user\"\n`,
`}'": 2`,
} {
if !strings.Contains(text, want) {
t.Fatalf("analyze output missing %q:\n%s", want, text)
}
}
}

func TestPruneDryRunDoesNotModifyFileAndReportsRemovedEntries(t *testing.T) {
historyPath := copySampleHistory(t)
before := readFileString(t, historyPath)
Expand Down Expand Up @@ -126,6 +151,36 @@ func TestPruneWriteCreatesBackupAndUpdatesFileContent(t *testing.T) {
}
}

func TestPruneDedupeRemovesWholeMultilineRecord(t *testing.T) {
before := multilineDuplicateHistory()
historyPath := writeHistoryFixture(t, before)
var out bytes.Buffer
runners := NewRunners(&out, nil)

err := runners.Prune(context.Background(), cli.PruneOptions{File: historyPath, Dedupe: true, Write: true})
if err != nil {
t.Fatalf("Prune --dedupe --write returned error: %v", err)
}
want := `: 1714752001:0;curl https://example.test \\
--data '{\
"role": "user"\
}'
: 1714752002:0;git status
`
if got := readFileString(t, historyPath); got != want {
t.Fatalf("pruned history = %q, want %q", got, want)
}
for _, wantText := range []string{"Scanned: 3", "Removed: 1", "Kept: 2", "dedupe: 1"} {
if !strings.Contains(out.String(), wantText) {
t.Fatalf("prune output missing %q:\n%s", wantText, out.String())
}
}
backups := validBackups(t, historyPath)
if len(backups) != 1 || readFileString(t, backups[0]) != before {
t.Fatalf("backup did not preserve original multiline history: %#v", backups)
}
}

func TestPruneWriteWithNoRulesReturnsErrorWithoutBackupOrModification(t *testing.T) {
historyPath := copySampleHistory(t)
before := readFileString(t, historyPath)
Expand Down Expand Up @@ -251,6 +306,26 @@ func copySampleHistory(t *testing.T) string {
return path
}

func writeHistoryFixture(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), ".zsh_history")
writeFileString(t, path, content)
return path
}

func multilineDuplicateHistory() string {
return `: 1714752000:0;curl https://example.test \\
--data '{\
"role": "user"\
}'
: 1714752001:0;curl https://example.test \\
--data '{\
"role": "user"\
}'
: 1714752002:0;git status
`
}

func validBackups(t *testing.T, historyPath string) []string {
t.Helper()
entries, err := os.ReadDir(filepath.Dir(historyPath))
Expand Down
82 changes: 66 additions & 16 deletions internal/history/zsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ const (
zshMetaMask = 0x20
)

// ParseLine parses one zsh history line. Line numbers are 1-based when called
// from ParseContent, but callers may provide any source line number they track.
// ParseLine parses one logical zsh history record. Records may contain physical
// continuation lines escaped by zsh. Line numbers are 1-based when called from
// ParseContent, but callers may provide any source line number they track.
func ParseLine(lineNo int, line string) Entry {
entry := Entry{
Raw: line,
Expand All @@ -24,45 +25,84 @@ func ParseLine(lineNo int, line string) Entry {
return parseExtendedLine(entry, line)
}

entry.Command = unmetafy(line)
entry.Command = decodeHistoryRecord(line)
if strings.HasPrefix(entry.Command, `\:`) {
entry.Command = entry.Command[1:]
}
entry.Format = FormatPlain
return entry
}

func parseExtendedLine(entry Entry, line string) Entry {
rest := strings.TrimPrefix(line, zshExtendedPrefix)
timestamp, duration, command, ok := parseExtendedFields(line)
if !ok {
return malformedEntry(entry)
}

entry.Command = decodeHistoryRecord(command)
entry.Timestamp = &timestamp
entry.Duration = &duration
entry.Format = FormatZshExtended
return entry
}

func parseExtendedFields(line string) (int64, int, string, bool) {
rest, ok := strings.CutPrefix(line, zshExtendedPrefix)
if !ok {
return 0, 0, "", false
}
firstColon := strings.IndexByte(rest, ':')
semicolon := strings.IndexByte(rest, ';')
if firstColon <= 0 || semicolon < 0 || semicolon < firstColon {
return malformedEntry(entry)
return 0, 0, "", false
}

timestamp, err := strconv.ParseInt(rest[:firstColon], 10, 64)
if err != nil {
return malformedEntry(entry)
return 0, 0, "", false
}
duration, err := strconv.Atoi(rest[firstColon+1 : semicolon])
if err != nil {
return malformedEntry(entry)
return 0, 0, "", false
}

entry.Command = unmetafy(rest[semicolon+1:])
entry.Timestamp = &timestamp
entry.Duration = &duration
entry.Format = FormatZshExtended
return entry
return timestamp, duration, rest[semicolon+1:], true
}

func isExtendedHistoryStart(line string) bool {
_, _, _, ok := parseExtendedFields(line)
return ok
}

func malformedEntry(entry Entry) Entry {
command := entry.Raw
if semicolon := strings.IndexByte(entry.Raw, ';'); semicolon >= 0 {
command = entry.Raw[semicolon+1:]
}
entry.Command = unmetafy(command)
entry.Command = decodeHistoryRecord(command)
entry.Format = FormatMalformed
return entry
}

// decodeHistoryRecord mirrors zsh's history-file decoding. Zsh writes an
// additional backslash before every newline inside a logical history entry and
// removes it when reading the file. It also appends one padding space when the
// command itself ends in a backslash followed by optional spaces, preventing
// the record terminator from being mistaken for a continuation.
func decodeHistoryRecord(text string) string {
text = strings.ReplaceAll(text, "\\\n", "\n")

lastNonSpace := len(text) - 1
for lastNonSpace >= 0 && text[lastNonSpace] == ' ' {
lastNonSpace--
}
if lastNonSpace >= 0 && lastNonSpace < len(text)-1 && text[lastNonSpace] == '\\' {
text = text[:len(text)-1]
}

return unmetafy(text)
}

func unmetafy(text string) string {
metaAt := strings.IndexByte(text, zshMetaByte)
if metaAt < 0 || utf8.ValidString(text) {
Expand Down Expand Up @@ -114,7 +154,9 @@ func (h ParsedHistory) SerializeEntries(entries []Entry) string {
return content
}

// ParseContent parses full history file contents into entries.
// ParseContent parses full history file contents into logical entries. A
// physical line ending in a backslash continues the current record, while a
// valid extended-history prefix takes precedence as a new record boundary.
func ParseContent(content string) []Entry {
if content == "" {
return nil
Expand All @@ -123,8 +165,16 @@ func ParseContent(content string) []Entry {
trimmed := strings.TrimSuffix(content, "\n")
lines := strings.Split(trimmed, "\n")
entries := make([]Entry, 0, len(lines))
for i, line := range lines {
entries = append(entries, ParseLine(i+1, line))
for start := 0; start < len(lines); {
end := start
for end+1 < len(lines) &&
strings.HasSuffix(lines[end], `\`) &&
!isExtendedHistoryStart(lines[end+1]) {
end++
}
record := strings.Join(lines[start:end+1], "\n")
entries = append(entries, ParseLine(start+1, record))
start = end + 1
}
return entries
}
Expand Down
99 changes: 99 additions & 0 deletions internal/history/zsh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,102 @@ func TestParseHistorySerializeSingleBlankLine(t *testing.T) {
t.Fatalf("Serialize() = %q, want %q", got, want)
}
}

func TestParseContentGroupsMultilineExtendedHistory(t *testing.T) {
content := `: 1714752000:0;curl https://example.test \\
--data '{\
"input": []\
}'
: 1714752001:0;git status`

entries := ParseContent(content)

if got, want := len(entries), 2; got != want {
t.Fatalf("ParseContent() returned %d entries, want %d", got, want)
}
wantCommand := `curl https://example.test \
--data '{
"input": []
}'`
if got := entries[0].Command; got != wantCommand {
t.Fatalf("multiline command = %q, want %q", got, wantCommand)
}
if got, want := entries[0].Format, FormatZshExtended; got != want {
t.Fatalf("multiline format = %v, want %v", got, want)
}
if got, want := entries[1].LineNo, 5; got != want {
t.Fatalf("second entry line = %d, want %d", got, want)
}
if got := SerializeEntries(entries); got != content {
t.Fatalf("SerializeEntries() = %q, want %q", got, content)
}
}

func TestParseContentGroupsMultilinePlainHistory(t *testing.T) {
content := "for x in a b\\\ndo\\\n : $x\\\ndone\nplain command"

entries := ParseContent(content)

if got, want := len(entries), 2; got != want {
t.Fatalf("ParseContent() returned %d entries, want %d", got, want)
}
if got, want := entries[0].Command, "for x in a b\ndo\n : $x\ndone"; got != want {
t.Fatalf("multiline command = %q, want %q", got, want)
}
if got, want := entries[0].Format, FormatPlain; got != want {
t.Fatalf("multiline format = %v, want %v", got, want)
}
if got, want := entries[1].LineNo, 5; got != want {
t.Fatalf("second entry line = %d, want %d", got, want)
}
if got := SerializeEntries(entries); got != content {
t.Fatalf("SerializeEntries() = %q, want %q", got, content)
}
}

func TestParseContentDoesNotMergeExtendedEntryAfterTrailingBackslash(t *testing.T) {
content := `: 1714752000:0; --account example \
: 1714752001:0;next command`

entries := ParseContent(content)

if got, want := len(entries), 2; got != want {
t.Fatalf("ParseContent() returned %d entries, want %d", got, want)
}
if got, want := entries[0].Command, ` --account example \`; got != want {
t.Fatalf("first command = %q, want %q", got, want)
}
if got, want := entries[1].Command, "next command"; got != want {
t.Fatalf("second command = %q, want %q", got, want)
}
if got, want := entries[1].LineNo, 2; got != want {
t.Fatalf("second entry line = %d, want %d", got, want)
}
if got := SerializeEntries(entries); got != content {
t.Fatalf("SerializeEntries() = %q, want %q", got, content)
}
}

func TestParseLineDecodesZshRecordEscapes(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "escaped leading colon", raw: `\: plain command`, want: ": plain command"},
{name: "trailing backslash padding", raw: `print -r -- \\ `, want: `print -r -- \\`},
{name: "trailing backslash and spaces padding", raw: `print -r -- \\ `, want: `print -r -- \\ `},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entry := ParseLine(1, tt.raw)
if got := entry.Command; got != tt.want {
t.Fatalf("Command = %q, want %q", got, tt.want)
}
if got := entry.Serialize(); got != tt.raw {
t.Fatalf("Serialize() = %q, want %q", got, tt.raw)
}
})
}
}
11 changes: 11 additions & 0 deletions internal/render/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package render
import (
"fmt"
"io"
"strconv"
"strings"
)

type Content struct {
Expand Down Expand Up @@ -31,3 +33,12 @@ func (c *Content) Writef(format string, args ...any) {
func (c *Content) Err() error {
return c.err
}

// CommandText keeps one-line commands unchanged and quotes multiline commands
// so text reports remain one physical line per result.
func CommandText(command string) string {
if strings.ContainsAny(command, "\r\n") {
return strconv.Quote(command)
}
return command
}
Loading