@harryisfish/gitt 1.4.1 → 1.5.0

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
@@ -18,8 +18,10 @@ pnpm add -g @harryisfish/gitt
18
18
 
19
19
  ## Usage
20
20
 
21
+ ### Basic Commands
22
+
21
23
  ```bash
22
- # Default behavior (auto-clean)
24
+ # Default behavior (auto-clean deleted branches)
23
25
  gitt
24
26
 
25
27
  # Interactive mode (select branches to delete)
@@ -31,8 +33,63 @@ gitt --interactive
31
33
  gitt -d
32
34
  # or
33
35
  gitt --dry-run
36
+
37
+ # Check version
38
+ gitt -v
39
+ # or
40
+ gitt --version
41
+
42
+ # Show help
43
+ gitt -h
44
+ # or
45
+ gitt --help
46
+ ```
47
+
48
+ ### Stale Branch Cleaning (NEW)
49
+
50
+ Clean up branches that haven't been updated for a long time:
51
+
52
+ ```bash
53
+ # Find branches inactive for 90+ days (default)
54
+ gitt --stale
55
+
56
+ # Custom threshold (e.g., 30 days)
57
+ gitt --stale 30
58
+
59
+ # Preview stale branches without deleting
60
+ gitt --stale --dry-run
61
+
62
+ # Interactively select stale branches to delete
63
+ gitt --stale -i
34
64
  ```
35
65
 
66
+ **Note:** Stale branch detection:
67
+ - Checks the last commit date on each branch
68
+ - Automatically excludes the main branch
69
+ - Protects branches in use by Git worktrees
70
+ - Can be combined with interactive (`-i`) and dry-run (`-d`) modes
71
+
72
+
73
+ ## Features
74
+
75
+ ### 🔄 Auto Update Notification
76
+ Gitt automatically checks for updates on each run and notifies you when a new version is available.
77
+
78
+ ### 🌳 Worktree Protection
79
+ Branches currently checked out in Git worktrees are automatically protected from deletion to prevent errors.
80
+
81
+ ### 🧹 Smart Branch Cleanup
82
+ - **Remote-deleted branches**: Automatically detect and clean branches removed from remote
83
+ - **Stale branches**: Find and remove branches inactive for X days
84
+ - **Merge status check**: Safely handles both merged and unmerged branches
85
+ - **Interactive mode**: Manual selection with clear indicators
86
+ - **Dry-run mode**: Preview changes before applying
87
+
88
+ ### 🛡️ Branch Protection
89
+ - Honors `.gitt` ignore patterns
90
+ - Respects Git worktree usage
91
+ - Never touches the main branch
92
+
36
93
  ## Configuration
37
94
 
38
95
  ### Main Branch Detection
@@ -33,34 +33,77 @@ async function cleanDeletedBranches(options = {}) {
33
33
  {
34
34
  title: 'Switch to main branch',
35
35
  task: async (ctx) => {
36
- await git.checkout(ctx.mainBranch);
36
+ // Check if we are on a branch that will be deleted?
37
+ // For now, just try to switch to main to be safe.
38
+ // But if main is checked out in another worktree, this might fail?
39
+ // Let's just try.
40
+ try {
41
+ await git.checkout(ctx.mainBranch);
42
+ }
43
+ catch (e) {
44
+ // If we can't checkout main (e.g. dirty state), warn but continue?
45
+ // Ideally we should be on main to delete other branches safely.
46
+ }
37
47
  }
38
48
  },
39
49
  {
40
50
  title: 'Sync main with remote',
41
51
  task: async () => {
42
- await git.pull();
52
+ try {
53
+ await git.pull();
54
+ }
55
+ catch (e) {
56
+ // Ignore pull errors (e.g. if not on branch)
57
+ }
43
58
  }
44
59
  },
45
60
  {
46
- title: 'Fetch and prune remote branches',
61
+ title: 'Analyze branches',
47
62
  task: async (ctx) => {
48
63
  await git.fetch(['--prune']);
49
64
  const branchSummary = await git.branch(['-vv']);
50
65
  const config = await (0, config_1.readConfigFile)();
51
66
  const ignorePatterns = config.ignoreBranches || [];
52
- let deletedBranches = branchSummary.all.filter(branch => {
53
- const branchInfo = branchSummary.branches[branch];
54
- return branchInfo.label && branchInfo.label.includes(': gone]');
55
- });
67
+ const worktreeBranches = await (0, git_1.getWorktrees)();
68
+ let candidates = [];
69
+ if (options.stale) {
70
+ // Stale mode: check all local branches
71
+ const allBranches = branchSummary.all;
72
+ for (const branch of allBranches) {
73
+ if (branch === ctx.mainBranch)
74
+ continue;
75
+ const days = await (0, git_1.getBranchLastCommitTime)(branch);
76
+ if (days > (options.staleDays || 90)) {
77
+ candidates.push({
78
+ name: branch,
79
+ reason: `Stale (${days} days)`
80
+ });
81
+ }
82
+ }
83
+ }
84
+ else {
85
+ // Default mode: check "gone" branches
86
+ const goneBranches = branchSummary.all.filter(branch => {
87
+ const branchInfo = branchSummary.branches[branch];
88
+ return branchInfo.label && branchInfo.label.includes(': gone]');
89
+ });
90
+ candidates = goneBranches.map(b => ({ name: b, reason: 'Remote deleted' }));
91
+ }
56
92
  // Filter out ignored branches
57
93
  if (ignorePatterns.length > 0) {
58
- deletedBranches = deletedBranches.filter(branch => {
59
- const isIgnored = ignorePatterns.some(pattern => (0, minimatch_1.minimatch)(branch, pattern));
94
+ candidates = candidates.filter(c => {
95
+ const isIgnored = ignorePatterns.some(pattern => (0, minimatch_1.minimatch)(c.name, pattern));
60
96
  return !isIgnored;
61
97
  });
62
98
  }
63
- ctx.deletedBranches = deletedBranches;
99
+ // Filter out worktree branches
100
+ candidates = candidates.filter(c => !worktreeBranches.includes(c.name));
101
+ // Check merge status for each branch
102
+ const branchesWithStatus = await Promise.all(candidates.map(async (c) => {
103
+ const isMerged = await (0, git_1.isBranchMerged)(c.name, ctx.mainBranch);
104
+ return { ...c, isMerged };
105
+ }));
106
+ ctx.deletedBranches = branchesWithStatus;
64
107
  }
65
108
  }
66
109
  ]);
@@ -72,9 +115,14 @@ async function cleanDeletedBranches(options = {}) {
72
115
  }
73
116
  if (options.interactive) {
74
117
  try {
118
+ const choices = state.deletedBranches.map(b => ({
119
+ name: `${b.name} (${b.reason}${b.isMerged ? '' : ', Unmerged'})`,
120
+ value: b,
121
+ checked: b.isMerged // Only check merged branches by default
122
+ }));
75
123
  const selected = await (0, prompts_1.checkbox)({
76
124
  message: 'Select branches to delete:',
77
- choices: state.deletedBranches.map(b => ({ name: b, value: b, checked: true })),
125
+ choices: choices,
78
126
  });
79
127
  state.deletedBranches = selected;
80
128
  }
@@ -83,24 +131,34 @@ async function cleanDeletedBranches(options = {}) {
83
131
  throw new Error('Operation cancelled');
84
132
  }
85
133
  }
134
+ else {
135
+ // Auto mode: Filter out unmerged branches
136
+ const unmerged = state.deletedBranches.filter(b => !b.isMerged);
137
+ if (unmerged.length > 0) {
138
+ console.log('\nSkipping unmerged branches (use -i to force delete):');
139
+ unmerged.forEach(b => console.log(` - ${b.name} (${b.reason})`));
140
+ }
141
+ state.deletedBranches = state.deletedBranches.filter(b => b.isMerged);
142
+ }
86
143
  if (state.deletedBranches.length === 0) {
87
144
  (0, errors_2.printSuccess)('No branches selected for deletion');
88
145
  return;
89
146
  }
90
147
  if (options.dryRun) {
91
148
  console.log('\nDry Run: The following branches would be deleted:');
92
- state.deletedBranches.forEach(b => console.log(` - ${b}`));
149
+ state.deletedBranches.forEach(b => console.log(` - ${b.name} (${b.reason})`));
93
150
  return;
94
151
  }
95
152
  // Phase 3: Execution
96
153
  const deleteTasks = new listr2_1.Listr([
97
154
  {
98
- title: 'Delete branches removed on remote',
155
+ title: 'Delete branches',
99
156
  task: (ctx) => {
100
157
  return new listr2_1.Listr(state.deletedBranches.map(branch => ({
101
- title: `Delete ${branch}`,
158
+ title: `Delete ${branch.name}`,
102
159
  task: async () => {
103
- await git.branch(['-D', branch]);
160
+ // Always use -D to force delete if we are here (user confirmed or it's merged)
161
+ await git.branch(['-D', branch.name]);
104
162
  }
105
163
  })), { concurrent: false });
106
164
  }
package/dist/index.js CHANGED
@@ -86,12 +86,16 @@ Commands:
86
86
  Options:
87
87
  -i, --interactive Interactive mode: Select branches to delete
88
88
  -d, --dry-run Dry run: Show what would be deleted without deleting
89
+ --stale [days] Find stale branches (default: 90 days, use with a number for custom)
90
+ -v, --version Show version number
89
91
  -h, --help Show this help message
90
92
 
91
93
  Examples:
92
94
  gitt # Auto-clean deleted branches
93
95
  gitt -i # Select branches to delete interactively
94
96
  gitt -d # Preview deletion
97
+ gitt --stale # Find branches inactive for 90+ days
98
+ gitt --stale 30 # Find branches inactive for 30+ days
95
99
  gitt ignore "temp/*" # Ignore branches matching "temp/*"
96
100
  gitt set-main master # Set main branch to 'master'
97
101
  `);
@@ -100,11 +104,27 @@ async function main() {
100
104
  try {
101
105
  const args = process.argv.slice(2);
102
106
  const command = args[0];
107
+ // Check for version command
108
+ if (args.includes('-v') || args.includes('--version')) {
109
+ const packageJson = require('../package.json');
110
+ console.log(`v${packageJson.version}`);
111
+ process.exit(0);
112
+ }
103
113
  // Check for help command
104
114
  if (args.includes('-h') || args.includes('--help')) {
105
115
  printHelp();
106
116
  process.exit(0);
107
117
  }
118
+ // Check for updates
119
+ try {
120
+ // Use eval to prevent TypeScript from transpiling dynamic import to require()
121
+ const { default: updateNotifier } = await eval('import("update-notifier")');
122
+ const pkg = require('../package.json');
123
+ updateNotifier({ pkg }).notify();
124
+ }
125
+ catch (e) {
126
+ // Ignore update check errors
127
+ }
108
128
  // 检查 Git 仓库
109
129
  await checkGitRepo();
110
130
  if (command === 'set-main') {
@@ -125,8 +145,25 @@ async function main() {
125
145
  // Parse options
126
146
  const isInteractive = args.includes('-i') || args.includes('--interactive');
127
147
  const isDryRun = args.includes('-d') || args.includes('--dry-run');
148
+ // Parse stale options
149
+ const staleIndex = args.indexOf('--stale');
150
+ const isStale = staleIndex !== -1;
151
+ let staleDays = 90; // Default 3 months
152
+ if (isStale) {
153
+ // Check if next arg is a number (days)
154
+ // Note: This is a simple check, ideally use a proper arg parser like commander or yargs
155
+ const nextArg = args[staleIndex + 1];
156
+ if (nextArg && !nextArg.startsWith('-') && !isNaN(Number(nextArg))) {
157
+ staleDays = Number(nextArg);
158
+ }
159
+ }
128
160
  // 默认执行清理操作
129
- await (0, clean_1.cleanDeletedBranches)({ interactive: isInteractive, dryRun: isDryRun });
161
+ await (0, clean_1.cleanDeletedBranches)({
162
+ interactive: isInteractive,
163
+ dryRun: isDryRun,
164
+ stale: isStale,
165
+ staleDays: staleDays
166
+ });
130
167
  }
131
168
  // 退出程序
132
169
  process.exit(0);
package/dist/utils/git.js CHANGED
@@ -2,6 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getMainBranch = getMainBranch;
4
4
  exports.setMainBranch = setMainBranch;
5
+ exports.isBranchMerged = isBranchMerged;
6
+ exports.getWorktrees = getWorktrees;
7
+ exports.getBranchLastCommitTime = getBranchLastCommitTime;
5
8
  const simple_git_1 = require("simple-git");
6
9
  const errors_1 = require("../errors");
7
10
  const config_1 = require("./config");
@@ -74,3 +77,59 @@ async function setMainBranch(branch) {
74
77
  throw new errors_1.GitError('Failed to set main branch configuration');
75
78
  }
76
79
  }
80
+ /**
81
+ * Check if a branch is merged into the main branch.
82
+ */
83
+ async function isBranchMerged(branch, mainBranch) {
84
+ try {
85
+ const mergedBranches = await git.branch(['--merged', mainBranch]);
86
+ return mergedBranches.all.includes(branch);
87
+ }
88
+ catch (error) {
89
+ return false;
90
+ }
91
+ }
92
+ /**
93
+ * Get a list of branches that are currently checked out in worktrees.
94
+ */
95
+ async function getWorktrees() {
96
+ try {
97
+ // Output format:
98
+ // /path/to/repo (HEAD detached at 123456)
99
+ // /path/to/worktree [branch-name]
100
+ const worktrees = await git.raw(['worktree', 'list']);
101
+ const lines = worktrees.split('\n').filter(Boolean);
102
+ const branches = [];
103
+ for (const line of lines) {
104
+ // Extract branch name from [branch-name]
105
+ const match = line.match(/\[(.*?)\]/);
106
+ if (match && match[1]) {
107
+ branches.push(match[1]);
108
+ }
109
+ }
110
+ return branches;
111
+ }
112
+ catch (error) {
113
+ // If worktrees are not supported or error occurs, return empty list
114
+ return [];
115
+ }
116
+ }
117
+ /**
118
+ * Get the last commit time (in days) for a branch.
119
+ * Returns the number of days since the last commit.
120
+ */
121
+ async function getBranchLastCommitTime(branch) {
122
+ try {
123
+ // Get unix timestamp of last commit
124
+ const timestamp = await git.raw(['log', '-1', '--format=%at', branch]);
125
+ const lastCommitDate = new Date(parseInt(timestamp.trim()) * 1000);
126
+ const now = new Date();
127
+ const diffTime = Math.abs(now.getTime() - lastCommitDate.getTime());
128
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
129
+ return diffDays;
130
+ }
131
+ catch (error) {
132
+ // If branch doesn't exist or error, return 0 (treat as active/new to be safe)
133
+ return 0;
134
+ }
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harryisfish/gitt",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "A command-line tool to help you manage Git repositories and remote repositories, such as keeping in sync, pushing, pulling, etc.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -39,9 +39,11 @@
39
39
  "dependencies": {
40
40
  "@inquirer/prompts": "^3.3.0",
41
41
  "@types/minimatch": "^6.0.0",
42
+ "@types/update-notifier": "^6.0.8",
42
43
  "listr2": "^8.0.0",
43
44
  "minimatch": "^10.1.1",
44
- "simple-git": "^3.22.0"
45
+ "simple-git": "^3.22.0",
46
+ "update-notifier": "^7.3.1"
45
47
  },
46
48
  "engines": {
47
49
  "node": ">=14.0.0"