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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ aifr list --depth -1 --type f .
# Git refs and log
aifr refs --branches --tags
aifr log --max-count 5
aifr log --oneline --max-count 10
aifr log --format=text --divider=xml --max-count 3
aifr log --verbose --max-count 1 # tree hash, parent hashes, committer

# Compare files across refs
aifr diff HEAD~1:README.md README.md
Expand Down Expand Up @@ -222,7 +225,7 @@ Run `aifr sensitive` to see the full pattern list.
| `hexdump` | Hex dump of file contents |
| `checksum` | Compute file checksums |
| `refs` | List git branches, tags, remotes |
| `log` | Git commit log with files changed |
| `log` | Git commit log (text/oneline/xml/json, verbose mode) |
| `reflog` | Show git reflog for a ref |
| `stash-list` | List git stashes |
| `rev-parse` | Resolve a git ref to a commit hash |
Expand Down
33 changes: 31 additions & 2 deletions cmd/aifr/cmd_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,28 @@ import (

var (
logMaxCount int
logSkip int
logOneline bool
logDivider string
logVerbose bool
)

var logCmd = &cobra.Command{
Use: "log [repo|path][:<ref>]",
Short: "Git commit log",
Args: cobra.MaximumNArgs(1),
Long: `Show git commit log with structured entries.

Output formats for --format text:
default git-log style with commit/Author/Date headers
--oneline compact one-line-per-commit (hash + subject)

Divider formats for --format text (ignored with --oneline):
plain git-log style (default)
xml XML-tagged output with escaped content

Use --verbose to include tree hash, parent hashes, and committer
details (when they differ from the author) in JSON output.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
eng, err := buildEngine()
if err != nil {
Expand Down Expand Up @@ -46,7 +62,16 @@ var logCmd = &cobra.Command{
}
}

resp, err := eng.Log(repoName, ref, engine.LogParams{MaxCount: logMaxCount})
// --oneline implies text format.
if logOneline {
flagFormat = "oneline"
}

resp, err := eng.Log(repoName, ref, engine.LogParams{
MaxCount: logMaxCount,
Skip: logSkip,
Verbose: logVerbose,
})
if err != nil {
exitWithError(err)
return nil
Expand All @@ -58,5 +83,9 @@ var logCmd = &cobra.Command{

func init() {
logCmd.Flags().IntVar(&logMaxCount, "max-count", 20, "maximum commits to show")
logCmd.Flags().IntVar(&logSkip, "skip", 0, "skip this many commits before showing results")
logCmd.Flags().BoolVar(&logOneline, "oneline", false, "compact one-line-per-commit output")
logCmd.Flags().StringVar(&logDivider, "divider", "plain", "divider format for text output: plain, xml")
logCmd.Flags().BoolVar(&logVerbose, "verbose", false, "include tree hash, parent hashes, committer details")
rootCmd.AddCommand(logCmd)
}
21 changes: 16 additions & 5 deletions cmd/aifr/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,10 @@ func writeJSON(v any) {

// writeOutput writes the response in the selected format.
func writeOutput(v any) {
if flagNumberLines && flagFormat != "text" {
if flagNumberLines && flagFormat != "text" && flagFormat != "oneline" {
applyNumberLines(v)
}
if flagFormat != "text" {
if flagFormat != "text" && flagFormat != "oneline" {
writeJSON(v)
return
}
Expand All @@ -143,7 +143,14 @@ func writeOutput(v any) {
case *protocol.DiffResponse:
output.WriteDiffText(w, resp)
case *protocol.LogResponse:
output.WriteLogText(w, resp)
switch {
case flagFormat == "oneline":
output.WriteLogOneline(w, resp)
case logDivider == "xml":
output.WriteLogXML(w, resp)
default:
output.WriteLogText(w, resp)
}
case *protocol.RefsResponse:
output.WriteRefsText(w, resp)
case *protocol.WcResponse:
Expand Down Expand Up @@ -200,10 +207,14 @@ func applyNumberLines(v any) {

// cliSupportedFormats returns the list of output formats a command supports.
func cliSupportedFormats(cmd *cobra.Command) []string {
if cmd.Name() == "version" {
switch cmd.Name() {
case "version":
return []string{"json", "text", "short"}
case "log":
return []string{"json", "text", "oneline"}
default:
return []string{"json", "text"}
}
return []string{"json", "text"}
}

// loadConfig loads the effective configuration.
Expand Down
82 changes: 79 additions & 3 deletions internal/engine/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/utils/merkletrie"

"go.pennock.tech/aifr/internal/gitprovider"
"go.pennock.tech/aifr/pkg/protocol"
Expand Down Expand Up @@ -271,7 +272,9 @@ func (e *Engine) Refs(repoName string, branches, tags, remotes bool) (*protocol.
// LogParams controls git log queries.
type LogParams struct {
MaxCount int // 0 = default (20)
Skip int // skip this many commits before collecting entries
StartHash string // start from this commit's parent (for pagination)
Verbose bool // include tree hash, parent hashes, committer details
}

// Log returns git commit log entries.
Expand Down Expand Up @@ -320,9 +323,23 @@ func (e *Engine) Log(repoName, ref string, params LogParams) (*protocol.LogRespo
}
}

// Skip commits if requested.
for skipped := 0; skipped < params.Skip && current != nil; skipped++ {
if current.NumParents() == 0 {
current = nil
break
}
current, err = current.Parent(0)
if err != nil {
current = nil
break
}
}

resp := &protocol.LogResponse{
Repo: repoName,
Ref: ref,
Repo: repoName,
Ref: ref,
Skipped: params.Skip,
}

hitLimit := false
Expand All @@ -332,7 +349,22 @@ func (e *Engine) Log(repoName, ref string, params LogParams) (*protocol.LogRespo
Author: current.Author.Name,
AuthorEmail: current.Author.Email,
Date: current.Author.When.UTC().Format("2006-01-02T15:04:05Z"),
Message: strings.TrimSpace(current.Message),
Message: sanitizeMessage(strings.TrimSpace(current.Message)),
}

if params.Verbose {
entry.TreeHash = current.TreeHash.String()
for _, ph := range current.ParentHashes {
entry.ParentHashes = append(entry.ParentHashes, ph.String())
}
// Include committer fields only when they differ from the author.
if current.Committer.Name != current.Author.Name ||
current.Committer.Email != current.Author.Email ||
!current.Committer.When.Equal(current.Author.When) {
entry.Committer = current.Committer.Name
entry.CommitterEmail = current.Committer.Email
entry.CommitterDate = current.Committer.When.UTC().Format("2006-01-02T15:04:05Z")
}
}

// Get changed files (compare with parent).
Expand All @@ -350,6 +382,22 @@ func (e *Engine) Log(repoName, ref string, params LogParams) (*protocol.LogRespo
name = ch.From.Name
}
entry.FilesChanged = append(entry.FilesChanged, name)

action := "M"
if a, aErr := ch.Action(); aErr == nil {
switch a {
case merkletrie.Insert:
action = "A"
case merkletrie.Delete:
action = "D"
case merkletrie.Modify:
action = "M"
}
}
entry.Changes = append(entry.Changes, protocol.FileChange{
Path: name,
Action: action,
})
}
}
}
Expand Down Expand Up @@ -390,6 +438,34 @@ func (e *Engine) Log(repoName, ref string, params LogParams) (*protocol.LogRespo
return resp, nil
}

// sanitizeMessage replaces control characters (especially \r) in commit
// messages with visible representations to prevent terminal manipulation
// and ensure safe display in all output formats.
func sanitizeMessage(msg string) string {
if !strings.ContainsAny(msg, "\r\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x7f") {
return msg
}
var b strings.Builder
b.Grow(len(msg))
for _, r := range msg {
switch {
case r == '\n' || r == '\t':
// Preserve newlines and tabs — they're structurally meaningful.
b.WriteRune(r)
case r == '\r':
b.WriteString("\\r")
case r == '\x1b':
b.WriteString("\\e")
case r < 0x20 || r == 0x7f:
// C0 control characters and DEL: show as \xNN.
fmt.Fprintf(&b, "\\x%02x", r)
default:
b.WriteRune(r)
}
}
return b.String()
}

// DiffParams controls the diff operation mode.
type DiffParams struct {
ByteLevel bool // if true, byte-level comparison (cmp mode)
Expand Down
54 changes: 54 additions & 0 deletions internal/engine/pagination_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,60 @@ func TestLogCompleteField(t *testing.T) {
}
}

func TestLogSkip(t *testing.T) {
dir := t.TempDir()
initTestGitRepo(t, dir, 5)
eng := newTestEngine(t, dir)

// Get all commits to know the expected order.
all, err := eng.Log(dir, "HEAD", LogParams{MaxCount: 100})
if err != nil {
t.Fatal(err)
}
if len(all.Entries) != 5 {
t.Fatalf("expected 5 commits, got %d", len(all.Entries))
}

// Skip 2, get 2 — should start at the 3rd commit.
resp, err := eng.Log(dir, "HEAD", LogParams{MaxCount: 2, Skip: 2})
if err != nil {
t.Fatal(err)
}
if len(resp.Entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(resp.Entries))
}
if resp.Entries[0].Hash != all.Entries[2].Hash {
t.Errorf("first entry after skip=2: got %s, want %s",
resp.Entries[0].Hash[:12], all.Entries[2].Hash[:12])
}
if resp.Entries[1].Hash != all.Entries[3].Hash {
t.Errorf("second entry after skip=2: got %s, want %s",
resp.Entries[1].Hash[:12], all.Entries[3].Hash[:12])
}

// Skip past all commits — should return empty.
resp2, err := eng.Log(dir, "HEAD", LogParams{MaxCount: 10, Skip: 100})
if err != nil {
t.Fatal(err)
}
if len(resp2.Entries) != 0 {
t.Errorf("expected 0 entries after skipping past all, got %d", len(resp2.Entries))
}
if !resp2.Complete {
t.Error("expected Complete=true when no entries returned")
}

// Skip 0 — same as no skip.
resp3, err := eng.Log(dir, "HEAD", LogParams{MaxCount: 2, Skip: 0})
if err != nil {
t.Fatal(err)
}
if resp3.Entries[0].Hash != all.Entries[0].Hash {
t.Errorf("skip=0 first entry: got %s, want %s",
resp3.Entries[0].Hash[:12], all.Entries[0].Hash[:12])
}
}

// initTestGitRepo creates a bare-minimum git repo with N commits using go-git.
func initTestGitRepo(t *testing.T, dir string, numCommits int) {
t.Helper()
Expand Down
87 changes: 87 additions & 0 deletions internal/engine/sanitize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright 2026 — see LICENSE file for terms.
package engine

import "testing"

func TestSanitizeMessage(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "plain text unchanged",
input: "feat: add logging support",
want: "feat: add logging support",
},
{
name: "newlines preserved",
input: "subject\n\nbody line 1\nbody line 2",
want: "subject\n\nbody line 1\nbody line 2",
},
{
name: "tabs preserved",
input: "subject\n\n\tindented body",
want: "subject\n\n\tindented body",
},
{
name: "carriage return escaped",
input: "legit subject\rmalicious overlay",
want: `legit subject\rmalicious overlay`,
},
{
name: "CR at start of line",
input: "line1\n\rline2",
want: `line1` + "\n" + `\rline2`,
},
{
name: "CRLF: CR escaped, LF preserved",
input: "line1\r\nline2",
want: `line1\r` + "\n" + "line2",
},
{
name: "escape sequence",
input: "normal\x1b[31mred text\x1b[0m",
want: `normal\e[31mred text\e[0m`,
},
{
name: "null byte",
input: "before\x00after",
want: `before\x00after`,
},
{
name: "bell character",
input: "ding\x07dong",
want: `ding\x07dong`,
},
{
name: "DEL character",
input: "test\x7fvalue",
want: `test\x7fvalue`,
},
{
name: "multiple control chars",
input: "\x01\x02\x03",
want: `\x01\x02\x03`,
},
{
name: "empty string",
input: "",
want: "",
},
{
name: "no control chars fast path",
input: "just a normal commit message with unicode: café 日本語",
want: "just a normal commit message with unicode: café 日本語",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeMessage(tt.input)
if got != tt.want {
t.Errorf("sanitizeMessage(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
Loading
Loading