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.
This commit is contained in:
+72
-7
@@ -10,7 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func DetectRepo() (*GitStatus, error) {
|
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()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &GitStatus{IsRepo: false}, nil
|
return &GitStatus{IsRepo: false}, nil
|
||||||
@@ -23,9 +27,62 @@ func DetectRepo() (*GitStatus, error) {
|
|||||||
}, nil
|
}, 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()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get staged files: %v", err)
|
return nil, fmt.Errorf("failed to get staged files: %v", err)
|
||||||
@@ -58,9 +115,13 @@ func GetStagedChanges() ([]FileChange, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetUnstagedChanges() ([]FileChange, error) {
|
func GetUnstagedChanges() ([]FileChange, error) {
|
||||||
|
if _, ok := jjRoot(); ok {
|
||||||
|
return getJJChanges()
|
||||||
|
}
|
||||||
|
|
||||||
var changes []FileChange
|
var changes []FileChange
|
||||||
|
|
||||||
cmd := exec.Command("git", "diff", "--name-only")
|
cmd := exec.Command("git", "--no-pager", "diff", "--name-only")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get unstaged modified files: %v", err)
|
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()
|
output, err = cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get untracked files: %v", err)
|
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) {
|
func getLineRangesFromDiff(file string, staged bool) ([]LineRange, error) {
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
if staged {
|
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 {
|
} 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()
|
output, err := cmd.Output()
|
||||||
@@ -172,6 +233,10 @@ func IsInLineRanges(lineNum int, ranges []LineRange) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetChangesOnly() ([]FileChange, error) {
|
func GetChangesOnly() ([]FileChange, error) {
|
||||||
|
if _, ok := jjRoot(); ok {
|
||||||
|
return getJJChanges()
|
||||||
|
}
|
||||||
|
|
||||||
var allChanges []FileChange
|
var allChanges []FileChange
|
||||||
|
|
||||||
staged, err := GetStagedChanges()
|
staged, err := GetStagedChanges()
|
||||||
|
|||||||
@@ -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) {
|
func TestParseGitDiffUnified(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package git
|
|||||||
type GitStatus struct {
|
type GitStatus struct {
|
||||||
IsRepo bool
|
IsRepo bool
|
||||||
RootDir string
|
RootDir string
|
||||||
|
IsJJ bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileStatus int
|
type FileStatus int
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ func (p *Processor) processGitChanges() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if p.cli.Verbose {
|
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
|
var changes []git.FileChange
|
||||||
|
|||||||
Reference in New Issue
Block a user