git-stint 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,7 +96,7 @@ git stint end
96
96
  | `git stint squash -m "msg"` | Collapse all commits into one |
97
97
  | `git stint merge` | Merge session into current branch (no PR) |
98
98
  | `git stint pr [--title "..."]` | Push branch and create GitHub PR |
99
- | `git stint end` | Finalize session, clean up everything |
99
+ | `git stint end` | Finalize session, clean up everything (deletes remote branch if merged) |
100
100
  | `git stint abort` | Discard session — delete all changes |
101
101
  | `git stint undo` | Revert last commit, changes become pending |
102
102
  | `git stint conflicts` | Check file overlap with other sessions |
@@ -346,6 +346,7 @@ Combined testing creates a temporary octopus merge of the specified sessions, ru
346
346
  - **`execFileSync` everywhere** — array arguments prevent shell injection. No `execSync` with string interpolation.
347
347
  - **Atomic manifest writes** — write to `.tmp`, then `rename()`. Crash-safe.
348
348
  - **Symlinks for shared data** — gitignored dirs (caches, data) symlink into worktrees instead of being copied or lost.
349
+ - **Safe remote branch cleanup** — on `end`/`merge`, deletes the remote branch only when all changes are verified merged **on the remote**. Checks against remote tracking refs (`origin/main`), not local branches — so a local-only merge won't delete the remote branch until you push. Uses a two-tier check (commit ancestry + content diff) that handles regular merges, squash merges, and rebase merges. Unmerged branches are always preserved with a warning.
349
350
  - **Zero runtime dependencies** — only Node.js built-ins. Dev deps are TypeScript and @types/node.
350
351
 
351
352
  ## git-stint vs GitButler
@@ -152,7 +152,8 @@ if [ "$MAIN_BRANCH_POLICY" = "prompt" ]; then
152
152
  fi
153
153
  echo "BLOCKED: Writing to main branch." >&2
154
154
  echo "To allow, run: git stint allow-main --client-id $CLIENT_ID" >&2
155
- echo "To create a session instead, run: git stint start <name>" >&2
155
+ echo "To create a session instead, run: git stint start <descriptive-name>" >&2
156
+ echo " (pick a short name that describes your task, e.g. fix-auth-refresh, add-user-search)" >&2
156
157
  exit 2
157
158
  fi
158
159
 
package/dist/git.d.ts CHANGED
@@ -20,6 +20,16 @@ export declare function deleteBranch(name: string): void;
20
20
  */
21
21
  export declare function remoteBranchExists(name: string): boolean;
22
22
  export declare function deleteRemoteBranch(name: string): void;
23
+ /**
24
+ * Check if all changes from `branch` are present in `into`.
25
+ * Handles regular merges, squash merges, and rebase merges.
26
+ *
27
+ * 1. Fast path: branch tip is an ancestor of `into` (regular merge / ff).
28
+ * 2. Content check: every file the branch changed has identical content in
29
+ * `into`. This catches squash and rebase merges where SHAs differ but
30
+ * the content is the same.
31
+ */
32
+ export declare function isBranchMergedInto(branch: string, into: string): boolean;
23
33
  export declare function addWorktree(path: string, branch: string): void;
24
34
  /** Create a worktree in detached HEAD mode at a given ref. */
25
35
  export declare function addWorktreeDetached(path: string, ref: string): void;
package/dist/git.js CHANGED
@@ -78,6 +78,39 @@ export function remoteBranchExists(name) {
78
78
  export function deleteRemoteBranch(name) {
79
79
  git("push", "origin", "--delete", name);
80
80
  }
81
+ /**
82
+ * Check if all changes from `branch` are present in `into`.
83
+ * Handles regular merges, squash merges, and rebase merges.
84
+ *
85
+ * 1. Fast path: branch tip is an ancestor of `into` (regular merge / ff).
86
+ * 2. Content check: every file the branch changed has identical content in
87
+ * `into`. This catches squash and rebase merges where SHAs differ but
88
+ * the content is the same.
89
+ */
90
+ export function isBranchMergedInto(branch, into) {
91
+ // Fast path — works for regular merges and fast-forwards
92
+ try {
93
+ git("merge-base", "--is-ancestor", branch, into);
94
+ return true;
95
+ }
96
+ catch {
97
+ // Not an ancestor — may still be squash/rebase merged
98
+ }
99
+ // Content check — compare files the branch changed against `into`
100
+ try {
101
+ const mergeBase = git("merge-base", into, branch);
102
+ const changedOutput = git("diff", "--name-only", mergeBase, branch);
103
+ if (!changedOutput)
104
+ return true; // branch has no file changes
105
+ const files = changedOutput.split("\n");
106
+ const contentDiff = git("diff", into, branch, "--", ...files);
107
+ return contentDiff.length === 0;
108
+ }
109
+ catch {
110
+ // If anything fails (e.g. branch already deleted), assume not merged
111
+ return false;
112
+ }
113
+ }
81
114
  export function addWorktree(path, branch) {
82
115
  git("worktree", "add", path, branch);
83
116
  }
@@ -93,9 +93,17 @@ const RULES_CONTENT = `# Git Stint Workflow
93
93
  All file edits are intercepted by git-stint hooks and redirected to isolated
94
94
  worktrees. One stint session = one branch = one PR.
95
95
 
96
+ ## Session Naming
97
+
98
+ When creating a session, pick a short descriptive name that captures the task:
99
+ - Good: \`fix-auth-refresh\`, \`add-user-search\`, \`refactor-db-queries\`
100
+ - Bad: \`session-1\`, \`changes\`, \`test\`, \`update\`
101
+
102
+ The name becomes the branch (\`stint/<name>\`) and the PR title context.
103
+
96
104
  ## Session Lifecycle
97
105
 
98
- - Session auto-creates on first Write/Edit (via PreToolUse hook).
106
+ - If the hook blocks a write, create a session: \`git stint start <descriptive-name>\`
99
107
  - All edits redirect to \`.stint/<session>/\` worktree.
100
108
  - \`git stint commit -m "msg"\` to commit logical units of work.
101
109
  - \`git stint pr\` to push and create PR.
@@ -103,6 +111,10 @@ worktrees. One stint session = one branch = one PR.
103
111
 
104
112
  ## Rules
105
113
 
114
+ - **NEVER end or delete a stint session you didn't create.** Other sessions
115
+ belong to other conversations or agents. Only operate on your own session
116
+ (the one auto-created by the hook for your edits). Use \`git stint list\` to
117
+ see all sessions — leave others alone.
106
118
  - Do NOT call \`git stint end\` until all changes are committed (code, tests,
107
119
  config updates, follow-up tasks). Premature \`end\` kills the session; the
108
120
  next edit auto-creates a NEW session, fragmenting work across multiple PRs.
package/dist/session.js CHANGED
@@ -696,15 +696,58 @@ function cleanup(manifest, force = false) {
696
696
  }
697
697
  }
698
698
  }
699
+ // Check if remote branch should be cleaned up (before deleting local branch,
700
+ // since we need it for the merge check).
701
+ //
702
+ // IMPORTANT: We check against REMOTE tracking refs (origin/main), not local
703
+ // branches. A local `git stint merge` merges into local main, but the user
704
+ // may not have pushed yet. Deleting the remote session branch before the
705
+ // remote main has the changes would destroy the only remote copy of the work.
706
+ // By checking origin/main, we only delete when the remote already has the
707
+ // changes — matching how GitHub/GitLab auto-delete works.
708
+ let shouldDeleteRemote = false;
709
+ if (git.remoteBranchExists(manifest.branch)) {
710
+ // Build list of remote tracking refs to check against.
711
+ const targets = new Set();
712
+ const defaultBranch = git.getDefaultBranch();
713
+ targets.add(`origin/${defaultBranch}`);
714
+ try {
715
+ const current = git.currentBranch(topLevel);
716
+ if (current !== manifest.branch && git.remoteBranchExists(current)) {
717
+ targets.add(`origin/${current}`);
718
+ }
719
+ }
720
+ catch { /* detached HEAD — skip */ }
721
+ for (const target of targets) {
722
+ if (git.isBranchMergedInto(manifest.branch, target)) {
723
+ shouldDeleteRemote = true;
724
+ break;
725
+ }
726
+ }
727
+ if (!shouldDeleteRemote) {
728
+ console.log(`Remote branch 'origin/${manifest.branch}' was NOT deleted (has unmerged changes).\n` +
729
+ ` To delete manually: git push origin --delete ${manifest.branch}`);
730
+ }
731
+ }
699
732
  // Delete local branch
700
733
  try {
701
734
  git.deleteBranch(manifest.branch);
702
735
  }
703
736
  catch { /* branch may already be deleted */ }
704
- // Delete remote tracking ref if it exists (no network call — just local ref).
705
- // We intentionally do NOT delete the remote branch itself:
706
- // the user may have an open PR. They can clean up with `git push origin --delete`.
707
- // The local tracking ref is cleaned up by `git branch -D` above.
737
+ // Delete remote branch if all changes are merged
738
+ if (shouldDeleteRemote) {
739
+ try {
740
+ git.deleteRemoteBranch(manifest.branch);
741
+ console.log(`Deleted remote branch 'origin/${manifest.branch}'.`);
742
+ }
743
+ catch {
744
+ // Branch may already be deleted on the remote (e.g., GitHub auto-delete
745
+ // after PR merge) or the network may be down. Either way, not critical.
746
+ console.log(`Could not delete remote branch 'origin/${manifest.branch}'.\n` +
747
+ ` It may have already been deleted (e.g., by GitHub after PR merge).\n` +
748
+ ` To delete manually: git push origin --delete ${manifest.branch}`);
749
+ }
750
+ }
708
751
  // Delete manifest last — if anything above fails, manifest persists for prune
709
752
  deleteManifest(manifest.name);
710
753
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-stint",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Session-scoped change tracking for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {