Compare commits

...
2 Commits
Author SHA1 Message Date
mroberts 5b101bc6dd ci(release): fix Gitea repo slug; bump to 0.6.2
CI / build (push) Successful in 59s
The release workflow targeted mroberts/sush, which does not exist
(`tea releases list --repo mroberts/sush` returns "not found"), so the
release step would fail after building the binaries. Present since the
workflow was added in a7a561f.

Also bump softprops/action-gh-release to v2 in the unused .github
workflow; actionlint rejects v1 as too old to run.
2026-07-21 15:07:46 -05:00
mroberts 70188eb4ea fix(git): use jj as diff source in jujutsu repos
In a colocated jj repo, jj does not maintain git's index for git's own
consumption. git can therefore report an unmodified tracked file as a
whole-file add: for tests/integration/test_texttoentities_endpoint.py in
the IO repo, `git ls-tree HEAD` and `git ls-files -s` both showed the same
blob, yet `git status` reported `AM` and `git diff --cached --unified=0`
emitted a single `@@ -0,0 +1,330 @@` hunk.

shush treats every line in that hunk as agent-changed, so `--changes-only`
stripped all six comments from a file the agent had never touched (`jj diff`
confirmed 0 insertions). Diffing against HEAD instead of the index does not
help: it is poisoned the same way.

jj is authoritative when present, so DetectRepo now prefers `jj root` and
the three change queries route to `jj diff --git --context=0`. jj has no
staging area, so --staged, --unstaged and --changes-only all resolve to the
working-copy change; GetChangesOnly short-circuits so files are not
duplicated across the staged and unstaged passes.

Empty line ranges are overloaded to mean "process the whole file" (untracked
semantics). A deletion-only change parses to zero ranges, which would have
made shush strip an entire file whose only edit removed lines, so such files
are now skipped. New files still get correct full-file ranges from jj's
`@@ -0,0 +1,N @@` hunk, so they do not need the fallback.

Pagers are disabled explicitly on every git and jj invocation so output
stays machine-parseable regardless of the user's config.
2026-07-21 15:07:46 -05:00
7 changed files with 187 additions and 12 deletions
+2 -2
View File
@@ -53,10 +53,10 @@ jobs:
tea login add --name ci --url https://git.mroberts.dev --token "$GITEA_TOKEN"
# Idempotent: drop any existing release for this tag so re-runs replace
# stale assets instead of failing with "already a release for this tag".
tea releases delete -y --login ci --repo mroberts/sush "$TAG" >/dev/null 2>&1 || true
tea releases delete -y --login ci --repo mroberts/shush "$TAG" >/dev/null 2>&1 || true
tea releases create \
--login ci \
--repo mroberts/sush \
--repo mroberts/shush \
--tag "$TAG" \
--title "$TAG" \
--asset dist/shush-linux-x86_64 \
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
ls -la release-assets/
- name: Create release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
files: release-assets/*
fail_on_unmatched_files: true
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/carlosarraes/shush/internal/types"
)
var version = "0.6.1"
var version = "0.6.2"
func main() {
var cliArgs types.CLI
+72 -7
View File
@@ -10,7 +10,11 @@ import (
)
func DetectRepo() (*GitStatus, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
if root, ok := jjRoot(); ok {
return &GitStatus{IsRepo: true, RootDir: root, IsJJ: true}, nil
}
cmd := exec.Command("git", "--no-pager", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return &GitStatus{IsRepo: false}, nil
@@ -23,9 +27,62 @@ func DetectRepo() (*GitStatus, error) {
}, nil
}
func GetStagedChanges() ([]FileChange, error) {
func jjRoot() (string, bool) {
output, err := exec.Command("jj", "--no-pager", "root").Output()
if err != nil {
return "", false
}
return strings.TrimSpace(string(output)), true
}
cmd := exec.Command("git", "diff", "--cached", "--name-only")
func getJJChanges() ([]FileChange, error) {
output, err := exec.Command("jj", "--no-pager", "diff", "--name-only").Output()
if err != nil {
return nil, fmt.Errorf("failed to list jj changes: %v", err)
}
var changes []FileChange
for _, file := range strings.Split(strings.TrimSpace(string(output)), "\n") {
if file == "" {
continue
}
lineRanges, err := getJJLineRanges(file)
if err != nil {
return nil, fmt.Errorf("failed to get line ranges for %s: %v", file, err)
}
if len(lineRanges) == 0 {
continue
}
changes = append(changes, FileChange{
Path: file,
Status: StatusUnstaged,
LineRanges: lineRanges,
})
}
return changes, nil
}
func getJJLineRanges(file string) ([]LineRange, error) {
cmd := exec.Command("jj", "--no-pager", "diff", "--git", "--context=0", "--", file)
output, err := cmd.Output()
if err != nil {
return []LineRange{}, nil
}
return ParseDiffUnified(string(output))
}
func GetStagedChanges() ([]FileChange, error) {
if _, ok := jjRoot(); ok {
return getJJChanges()
}
cmd := exec.Command("git", "--no-pager", "diff", "--cached", "--name-only")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to get staged files: %v", err)
@@ -58,9 +115,13 @@ func GetStagedChanges() ([]FileChange, error) {
}
func GetUnstagedChanges() ([]FileChange, error) {
if _, ok := jjRoot(); ok {
return getJJChanges()
}
var changes []FileChange
cmd := exec.Command("git", "diff", "--name-only")
cmd := exec.Command("git", "--no-pager", "diff", "--name-only")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to get unstaged modified files: %v", err)
@@ -84,7 +145,7 @@ func GetUnstagedChanges() ([]FileChange, error) {
})
}
cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard")
cmd = exec.Command("git", "--no-pager", "ls-files", "--others", "--exclude-standard")
output, err = cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to get untracked files: %v", err)
@@ -109,9 +170,9 @@ func GetUnstagedChanges() ([]FileChange, error) {
func getLineRangesFromDiff(file string, staged bool) ([]LineRange, error) {
var cmd *exec.Cmd
if staged {
cmd = exec.Command("git", "diff", "--no-ext-diff", "--cached", "--unified=0", file)
cmd = exec.Command("git", "--no-pager", "diff", "--no-ext-diff", "--cached", "--unified=0", file)
} else {
cmd = exec.Command("git", "diff", "--no-ext-diff", "--unified=0", file)
cmd = exec.Command("git", "--no-pager", "diff", "--no-ext-diff", "--unified=0", file)
}
output, err := cmd.Output()
@@ -172,6 +233,10 @@ func IsInLineRanges(lineNum int, ranges []LineRange) bool {
}
func GetChangesOnly() ([]FileChange, error) {
if _, ok := jjRoot(); ok {
return getJJChanges()
}
var allChanges []FileChange
staged, err := GetStagedChanges()
+105
View File
@@ -54,6 +54,111 @@ func TestGetLineRangesFromDiffIgnoresExternalDiff(t *testing.T) {
}
}
func runJJ(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("jj", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "JJ_USER=t", "JJ_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("jj %v: %v\n%s", args, err, out)
}
}
func TestJJRepoUsesJJDiffNotGitIndex(t *testing.T) {
if _, err := exec.LookPath("jj"); err != nil {
t.Skip("jj not installed")
}
dir := t.TempDir()
runGit(t, dir, "init", "-q")
runGit(t, dir, "config", "user.email", "t@t")
runGit(t, dir, "config", "user.name", "t")
path := filepath.Join(dir, "f.py")
original := "a = 1\nb = 2\nc = 3\nd = 4\ne = 5\n"
if err := os.WriteFile(path, []byte(original), 0644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "f.py")
runGit(t, dir, "commit", "-qm", "init")
runJJ(t, dir, "git", "init", "--colocate")
modified := "a = 1\nb = 2\nc = 3 changed\nd = 4\ne = 5\n"
if err := os.WriteFile(path, []byte(modified), 0644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "rm", "-q", "--cached", "f.py")
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer os.Chdir(wd)
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
changes, err := GetChangesOnly()
if err != nil {
t.Fatalf("GetChangesOnly: %v", err)
}
if len(changes) != 1 {
t.Fatalf("changes = %v, want exactly one file (duplicated per staged/unstaged pass?)", changes)
}
want := []LineRange{{Start: 3, End: 3}}
got := changes[0].LineRanges
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("ranges = %v, want %v (whole file treated as changed?)", got, want)
}
}
func TestJJDeletionOnlyChangeIsNotWholeFile(t *testing.T) {
if _, err := exec.LookPath("jj"); err != nil {
t.Skip("jj not installed")
}
dir := t.TempDir()
runGit(t, dir, "init", "-q")
runGit(t, dir, "config", "user.email", "t@t")
runGit(t, dir, "config", "user.name", "t")
path := filepath.Join(dir, "f.py")
original := "a = 1 # keep\nb = 2\nc = 3\n"
if err := os.WriteFile(path, []byte(original), 0644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "f.py")
runGit(t, dir, "commit", "-qm", "init")
runJJ(t, dir, "git", "init", "--colocate")
if err := os.WriteFile(path, []byte("a = 1 # keep\nb = 2\n"), 0644); err != nil {
t.Fatal(err)
}
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer os.Chdir(wd)
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
changes, err := GetChangesOnly()
if err != nil {
t.Fatalf("GetChangesOnly: %v", err)
}
for _, c := range changes {
if c.Path == "f.py" && len(c.LineRanges) == 0 {
t.Fatal("deletion-only change yielded empty ranges, which means whole-file processing")
}
}
}
func TestParseGitDiffUnified(t *testing.T) {
tests := []struct {
name string
+1
View File
@@ -3,6 +3,7 @@ package git
type GitStatus struct {
IsRepo bool
RootDir string
IsJJ bool
}
type FileStatus int
+5 -1
View File
@@ -31,7 +31,11 @@ func (p *Processor) processGitChanges() error {
}
if p.cli.Verbose {
fmt.Printf("Git repository detected: %s\n", gitStatus.RootDir)
vcs := "Git"
if gitStatus.IsJJ {
vcs = "Jujutsu"
}
fmt.Printf("%s repository detected: %s\n", vcs, gitStatus.RootDir)
}
var changes []git.FileChange