diff --git a/README.md b/README.md index a38b0c1..dfdf429 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 30f4024..569b899 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ ## 特性 -- first-class 支持 zsh history,包括 extended history 格式和 zsh metafication 解码 +- first-class 支持 zsh history,包括 extended history、多行命令和 zsh metafication 解码 - 默认 dry-run,只预览不写入 - 每条删除记录都有原因说明 - 去重时保留最后一次出现的命令 diff --git a/internal/app/app.go b/internal/app/app.go index 18cae87..bf698a0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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() } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 3d7030c..13ab49d 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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) @@ -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) @@ -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)) diff --git a/internal/history/zsh.go b/internal/history/zsh.go index c9e9a82..231c692 100644 --- a/internal/history/zsh.go +++ b/internal/history/zsh.go @@ -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, @@ -24,33 +25,53 @@ 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 = ×tamp + 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 = ×tamp - 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 { @@ -58,11 +79,30 @@ func malformedEntry(entry Entry) Entry { 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) { @@ -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 @@ -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 } diff --git a/internal/history/zsh_test.go b/internal/history/zsh_test.go index 1dbb767..8dcccde 100644 --- a/internal/history/zsh_test.go +++ b/internal/history/zsh_test.go @@ -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) + } + }) + } +} diff --git a/internal/render/content.go b/internal/render/content.go index 3c41fc4..c0fc192 100644 --- a/internal/render/content.go +++ b/internal/render/content.go @@ -3,6 +3,8 @@ package render import ( "fmt" "io" + "strconv" + "strings" ) type Content struct { @@ -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 +} diff --git a/internal/render/content_test.go b/internal/render/content_test.go index a2eccf1..79db905 100644 --- a/internal/render/content_test.go +++ b/internal/render/content_test.go @@ -36,6 +36,15 @@ func TestContentReturnsFirstWriteErrorAndStops(t *testing.T) { } } +func TestCommandTextQuotesOnlyMultilineCommands(t *testing.T) { + if got, want := CommandText("git status"), "git status"; got != want { + t.Fatalf("CommandText(single line) = %q, want %q", got, want) + } + if got, want := CommandText("printf 'a\nb'"), `"printf 'a\nb'"`; got != want { + t.Fatalf("CommandText(multiline) = %q, want %q", got, want) + } +} + type recordingWriter struct { text string err error diff --git a/internal/report/text.go b/internal/report/text.go index 7197e00..34c4f33 100644 --- a/internal/report/text.go +++ b/internal/report/text.go @@ -92,7 +92,7 @@ func WriteText(w io.Writer, summary Summary) error { if len(summary.RemovedEntries) > 0 { content.WriteString("Removed entries:\n") for _, entry := range summary.RemovedEntries { - content.Writef(" Line %d: %s\n", entry.LineNo, entry.Command) + content.Writef(" Line %d: %s\n", entry.LineNo, render.CommandText(entry.Command)) for _, reason := range entry.Reasons { if reason.Detail == "" { content.Writef(" - %s\n", reason.Type)