Skip to content
Open
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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

All notable changes to nightshift are documented in this file.

## [Unreleased]

### Features
- **Semantic diff explainer** — new `nightshift explain-diff` command parses
`git diff` output and applies rule-based, Go-aware heuristics to classify
hunks into ChangeKinds (AddFunction, RenameSymbol, ChangeSignature,
AddTest, AddImport, ModifyComment, FormatOnly, etc.). Supports
`--staged`, `--range`, `--json`, and `--path`.

## [v0.3.4] - 2026-02-28

### Features
- **Configurable agent timeouts** — add `--timeout` to `nightshift run` and `nightshift daemon`, with daemon re-exec forwarding the flag (#27)
- **Expanded PII scanner guidance** — add detailed instructions for detecting hardcoded PII, leaked env files, unsafe storage, and related exposure patterns in the built-in task (#34)

### Fixes
- **Timeout handling and diagnostics** — preserve partial output on timeout, terminate full process groups, and surface partial logs from failed plan/implement/review steps (#33)
- **Copilot CLI integration** — improve binary resolution, permission gating, and CLI flag handling for Copilot runs (#39)
- **Provider config YAML serialization** — write provider settings with the correct YAML keys during setup (#43)
- **Configured run limits and budget fallback** — honor `schedule.max_projects` and `schedule.max_tasks`, improve budget calibration at day and week boundaries, and preserve Codex fallback permissions in headless runs (#42)

### Other
- **Task reference docs** — add a comprehensive reference page for all 59 built-in tasks and refresh related task documentation (#30)
- **Docs cleanup** — remove auto-generated implementation docs from the repository (#40)
- **Low-risk cleanup** — resolve Copilot helper lint warnings and replace `WriteString(fmt.Sprintf(...))` patterns with `fmt.Fprintf` in reporting and setup code (#38, #41)

## [v0.3.3] - 2026-02-19

### Features
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,28 @@ nightshift task show lint-fix --prompt-only
nightshift task run lint-fix --provider claude
nightshift task run skill-groom --provider codex --dry-run
nightshift task run lint-fix --provider codex --dry-run

# Explain the semantic meaning of pending diffs
nightshift explain-diff
nightshift explain-diff --staged
nightshift explain-diff --range main..HEAD
nightshift explain-diff --json
```

### `nightshift explain-diff`

Parses `git diff` output and classifies each hunk into a high-level
ChangeKind — added/removed functions, renames, signature changes, new tests,
import shifts, comment-only edits, or formatting churn. Rule-based and
Go-aware; no LLM call is made.

| Flag | Default | Description |
|------|---------|-------------|
| `--staged` | `false` | Inspect the staged (index) diff instead of the working tree |
| `--range` | _(none)_ | Inspect a git revision range like `main..HEAD` |
| `--json` | `false` | Emit structured JSON instead of human-readable text |
| `--path` | _(cwd)_ | Path to the git repository |

If `gum` is available, preview output is shown through the gum pager. Use `--plain` to disable.

### `nightshift run`
Expand Down
54 changes: 54 additions & 0 deletions cmd/nightshift/commands/explain_diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package commands

import (
"fmt"

"github.com/spf13/cobra"

"github.com/marcus/nightshift/internal/semdiff"
)

var explainDiffCmd = &cobra.Command{
Use: "explain-diff",
Short: "Explain the semantic meaning of pending diff",
Long: `Parse the current git diff and produce a high-level, semantic explanation
of what changed: added/removed functions, renames, signature changes, new
tests, import shifts, comment-only edits, formatting churn, and more.

By default it inspects the unstaged working tree. Use --staged for the
index, or --range to inspect a commit range like main..HEAD.`,
RunE: func(cmd *cobra.Command, args []string) error {
staged, _ := cmd.Flags().GetBool("staged")
rng, _ := cmd.Flags().GetString("range")
asJSON, _ := cmd.Flags().GetBool("json")
path, _ := cmd.Flags().GetString("path")

files, err := semdiff.Gather(semdiff.Options{
RepoPath: path,
Staged: staged,
Range: rng,
})
if err != nil {
return err
}
exp := semdiff.Explain(files)
if asJSON {
out, err := exp.RenderJSON()
if err != nil {
return err
}
fmt.Println(out)
return nil
}
fmt.Print(exp.Render())
return nil
},
}

func init() {
explainDiffCmd.Flags().Bool("staged", false, "Inspect staged (index) changes instead of working tree")
explainDiffCmd.Flags().String("range", "", "Inspect a git revision range, e.g. main..HEAD")
explainDiffCmd.Flags().Bool("json", false, "Emit output as JSON")
explainDiffCmd.Flags().String("path", "", "Path to the git repository (default: current directory)")
rootCmd.AddCommand(explainDiffCmd)
}
214 changes: 214 additions & 0 deletions internal/semdiff/classifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package semdiff

import (
"regexp"
"sort"
"strings"
)

// ChangeKind is a high-level label for a semantic change.
type ChangeKind string

const (
ChangeAddFunction ChangeKind = "AddFunction"
ChangeRemoveFunction ChangeKind = "RemoveFunction"
ChangeChangeSignature ChangeKind = "ChangeSignature"
ChangeRenameSymbol ChangeKind = "RenameSymbol"
ChangeAddTest ChangeKind = "AddTest"
ChangeModifyTest ChangeKind = "ModifyTest"
ChangeAddImport ChangeKind = "AddImport"
ChangeRemoveImport ChangeKind = "RemoveImport"
ChangeAddErrorHandling ChangeKind = "AddErrorHandling"
ChangeModifyComment ChangeKind = "ModifyComment"
ChangeFormatOnly ChangeKind = "FormatOnly"
ChangeAddType ChangeKind = "AddType"
ChangeOther ChangeKind = "Other"
)

var (
reFuncDecl = regexp.MustCompile(`^\s*func(?:\s+\([^)]*\))?\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)`)
reTypeDecl = regexp.MustCompile(`^\s*type\s+([A-Za-z_][A-Za-z0-9_]*)\s+`)
reImportLine = regexp.MustCompile(`^\s*(?:import\s+)?"[^"]+"\s*$`)
reCommentSL = regexp.MustCompile(`^\s*//`)
reErrCheck = regexp.MustCompile(`^\s*if\s+err\s*(!=|==)\s*nil\s*\{?`)
)

type funcInfo struct {
name string
args string
}

// ClassifyFile applies heuristics to a single FileDiff and returns the set of
// detected ChangeKinds, in priority order with duplicates removed.
func ClassifyFile(f FileDiff) []ChangeKind {
seen := map[ChangeKind]bool{}
var order []ChangeKind
add := func(k ChangeKind) {
if !seen[k] {
seen[k] = true
order = append(order, k)
}
}

isGo := strings.HasSuffix(f.NewPath, ".go") || strings.HasSuffix(f.OldPath, ".go")
isTest := strings.HasSuffix(f.NewPath, "_test.go") || strings.HasSuffix(f.OldPath, "_test.go")

for _, h := range f.Hunks {
classifyHunk(h, isGo, isTest, add)
}

if len(order) == 0 {
add(ChangeOther)
}
return order
}

func classifyHunk(h Hunk, isGo, isTest bool, add func(ChangeKind)) {
adds := h.Additions()
dels := h.Deletions()

// FormatOnly: same non-whitespace content on add/delete sides.
if len(adds) > 0 && len(adds) == len(dels) && allMatchIgnoringWhitespace(adds, dels) {
add(ChangeFormatOnly)
return
}

// Comment-only changes.
if onlyComments(adds) && onlyComments(dels) && (len(adds)+len(dels)) > 0 {
add(ChangeModifyComment)
return
}

if isGo {
addedFuncs := collectFuncs(adds)
removedFuncs := collectFuncs(dels)

// Renames: same arg list, different name on the same hunk.
if len(addedFuncs) > 0 && len(removedFuncs) > 0 {
for _, a := range addedFuncs {
for _, r := range removedFuncs {
switch {
case a.name == r.name && normalizeArgs(a.args) != normalizeArgs(r.args):
add(ChangeChangeSignature)
case a.name != r.name && normalizeArgs(a.args) == normalizeArgs(r.args):
add(ChangeRenameSymbol)
}
}
}
}

switch {
case len(addedFuncs) > len(removedFuncs):
if isTest {
for _, f := range addedFuncs {
if strings.HasPrefix(f.name, "Test") || strings.HasPrefix(f.name, "Benchmark") || strings.HasPrefix(f.name, "Example") {
add(ChangeAddTest)
} else {
add(ChangeAddFunction)
}
}
} else {
add(ChangeAddFunction)
}
case len(removedFuncs) > len(addedFuncs):
add(ChangeRemoveFunction)
}

// Imports.
if hasImport(adds) {
add(ChangeAddImport)
}
if hasImport(dels) {
add(ChangeRemoveImport)
}

// Type declarations.
for _, line := range adds {
if reTypeDecl.MatchString(line) {
add(ChangeAddType)
}
}

// Error handling.
for _, line := range adds {
if reErrCheck.MatchString(line) {
add(ChangeAddErrorHandling)
}
}

// Test modifications (changes inside Test funcs already covered by other rules).
if isTest && len(addedFuncs) == 0 && len(removedFuncs) == 0 && (len(adds) > 0 || len(dels) > 0) {
add(ChangeModifyTest)
}
}
}

func collectFuncs(lines []string) []funcInfo {
var out []funcInfo
for _, line := range lines {
m := reFuncDecl.FindStringSubmatch(line)
if m != nil {
out = append(out, funcInfo{name: m[1], args: m[2]})
}
}
return out
}

func hasImport(lines []string) bool {
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if reImportLine.MatchString(line) {
return true
}
if strings.HasPrefix(trimmed, "import ") {
return true
}
}
return false
}

func onlyComments(lines []string) bool {
any := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
any = true
if !reCommentSL.MatchString(line) && !strings.HasPrefix(trimmed, "/*") && !strings.HasPrefix(trimmed, "*") {
return false
}
}
return any
}

func allMatchIgnoringWhitespace(a, b []string) bool {
if len(a) != len(b) {
return false
}
na := make([]string, len(a))
nb := make([]string, len(b))
for i := range a {
na[i] = collapseWS(a[i])
nb[i] = collapseWS(b[i])
}
sort.Strings(na)
sort.Strings(nb)
for i := range na {
if na[i] != nb[i] {
return false
}
}
return true
}

func collapseWS(s string) string {
return strings.Join(strings.Fields(s), " ")
}

func normalizeArgs(args string) string {
return collapseWS(args)
}
Loading
Loading